CI on GitHub Actions is a YAML file in .github/workflows/ that, on every pull request, installs dependencies with npm ci and runs the linter, type checking, tests and the build on a clean GitHub machine. To keep broken code out of main, you make the final check required in branch protection, and move publishing and deployment into a separate tag-triggered workflow — with a least-privilege token, actions pinned to a SHA and secrets stored in the production environment.
After this you will be able to
- write a
ci.ymlfor a Node.js project:npm ciwith caching, lint, type checking, unit tests across a Node.js version matrix, integration tests with PostgreSQL in a service container, and a Docker image build - restrict
GITHUB_TOKENpermissions, pin actions to a SHA and pass untrusted input torunonly through environment variables - make a single final check required for merging and explain why a skipped job does not block a pull request
- check a workflow before pushing with
actionlintandact, and set up image publishing and a tag-triggered deployment through anenvironment
Know beforehand
Verified with
GitHub Actions (runner ubuntu-24.04)actions/checkout 7.0.1actions/setup-node 7.1.0actions/upload-artifact 7.0.2docker/build-push-action 7.4.0Node.js 22 / 24 / 26actionlint 1.7.12act 0.2.89
Checked against docs: October 11, 2026
Contents
- CI and CD: what to check automatically
- How a workflow is built: events, jobs, steps
- Pull request checks: Node.js, cache, matrix, PostgreSQL
- Workflow security: permissions, action versions, untrusted input
- Checking before you push: actionlint and a local run with act
- Required checks: branch protection
- Publishing the image and deploying on a tag
- Exercises
CI and CD: what to check automatically
CI (continuous integration) is the practice of automatically building and checking every change before it is merged into the main branch. CD (continuous delivery/deployment) is the next step: the checked version is published and rolled out to a server just as automatically. In GitHub Actions both are described by YAML files in the repository's own .github/workflows/ directory, so the pipeline lives next to the code and changes through the same pull requests.
The practice project is the leads API from the backend testing lesson: Node.js, Express, PostgreSQL, unit and integration tests with Vitest. We put it on a pipeline: every pull request goes through the linter, type checking, unit tests on three Node.js versions, integration tests with a real PostgreSQL and a Docker image build, and a tag like v1.2.0 publishes the image and deploys it to production.
pull_request
- What runs
ci.yml: all checks; the image is built but not published- Why
- keep code that fails the checks out of
main
push to main
- What runs
ci.yml- Why
- check the merge result and refresh the
maincache that every pull request uses
tag v*.*.*
- What runs
release.yml: CI → publish image → deploy- Why
- release a version the same checked way, not by hand from a laptop
How a workflow is built: events, jobs, steps
A workflow is one YAML file. The on key lists the events it reacts to, jobs lists the jobs. GitHub runs each job on a separate clean virtual machine — a runner — chosen by runs-on. Jobs run in parallel unless you link them with needs. Inside a job, steps run in order: either a shell command (run) or a ready-made action (uses) — for example, actions/checkout downloads the repository code.
Event (on)
- What it is
- what triggers the workflow
- In our
ci.yml pull_request,pushtomain,workflow_call— a call from another workflow
Job
- What it is
- a set of steps on one machine; each job is a separate check on the pull request
- In our
ci.yml checks,unit,integration,image,ci-ok
Runner
- What it is
- the machine that runs a job
- In our
ci.yml ubuntu-24.04— GitHub's Ubuntu image with Node.js and Docker
Action
- What it is
- a reusable ready-made step from another repository
- In our
ci.yml actions/setup-nodeinstalls Node.js and caches npm
Matrix
- What it is
- one job multiplied over a set of values
- In our
ci.yml node: [22, 24, 26]— three unit test jobs
Service container
- What it is
- a Docker container that lives next to the job
- In our
ci.yml postgres:17-alpinefor integration tests
Two properties of the pull_request event are worth knowing up front. First, it checks not the tip of the branch but the result of merging it into the target branch: GITHUB_REF is refs/pull/<number>/merge, and actions/checkout downloads exactly that merge commit. Second, if the pull request has a merge conflict, pull_request workflows do not run at all — resolve the conflict first. By default the event fires when a pull request is opened, reopened or receives new commits (opened, reopened, synchronize).
Pull request checks: Node.js, cache, matrix, PostgreSQL
A workflow should call the same commands a developer runs locally — then "works on my machine" and "works in CI" mean the same thing. So we start with the scripts in package.json:
{
"scripts": {
"lint": "oxlint --deny-warnings src tests",
"typecheck": "tsc -p .",
"test:unit": "vitest run --project unit",
"test:integration": "vitest run --project integration",
"test": "vitest run"
}
}The linter is oxlint: the project uses TypeScript 7.0, and typescript-eslint 8.71 declared compatibility only with typescript <6.1.0 at the time of checking. On TypeScript 5.x the familiar ESLint plus typescript-eslint setup works — for CI it makes no difference, the step calls npm run lint either way. The workflow itself:
name: CI
on:
pull_request:
push:
branches: [main]
workflow_call:
# Least privilege: the token can only read the code
permissions:
contents: read
# A new push to the same PR cancels the previous, now useless run
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
checks:
name: Lint and typecheck
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run typecheck
unit:
name: Unit tests (Node ${{ matrix.node }})
runs-on: ubuntu-24.04
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
node: [22, 24, 26]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: ${{ matrix.node }}
cache: npm
# Node 22 ships npm 10, which rejects a lockfile written by npm 11
- run: npm install --global npm@11
- run: npm ci
- run: npm run test:unit
integration:
name: Integration tests
runs-on: ubuntu-24.04
timeout-minutes: 15
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_PASSWORD: test # a throwaway database: not a secret
POSTGRES_DB: leads_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
TEST_DATABASE_URL: postgres://postgres:test@localhost:5432/leads_test
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run test:integration -- --reporter=default --reporter=junit --outputFile.junit=reports/integration.xml
- name: Upload test report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: integration-report
path: reports/
retention-days: 7
image:
name: Docker image
needs: [checks, unit, integration]
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
push: false
tags: leads-api:ci
# The single check to mark as required in branch protection
ci-ok:
name: CI passed
if: ${{ always() }}
needs: [checks, unit, integration, image]
runs-on: ubuntu-24.04
steps:
- name: Fail if any job failed or was cancelled
if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
run: exit 1npm ciinstalls packages strictly frompackage-lock.jsonand fails if the lockfile does not matchpackage.json. That is exactly what CI needs: it builds precisely what was checked locally.cache: npminsetup-nodesaves the global npm cache (~/.npm), notnode_modules; the cache key is a hash ofpackage-lock.json. Packages are not downloaded again, butnpm cistill buildsnode_modulesfrom scratch.- A cache created in a pull request is scoped to its merge ref and can only be restored by re-runs of that same pull request. That is why CI also runs on
pushtomain: every pull request intomaincan restore themaincache. - The matrix
node: [22, 24, 26]creates threeunitjobs. According to the release schedule, Node.js 22 is supported until 30 April 2027, 24 is LTS, and 26 becomes LTS on 28 October 2026.fail-fastdefaults totrue— when one version fails, the rest are cancelled; withfalseyou see results for all versions at once. - The linter and integration tests stay out of the matrix: their result barely depends on the Node.js version, while runner time costs money and quota minutes.
services.postgresstarts PostgreSQL 17 next to the job. The health check (pg_isready) holds the steps until the database is ready, andportsmaps the port to the runner — hence thelocalhost:5432address.TEST_DATABASE_URLpointsglobal-setup.tsfrom the testing lesson at this database instead of Testcontainers. Thetestpassword is not a secret: the database lives a few minutes and is not reachable from outside.- The JUnit report is uploaded as an artifact with the
!cancelled()condition: also when tests fail — that is exactly when you need it — but not when the run is cancelled.retention-days: 7instead of the default 90 days saves storage. concurrencywithcancel-in-progressonly forpull_request: a new push to the branch cancels the outdated run, while runs onmainalways finish.timeout-minutesmatters: by default a hung job lives for 360 minutes.
The npm install --global npm@11 line in the matrix appeared after the very first local run. The lockfile was written by npm 11, and Node.js 22 ships npm 10, which considers that lockfile out of sync: the project uses msw 3, while an optional peer dependency of @vitest/mocker expects msw 2. On Node.js 24 and 26 the same npm ci passes. The real output of the Node.js 22 job before the fix:
[CI/Unit tests (Node 22)-1] ⭐ Run Main npm ci
[CI/Unit tests (Node 22)-1] | npm error code EUSAGE
[CI/Unit tests (Node 22)-1] | npm error `npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with `npm install` before continuing.
[CI/Unit tests (Node 22)-1] | npm error Missing: msw@2.15.0 from lock file
[CI/Unit tests (Node 22)-1] | npm error Missing: @mswjs/interceptors@0.41.9 from lock file
…
[CI/Unit tests (Node 22)-1] ❌ Failure - Main npm ci [4.8119949s]
[CI/Unit tests (Node 22)-1] 🏁 Job failed
…
[CI/Unit tests (Node 24)-2] ✅ Success - Main npm run test:unit [1.3380536s]
[CI/Unit tests (Node 26)-3] ✅ Success - Main npm run test:unit [671.0905ms]Workflow security: permissions, action versions, untrusted input
A pull_request workflow runs the code from that pull request: anything the workflow can access, that code can access too. So the rules from GitHub's "Secure use reference" belong in the very first workflow file, not "some day later".
Least-privilege token
GitHub gives every job a
GITHUB_TOKEN.permissions: contents: readat the workflow level leaves only read access to the code: once any permission is specified, all unspecified ones becomenone. Widen permissions per job — inrelease.ymlonly the publishing job haspackages: write.Actions pinned to a full SHA
A tag like
@v7can be moved by the action's owner, and if the repository is compromised, the new version silently reaches every workflow. According to GitHub's documentation, a full commit SHA is the only way to reference an immutable version. The# v7.0.1comment is for humans. To keep pinned versions from going stale, GitHub recommends Dependabot withpackage-ecosystem: github-actions— it keeps track of action references in workflows. Take the SHA from the action's own repository, not from a fork.No unnecessary credentials
actions/checkoutkeeps the token by default so later steps can run git commands. Our CI does not needgit push, hencepersist-credentials: false.Untrusted input only via env
The pull request title, branch name and comment text are written by the pull request author. A
${{ … }}expression is substituted into the script before the shell starts, so a title with quotes and;becomes a command. Pass such values throughenvand use them as ordinary variables.Careful with privileged events
pull_request_targetandworkflow_runrun in the context of the base repository — with secrets and write access. Do not download or run pull request code in them; starting with v7,actions/checkoutrefuses to do so by default.
name: PR title
on:
pull_request:
types: [opened, edited, synchronize, reopened]
permissions: {}
jobs:
title:
name: PR title format
runs-on: ubuntu-24.04
steps:
- name: Check PR title
env:
TITLE: ${{ github.event.pull_request.title }} # untrusted input: only via env
run: |
pattern='^(feat|fix|docs|chore): '
if [[ ! "$TITLE" =~ $pattern ]]; then
echo "PR title must start with feat:, fix:, docs: or chore:"
exit 1
fi
echo "PR title is OK"We ran this workflow through act with the title a"; touch /tmp/pwned; echo ": the format check failed, and the command from the title did not run — there is no /tmp/pwned in the container. In a control variant with ${{ github.event.pull_request.title }} placed directly in run, the same title created the file. Secrets from Settings → Secrets and variables → Actions (or gh secret set NAME) are read as ${{ secrets.NAME }}. What matters for CI: a workflow triggered by a pull request from a fork receives no secrets (except a read-only GITHUB_TOKEN), and Dependabot pull request runs behave like forks. So pull request checks must work without secrets — like our ci.yml. How to store and rotate secrets is covered in the material on secrets and environment variables.
Checking before you push: actionlint and a local run with act
GitHub reports a YAML mistake only after a push — and while you fix it, the branch history fills up with "fix ci" commits. actionlint is a static checker for workflows: it validates syntax, ${{ }} expressions, needs references, context types and known security pitfalls, and runs run scripts through shellcheck. The easiest way is to run it in Docker from the repository root:
docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:1.7.12Here is what it finds in a workflow with three typical mistakes — a typo in a matrix variable name, the pull request title placed directly in a script and a reference to a job that does not exist:
name: CI
on: pull_request
jobs:
unit:
runs-on: ubuntu-24.04
strategy:
matrix:
node: [22, 24]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.nodes }}
- run: echo "Testing ${{ github.event.pull_request.title }}"
- run: npm ci && npm test
image:
needs: [checks]
runs-on: ubuntu-24.04
steps:
- run: docker build .$ docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:1.7.12
.github/workflows/ci.yml:15:29: property "nodes" is not defined in object type {node: number} [expression]
|
15 | node-version: ${{ matrix.nodes }}
| ^~~~~~~~~~~~
.github/workflows/ci.yml:16:32: "github.event.pull_request.title" is potentially untrusted. avoid using it directly in inline scripts. instead, pass it through an environment variable. see https://docs.github.com/en/actions/reference/security/secure-use#good-practices-for-mitigating-script-injection-attacks for more details [expression]
|
16 | - run: echo "Testing ${{ github.event.pull_request.title }}"
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.github/workflows/ci.yml:19:3: job "image" needs job "checks" which does not exist in this workflow [job-needs]
|
19 | image:
| ^~~~~~
$ echo $?
1actionlint does not run jobs. That is what act is for — it reads the same files and runs the jobs in Docker containers that imitate a GitHub runner. We ran ci.yml on a pull_request event with the catthehacker/ubuntu:act-24.04 image:
act pull_request -e pr-event.json \
-P ubuntu-24.04=catthehacker/ubuntu:act-24.04 \
--artifact-server-path /tmp/artifacts$ act pull_request -e pr-event.json \
-P ubuntu-24.04=catthehacker/ubuntu:act-24.04 \
--artifact-server-path /tmp/artifacts
…
[CI/Integration tests ] container health of 966c061c5bbf… (postgres:17-alpine) is healthy
…
[CI/Lint and typecheck ] ✅ Success - Main npm run lint [1.144679915s]
[CI/Lint and typecheck ] ✅ Success - Main npm run typecheck [1.023296871s]
[CI/Lint and typecheck ] 🏁 Job succeeded
[CI/Unit tests (Node 22)-1] | Tests 9 passed (9)
[CI/Unit tests (Node 22)-1] 🏁 Job succeeded
[CI/Unit tests (Node 26)-3] 🏁 Job succeeded
[CI/Unit tests (Node 24)-2] 🏁 Job succeeded
[CI/Integration tests ] | Tests 10 passed (10)
[CI/Integration tests ] | Artifact integration-report has been successfully uploaded! Final size is 750 bytes. Artifact ID is 3500521754
[CI/Integration tests ] 🏁 Job succeeded
[CI/Docker image ] ✅ Success - Main docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc [19.041554739s]
[CI/Docker image ] 🏁 Job succeeded
[CI/CI passed ] 🏁 Job succeededsyntax, expressions, needs, shellcheck
- Where it is checked
- actionlint — locally and in CI
steps, matrix, npm ci, tests, the PostgreSQL service, image build, artifact
- Where it is checked
- act — locally, in Docker (with the caveats below)
GITHUB_TOKEN permissions, secrets, environment, branch protection, publishing to ghcr.io
- Where it is checked
- only on GitHub: act injects its own token and knows nothing about repository settings
Required checks: branch protection
On its own, CI only reports a result: a red cross does not stop anyone from pressing Merge. Merging is blocked by a branch protection rule or a ruleset with "Require status checks to pass before merging" — what else branch protection includes is covered in the pull request material. The question is which checks to make required.
Every job is a separate check, and a matrix produces one check per version: Unit tests (Node 22), Unit tests (Node 24) and so on. Listing them all is inconvenient: adding a version to the matrix would mean changing repository settings. Worse, according to GitHub's documentation a job that depends on a failed job is skipped, and a skipped job counts as successful — so a required Docker image check may not block the merge after the tests fail.
That is why ci.yml has a final ci-ok job named CI passed. It depends on all the other jobs, always runs (if: ${{ always() }}) and fails if any job ended with failure or was cancelled. Only this check is made required.
- Do not put
pathsorbranchesfilters on a required workflow: if a filter skips the workflow, its checks stay "Pending" and block merging forever. - Job names must be unique across all workflows in the repository — identical names produce an ambiguous check status.
- The strict mode "Require branches to be up to date before merging" is on by default: the branch must be updated from the base, and the checks run again. With a high volume of pull requests, a merge queue gives the same guarantee without manual updates.
Publishing the image and deploying on a tag
Deployment is triggered by a version tag, not by every merge: production gets a deliberately released version, and it is always clear which one. The image is built from the Dockerfile from the Docker basics lesson:
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src ./src
USER node
EXPOSE 3000
CMD ["node", "src/server.ts"]name: Release
on:
push:
tags: ['v*.*.*']
permissions:
contents: read
# Never run two deployments at once and never cancel one halfway
concurrency:
group: release-production
cancel-in-progress: false
jobs:
ci:
name: CI
uses: ./.github/workflows/ci.yml
publish:
name: Publish image
needs: ci
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read
packages: write # push to ghcr.io with GITHUB_TOKEN
outputs:
digest: ${{ steps.push.outputs.digest }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- id: push
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
deploy:
name: Deploy to production
needs: publish
runs-on: ubuntu-24.04
timeout-minutes: 10
# Environment secrets, required reviewers and allowed tags are set in Settings → Environments
environment:
name: production
url: https://leads.example.com
steps:
- name: Trigger the hosting deploy hook
env:
DEPLOY_HOOK_URL: ${{ secrets.DEPLOY_HOOK_URL }}
REPOSITORY: ${{ github.repository }}
DIGEST: ${{ needs.publish.outputs.digest }}
run: |
image="ghcr.io/${REPOSITORY,,}@${DIGEST}"
echo "Deploying $image"
curl --fail-with-body --silent --show-error -X POST "$DEPLOY_HOOK_URL" \
--data-urlencode "image=$image"uses: ./.github/workflows/ci.ymlcalls CI as a reusable workflow (that is what theworkflow_callevent inci.ymlis for) on the same commit as the tag. According to the documentation, token permissions in such a chain can only be kept or reduced, never elevated.- The
publishjob is the only one withpackages: write:GITHUB_TOKENlogs in toghcr.io,docker/metadata-actionderives the1.2.0and1.2image tags from the git tag and lowercases the image name, andbuild-push-actionbuilds and pushes the image and returns its digest. environment: productionis an environment from Settings → Environments. Its secrets, such asDEPLOY_HOOK_URL, are available only to jobs that reference it. The same page configures required deployment reviewers and the tags and branches allowed to deploy to it.- The deployment references the image by digest (
@sha256:…) rather than by tag: a tag can be overwritten, a digest cannot.${REPOSITORY,,}in bash lowercases the repository name, as the registry requires. concurrencywithcancel-in-progress: false: two releases never run at once, and a started deployment is never cut off halfway.
git switch main && git pull
git tag -a v1.2.0 -m "Lead source field"
git push origin v1.2.0Exercises
How this was verified5 · passed3 · partialchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.