Pipeline Architecture and the Software Delivery Lifecycle

A pipeline is more than a single YAML file that runs tests. It is an architecture: a directed graph of stages that carries a code change from a developer’s commit to a running, observable production system, with explicit quality and security checkpoints along the way. Once you have written a first GitHub Actions workflow, the next skill is designing that graph deliberately — choosing which stages run in parallel, which gate on which, and where a human has to click approve before anything ships. This lesson gives you the vocabulary and the workflow structure to do that.

Overview / How it works

The software delivery lifecycle describes everything that happens to a change after a developer commits it: build, automated test, security and dependency scanning, packaging into a versioned artifact, publishing that artifact to a registry, deploying it to one or more environments, and observing it afterward so problems can be caught and rolled back. A pipeline is the automated implementation of that lifecycle, expressed as jobs and steps.

Three terms get used loosely outside professional contexts, but they mean specific and different things.

Continuous integration (CI) is the practice of merging small changes frequently and having automation build and test every change immediately, so integration problems surface within minutes instead of at release time. CI says nothing about deployment; a repository can have excellent CI and still ship manually, rarely, or never.

Continuous delivery extends CI so that every change that passes all checks is automatically packaged into a deployable, release-ready artifact and, typically, pushed to a staging environment. A human still decides when and whether to release to production, usually by approving a gated job. The guarantee continuous delivery makes is that main is always in a shippable state — not that it always ships.

Continuous deployment removes that last human gate. Every change that passes every automated check is deployed to production automatically, with no manual approval step. It demands the most mature test and monitoring practices, because there is no human backstop between a bad change and production traffic.

Most real projects land on continuous delivery for production and continuous deployment for staging or internal environments, and that split is a deliberate architectural decision, not a compromise.

Syntax or workflow structure

GitHub Actions expresses a pipeline as one or more jobs inside a workflow file under .github/workflows/. Each job runs on its own isolated runner, so nothing is shared between jobs automatically; artifacts produced in one job must be explicitly uploaded and downloaded, or passed through job outputs, to reach another job.

Three structural elements turn a flat list of jobs into a real pipeline graph:

  • needs — lists the jobs that must complete successfully first. Jobs with no needs, or whose needs are already satisfied, run in parallel automatically; Actions builds the dependency graph for you from these declarations.
  • permissions — controls what the built-in GITHUB_TOKEN can do for a run or for an individual job. The default on many repositories is broader than any single job requires. Setting permissions: contents: read at the top of the workflow, then adding only the specific write scope a job needs (such as packages: write on the one job that pushes an image), limits what a compromised dependency or step inside that job could do.
  • environment — ties a deployment job to a GitHub Environment, configured in repository settings with protection rules: required reviewers, a wait timer, or branch restrictions. A job targeting a protected environment pauses until its conditions are met before any of its steps run. This is how continuous delivery’s human gate is actually implemented in Actions, and how continuous deployment is implemented by an environment with no reviewers at all.

Examples

The first example is the smallest real pipeline: build the project, then run its test suite, with test gated on build‘s success.

name: Pipeline

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

  test:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm test -- --ci

After this workflow exists, every push to main and every pull request produces two jobs in sequence, build then test, both read-only against the repository because neither needs to write anywhere.

The second example expands the graph. A security-scan job also depends only on build, so it runs in parallel with test rather than after it. A new package job depends on both test and security-scan finishing successfully, builds a container image tagged with the commit SHA rather than a floating tag, and pushes it to GitHub Container Registry. It records the resulting image digest as a job output so later jobs can reference the exact image that was scanned, not just its mutable tag.

  security-scan:
    needs: build
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist/
      - name: Audit dependencies
        run: npm audit --audit-level=high

  package:
    needs: [test, security-scan]
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    outputs:
      digest: ${{ steps.build-image.outputs.digest }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist/
      - name: Log in to registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Build and push image
        id: build-image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}

After this change, a push to main runs build, then test and security-scan in parallel, and only once both succeed does package run. If the audit step fails, package never starts and nothing reaches the registry.

The third example adds deployment. deploy-staging needs package and targets a staging environment with no reviewers, so it runs automatically once the image is published. deploy-production needs both package and deploy-staging, is restricted to runs triggered on main, and targets a production environment configured with required reviewers. Both deployment jobs deploy the image by its recorded digest rather than by the mutable tag.

  deploy-staging:
    needs: package
    runs-on: ubuntu-latest
    environment: staging
    permissions:
      contents: read
    steps:
      - name: Deploy image by digest
        run: ./deploy.sh staging ghcr.io/${{ github.repository }}@${{ needs.package.outputs.digest }}
      - name: Health check
        run: curl --fail https://staging.example.com/healthz

  deploy-production:
    needs: [package, deploy-staging]
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://example.com
    permissions:
      contents: read
    steps:
      - name: Deploy image by digest
        run: ./deploy.sh production ghcr.io/${{ github.repository }}@${{ needs.package.outputs.digest }}
      - name: Health check
        run: curl --fail https://example.com/healthz

