Matrix Builds and Deployment Gates

Matrix: one job definition, many parallel variants

jobs:
  test:
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest]
        node: ["18", "20"]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
      - run: npm test
graph TD def["1 job definition:<br/>os x node"] --> j1["ubuntu-latest + node 18"] def --> j2["ubuntu-latest + node 20"] def --> j3["macos-latest + node 18"] def --> j4["macos-latest + node 20"]

A matrix with two dimensions of two values each expands into 4 actual parallel jobs — every combination — without writing four separate job blocks. This is the direct way to confirm something works across every OS/version combination a project supports, instead of testing only one and hoping the others behave the same.

Deployment environments and protection rules

jobs:
  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment: production
    steps:
      - run: ./deploy.sh
graph TD build["build job passes"] --> gate{"environment: production<br/>has protection rules"} gate -->|"required reviewer approves"| deploy["deploy job runs"] gate -->|"no approval yet"| wait["deploy job PAUSED,<br/>waiting"]

Declaring environment: production on a job lets that environment carry protection rules configured in the repository settings — commonly, a required reviewer who must manually approve before the job proceeds, even though the workflow already triggered and the build job already passed. This is the concrete mechanism behind "code is merged and CI is green, but a human still approves the actual production deploy" — the gate lives in the deployment job's environment configuration, not in the trigger or the build step.

Secrets

steps:
  - run: ./deploy.sh
    env:
      API_KEY: ${{ secrets.PROD_API_KEY }}

Secrets configured in repository (or environment-specific) settings are injected as environment variables at run time — never checked into the workflow YAML itself, and GitHub automatically masks a secret's value in logs if it ever appears in output, replacing it with ***. Environment-scoped secrets (rather than repository-wide) let a PROD_API_KEY only be accessible to jobs explicitly running under the production environment — a staging deploy job simply cannot read it, which is a real access boundary, not just an organizational convention.

Concurrency: cancelling superseded runs

concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: true

Without this, pushing three commits in quick succession to the same branch queues three full deploy runs, potentially deploying an older commit after a newer one if they finish out of order. concurrency groups runs by a key (here, the branch ref) and cancel-in-progress automatically cancels an already-running deploy for that same group the moment a newer one starts — ensuring the last triggered run is the one that actually completes and deploys, not whichever happened to finish first.

Common pitfall

Relying on required-reviewer approval as the only safeguard before a production deploy, without also gating on the actual build/test results via needs:, can let a human approve a deploy whose underlying build silently failed or was skipped — the approval gate and the "did the code actually pass CI" gate are two separate mechanisms, and forgetting to wire needs: build (or equivalent) into the deploy job means the environment's protection rule is guarding against the wrong risk, approving when to deploy without actually confirming what's being deployed is sound.