Fix the image attestation subject name #130
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |