GitHub Actions CI/CD in Practice
GitHub Actions is one of the easiest CI/CD platforms for developers to adopt, but using it well takes care. This article covers workflow structure, triggers, jobs, steps, matrices, caching, artifacts, secrets, concurrency, costs, and a practical "frontend + backend + deployment" workflow for fast, reliable, quota-efficient CI.
Key Points
- Topic Focus: DevOps
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
CI/CD has enough tools to fill an entire trade show: Jenkins, GitLab CI, CircleCI, Drone, Argo CD, and many more. Over the past three years, however, I have moved the pipelines for nearly all my projects to GitHub Actions. Not because it is the most powerful, but because it has the lowest barrier to entry and sits closest to the code.
But "easy to learn" does not mean "easy to do well." There are more than a dozen hard-earned lessons between a workflow that merely runs and one that is fast, reliable, and economical. This article covers the pitfalls I have encountered and the practices I have adopted.
1. Why GitHub Actions
Three reasons, in order of importance.
First, it lives with the repository. Your .github/workflows/*.yml files are reviewed, versioned, and rolled back alongside your application code. Changing the pipeline does not require clicking around on another platform; you simply open a PR. This advantage is often underestimated: it means CI configuration receives all the benefits of code review. It is always clear who changed a deployment script or added a new secret. New contributors also need to read only one repository instead of switching between the Jenkins console and Confluence documentation.
Second, the free quota is sufficient for individuals and small teams. Public repositories are free without limit; private repositories receive 2,000 minutes per month on free accounts, while Pro includes 3,000. That allowance lasts a long time for most indie projects and small companies. Even after exceeding it, usage-based billing is still less work than maintaining a Jenkins server, which also requires someone to manage plugin upgrades, disk expansion, and elastic scaling of build nodes.
Third, the ecosystem is extensive. The Marketplace has thousands of actions. Installing Node, configuring AWS, or sending a Feishu notification takes a single uses: line. There is no need to write a pile of shell scripts for chores such as nvm install and aws configure. Officially maintained actions including actions/checkout, actions/setup-node, and actions/cache are high quality and safe to rely on.
Its drawbacks are equally clear: billing is based on minutes, with different multipliers for different operating systems (Linux 1x, Windows 2x, and macOS 10x); self-hosted runners have a learning curve; and concurrency queues can become a bottleneck for large monorepos. The sections below explain how to work around each issue.
2. The workflow skeleton: trigger / job / step / action
A workflow file comes down to four concepts: trigger (when it runs), job (a sequence of tasks on one machine), step (each operation within a job), and action (a reusable unit of steps).
name: CI
on:
push:
branches: [main]
paths-ignore: ['**/*.md', 'docs/**'] # 文档改动不触发,省额度
pull_request:
schedule:
- cron: '0 2 * * *' # 每天凌晨 2 点跑一次,适合扫依赖漏洞
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm test
Several practices I insist on:
- Use a versioned tag such as
actions/checkout@v4; do not use@main. If upstream ships a breaking update, your CI could suddenly fail. Pinning to a major version such as@v4provides a practical balance. Teams with stricter supply-chain security requirements should pin to a commit SHA. - Always declare
permissionsexplicitly and default to the minimum required permissions. GITHUB_TOKEN permissions used to be overly broad. GitHub changed the default token for new repositories to read-only in 2023, but an explicit declaration signals intent to future maintainers and prevents someone from casually changing it back. - Use
npm ci, notnpm install. The former installs strictly from the lockfile, making it faster and more reproducible, which is exactly what CI needs. The pnpm equivalent ispnpm install --frozen-lockfile. - Use
needsto connect multiple jobs into a DAG.needs: [lint, test]determines both execution order and dependencies: if an upstream job fails, the downstream job does not run, saving wasted execution time. You can also combineneedswithoutputsto pass values between jobs, such as an artifact version or image digest.
3. Matrix builds and conditional execution: two ways to put CI to work
A matrix lets one configuration run multiple combinations, such as multiple Node versions, operating systems, or dependency versions. The classic use case for an open-source library is testing Node 18, 20, and 22 simultaneously.
jobs:
test:
strategy:
fail-fast: false # 一个挂了别把其他也取消,我想看全貌
matrix:
node: [18, 20, 22]
os: [ubuntu-latest, windows-latest]
exclude:
- node: 18
os: windows-latest # 老版本在 Windows 上不再支持,排掉
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm test
Remember that windows-latest is billed at 2x, so do not add it without a reason. If you genuinely need Windows coverage, first consider whether a containerized Linux environment can handle the test. include adds variables to specific combinations, such as running an extra e2e suite only for the Node 22 job. exclude removes unsuitable combinations from the Cartesian product. Together, these keywords keep a matrix flexible without making it redundant.
Conditional execution (if) is another powerful tool. The most common cases are deploying only from the main branch and publishing a release only when a tag is pushed.
- name: Deploy
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: ./deploy.sh
Inside if, you can use a wide range of contexts: github.event_name, github.actor, steps.build.outputs.skip, and even contains(github.event.head_commit.message, '[skip ci]'). I often use the last one to support the convention that including [skip ci] in a commit message skips CI. One detail to remember: string comparisons in an if expression need fully quoted strings, and it is best to leave spaces around &&; otherwise, the parser can behave strangely.
4. Caching and artifacts: the keys to faster CI
When CI is slow, 90% of the time is spent installing dependencies and repeating builds. Caching can drastically reduce both. A cold installation that takes 90 seconds can finish in eight seconds with a cache hit. The difference is that large.
actions/setup-node@v4 includes npm caching; you only need to enable it:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # 自动缓存 ~/.npm
pnpm projects need a little manual setup:
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
The more general solution is actions/cache@v4, where you specify the path and key yourself. The key must include the lockfile hash. Otherwise, dependency changes can still hit an old cache, defeating the point of caching. This bug is particularly insidious: after upgrading a dependency, you may inexplicably test against an older version of the code.
- uses: actions/cache@v4
with:
path: |
~/.pnpm-store
.next/cache
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
restore-keys provides fallback prefix matching. When the exact key misses, it falls back to the next-best match, which is much faster than a cold start. Build caches such as .next/cache and Vite's node_modules/.vite are also worth storing; they can cut a subsequent build's duration in half.
An artifact passes build outputs between jobs or stores build results for later download. A typical frontend workflow has the build job produce .next or dist, then lets the deploy job download and deploy it:
- name: Upload build
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
retention-days: 7 # 别无限期存,artifact 也占额度
Use actions/download-artifact@v4 to retrieve it in a downstream job. Starting with v4, artifacts cannot be shared across workflows by default, so they must be passed within the same workflow. Always set retention-days. The default is 90 days, and keeping debugging artifacts for three months can produce a surprising storage bill.
5. Secrets and environments: never hard-code a token in YAML
Rule number one: no secret should ever appear in YAML or application code. Once pushed to a repository, it remains in Git history even if you delete it later, and scanners can find it within seconds. GitHub also scans for secrets. When it finds one, it automatically notifies the cloud provider to revoke the token, but by then you are already responding to an incident. That is not a safeguard you should depend on.
The correct approach is to add the secret under Settings → Secrets and variables → Actions in the repository, then reference it in the workflow with ${{ secrets.XXX }}. Always pass it through env; do not interpolate it into the run command string:
- name: Deploy
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
run: ./deploy.sh # 脚本内部读 $DEPLOY_KEY,日志里会自动打码
Environments are an advanced tool. You can protect production with a stricter set of secrets, require manual approval, and restrict branches:
jobs:
deploy:
environment:
name: production
url: https://example.com
runs-on: ubuntu-latest
When configuring an environment, the UI lets you require reviewer approval before deploying to production, allow triggers only from the main branch, or wait one hour before continuing. This capability is available even on the free tier, yet nine out of ten teams do not use it. Adding an approval gate to production deployments can save you when it matters most.
The next step is OIDC keyless authentication. The traditional approach stores an AWS Access Key in a secret. It remains valid for a long time, often has broad permissions, and creates a serious incident if leaked. OIDC lets GitHub obtain a one-time token from the cloud provider. It expires when the workflow finishes, and the repository contains no long-lived credentials at all. AWS, GCP, and Azure all support it. Configure it once and benefit indefinitely. In 2026, this should be the baseline for every team that takes security seriously.
6. Concurrency, timeouts, and retries: reliable CI without wasting quota
Concurrency control (concurrency) is critical for saving money. When the same branch receives repeated pushes, the old CI run should be canceled so only the latest one continues:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
This is especially valuable for PRs. If a developer pushes ten times in one day and old runs remain active, the quota disappears quickly. Group naming matters: workflow-ref gives branch-level granularity and works for most situations. Sensitive operations such as deployments should not be canceled, because cancellation could interrupt a deployment halfway through. In that case, cancel-in-progress: false is safer.
Timeouts (timeout-minutes) are mandatory. A hung job can run for six hours by default and consume your entire monthly quota. I once saw a project where a third-party API failure left a deployment job stuck for five hours, wiping out the month's free allowance. Set a reasonable upper bound for every job: 15 minutes for CI, 20 minutes for deployments, and 30 minutes for e2e tests. Killing the job and reporting an error is better than leaving it running.
For failure retries, use strategy.retry-attempts for the entire job or implement retries in a step-level script. One retry for occasional flaky failures in third-party dependencies can save a lot of time otherwise spent manually rerunning CI. Do not overuse it. If a test is frequently flaky, the root cause is the test itself. Adding retries merely sweeps the problem under the rug, where someone will eventually trip over it.
7. Hands-on: a workflow you can copy directly
The following is my current standard setup: quality checks, tests, build, and deployment, all with cost controls. Remove the sensitive information and it is ready to use:
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
quality:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 9 }
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 9 }
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'pnpm' }
- run: pnpm install --frozen-lockfile
- run: pnpm test -- --coverage
build-and-deploy:
needs: [quality, test]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 20
environment: production
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 9 }
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'pnpm' }
- run: pnpm install --frozen-lockfile
- run: pnpm build
- name: Configure AWS
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::xxx:role/github-actions # OIDC 无密钥
aws-region: ap-northeast-1
- run: ./scripts/deploy.sh
The essence of cost control is this line: if: github.ref == 'refs/heads/main'. Deployment runs only on pushes to the main branch. PRs neither upload build artifacts nor deploy. needs: [quality, test] ensures the first two jobs pass before deployment begins, preventing wasted runs. The quality and test jobs run in parallel, so the total duration is max(quality, test) + deploy rather than the sum of all three. The first principle of pipeline design is: "Never serialize work that can run in parallel."
Here is another cost-saving lesson: do not run the full e2e suite on PRs. A Playwright or Cypress run takes several minutes. Ten PR runs per day can consume dozens of minutes. Instead, run only unit tests and critical smoke tests on PRs, then run the full e2e suite once on a nightly schedule for the main branch. Quality remains protected without exhausting the quota. Likewise, lint and typecheck can use paths filters so documentation-only changes do not trigger them.
Summary
- Keep the workflow's four building blocks straight: trigger (when it runs), job (a sequence of tasks on one machine), step (each operation), and action (a reusable unit). Pin action versions, declare
permissionsexplicitly, usenpm cior--frozen-lockfilein CI, and connect jobs into a DAG withneeds. - Use matrices for versions and
iffor conditions: use a matrix for multiple Node versions, and useifto restrict deployments and releases by branch and event. Windows is billed at 2x, so do not add it without a reason. - Caching and artifacts are the two main tools for speed: setup actions include caching, cache keys should include the lockfile hash, and
restore-keysprovides prefix fallbacks. Remember to setretention-daysfor artifacts. - Secrets never enter the code, environments add production approvals, and OIDC provides keyless authentication: these three practices are the security baseline in 2026, and they are available on the free tier.
- Concurrency cancellation + timeouts + layered testing = the quota-saving trio:
cancel-in-progresseliminates redundant runs,timeout-minutesprevents hung jobs from wasting money, and PRs avoid the full e2e suite.
In one sentence: good CI is not merely "able to run"; it runs only when it should, runs quickly, and does not take others down when it fails. Treat the pipeline as code: it should be reviewed, optimized, and respected just like business logic.
Related
Related Content
Article
Considerations for Deploying a Next.js Community Project with Docker
Technical trends, engineering practices, and an independent developer perspective on DevOps and Next.js.
Article
How Can OpenTelemetry Help You Observe AI Applications?
Technical trends, engineering practices, and an independent developer's perspective on DevOps and AI.
Article
Long-Running Tool Calls: The Terminal-State Protocol for Operation, Poll, and Cancel
After the initial request times out, the tool may still be running server-side; issuing a cancel does not mean the side effects have stopped. This article establishes an operation identity, polling watermark, cancellation race handling, and a single terminal-state acceptance point for a single long-running tool call.
Article
Agent Memory Erasure Propagation: From User Forgetting Request to Verifiable Purge
Deleting one row in the primary table won't clear vectors, summaries, caches, or restored backups. This article uses a deletion state machine, generational tombstones, derived lineage, and end-to-end canary verification to truly forget.