Who this is for
You have a repository on GitHub and tests that currently run on somebody's laptop. You do not need Docker, a cloud account, or a self-hosted runner to follow this.
The pieces
| Term | What it is |
|---|---|
| Workflow | One YAML file, triggered by an event |
| Job | A set of steps on one runner, isolated from other jobs |
| Step | One command, or one call to a published action |
| Runner | The machine that executes a job |
Jobs run in parallel by default and get a fresh runner each. Two jobs do not
share a filesystem — if the second needs something the first built, that is
what artifacts and needs: are for.
A workflow that earns its keep
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
# Least privilege, set explicitly. Without this block the job gets whatever the
# repository default is, which is frequently more than a test run needs.
permissions:
contents: read
# A second push to the same pull request cancels the first. Without it a busy
# branch queues runs nobody is waiting for any more. Pushes to main are never
# cancelled, so a second merge cannot stop a deploy halfway through.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: "npm"
- run: npm ci
- run: npm run lint
- run: npm test
- run: npm run build
Four details worth the space:
npm ci rather than npm install. It installs exactly what the lockfile says
and fails if package.json and the lockfile disagree. install will quietly
resolve a newer version, which is how a build passes locally and fails here —
or worse, passes here and ships something nobody tested.
cache: "npm" in setup-node already caches the npm download directory, keyed
on your lockfile. You usually do not need a separate actions/cache step as
well; adding one often just caches the same directory twice.
timeout-minutes because the default is generous. A hung test does not fail —
it occupies a runner until something eventually stops it.
concurrency because the alternative is paying for runs whose results are
already stale.
Integration tests need a database
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: testdb
POSTGRES_PASSWORD: testpass
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql://postgres:testpass@localhost:5432/testdb
The --health-cmd block is not decoration. Without it the container is
"started" the moment Docker returns, and your first test connects before
Postgres is accepting connections. That failure is intermittent, which makes it
expensive — it gets re-run, it passes, and nobody investigates.
Deploying only after tests pass
deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production
steps:
- uses: actions/checkout@v7
- name: Deploy
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: ./scripts/deploy.sh
needs: test makes the dependency real; if: keeps it off pull requests.
environment: is the one that is easy to skip, and it is where required reviewers
and environment-scoped secrets live — so a token that can reach production is
not readable by every other job in the repository.
The security part, which is most of it
Secrets and forks. A pull_request run from a fork does not receive
your secrets. That is a deliberate protection: the code in that PR is written
by someone who does not have write access. If a workflow appears to break only
for outside contributors, this is usually why, and the answer is to split the
job, not to reach for pull_request_target — which runs in the base
repository's context with secrets. It runs the base branch's workflow, and
it becomes dangerous the moment that workflow checks out and builds the fork's
code — a combination that has been the root of real compromises.
Pin actions to a commit SHA.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
A tag is a moving pointer. Whoever controls the action's repository can move
v4 to different code, and your workflow will run it on the next trigger
without a commit on your side. A SHA cannot be moved. Dependabot updates these,
so the cost is a pull request rather than vigilance.
Set permissions: explicitly, as in the workflow above, and add only what
each job needs — id-token: write for OIDC, packages: write to publish.
Stop storing long-lived cloud keys. OIDC lets a workflow exchange a short-lived GitHub token for cloud credentials, so there is no secret to leak or rotate. When the trust policy is wrong it fails in one specific way, which this site covers: Not authorized to perform sts:AssumeRoleWithWebIdentity.
Matrix builds
strategy:
fail-fast: false
matrix:
node-version: [22, 24, 26]
fail-fast: false is worth setting deliberately. The default cancels the whole
matrix when one entry fails, which is efficient and hides whether the failure
is version-specific — usually the one thing you ran a matrix to find out.
ubuntu-latest is changing under this workflow
Added 22 October 2026. Both jobs above run on ubuntu-latest, and GitHub is
moving that label from Ubuntu 24.04 to Ubuntu 26.04 between 19 October and
19 November 2026. Most workflows notice nothing. Three changes can reach this
one:
- /tmp is a RAM disk with a quota on 26.04 images built before a fix merged
on 20 October 2026. A job that writes gigabytes to
/tmpcan fail with "disk quota exceeded" while the disk still has space. Make the first stepecho "TMPDIR=$RUNNER_TEMP" >> "$GITHUB_ENV"so later steps write to the work disk instead. - Containers get 1024 open files instead of 1048576. Docker 29 uses
systemd's default limit, and prints nothing about it. A busy database in a
service container can fail later with "Too many open files". Raise it with
Docker's
--ulimit nofile=...option where the container is started. - Preinstalled versions jump:
python3from 3.12 to 3.14, the default Node.js from 22 to 24, Docker from 28 to 29, Compose from v2 to v5, Helm from 3 to 4, PostgreSQL from 16 to 18. The workflow above picks its Node.js version withactions/setup-node, so the new default does not affect it; anything that relied on a preinstalled tool should pin it the same way.
To keep the old image while you test, change the label to
runs-on: ubuntu-24.04. The full list, each item with the check that shows
whether it affects you, is in the
ubuntu-latest 26.04 notes, and the
/tmp failure has its own page:
disk quota exceeded in /tmp on Ubuntu 26.04.
Common mistakes
- Secrets echoed into logs. GitHub masks known secret values, but
redaction relies on an exact match, so it cannot reliably mask one you
derived — base64 it, URL-encode it, or pull one field out of a JSON
secret, and it can appear in plain text in a log anyone with read access
can see. Register a derived value with
::add-mask::before it is printed. ${{ ... }}interpolated straight intorun:. The expression is substituted into the shell script before it runs, so a value containing shell metacharacters is executed. Pass it throughenv:and reference"$VAR"instead. Branch names and issue titles are attacker-controlled.- Caching the wrong thing. A cache keyed on something that rarely changes is a cache that serves stale dependencies. Key on the lockfile hash.
- Treating a green check as a deploy gate without branch protection. The check is advisory until a rule requires it.
If this did not work
- The workflow does not run at all. Check the file is on the default branch
and under
.github/workflows/. A workflow added only on a feature branch will not trigger forpushevents on other branches. - "Resource not accessible by integration." The job's
permissions:are narrower than what a step needs. Add the single scope, notwrite-all. - It passes locally and fails here. Usually an environment difference — Node version, a missing env var, or a test depending on files Git does not track. Print the versions in a step before assuming the runner is at fault.
- Intermittent failures on a service container. See the health-check note above.
Next steps
Turn on branch protection requiring this workflow to pass, then add a second workflow on a schedule to run the same tests against the latest dependency versions — that catches an upstream break on a day you changed nothing, rather than during a release.
Related on this site
- Not authorized to perform sts:AssumeRoleWithWebIdentity — Keyless AWS auth from Actions, and why the trust policy refuses it.
- CI/CD security risks — The OWASP top ten for pipelines, each with what it looks like in practice.
- Why secrets do not belong in Git — Before you put one in a workflow file.
Mentioned in this article
- AWS IAM explainedAWS guide
- GitHub Actions OIDC Trust Policy CheckerTool · Runs in the browser
- Too many open files — LinuxProblem · Linux
Tagged with
Enjoyed this article?
Get more DevOps insights delivered to your inbox.
Get new posts by email
Subscribe to get an email when a new blog post is published. Skip anytime.
No spam, unsubscribe anytime.
Terms used here (4)plain definitions
Short definitions for the vocabulary on this page, in case any of it is new. Each links to the full entry.
- CI/CD
- Continuous Integration and Continuous Delivery/Deployment — the practice of automating the build, test, and release process. CI merges code changes frequently and runs automated tests. Continuous delivery keeps every change that passes in a releasable state, so it can go to production at any time; continuous deployment goes one step further and releases every passing change to production automatically.
- DevOps
- A way of building and running software in which the people who write it and the people who operate it share responsibility for how it behaves in production, and automate the path between the two. In practice that means small changes merged often (continuous integration), an automated route to production (continuous delivery), infrastructure described in code, and monitoring that tells the team quickly when something breaks. It is a set of practices and a way of working rather than a single tool. DORA's research measures it with four numbers: how often a team deploys, how long a change takes to reach production, how often a deployment fails, and how quickly the team recovers when one does.
- Jenkins
- An open-source automation server widely used for building CI/CD pipelines. Jenkins supports hundreds of plugins for integration with virtually every tool in the DevOps ecosystem, though it requires more operational overhead than newer alternatives.
- Pipeline
- An automated sequence of stages that code passes through from commit to production. A typical pipeline includes building, testing, security scanning, staging deployment, and production release, with gates between each stage.
Related Posts
Discussion
0 comments
Sign in to join the conversation.
Be the first to comment
Start a conversation about this post



