Skip to content

Fix the image attestation subject name #130

Fix the image attestation subject name

Fix the image attestation subject name #130

Workflow file for this run

name: Release
on:
push:
tags: ['v*']
# Manual escape hatch for a stranded tag: the 2026-08-06 Actions outage
# throttled webhooks and the TAG event never replayed (push events did, tag
# events do not), leaving a pushed tag with no release run - the remedy was
# deleting and re-pushing the tag. With this, dispatch the workflow AT the
# tag ref instead (Actions UI -> Release -> Run workflow -> pick the tag).
workflow_dispatch:
# Only one release run per tag at a time. Re-cutting a tag (delete + re-push, as
# the rc loop does) or a manual re-dispatch QUEUES behind the in-flight run
# rather than cancelling it. Serializing still prevents the concurrent asset-
# upload collision the cancel was added for (422 already_exists / http2
# REFUSED_STREAM) - two runs never upload at once - WITHOUT the hazard cancel
# introduced: a workflow_dispatch (or a late-delivered throttled tag webhook)
# sharing this group could otherwise cancel a run mid-publish, leaving a
# half-uploaded release that the stable guard then refuses to complete. The cost
# is only that an rc re-cut waits for the previous run to finish instead of
# aborting it - cheap next to corrupting a shipped stable release.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
# Least privilege by default; the publish job widens its own grant below.
permissions:
contents: read
jobs:
guard:
runs-on: ubuntu-latest
steps:
# workflow_dispatch (the stranded-tag escape hatch) can fire on a BRANCH,
# and a branch may be NAMED like a version - the SemVer check below reads
# GITHUB_REF_NAME and would pass it, while GoReleaser publishes the git tag
# at HEAD instead. On a v-named branch whose HEAD sits on an existing
# release tag that would OVERWRITE a shipped release. Refuse anything that
# is not a tag ref outright, before any checkout or token use.
- name: Require a tag ref
run: |
if [ "${GITHUB_REF_TYPE}" != "tag" ]; then
echo "::error::release runs only on a tag ref (got ${GITHUB_REF_TYPE} '${GITHUB_REF_NAME}'); dispatch the workflow with a tag ref, not a branch"
exit 1
fi
# The trigger glob 'v*' also matches junk like 'v', 'vtest', or 'v1.2' - a
# stray or malformed tag must not cut a release. Enforce a proper SemVer
# grammar (leading v) before any checkout or token use. This is a regex, not a
# full parser, but it is a real SemVer grammar: it rejects the shapes that
# actually slip through a naive pattern - leading-zero numeric core/prerelease
# identifiers (v01.2.3, v1.2.3-01) and empty dot identifiers (v1.2.3-a..b) -
# while GoReleaser stays the authoritative validator downstream.
- name: Guard SemVer tag
run: |
tag="${GITHUB_REF_NAME}"
semver='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*))?(\+([0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*))?$'
if ! printf '%s' "$tag" | grep -Eq "$semver"; then
echo "::error::tag '$tag' is not a valid SemVer tag (vMAJOR.MINOR.PATCH[-prerelease]); refusing to release"
exit 1
fi
# SemVer permits +build metadata, but this pipeline CANNOT ship it
# safely: the prerelease test below is a shell `*-*` glob, and build
# metadata like +build-1 contains a hyphen, so a STABLE tag with build
# metadata would be misread as a prerelease and skip the immutable-
# stable overwrite guard. Docker tags reject '+' outright as well.
# Reject build metadata here rather than silently mishandle it.
case "$tag" in
*+*)
echo "::error::tag '$tag' carries +build metadata, which this release pipeline does not support (it breaks the stable/prerelease split and Docker tagging); cut vMAJOR.MINOR.PATCH[-prerelease] instead"
exit 1
;;
esac
# Stable releases are immutable: their checksums are attested in the publish
# job, so silently overwriting a published stable asset would invalidate that
# provenance. .goreleaser.yaml keeps replace_existing_artifacts: true for the
# rc re-cut loop (a plain bool there, not templateable per channel), so this
# guard confines that overwrite to prerelease tags - it fails the run if a
# STABLE tag's GitHub release already exists. Cut a new version instead.
#
# FAIL CLOSED: `gh release view` (and `gh api`) exit non-zero for a genuine
# 404 AND for auth failures, rate limiting, and outages alike, so treating
# every non-zero exit as "absent" would let a transient API blip green-light
# an overwrite. Only a definite HTTP 404 may proceed; a 200 refuses; any
# other result retries and then fails closed. The workflow_dispatch escape
# hatch exists for the very outages that cause those transient errors, so
# this correlation is real, not hypothetical.
- name: Forbid re-cutting a published stable release
env:
GH_TOKEN: ${{ github.token }}
run: |
tag="${GITHUB_REF_NAME}"
case "$tag" in
*-*) echo "prerelease tag ($tag): re-cut permitted"; exit 0 ;;
esac
for attempt in 1 2 3; do
if out=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${tag}" 2>&1); then
echo "::error::stable release '$tag' already exists; refusing to overwrite published, attested assets. Cut a new version instead."
exit 1
fi
case "$out" in
*"HTTP 404"*) echo "stable tag ($tag): no existing release - ok to publish"; exit 0 ;;
esac
echo "attempt $attempt: could not determine release state for '$tag' (not a 404): $out"
sleep 5
done
echo "::error::could not confirm whether stable release '$tag' already exists (API errors, not a 404); failing closed. Re-run once the API is healthy."
exit 1
# Gate the publish on the FULL exact-SHA CI (race, vet, gofmt, UI, govulncheck,
# cross-build, native smoke, goreleaser check) so a tag can never publish code the
# regular CI would have failed. The reusable workflow checks out this same commit.
ci:
needs: [guard]
uses: ./.github/workflows/ci.yml
release:
needs: [ci]
runs-on: ubuntu-latest
permissions:
contents: write # create the GitHub Release + upload assets
packages: write # push the GHCR image
id-token: write # mint the short-lived OIDC token the attestations are signed with
attestations: write # write the build-provenance attestations to the attestation store
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
fetch-depth: 0 # full history so goreleaser can build the changelog
# Don't leave the default GITHUB_TOKEN persisted in .git/config: goreleaser
# uses its own scoped tokens below, so nothing here needs the credential,
# and a persisted one is a needless secret at rest in the workspace.
persist-credentials: false
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version-file: go.mod
# GoReleaser does NOT resolve the current tag with `git describe`: it reads
# GORELEASER_CURRENT_TAG first (pinned on the Run GoReleaser step below to
# the guarded ref), else the HIGHEST version tag pointing at HEAD. The env
# pin is what guarantees the publish uses the exact tag the guards
# approved; this assert is the belt-and-suspenders check that the
# checked-out HEAD actually carries that tag. Membership, not equality: a
# commit may legitimately carry several tags (the rc-to-stable promote
# cuts the stable tag on the same SHA as the final rc), so require
# GITHUB_REF_NAME to be AMONG the tags pointing at HEAD.
- name: Assert the pushed tag points at HEAD
run: |
if ! git tag --points-at HEAD | grep -Fxq -- "${GITHUB_REF_NAME}"; then
echo "::error::tag '${GITHUB_REF_NAME}' does not point at HEAD (tags at HEAD: $(git tag --points-at HEAD | paste -sd ' ' - || true)); the release would publish a different commit than was guarded"
exit 1
fi
# Re-run the stable-overwrite guard immediately before publishing. The guard
# JOB ran separately with the full CI run in between, and "Re-run failed
# jobs" skips an already-passed guard - so a transient API blip that let the
# guard pass must not carry through to the actual overwrite. Same fail-closed
# logic as the guard job (kept inline because the guard job deliberately runs
# before any checkout, so the two cannot share a script without weakening it).
- name: Recheck no published stable release before publishing
env:
GH_TOKEN: ${{ github.token }}
run: |
tag="${GITHUB_REF_NAME}"
case "$tag" in
*-*) echo "prerelease tag ($tag): re-cut permitted"; exit 0 ;;
esac
for attempt in 1 2 3; do
if out=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${tag}" 2>&1); then
echo "::error::stable release '$tag' already exists; refusing to overwrite published, attested assets."
exit 1
fi
case "$out" in
*"HTTP 404"*) echo "stable tag ($tag): still no existing release - ok to publish"; exit 0 ;;
esac
echo "attempt $attempt: could not determine release state for '$tag' (not a 404): $out"
sleep 5
done
echo "::error::could not confirm whether stable release '$tag' already exists (API errors, not a 404); failing closed."
exit 1
- name: Log in to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0
with:
# Pinned to an EXACT version (not '~> v2') so a new goreleaser release
# can't silently change tag-push behaviour; bump it deliberately, in step
# with the pin in ci.yml's goreleaser-check job.
version: 'v2.17.0'
args: release --clean
env:
# Pin GoReleaser's current-tag resolution to the exact ref the guards
# validated. Without this it resolves the highest version tag at HEAD,
# which diverges from the guarded ref when a commit carries more than
# one tag - letting it publish/overwrite a DIFFERENT release than was
# checked. This is the real guarantee behind the assert step above.
GORELEASER_CURRENT_TAG: ${{ github.ref_name }}
# Creates the GitHub Release and pushes the GHCR image.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Publishing the Homebrew tap and winget manifests writes to OTHER
# repos, which GITHUB_TOKEN cannot reach. Provide a token for that, and
# keep it least-privilege: a FINE-GRAINED PAT scoped to ONLY the tap and
# winget repos, "Contents: read and write" and nothing else, with the
# SHORTEST practical expiry (rotate on expiry). A classic/broadly-scoped
# PAT here would let a compromised release step write to every repo the
# owner can reach. goreleaser reads it as its release token.
TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }}
# Provenance for every downloadable artifact: subject-checksums attests each
# file listed in checksums.txt (the .tar.gz/.zip archives and the .deb/.rpm
# packages), so a consumer can run `gh attestation verify <file> --repo ...`
# and prove it was built by THIS workflow from THIS commit. Signed with the
# OIDC id-token above - no long-lived key at rest. The GHCR images get the
# same treatment below, keyed on their pushed manifest digests.
- name: Attest build provenance (release artifacts)
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-checksums: dist/checksums.txt
# Image provenance needs the digest of what was actually PUSHED - the
# multi-arch index digest is minted by the registry, so it can only be read
# back, never precomputed. Resolve both variants' version tags ({{.Version}}
# is the tag minus the leading v, so this works on prerelease and stable
# alike; `latest`/`latest-iperf` are the same manifests when they exist).
# Fail closed on anything that does not parse as a digest: publishing an
# image without its attestation would be silent, and silent is the failure
# mode this step exists to remove.
- name: Resolve pushed image digests
id: image_digests
run: |
version="${GITHUB_REF_NAME#v}"
resolve() { # resolve <output-name> <image-ref>
digest=$(docker buildx imagetools inspect "$2" --format '{{json .Manifest.Digest}}' | tr -d '"')
case "$digest" in
sha256:*) echo "$2 -> $digest"; echo "$1=$digest" >> "$GITHUB_OUTPUT" ;;
*) echo "::error::could not resolve the pushed digest for $2 (got '$digest'); refusing to skip its attestation"; exit 1 ;;
esac
}
resolve default "ghcr.io/pingular/pingularity:${version}"
resolve iperf "ghcr.io/pingular/pingularity:${version}-iperf"
# subject-name + subject-digest is the image form the attestation store
# keys on, so `gh attestation verify oci://ghcr.io/pingular/pingularity:<tag>
# --repo pingular/pingularity` works for pulls. The action takes the BARE
# image name - the oci:// scheme belongs only to the verify CLI's syntax,
# and the action rejects it as an invalid image name. push-to-registry also
# attaches the attestation to GHCR itself (the login above is still live),
# so registry-side policy tooling can find it without the GitHub API.
- name: Attest build provenance (default image)
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-name: ghcr.io/pingular/pingularity
subject-digest: ${{ steps.image_digests.outputs.default }}
push-to-registry: true
- name: Attest build provenance (iperf image)
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-name: ghcr.io/pingular/pingularity
subject-digest: ${{ steps.image_digests.outputs.iperf }}
push-to-registry: true