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

