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. This article covers 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.

๐Ÿค” Sound familiar?
  • 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 cut pipeline times by 40%+ and eliminate credential rotation toil.

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@v4
      - 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
    # Run in parallel with validate โ€” doesn't wait for it
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm test --coverage
      - uses: codecov/codecov-action@v4

  build:
    needs: [validate, test]
    runs-on: ubuntu-latest
    outputs:
      image-tag: ${{ steps.meta.outputs.tags }}
    steps:
      - uses: actions/checkout@v4
      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=sha,prefix=,suffix=,format=short
      - uses: docker/build-push-action@v5
        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@v4
      
      # Exchange GitHub token for AWS credentials โ€” no secrets stored in GitHub
      - uses: aws-actions/configure-aws-credentials@v4
        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@v4
      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ inputs.image-name }}
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.registry-token }}
      - uses: docker/build-push-action@v5
        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: ['18', '20', '22']
        os: [ubuntu-latest, windows-latest]
        exclude:
          - os: windows-latest
            node: '18'  # skip this combination
    
    runs-on: ${{ matrix.os }}
    name: Test (Node ${{ matrix.node }} on ${{ matrix.os }})
    
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
      - run: npm test

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.