Skip to content

Container images

Two reusable workflows cover container work. reusable-docker-lint-build.yaml lints the Dockerfile and dry-builds it for pull-request feedback. reusable-docker-publish.yaml builds an image, pushes it to a registry, and attests its build provenance. It builds linux/amd64 only unless the caller passes platforms.

Usage

name: Release Deliver Docker

on:
  release:
    types: [published]

permissions:
  contents: read

jobs:
  publish:
    permissions:
      contents: read
      packages: write
      id-token: write
      attestations: write
    uses: nolte/gh-plumbing/.github/workflows/reusable-docker-publish.yaml@<tag>
    with:
      image_name: my-service
      context: "."
      dockerfile: Dockerfile
      platforms: linux/amd64,linux/arm64
    secrets:
      token: ${{ secrets.GITHUB_TOKEN }}

The calling job must grant id-token and attestations

Without them the workflow does not start. It does not fall back to publishing an image without provenance, and it does not run with a reduced token: GitHub reports startup_failure with zero jobs.

That failure looks like nothing happened at all, and it happens during a release, so it is easy to miss. Grant both before bumping to a version of the reusable that attests.

Both scopes belong on the job, not on the workflow. id-token is a signing identity, and widening one beyond the job that needs it is what spec/project/github-actions-best-practices §H forbids.

Build provenance

The publish workflow records how each image was built, using GitHub's own attestation mechanism through actions/attest-build-provenance. The platform generates and signs the record independently of the build.

That independence is the point. A build that attests to its own integrity can't detect its own compromise, which is why spec/project/continuous-delivery/ §C requires the pipeline rather than the build to produce the record. Earlier versions of this workflow used BuildKit's provenance output, which is generated by the build being attested.

Verify a published image with:

gh attestation verify oci://ghcr.io/<owner>/<image>:<tag> --owner <owner>

For a package that isn't public, the gh login needs the read:packages scope or the command can't reach the image.

Verify against the tag or the index digest, not a per-architecture one

A multi-architecture build publishes an image index, and every tag points at that index. The attestation names the index digest, which is what the workflow also emits as its digest output.

Individual per-architecture manifests inside the index carry their own digests, and docker buildx imagetools inspect shows them. No attestation exists for those, so verifying one reports nothing found even though the image is attested. Integrity still covers them through the index; only discoverability does not.

An attestation records origin, not safety

It tells you which workflow, commit and runner produced an image. It makes no claim that the image is free of vulnerabilities. Security findings stay with the scanning stages.

Tags

The metadata step publishes an immutable type=sha tag and, for releases, type=semver tags.

It sets latest on two paths, not one: a non-prerelease release publish, and a workflow_dispatch fired against a non-prerelease type=semver tag ref. The second path is easy to forget and has already moved latest unexpectedly once, in nolte/reachy-mini-mcp v0.1.1.

Three of the published tags move and two don't:

Tag Moves?
type=sha no
type=semver no
latest yes, on either path above
type=ref,event=branch yes, on every push to that branch
type=ref,event=pr yes, on every push to that pull request

A moving tag is a convenience alias for humans and never a deployment reference. Deployments consume the index digest or a type=semver tag.

Dry builds

reusable-docker-lint-build.yaml builds without pushing, so it produces no digest and no attestation. The lint and the build run independently, so one run reports both a Dockerfile finding and a build failure rather than hiding the second behind the first.