Skip to main content
PProfileMatch

CI/CD Build, Test and Artifact Stages with GitHub Actions

DevOpsLesson 2 of 45 min read

What you'll learn

  • Read and write a GitHub Actions workflow with triggers, jobs and steps
  • Explain job dependencies, caching and the artifact upload step
  • Apply the build-once, promote-the-same-artifact principle
  • Diagnose common workflow failures from their error messages

On this page

  1. The workflow
  2. Line by line
  3. Build once, promote the same artifact
  4. What belongs in each stage
  5. Common mistakes and the symptoms they cause
  6. How this shows up in real projects and interviews

A bus-timetable API is written in Node. The team wants three things on every pull request: lint and tests must pass, the project must compile, and the compiled output must be kept so that the exact bytes that passed the checks are what get deployed later. Below is a complete GitHub Actions workflow that does that, followed by a careful walk through it.

The workflow

.github/workflows/ci.yml
name: ci

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

concurrency:
  group: ci-${{ 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 lint
      - run: npm test

  build:
    needs: 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 build
      - uses: actions/upload-artifact@v4
        with:
          name: timetable-dist-${{ github.sha }}
          path: dist/
          if-no-files-found: error
          retention-days: 7

Line by line

  • name: ci is the label shown in the Actions tab and in status checks on pull requests.
  • on: lists triggers. pull_request: with no filters runs on every pull request update; push with branches: [main] runs after a merge. Together they give you feedback before merging and a record after.
  • permissions: contents: read restricts the automatic GITHUB_TOKEN to read-only for the whole workflow. Grant more per job only when needed. It is a small line with a large security benefit, covered further in the last lesson.
  • concurrency groups runs by branch or ref (github.ref). With cancel-in-progress: true, pushing a new commit cancels the outdated run for the same pull request, saving minutes and runner capacity. Do not use this for a production deployment workflow, where cancelling midway could leave things half-updated.
  • jobs: defines independent units. Each job gets a fresh virtual machine, which is why the build job repeats checkout and install: nothing carries over between jobs unless you pass it explicitly.
  • runs-on: ubuntu-latest selects the runner image. timeout-minutes: 15 stops a hung job instead of letting it burn the default limit.
  • uses: actions/checkout@v4 clones the repository at the commit that triggered the run. uses runs a reusable action; @v4 is a version tag (see the last lesson on pinning).
  • actions/setup-node@v4 installs Node 22 and, with cache: npm, saves and restores the npm download cache keyed on your lockfile, so repeat runs skip most downloads.
  • run: npm ci installs exactly what package-lock.json specifies and fails if the lockfile is out of sync, which is what you want in automation. run lines execute in a shell; each run is a step.
  • npm run lint and npm test are steps in the same job. If either exits non-zero, the job stops and shows red. Order them cheapest first so you fail fast.
  • needs: test on the build job means it starts only after test succeeds. Without it, jobs run in parallel.
  • actions/upload-artifact@v4 stores dist/ with the run. The name includes github.sha so each commit's output is distinguishable; if-no-files-found: error fails the step if the build produced nothing, rather than silently uploading an empty artifact; retention-days limits storage.

Build once, promote the same artifact

The artifact step is the heart of the "CD" half. If your deploy stage rebuilds the code from source, you are deploying something that was never tested: a different dependency resolution, a different timestamp, possibly a different compiler patch. Instead, build exactly once, test that output, store it (a build artifact, a container image in a registry, a package), and have every later stage consume that stored thing. Staging and production then differ only in configuration, not in code. A downstream job fetches it like this:

Excerpt of a deploy job (illustrative)
  deploy-staging:
    needs: build
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: timetable-dist-${{ github.sha }}
          path: dist/
      - run: ./scripts/deploy.sh staging dist/

For services shipped as containers the artifact is an image tagged with the commit SHA, as described in the Docker course. The rule is identical: build the image once, scan it, push it, and deploy by tag.

What belongs in each stage

StagePurposeKeep it
Lint / format / type checkCheap, catches style and obvious errorsUnder a minute; first in order
Unit testsVerify logic in isolationFast and deterministic, no network
Integration testsVerify code with a real database or queueUse service containers; fewer, targeted
Build / packageProduce the deployable outputReproducible: lockfiles, pinned versions
Publish artifactStore the tested outputImmutable and tagged by commit

Common mistakes and the symptoms they cause

  • Tabs or misaligned indentation in YAML. Symptom: GitHub shows Invalid workflow file with a line number such as You have an error in your yaml syntax on line 14. Fix: use spaces and an editor with YAML validation.
  • Expecting files to persist between jobs. Symptom: the second job fails with No such file or directory for something the first job built. Fix: upload and download an artifact, or build in the same job.
  • Missing script in package.json. Symptom: npm error Missing script: "lint". Fix: add the script or remove the step.
  • `npm ci` failing. Symptom: npm ci can only install packages when your package.json and package-lock.json are in sync. Fix: run npm install locally and commit the updated lockfile.
  • Wrong cache path or key. Symptom: every run re-downloads everything even though cache: npm is set, because the lockfile is not at the repository root. Fix: set cache-dependency-path.
  • Rebuilding at deploy time. Symptom: production behaves differently from the version that passed tests. Fix: deploy the stored artifact.

How this shows up in real projects and interviews

Almost every developer job involves reading or editing a workflow like this, so interviewers may hand you one and ask what happens on a pull request, or why the build job re-runs npm ci. They also like "how would you speed this up?", where good answers are caching, running jobs in parallel and skipping unaffected work. The same concepts transfer to GitLab CI, Jenkins pipelines and CircleCI; only the syntax changes. If you want practice, put this workflow in a small repository and break each stage on purpose.

Try it yourself

Create a small repository with a project that has a test command and a build command, and add the workflow above adapted to your language. Open a pull request with a failing test to see the red check, fix it, then download the uploaded artifact from the run summary and confirm it contains your build output.

Show a hint

In the GitHub web UI, open the run and look for the Artifacts section at the bottom of the run summary page.

Key takeaways

  • A workflow has triggers, jobs and steps; each job runs on a fresh machine unless you pass data explicitly.
  • needs controls ordering, and cheap checks should run before expensive ones to fail fast.
  • Use npm ci (or the equivalent lockfile-strict install) and dependency caching in automation.
  • Build once, store an immutable artifact or image tagged by commit, and promote that same artifact between environments.
  • Set permissions, timeouts and concurrency deliberately instead of accepting defaults.

Test yourself

A trick from this lesson's ride: No needs, no queue: jobs start in parallel. needs = wait for that job to pass. Four more tricks like it wait along the route, each followed by a question.

Ride this lesson

Frequently asked questions

How do I create a GitHub Actions workflow?

Add a YAML file under .github/workflows/ in your repository, for example ci.yml. Define the trigger under on:, one or more jobs under jobs:, and the steps each job runs. GitHub picks up the file automatically on the next push or pull request.

What is the difference between a job and a step in GitHub Actions?

A job is a set of steps that run on one fresh runner machine, and jobs are independent unless connected with needs. A step is a single task in a job, either a shell command with run or a reusable action with uses. Steps in a job share a filesystem; jobs do not.

What is a build artifact in CI/CD?

A build artifact is the packaged output of a build, such as a compiled bundle, a jar, an installer or a container image, stored so later stages can deploy exactly that output. Building once and promoting the same artifact keeps what you test identical to what you ship.