Skip to content

Ship the container data directory locked down #142

Ship the container data directory locked down

Ship the container data directory locked down #142

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. Releases are
# draft-until-complete, so a cancelled run strands only an invisible draft that
# a re-run resumes - but for stable it can also leave the brew cask pointing at
# download URLs that stay 404 until that re-run completes and flips the release
# public. The cost is only that an rc re-cut waits for the previous run to
# finish instead of aborting it - cheap next to a dangling stable window.
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 ONCE PUBLISHED: 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 is already published. Cut a new version instead.
#
# Published, not merely existing: releases are created as DRAFTS and only
# flipped public by the publish job's final step, after every attestation
# succeeds, so a stranded draft from an incomplete run must be RESUMABLE.
# It is, by the API's own semantics: "get a release by tag name" returns
# "a published release with the specified tag" (GitHub REST docs), so a
# draft answers HTTP 404 here and the re-run replaces it wholesale via
# release.replace_existing_draft.
#
# 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' is already published; refusing to overwrite published, attested assets. Cut a new version instead."
exit 1
fi
case "$out" in
*"HTTP 404"*) echo "stable tag ($tag): no published release - ok to publish (a stranded draft, if any, is replaced and resumed)"; 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), and the same published-only semantics: a stranded DRAFT answers 404
# here and is resumed, only a PUBLISHED stable release blocks.
- 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' is already published; refusing to overwrite published, attested assets."
exit 1
fi
case "$out" in
*"HTTP 404"*) echo "stable tag ($tag): still no published release - ok to publish (a stranded draft, if any, is replaced and resumed)"; 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
# Re-cutting an rc replaces the assets of a release that is already PUBLIC
# (replace_existing_artifacts, the amend loop this repo actually uses), so
# for the length of that run the rc's download URLs served bytes whose new
# digests nothing had attested yet - the draft-until-complete flow covered
# the first cut of a tag and not its second. Putting the release back into
# draft first restores the property for re-cuts too: the tag goes dark
# while it is incomplete, and the same final step that publishes a first
# cut publishes this one. A tag with no published release yet is untouched
# (the normal case), and a stable tag never reaches here - its own guard
# refuses a re-cut outright.
- name: Re-draft a published prerelease before replacing its bytes
if: ${{ contains(github.ref_name, '-') }}
env:
GH_TOKEN: ${{ github.token }}
run: |
tag="${GITHUB_REF_NAME}"
# Only a PUBLISHED release answers here; a draft is already invisible
# (the tags endpoint returns published releases only), so it needs no
# action and a definite 404 is the first-cut case.
#
# Same fail-closed discipline as the stable guard: `gh api` exits
# non-zero for auth failures, rate limits and outages as well as for a
# genuine 404, so treating every error as "absent" would skip the
# re-draft exactly when the API is unhealthy - and then replace a live
# release's bytes anyway. Only a definite 404 may proceed.
if out=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${tag}" 2>&1); then
:
else
case "$out" in
*"HTTP 404"*)
echo "no published release for '$tag' - nothing to re-draft"
exit 0 ;;
*)
echo "::error::could not determine whether '$tag' is already published ($out); refusing to replace its assets blind"
exit 1 ;;
esac
fi
echo "'$tag' is already published; putting it back to draft while its assets are replaced"
for attempt in 1 2 3; do
if gh release edit "$tag" --draft=true --repo "${GITHUB_REPOSITORY}"; then
exit 0
fi
echo "attempt $attempt: could not re-draft '$tag'"
sleep 5
done
# Fail closed: continuing would publish replacement bytes into a live
# release with no attestation, which is the thing this step exists to
# prevent.
echo "::error::could not put '$tag' back to draft before replacing its assets; refusing to overwrite a published prerelease in place"
exit 1
- 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 as a DRAFT (release.draft in
# .goreleaser.yaml; the final step below flips it public once the
# attestations are in) 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). Only those: the floating tags still point at the previous release
# at this point in the run, and are moved onto these digests further down,
# after the attestations below have succeeded.
# 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
# Promote the floating tags LAST, and only for a stable release. GoReleaser
# pushes the immutable version tags; `latest` and `latest-iperf` are moved
# here, by digest, once every attestation above has succeeded - so the
# channel `docker pull` serves by default can never point at an image whose
# provenance does not exist yet. A run that dies at an attestation leaves
# latest exactly where it was, on the previous release, which is the safe
# place for it to be. imagetools create re-points a tag at an existing
# multi-arch index without rebuilding or re-pushing any layer.
- name: Promote the floating tags (stable only)
if: ${{ !contains(github.ref_name, '-') }}
env:
GORELEASER_CURRENT_TAG: ${{ github.ref_name }}
run: |
version="${GORELEASER_CURRENT_TAG#v}"
repo="ghcr.io/pingular/pingularity"
for pair in "latest:${version}" "latest-iperf:${version}-iperf"; do
floating="${pair%%:*}"
pinned="${pair#*:}"
echo "promoting ${floating} -> ${pinned}"
docker buildx imagetools create --tag "${repo}:${floating}" "${repo}:${pinned}"
done
# Prove the move landed on the digest we just attested. Read each digest
# through a helper that FAILS on anything but a real one: comparing the
# raw command output would pass when BOTH reads fail, since two empty
# strings are equal - a verification step that cannot fail is worse than
# none, because it is quoted as evidence. The pipeline's exit status is
# `tr`'s, so `set -e` does not catch it either.
digest_of() {
ref="$1"
if ! out=$(docker buildx imagetools inspect "$ref" --format '{{json .Manifest.Digest}}' 2>&1); then
echo "::error::could not read the digest of $ref: $out" >&2
return 1
fi
out=$(printf '%s' "$out" | tr -d '"' | tr -d '[:space:]')
case "$out" in
sha256:*) printf '%s' "$out" ;;
*) echo "::error::unexpected digest for $ref: '$out'" >&2; return 1 ;;
esac
}
for pair in "latest:${version}" "latest-iperf:${version}-iperf"; do
floating="${pair%%:*}"
pinned="${pair#*:}"
a=$(digest_of "${repo}:${floating}") || exit 1
b=$(digest_of "${repo}:${pinned}") || exit 1
if [ "$a" != "$b" ]; then
echo "::error::${floating} resolves to $a but ${pinned} is $b; the floating tag did not move"
exit 1
fi
done
# The step that COMPLETES the release: everything above - assets, images,
# and all three attestations - has succeeded, so flip the draft release
# public. Deliberately the FINAL step, so a failure anywhere earlier
# strands only an invisible draft (which the stable guard treats as
# resumable - a plain re-run finishes the job), never a public-but-
# incomplete release. --latest is applied here and ONLY here, for STABLE
# tags: GoReleaser skips its own undraft/make_latest handling when
# release.draft is true, and GitHub refuses to mark drafts or prereleases
# as latest. On an rc re-cut the release is already published and this
# edit is a harmless no-op (gh resolves drafts by their pending tag name,
# published releases by tag). Same fail-closed retry shape as the guards:
# if the flip cannot be confirmed, fail loudly - the release is still a
# draft and a re-run resumes it.
- name: Publish the release (flip out of draft)
env:
GH_TOKEN: ${{ github.token }}
run: |
tag="${GITHUB_REF_NAME}"
set -- release edit "$tag" --draft=false
case "$tag" in
*-*) ;; # prerelease: GitHub cannot mark it latest
*) set -- "$@" --latest ;;
esac
set -- "$@" --repo "${GITHUB_REPOSITORY}"
for attempt in 1 2 3; do
if gh "$@"; then
echo "release '$tag' is published"
exit 0
fi
echo "attempt $attempt: could not flip release '$tag' out of draft"
sleep 5
done
echo "::error::release '$tag' is complete but still a DRAFT: assets and attestations are all in place, only the flip failed. Re-run this workflow once the API is healthy - the stable guard treats a draft as resumable."
exit 1