Skip to content
Français
CI/CD & Automatisation medium

Promoting an artifact: build once, deploy twice

30 min de lecture

Read this page in French

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 image
jobs:
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 production

Three 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 moment
my-app:1.4.2 -> can be republished under the same tag
my-app@sha256:9f2a... -> immutable, it is the artifact itself

The 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: true

The 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

  1. Build once and publish the artifact with its provenance attestation.

  2. Export the digest as a job output, the only reference carried onwards.

  3. Deploy to staging while verifying the provenance right before.

  4. Wait for the approval carried by the rules of the production environment.

  5. 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-id and a token, and disappear after 90 days by default.
  • Manual redeployment is parameterised by the digest, treated as untrusted input, through env:.

Next steps

Is this site useful to you?

Fewer than 1% of readers support this site.

I maintain more than 700 free guides, with no ads and no tracking. Any support, even a symbolic one, helps cover hosting and keeps these resources free. Thank you for the help.

The form does not show? Open Ko-fi in a new tab.

Subscribe and follow my DevSecOps work on LinkedIn