After this change, every successful main build reaches staging automatically — continuous deployment for that environment — while production waits for a person to approve the paused run — continuous delivery for that environment. Referencing the digest guarantees the artifact a reviewer approves is byte-for-byte the one that was scanned and tested, because a tag can be silently repointed to a different image afterward while a digest cannot.

A tag and a digest are not the same kind of reference. A tag is a mutable pointer that can be reassigned to a new image at any time. A digest is a content hash of the image manifest; it can only ever resolve to the exact bytes it was computed from. You can see the difference directly:

docker pull ghcr.io/acme/api:latest
docker inspect --format '{{index .RepoDigests 0}}' ghcr.io/acme/api:latest

Step by step

When a push lands on main, Actions first evaluates the workflow’s on conditions and determines which jobs are eligible to run. It then builds the job dependency graph from every needs declaration and starts any job whose dependencies are already satisfied, including running several such jobs concurrently on separate runners.

As each job finishes, Actions re-evaluates the graph and starts any newly unblocked jobs, such as package starting the moment both test and security-scan have succeeded. When a job targets a protected environment, Actions pauses that job immediately before its steps execute and waits for the environment’s protection rules to be satisfied, such as a reviewer clicking approve.

Once approved, the job’s steps run against the real target, typically executing a deployment script followed immediately by a health check step that confirms the new version is actually serving traffic correctly. If a health check fails, the job fails, and the previous release remains what is actually receiving traffic until someone redeploys the previous artifact’s digest — the rollback path, which should be scripted and rehearsed, not improvised during an incident.

Common Mistakes

  • Treating a green check as production readiness. Teams sometimes merge to main, see CI pass, and consider the work done, then deploy separately by hand outside the pipeline. This quietly turns CI into an ungoverned, undocumented release process. The fix is to make delivery and deployment explicit jobs in the same workflow, gated by environments, so reaching production is an auditable, defined step rather than an assumption.
  • Granting broad permissions out of convenience, such as permissions: write-all at the workflow level, or omitting the permissions block entirely and relying on the repository’s default. This means a compromised dependency inside any job’s build or test step, not just the deployment job, could push commits or publish packages. The fix is to set contents: read at the top of the workflow and add only the exact elevated scope a specific job requires, on that job alone.
  • Exposing deployment credentials to fork-triggered events. Running a job that touches deployment secrets or self-hosted runners on pull_request or pull_request_target also exposes it to pull requests opened from forks, which run code the repository owners do not control. That can let an untrusted contributor exfiltrate credentials or run arbitrary code with privileged access. The fix is to restrict any job using deployment secrets to push events on protected branches or to workflow_dispatch, never to a fork-triggered pull request event.

Best Practices

  • Design the job graph for parallelism first; only add a needs dependency where a job genuinely requires another’s output, so independent checks like linting, tests, and security scanning run concurrently instead of in an unnecessary sequence.
  • Set contents: read at the workflow level and grant any elevated scope, such as packages: write or id-token: write, only on the specific job that uses it.
  • Deploy by image digest, not by a floating tag, so the artifact a reviewer approves and the artifact that runs in production are provably identical.
  • Protect staging and production with GitHub Environments, using required reviewers and branch restrictions rather than relying on convention to prevent accidental deploys.
  • Never expose deployment secrets or self-hosted runners to workflows triggered by pull_request_target or by pull requests from forks.
  • Pin third-party actions to a known version, and consider pinning to a specific commit SHA for anything with deployment or secret access; a SHA pin is immutable and auditable but must be bumped by hand, while a version tag is convenient but can be moved by its maintainer.
  • Always follow a deployment step with an automated health check, and keep a scripted, tested rollback path that redeploys the previous known-good digest rather than improvising recovery during an incident.

Practice Exercises

  1. Add a lint job that depends only on build and runs in parallel with test, and explain why it needs its own checkout and dependency install steps even though build already ran them.
  2. Rewrite the permissions blocks so only package can write to the container registry, every other job is read-only, and justify why test and security-scan need no elevated scope at all.
  3. Describe, for a production environment protected by two required reviewers, exactly what changes about how deploy-production behaves compared to deploy-staging, and at what point in a run that difference takes effect.
  4. Add a step before the deploy step in deploy-production that fails the job if package‘s digest output is empty, and explain what real-world failure this guards against.
  5. For a project you maintain, decide which environments should be continuous deployment and which should be continuous delivery, and justify the boundary in terms of blast radius if a bad change reaches that environment.

Summary

A CI/CD pipeline is a deliberately designed graph of jobs, not a single script, and GitHub Actions expresses that graph through needs, permissions, and environment. Continuous integration builds and tests every change; continuous delivery guarantees every passing change is releasable and gates production behind a human; continuous deployment removes that gate entirely. Building pipelines well means keeping permissions minimal and job-scoped, deploying artifacts by immutable digest rather than mutable tag, protecting sensitive environments with real approval gates, treating fork-triggered events as untrusted input, and always pairing a deployment with a health check and a rehearsed rollback path.