GitHub Actions CI/CD that remains understandable
A practical guide to building GitHub Actions workflows with useful feedback, narrow permissions, deliberate caching, durable artifacts and safer deployment boundaries.
Start with the workflow execution model
A workflow is a YAML file in .github/workflows. An event starts a workflow run, jobs contain steps, and each job runs on a runner. Steps in one job share its workspace; separate jobs should be treated as separate machines unless files are passed between them explicitly.
That distinction prevents a common source of confusion. A package installed or file produced in the build job does not simply appear in a later deploy job. The workflow needs an artifact, cache or repeated setup step depending on what the data represents.
Keep the first workflow small enough to explain: one pull-request trigger, one verification job and the commands developers already trust locally. CI should automate the project contract rather than invent a second build system in YAML.
name: verify
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: verify-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run typecheck
- run: npm test
- run: npm run generateMake pull-request feedback fast and meaningful
The checks should answer whether the proposed change is safe to review and merge. Run formatting or linting, type checking, focused tests and a production-shaped build where those commands protect real failure modes.
Order quick, diagnostic checks before slower work, but do not split every command into another job. More jobs add runner startup time and make shared setup explicit. Split work when it can run independently, needs a different runtime or has a genuinely different responsibility.
Use timeouts so a deadlocked test cannot consume a runner indefinitely. Cancelling superseded runs on the same branch also prevents old commits competing with the version a reviewer is actually reading.
Cache dependencies; publish build outputs as artifacts
A cache is an optimisation for data that can be recreated, such as downloaded npm or Maven dependencies. A cache miss must make the workflow slower, not incorrect. Keys should change when the dependency lock file changes and should not include secrets.
An artifact is an output that another job or a person needs from this particular run: a compiled package, generated report or test result. Upload it with a clear name and an intentional retention period. A deployment pipeline can then promote the exact artifact that passed verification instead of rebuilding potentially different source or dependencies.
Caching node_modules directly is often more fragile than caching the package manager download store and continuing to run npm ci. Setup actions can manage the relevant cache without hiding dependency installation.
- Cache: reusable performance data that may safely disappear
- Artifact: an output belonging to one workflow run
- Release asset: a versioned deliverable intended for consumers
Give the workflow only the permissions it needs
Set permissions explicitly. A verification job that only checks out source often needs contents: read and nothing more. Grant write permissions to the smallest job that performs the write rather than to the complete workflow.
Secrets are not ordinary configuration. Keep non-sensitive values in variables, restrict secrets to the environment and job that need them, and avoid printing command input that can contain credentials. Workflows triggered from forks have different secret behaviour by design.
Treat pull_request_target with particular care because it runs in the context of the base repository. Checking out and executing untrusted pull-request code in that context can expose privileged tokens or secrets. Use the ordinary pull_request event for building untrusted changes unless there is a carefully designed reason not to.
jobs:
deploy:
needs: build
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: application
- run: ./scripts/deploy.shUse environments as a deployment boundary
A GitHub environment can separate production from preview or test deployment configuration. Environment secrets, protection rules and deployment history make the boundary visible in the repository rather than burying it inside a long conditional script.
Build once, verify that build, then deploy the same artifact. Rebuilding inside the production job weakens the connection between what passed and what shipped. Database migrations and configuration compatibility still need their own release plan; an environment approval does not make an unsafe change reversible.
Use concurrency to prevent production deployments racing each other. CI for a superseded commit can be cancelled, while production work usually needs serialisation rather than interruption halfway through a release.
jobs:
deploy-production:
needs: build
environment: production
concurrency:
group: production
cancel-in-progress: false
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: application
- run: ./scripts/deploy.shMatrices and reusable workflows should remove proven repetition
A matrix is useful when the same verification genuinely needs several supported runtimes or operating systems. It is less useful when it multiplies combinations that nobody supports or knows how to diagnose.
Reusable workflows can centralise an established release path across repositories. Start by making one pipeline clear, then extract the stable shared contract. A generic organisation-wide workflow created too early tends to accumulate inputs and exceptions until application teams cannot tell what actually runs.
Keep application commands in the repository build tool where possible. npm scripts, Maven goals and sbt tasks can be run locally; a large shell program embedded only in workflow YAML is harder to reproduce.
Design failure output for the person responding
A red check should identify the command, relevant error and artifact needed to investigate. Name jobs after the promise they protect, preserve test reports when useful and keep logs free of unrelated setup noise.
Pin action versions deliberately and review dependency updates like application dependencies. Add monitoring around deployment outcomes rather than treating a green workflow as proof the service is healthy after release.
A dependable pipeline is not the YAML with the most stages. It is the shortest route from a proposed change to trustworthy evidence, followed by a controlled path that ships the same verified output.