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
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
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: 7Line by line
name: ciis 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;pushwithbranches: [main]runs after a merge. Together they give you feedback before merging and a record after.permissions: contents: readrestricts the automaticGITHUB_TOKENto 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.concurrencygroups runs by branch or ref (github.ref). Withcancel-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 thebuildjob repeats checkout and install: nothing carries over between jobs unless you pass it explicitly.runs-on: ubuntu-latestselects the runner image.timeout-minutes: 15stops a hung job instead of letting it burn the default limit.uses: actions/checkout@v4clones the repository at the commit that triggered the run.usesruns a reusable action;@v4is a version tag (see the last lesson on pinning).actions/setup-node@v4installs Node 22 and, withcache: npm, saves and restores the npm download cache keyed on your lockfile, so repeat runs skip most downloads.run: npm ciinstalls exactly whatpackage-lock.jsonspecifies and fails if the lockfile is out of sync, which is what you want in automation.runlines execute in a shell; eachrunis a step.npm run lintandnpm testare 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: teston thebuildjob means it starts only aftertestsucceeds. Without it, jobs run in parallel.actions/upload-artifact@v4storesdist/with the run. The name includesgithub.shaso each commit's output is distinguishable;if-no-files-found: errorfails the step if the build produced nothing, rather than silently uploading an empty artifact;retention-dayslimits 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:
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
| Stage | Purpose | Keep it |
|---|---|---|
| Lint / format / type check | Cheap, catches style and obvious errors | Under a minute; first in order |
| Unit tests | Verify logic in isolation | Fast and deterministic, no network |
| Integration tests | Verify code with a real database or queue | Use service containers; fewer, targeted |
| Build / package | Produce the deployable output | Reproducible: lockfiles, pinned versions |
| Publish artifact | Store the tested output | Immutable and tagged by commit |
Common mistakes and the symptoms they cause
- Tabs or misaligned indentation in YAML. Symptom: GitHub shows
Invalid workflow filewith a line number such asYou 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 directoryfor 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: runnpm installlocally and commit the updated lockfile. - Wrong cache path or key. Symptom: every run re-downloads everything even though
cache: npmis set, because the lockfile is not at the repository root. Fix: setcache-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.
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.