Skip to content

fix(release): publish the release only once it is signed - #4

Merged
sydorovdmytro merged 1 commit into
mainfrom
dmytrosydorov/devops-1386-fixsemstat-reconcile-the-release-pipeline-with-immutable
Aug 21, 2026
Merged

fix(release): publish the release only once it is signed#4
sydorovdmytro merged 1 commit into
mainfrom
dmytrosydorov/devops-1386-fixsemstat-reconcile-the-release-pipeline-with-immutable

Conversation

@sydorovdmytro

@sydorovdmytro sydorovdmytro commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Summary

  • release.draft: true, and release.yaml publishes it as the last step of the release job, after signing and attestation. Immutability engages at publish, so publishing last is what keeps a release from being frozen without its provenance.
  • It publishes by release id, not by tag. gh release edit <tag> resolves a tag by racing a published lookup against a draft one, so with more than one draft on the tag it can publish an older, unsigned draft and leave this run's behind.
  • Preflight refuses to start when the tag already has a published release, or a draft goreleaser cannot cleanly replace. Otherwise the run pushes images and moves the floating tags first, then fails afterwards for nothing.
  • replace_existing_draft added: goreleaser resolves an existing release by tag through an endpoint that returns published releases only, so without it a re-dispatch never sees the draft its own failed run left behind and adds a second one. replace_existing_artifacts stays for the in-run 422 retry on a landed-but-reported-failed upload. mode: replace dropped as unreachable.
  • release.name_template pinned, since goreleaser's draft cleanup and the publish step both key on the release name.
  • verify asserts .immutable == true. GET /repos/{owner}/{repo}/immutable-releases reports the repository setting directly but needs admin read, which no GITHUB_TOKEN permission grants, so a published release stays the only place an Actions run can see it.

Two trade-offs, both documented: verify runs after publish, so a verify failure needs a new tag rather than a re-dispatch; and goreleaser pushes the Homebrew cask while the release is still a draft, so its URL 404s until the publish step runs.

Test plan

  • actionlint on release.yaml, shellcheck included: clean
  • zizmor: no findings
  • goreleaser check against the pinned v2.17.0 binary: passes
  • shellcheck -x on the new run blocks, extracted verbatim: clean
  • preflight jq exercised against nine draft states (none, one matching, untitled, retitled, two matching, name matching another tag, decoy beside a good draft, unrelated draft, live repo state): each classified as intended
  • --paginate handling checked against a merged array, per-page arrays, [], and no output
  • both gh api pipelines confirmed to fail closed under set -euo pipefail, so a failed listing cannot pass the guard
  • go build ./... && go test ./...: pass
  • The draft flow itself only proves out on a real tag; the next release is where immutable=true gets confirmed

Closes DEVOPS-1386

@sydorovdmytro
sydorovdmytro requested a review from a team as a code owner August 21, 2026 09:03
@sydorovdmytro
sydorovdmytro force-pushed the dmytrosydorov/devops-1386-fixsemstat-reconcile-the-release-pipeline-with-immutable branch from 1ca123f to 61d928d Compare August 21, 2026 09:59
Release immutability is on for this repository, and it engages when a
release is published. goreleaser published first and uploaded after, so a
signing outage could freeze a release with no provenance and the
documented repair, a re-dispatch on the tag, could no longer replace
anything.

The release is now cut as a draft and published as the last step of the
job, after signing and attestation. A failed run leaves a deletable
draft, a re-dispatch repairs it, and a run that finds the tag already
published refuses to start rather than moving the image tags first and
failing on the release afterwards.

Two details make that repair actually work. goreleaser resolves an
existing release by tag through an endpoint that returns published ones
only, so without replace_existing_draft it cannot see the draft its own
failed run left behind and adds a second one for the tag. And gh resolves
a tag by racing a published lookup against a draft one, so the publish
step takes an id instead, which cannot pick an older unsigned draft over
this run's. Since goreleaser matches drafts on release name alone, the
preflight also refuses a draft it would fail to replace, and the name
template is pinned rather than left to its default.
@sydorovdmytro
sydorovdmytro force-pushed the dmytrosydorov/devops-1386-fixsemstat-reconcile-the-release-pipeline-with-immutable branch from 61d928d to 41e9ae0 Compare August 21, 2026 10:02
@sydorovdmytro
sydorovdmytro merged commit 436ec8a into main Aug 21, 2026
6 checks passed
@sydorovdmytro
sydorovdmytro deleted the dmytrosydorov/devops-1386-fixsemstat-reconcile-the-release-pipeline-with-immutable branch August 21, 2026 10:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant