Thuta Learning
ProjectsDevOps & Toolsbeginner

Project: Staging to Production with Approval

What you'll walk away with

  • Explain the core ideas behind Project: Staging to Production with Approval
  • Read the diagram and trace how the pipeline flows and what gates each stage
  • Read the workflow file and predict which jobs run, in what order, and what gates them

Build the mental model

Build once, deploy everywhere is the single idea this pipeline exists to teach, and it is worth stating as a rule: the bytes that reach production must be the identical bytes that passed staging. The tempting alternative, a build step inside each deploy job, looks harmless and is not. Two builds from the same commit can still differ. A transitive dependency published a new patch, a base image moved, a timestamp got embedded, an install without a lockfile resolved differently. Once that is possible, staging stops being evidence. You tested one binary and shipped another.

The mechanics come from pieces you already met. The build job compiles once and uploads dist as an artifact, then exposes the artifact name and a short version string through job outputs, because a downstream job cannot see another job's environment variables -- only its declared outputs. Both deploy jobs use needs to read those outputs and to sequence themselves, and both begin with download-artifact rather than a build step. Checkout still appears, but only to fetch the deploy scripts; the application bytes come from the artifact, never a rebuild.

Between them sits the smoke check, a job whose whole purpose is to fail. It requests a health endpoint and asserts the version it reports matches the version just deployed, catching the deploy that reported success without actually replacing anything.

The gate is the environment key. Naming environment: production makes the job wait when that environment has required reviewers, and the run pauses there until a human approves in the GitHub UI. Environments also scope secrets, so the production deploy credential is readable only by a job that has cleared the approval. Approval and credential live in the same place, which is why this gate cannot be bypassed by editing the workflow file alone.

text
BUILD ONCE, PROMOTE THROUGH ENVIRONMENTS
----------------------------------------
             +--------------------------+
             |          build           |
             |  npm ci && npm run build |
             |  outputs:                |
             |    artifact-name         |
             |    version               |
             +--------------------------+
                          |
                          |  upload-artifact: app-<sha12>
                          |  (built exactly once, here, forever)
                          v
             +--------------------------+
             |     deploy-staging       |  environment: staging
             |  download-artifact       |  no reviewers -> automatic
             +--------------------------+
                          |  needs
                          v
             +--------------------------+
             |     smoke-staging        |  GET /healthz
             |  version must match      |  red here stops the line
             +--------------------------+
                          |  needs
                          v
  =========================================================
  = APPROVAL GATE - environment: production                =
  = the run pauses; a required reviewer clicks Approve     =
  = environment-scoped secrets unlock only after that      =
  =========================================================
                          |
                          v
             +--------------------------+
             |    deploy-production     |  download-artifact
             |  the SAME app-<sha12>    |  no rebuild anywhere
             +--------------------------+

Connect it to a real scenario

Running this workflow requires two environments in the repository settings: staging and production. Staging has no protection rules. Production has required reviewers, and DEPLOY_TOKEN is stored as a secret scoped to that environment rather than to the repository. That distinction carries real weight: only when the production credential is an environment secret is it genuinely unreadable by any job that has not cleared the gate.

The build job derives version from the first twelve characters of the commit SHA, names the artifact app-<sha12>, and publishes both as job outputs. Three jobs reference that name. Sharing one output instead of retyping the same string in three places is what makes single-artifact promotion a structural guarantee rather than a convention someone can quietly break.

Notice that the smoke-staging job passes the URL and the version in through env rather than interpolating ${{ }} directly into the run line. Expressions expanded straight into a shell command are a script injection surface; routing them through environment variables is the habit to build early.

The production stakes are in deploy-production's needs: [build, smoke-staging]. build is listed so the job can read the artifact-name output; smoke-staging is listed so the job proceeds only if staging is genuinely healthy. If the smoke check goes red, the production job is skipped without ever requesting an approval, which means no one is ever paged to approve a build that already failed.

Try the working example

yaml
name: Deploy

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read

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

