The rule fits in one sentence: what goes to production must be exactly what was validated in staging. That forbids rebuilding between the two steps, and requires designating the artifact by its digest rather than by a tag. This page builds the matching promotion chain, with the provenance verification right before the deployment.
What you will learn
- Identify the anti-pattern of rebuilding per environment, and what it breaks
- Promote an image by its immutable digest, from staging to production
- Carry a file artifact between two distinct workflows
- Verify the attestation of the artifact before deploying it
- Parameterise a manual redeployment on a precise version
The anti-pattern: rebuilding at every step
The naive pipeline rebuilds for every target, because that is what stacking workflows produces:
# ❌ Each environment rebuilds its own imagejobs: deploy-staging: environment: staging steps: - run: docker build -t my-app:staging . && ./deploy.sh staging
deploy-production: needs: deploy-staging environment: production steps: - run: docker build -t my-app:prod . && ./deploy.sh productionThree guarantees fall at once. First reproducibility: the two builds resolve their dependencies at different moments, so nothing proves they produce the same binary. Then the value of the validation: the staging tests ran against an image nobody holds any more. Finally provenance: the attestation signed at release time does not cover the rebuilt image, and the production verification will fail or, worse, be removed from the pipeline to make it pass.
The digest, the only reliable address
A container tag is mutable: my-app:1.4.2 can be republished. A digest
is the cryptographic fingerprint of the content, in the form sha256:...: it
designates one and only one assembly of bytes.
my-app:latest -> can change at any momentmy-app:1.4.2 -> can be republished under the same tagmy-app@sha256:9f2a... -> immutable, it is the artifact itselfThe promotion chain therefore carries a digest, never a tag. The tag stays useful for humans, it never serves as an execution reference.
Build once, expose the digest
The build job publishes the image and exports its digest as a job output. That is what the release workflow of the reference repository already does, and it is what makes the rest possible:
jobs: build: runs-on: ubuntu-24.04 timeout-minutes: 20 permissions: packages: write # push to GHCR id-token: write # keyless OIDC for the provenance attestations: write # native attestation outputs: digest: ${{ steps.build.outputs.digest }} env: IMAGE: ghcr.io/my-org/my-app steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Login GHCR uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4.5.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push id: build uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . push: true tags: ${{ env.IMAGE }}:${{ github.sha }} - name: Provenance attestation uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: subject-name: ${{ env.IMAGE }} subject-digest: ${{ steps.build.outputs.digest }} push-to-registry: trueThe line that counts is outputs.digest. Without it, the following jobs have no
way to designate the produced image other than by a tag, and the whole chain
falls back onto a mutable reference.
Promoting to staging, then to production
Both deployment jobs consume the same digest, and differ only in their environment. The production job depends on staging, which guarantees the order and triggers the protection rules of its environment.
deploy-staging: needs: build runs-on: ubuntu-24.04 environment: name: staging url: https://staging.my-app.example.com env: IMAGE: ghcr.io/my-org/my-app DIGEST: ${{ needs.build.outputs.digest }} steps: - name: Verify the provenance before deploying env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: gh attestation verify "oci://${IMAGE}@${DIGEST}" --owner my-org - name: Deploy to staging run: ./deploy.sh "${IMAGE}@${DIGEST}" env: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
deploy-production: needs: [build, deploy-staging] runs-on: ubuntu-24.04 environment: name: production # approval required on this environment url: https://my-app.example.com env: IMAGE: ghcr.io/my-org/my-app DIGEST: ${{ needs.build.outputs.digest }} steps: - name: Verify the provenance before deploying env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: gh attestation verify "oci://${IMAGE}@${DIGEST}" --owner my-org - name: Deploy to production run: ./deploy.sh "${IMAGE}@${DIGEST}" env: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}The DEPLOY_TOKEN is not the same in both jobs: each reads the one from its
environment. The workflow itself is written once.
Carrying a file artifact between workflows
Not everything ships as an image. For a static site, a binary or an archive, the
artifact travels through upload-artifact then download-artifact. Inside a
single workflow the download is direct. From one workflow to another, you
have to designate the originating run and supply a token:
- name: Fetch the artifact from the build workflow uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: dist path: dist/ run-id: ${{ github.event.workflow_run.id }} github-token: ${{ secrets.GITHUB_TOKEN }}Two limits to keep in mind. Artifacts have a default retention of 90 days: beyond that, the promoted artifact no longer exists, and a late rollback will have to go through a rebuild. And an artifact is not signed merely by being stored: it is the attestation produced at build time that makes it verifiable, not its location.
Redeploying a precise version
The automatic chain covers the nominal case. You also need to be able to choose the deployed version, to replay a production release or to go back. A manual trigger parameterised by the digest is enough:
on: workflow_dispatch: inputs: digest: description: "Digest of the image to deploy (sha256:...)" required: true type: string
permissions: {}
jobs: deploy: runs-on: ubuntu-24.04 environment: name: production url: https://my-app.example.com permissions: contents: read env: IMAGE: ghcr.io/my-org/my-app DIGEST: ${{ inputs.digest }} steps: - name: Verify the provenance of the requested digest env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: gh attestation verify "oci://${IMAGE}@${DIGEST}" --owner my-org - name: Deploy run: ./deploy.sh "${IMAGE}@${DIGEST}" env: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}The input is data supplied by a human, therefore untrusted by principle: it
travels through an env: block and is never interpolated straight into the
command. The attestation check plays a second role here, refusing a digest that
would not come from your own workflows.
The complete chain
-
Build once and publish the artifact with its provenance attestation.
-
Export the digest as a job output, the only reference carried onwards.
-
Deploy to staging while verifying the provenance right before.
-
Wait for the approval carried by the rules of the production environment.
-
Deploy the same digest to production, after a fresh verification.
Key points
- Rebuilding between staging and production breaks reproducibility, cancels the value of the tests and invalidates the provenance attestation.
- The
sha256:...digest is the only immutable address of an artifact; the tag serves humans, never execution. - The build job exports its digest as an output, and every deployment job consumes that same value.
- A single deployment workflow serves both targets: it is the
environment:that changes which secrets are read. - Verify the attestation right before every deployment, production included, because an approval may have taken a long time.
- File artifacts travel between workflows with
run-idand a token, and disappear after 90 days by default. - Manual redeployment is parameterised by the digest, treated as untrusted input, through
env:.
Next steps
- Rollback and concurrency: replaying the previous digest, and stopping two deployments from treading on each other.
- Lab: promoting an approved deployment: the complete chain assembled on the reference repository, attested image included.
- Sharing between jobs (artifacts): the transport mechanism in detail, when the promoted artifact is a set of files rather than an image.