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_TOKENcan do for a run or for an individual job. The default on many repositories is broader than any single job requires. Settingpermissions: contents: readat the top of the workflow, then adding only the specific write scope a job needs (such aspackages: writeon 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-allat the workflow level, or omitting thepermissionsblock 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 setcontents: readat 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_requestorpull_request_targetalso 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 toworkflow_dispatch, never to a fork-triggered pull request event.
Best Practices
- Design the job graph for parallelism first; only add a
needsdependency 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: readat the workflow level and grant any elevated scope, such aspackages: writeorid-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_targetor 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
- Add a
lintjob that depends only onbuildand runs in parallel withtest, and explain why it needs its own checkout and dependency install steps even thoughbuildalready ran them. - Rewrite the permissions blocks so only
packagecan write to the container registry, every other job is read-only, and justify whytestandsecurity-scanneed no elevated scope at all. - Describe, for a production environment protected by two required reviewers, exactly what changes about how
deploy-productionbehaves compared todeploy-staging, and at what point in a run that difference takes effect. - Add a step before the deploy step in
deploy-productionthat fails the job ifpackage‘s digest output is empty, and explain what real-world failure this guards against. - 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.