jobs:
  build:
    name: Build once
    runs-on: ubuntu-latest
    outputs:
      artifact-name: ${{ steps.meta.outputs.artifact-name }}
      version: ${{ steps.meta.outputs.version }}
    steps:
      - uses: actions/checkout@v4
      - name: Set up Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: npm
      - run: npm ci
      - run: npm run build
      - name: Name this build
        id: meta
        run: |
          version="${GITHUB_SHA:0:12}"
          echo "version=$version" >> "$GITHUB_OUTPUT"
          echo "artifact-name=app-$version" >> "$GITHUB_OUTPUT"
      - name: Upload the one and only build
        uses: actions/upload-artifact@v4
        with:
          name: ${{ steps.meta.outputs.artifact-name }}
          path: dist/
          retention-days: 14
          if-no-files-found: error

  deploy-staging:
    name: Deploy to staging
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: https://staging.example.com
    steps:
      - name: Check out deploy scripts only
        uses: actions/checkout@v4
      - name: Download the built artifact
        uses: actions/download-artifact@v4
        with:
          name: ${{ needs.build.outputs.artifact-name }}
          path: dist
      - name: Deploy
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
          DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
        run: ./scripts/deploy.sh dist "$DEPLOY_HOST"

  smoke-staging:
    name: Smoke check staging
    needs: [build, deploy-staging]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Assert staging is serving this build
        env:
          TARGET_URL: https://staging.example.com
          EXPECTED_VERSION: ${{ needs.build.outputs.version }}
        run: ./scripts/smoke.sh "$TARGET_URL" "$EXPECTED_VERSION"

  deploy-production:
    name: Deploy to production
    needs: [build, smoke-staging]
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://app.example.com
    steps:
      - name: Check out deploy scripts only
        uses: actions/checkout@v4
      - name: Download the same artifact staging ran
        uses: actions/download-artifact@v4
        with:
          name: ${{ needs.build.outputs.artifact-name }}
          path: dist
      - name: Deploy
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
          DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
        run: ./scripts/deploy.sh dist "$DEPLOY_HOST"
      - name: Assert production is serving this build
        env:
          TARGET_URL: https://app.example.com
          EXPECTED_VERSION: ${{ needs.build.outputs.version }}
        run: ./scripts/smoke.sh "$TARGET_URL" "$EXPECTED_VERSION"
You should see
A push to main starts one job, build. It installs dependencies, builds the project, derives a version from the commit SHA, uploads dist as an artifact named app-<sha12>, and publishes artifact-name and version as job outputs. If dist is empty, if-no-files-found: error stops the run right there rather than promoting nothing.

deploy-staging then starts automatically, because the staging environment has no reviewers. It does not rebuild anything: it downloads that artifact and runs the deploy script. smoke-staging follows, calling the health endpoint and checking that the version it reports matches the version just deployed. If they differ the job goes red and the pipeline stops there.

If the smoke check passes, deploy-production does not run. It enters a waiting state pending approval. The job does not start, and the environment-scoped secrets are not made available to it, until a required reviewer approves in the GitHub UI. Once approved, the job proceeds, downloads the very same artifact staging validated, deploys it, and runs its own smoke check against production. If a reviewer rejects the deployment or the wait times out, the run ends there and nothing in production changes. At every stage the only thing moving forward is one artifact, produced once, in the build job.

5-minute try-it

Create both environments and install this pipeline in a repository, adding yourself as a required reviewer on production. Push, watch where the run parks itself, and reject the deployment once: note what state deploy-production ends in and what happens to the rest of the run. Then edit smoke.sh so it always fails and push again, confirming that this time no approval is ever requested. As a final exercise, write down in plain sentences exactly which guarantees you would lose if you added npm run build back into deploy-production -- and then check whether your existing deploy script would even notice the difference.

One important caution

Rebuilding separately inside each deploy job, so production ships a binary staging never tested. One dependency that moved between the two builds is enough to put a bug into production that staging could never have caught.

Storing the production deploy secret as a repository secret rather than an environment secret. The approval gate still appears in the UI, but any job in the workflow, including ones that never passed the gate, can read the credential -- which makes the gate decorative.

GitHub Docs - Using environments for deploymentCI/CD with GitHub Actions

Easy traps

  • Rebuilding separately inside each deploy job, so production ships a binary staging never tested. One dependency that moved between the two builds is enough to put a bug into production that staging could never have caught.
  • Storing the production deploy secret as a repository secret rather than an environment secret. The approval gate still appears in the UI, but any job in the workflow, including ones that never passed the gate, can read the credential -- which makes the gate decorative.
  • Validate a workflow on a branch or test repository before pointing it at a production deployment.

Exercise

Create both environments and install this pipeline in a repository, adding yourself as a required reviewer on production. Push, watch where the run parks itself, and reject the deployment once: note what state deploy-production ends in and what happens to the rest of the run. Then edit smoke.sh so it always fails and push again, confirming that this time no approval is ever requested. As a final exercise, write down in plain sentences exactly which guarantees you would lose if you added npm run build back into deploy-production -- and then check whether your existing deploy script would even notice the difference.

You'll know it worked when: A push to main starts one job, build. It installs dependencies, builds the project, derives a version from the commit SHA, uploads dist as an artifact named app-<sha12>, and publishes artifact-name and version as job outputs. If dist is empty, if-no-files-found: error stops the run right there rather than promoting nothing. deploy-staging then starts automatically, because the staging environment has no reviewers. It does not rebuild anything: it downloads that artifact and runs the deploy script. smoke-staging follows, calling the health endpoint and checking that the version it reports matches the version just deployed. If they differ the job goes red and the pipeline stops there. If the smoke check passes, deploy-production does not run. It enters a waiting state pending approval. The job does not start, and the environment-scoped secrets are not made available to it, until a required reviewer approves in the GitHub UI. Once approved, the job proceeds, downloads the very same artifact staging validated, deploys it, and runs its own smoke check against production. If a reviewer rejects the deployment or the wait times out, the run ends there and nothing in production changes. At every stage the only thing moving forward is one artifact, produced once, in the build job.

Project: Staging to Production with Approval | Thuta Learning