#DevOps

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
Author @admin·Published July 26, 2026·Updated July 26, 2026

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).

yaml
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 @v4 provides a practical balance. Teams with stricter supply-chain security requirements should pin to a commit SHA.
  • Always declare permissions explicitly 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, not npm install. The former installs strictly from the lockfile, making it faster and more reproducible, which is exactly what CI needs. The pnpm equivalent is pnpm install --frozen-lockfile.
  • Use needs to 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 combine needs with outputs to 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.

yaml
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.

yaml
- 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:

yaml
- uses: actions/setup-node@v4
  with:
    node-version: '20'
    cache: 'npm'           # 自动缓存 ~/.npm

pnpm projects need a little manual setup:

yaml
- 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.

yaml
- 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:

yaml
- 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:

yaml
- 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:

yaml
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:

yaml
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:

yaml
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 permissions explicitly, use npm ci or --frozen-lockfile in CI, and connect jobs into a DAG with needs.
  • Use matrices for versions and if for conditions: use a matrix for multiple Node versions, and use if to 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-keys provides prefix fallbacks. Remember to set retention-days for 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-progress eliminates redundant runs, timeout-minutes prevents 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