From 949b35239f385bf516779e5f2b7ab658ba68c981 Mon Sep 17 00:00:00 2001 From: Tulio Miranda Date: Tue, 4 Aug 2026 13:19:33 -0300 Subject: [PATCH 1/2] chore: harden the release publish path Three follow-ups from the v0.0.2 release, plus one hazard they exposed. Preview snapshot tags (v0.0.1-20260728 and friends) shared the `v*` trigger with real releases and failed the ancestry or version gate, leaving five red runs with more to come. The publish job now skips any tag containing `-`. The trigger stays broad on purpose: a narrow tag filter would also silence a malformed release tag like `vfoo`, making a botched release indistinguishable from no release. SemVer uses `-` for pre-release identifiers, and this workflow refuses pre-releases anyway since it always moves `:latest`, so the guard is strictly quieter and never more permissive. The Makefile pointed at `docker.io/hathor`, which does not exist on DockerHub. Correcting it to the real namespace would have turned a target that failed at auth into a one-word way to clobber production: `make push` builds single-arch, always moved `:latest`, and bypasses the tag/manifest match and the immutability check the workflow enforces. It now has no default registry and refuses to run without an explicit DOCKER_REGISTRY, never pushes `:latest`, and checks before building rather than after. `docker push` and `make push` join the deny list. Documents that a publish failing after the push is not retryable, and names the one unrecoverable state: `:` lands, `:latest` does not, and bump-and-retag leaves that version permanently non-latest. Closes HathorNetwork/hathor-integration-test-helper#28 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/settings.json | 6 +++++- .github/workflows/docker.yml | 13 +++++++++++++ CLAUDE.md | 21 +++++++++++++++++++++ Makefile | 28 +++++++++++++++++++++++----- 4 files changed, 62 insertions(+), 6 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index dba71bf..012c8a4 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -14,7 +14,11 @@ "Bash(git push * refs/tags/*)", "Bash(git push *:refs/tags*)", "Bash(gh release create*)", - "Bash(gh release delete*)" + "Bash(gh release delete*)", + "Bash(docker push*)", + "Bash(make push*)", + "Bash(docker manifest push*)", + "Bash(docker buildx build*--push*)" ] } } diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7db1eb1..ccb7d1c 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -18,6 +18,19 @@ env: jobs: publish: + # The `v*` trigger stays broad on purpose. A narrow tag filter + # (`v[0-9]+.[0-9]+.[0-9]+`) would work, but it would also mean a + # malformed release tag — `vfoo`, `v0.0.2.1` — produces no run at all, + # so a botched release looks identical to no release. Keeping the + # trigger broad preserves that signal: malformed tags still reach the + # version regex below and fail loudly. + # + # This guard only silences tags that are *deliberately* not releases. + # SemVer uses `-` to introduce a pre-release identifier, and this + # workflow refuses pre-releases anyway because it always moves + # `:latest`, so anything containing `-` would be rejected downstream + # regardless — skipping it is strictly quieter, never more permissive. + if: ${{ !contains(github.ref_name, '-') }} runs-on: ubuntu-latest timeout-minutes: 30 steps: diff --git a/CLAUDE.md b/CLAUDE.md index 2e30406..67e6d47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,6 +78,27 @@ Published versions are immutable. The workflow also refuses to publish a version that already exists on DockerHub, so re-releasing is never an option — the only way forward is a bump. +**A publish that fails after the push cannot be retried.** The +immutability check is what makes re-running the job fail, so recovery is +always "bump, merge, tag again", never "re-run the workflow". Budget for +that before cutting a release; discovering it mid-incident is expensive. +A run that fails *before* the push leaves DockerHub untouched and can be +re-run safely — every guard deliberately runs ahead of the push, and +`Build and push` is the last step. The one unrecoverable state is a +partial failure inside that step: `:` lands but `:latest` does +not, and the bump-and-retag recovery leaves that version permanently +non-`latest`. + +**The workflow is the only sanctioned publish path.** `make push` builds +single-arch and skips every guard, so it has no default registry and +refuses to run without an explicit `DOCKER_REGISTRY`; `:latest` is never +pushed from the Makefile. `docker push` and `make push` are on the deny +list for the same reason the tagging commands are. + +Snapshot or pre-release tags (anything with a `-`, e.g. +`v0.0.1-20260728`) are skipped by the workflow rather than failed, so +they never publish and never produce a red run. + `.claude/settings.json` denies the tagging commands as a backstop, not as a hard boundary — patterns cannot reliably see through compound commands. It also denies read-only `git tag -l`; list tags with diff --git a/Makefile b/Makefile index c5c544e..b23fc01 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,13 @@ # Docker configuration (override via environment: DOCKER_REGISTRY, DOCKER_TAG) IMAGE_NAME := hathor-integration-test-helper -REGISTRY := $(or $(DOCKER_REGISTRY),docker.io/hathor) +# Deliberately no default. Releases are published by +# .github/workflows/docker.yml, which builds multi-arch, verifies the tag +# against package.json, and refuses to overwrite a published version. This +# Makefile does none of that: it builds single-arch and pushes whatever it +# has. Defaulting to the real namespace would make `make push` a one-word +# way to clobber a published multi-arch image and move `:latest`, bypassing +# every guard. Set DOCKER_REGISTRY explicitly to push to a scratch registry. +REGISTRY := $(DOCKER_REGISTRY) # package.json is the source of truth for the version, so a local build # carries the same tag the publish workflow would produce for this commit. # Override with DOCKER_TAG for throwaway builds. @@ -12,7 +19,7 @@ TAG := $(or $(DOCKER_TAG),$(VERSION),latest) help: @echo "Docker commands:" @echo " make build - Build Docker image" - @echo " make push - Push image to registry" + @echo " make push - Push to a scratch registry (needs DOCKER_REGISTRY)" @echo " make run - Run container locally" @echo " make clean - Remove local images" @echo "" @@ -24,12 +31,23 @@ help: build: docker build -t $(IMAGE_NAME):$(TAG) -t $(IMAGE_NAME):latest . +# Checked in the recipe, not with `ifndef`: make evaluates conditionals at +# parse time, so a top-level $(error) would fire on `make build` too. +# `:latest` is intentionally not pushed here — the release workflow owns it. +# Not `push: build` — a prerequisite would run the whole image build before +# the guard could reject the push. Check first, then build. .PHONY: push -push: build +push: + @test -n "$(REGISTRY)" || { \ + echo "DOCKER_REGISTRY is unset."; \ + echo "Releases are published by .github/workflows/docker.yml from a"; \ + echo "signed tag — see the Releases section of CLAUDE.md."; \ + echo "Set DOCKER_REGISTRY to push a scratch image somewhere else."; \ + exit 1; \ + } + $(MAKE) build docker tag $(IMAGE_NAME):$(TAG) $(REGISTRY)/$(IMAGE_NAME):$(TAG) - docker tag $(IMAGE_NAME):latest $(REGISTRY)/$(IMAGE_NAME):latest docker push $(REGISTRY)/$(IMAGE_NAME):$(TAG) - docker push $(REGISTRY)/$(IMAGE_NAME):latest .PHONY: run run: From 298bd9deb522f5d18f98eacc02ad403102429a50 Mon Sep 17 00:00:00 2001 From: Tulio Miranda Date: Wed, 5 Aug 2026 13:01:57 -0300 Subject: [PATCH 2/2] ci: require a signed tag and commit to publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The publish path checked that a tag was well-formed and pointed at a reviewed commit, but never that anyone authorised the release. Anyone able to push a tag could ship a public image from any commit on main. The push guard already claimed releases came from a signed tag, which made the message a promise the pipeline did not keep. The workflow now reads verification state from GitHub's API and refuses unless both the tag object and the commit it points at are verified signatures. Reading it from the API rather than `git tag -v` keeps trust anchored to the keys registered to the org's accounts, instead of a keyring the runner imports and a workflow edit could quietly widen. Both objects are checked deliberately: a signed tag on an unsigned commit still leaves the thing actually shipping without provenance. Lightweight tags are rejected outright since they carry no signature at all — a release tag must be `git tag -s`. Verified against real history: v0.0.1 and v0.0.2 both pass, and the unsigned v0.0.1-20260728 preview snapshot is rejected as lightweight, which makes this defence-in-depth behind the pre-release skip. Also refuses `make push` when TAG resolves to `latest`. That happens when DOCKER_TAG is unset and the version cannot be read from package.json, and pushing then moves a floating tag rather than an identifiable build — contradicting the comment directly above it. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/docker.yml | 44 ++++++++++++++++++++++++++++++++++++ CLAUDE.md | 8 +++++++ Makefile | 12 ++++++++-- 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index ccb7d1c..9d4b7fc 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -51,6 +51,50 @@ jobs: exit 1 fi + # Ancestry proves the code was reviewed; this proves *who* asked for the + # release. Without it, anyone who can push a tag to this repository can + # publish a public image from any reviewed commit. Both objects are + # checked: a signed tag on an unsigned commit still means unreviewed + # provenance for the thing actually shipping. + # + # Verification is read from GitHub's API rather than `git tag -v`, so + # trust follows the keys registered to the org's accounts instead of a + # keyring the runner would have to import (and that a workflow edit + # could quietly widen). + - name: Require a signed tag on a signed commit + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + REF: ${{ github.ref_name }} + COMMIT: ${{ github.sha }} + run: | + set -euo pipefail + + # Only an annotated tag carries a signature at all; a lightweight + # tag is just a ref and can never be verified. + object_type="$(gh api "repos/${REPO}/git/ref/tags/${REF}" --jq '.object.type')" + if [ "$object_type" != "tag" ]; then + echo "::error::${REF} is a lightweight tag and carries no signature. Release tags must be annotated and signed (git tag -s)." + exit 1 + fi + + tag_sha="$(gh api "repos/${REPO}/git/ref/tags/${REF}" --jq '.object.sha')" + tag_verified="$(gh api "repos/${REPO}/git/tags/${tag_sha}" --jq '.verification.verified')" + if [ "$tag_verified" != "true" ]; then + reason="$(gh api "repos/${REPO}/git/tags/${tag_sha}" --jq '.verification.reason')" + echo "::error::Tag ${REF} is not a verified signature (reason: ${reason}). Refusing to publish." + exit 1 + fi + + commit_verified="$(gh api "repos/${REPO}/commits/${COMMIT}" --jq '.commit.verification.verified')" + if [ "$commit_verified" != "true" ]; then + reason="$(gh api "repos/${REPO}/commits/${COMMIT}" --jq '.commit.verification.reason')" + echo "::error::Commit ${COMMIT} is not a verified signature (reason: ${reason}). Refusing to publish." + exit 1 + fi + + echo "Tag ${REF} and commit ${COMMIT} are both verified signatures." + - name: Resolve and verify version id: tags run: | diff --git a/CLAUDE.md b/CLAUDE.md index 67e6d47..7409907 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,6 +74,14 @@ merged commit. Bumping the version is ordinary work; the *tag* is the release. Agents prepare the tag command and hand it over — a human runs it. +**Only a signed tag on a signed commit deploys.** The workflow verifies +both through GitHub's API and refuses anything else, so the release tag +must be annotated *and* signed — `git tag -s`, not `git tag`. A +lightweight tag carries no signature and is rejected outright. This is +what makes the human touch load-bearing rather than conventional: +ancestry proves the code was reviewed, the signature proves who asked +for it to ship. + Published versions are immutable. The workflow also refuses to publish a version that already exists on DockerHub, so re-releasing is never an option — the only way forward is a bump. diff --git a/Makefile b/Makefile index b23fc01..716daef 100644 --- a/Makefile +++ b/Makefile @@ -40,11 +40,19 @@ build: push: @test -n "$(REGISTRY)" || { \ echo "DOCKER_REGISTRY is unset."; \ - echo "Releases are published by .github/workflows/docker.yml from a"; \ - echo "signed tag — see the Releases section of CLAUDE.md."; \ + echo "Releases are published by .github/workflows/docker.yml, which"; \ + echo "requires a signed annotated tag on a signed commit — see the"; \ + echo "Releases section of CLAUDE.md."; \ echo "Set DOCKER_REGISTRY to push a scratch image somewhere else."; \ exit 1; \ } + @test "$(TAG)" != "latest" || { \ + echo "TAG resolved to 'latest', which this target refuses to push."; \ + echo "That happens when DOCKER_TAG is unset and the version could not"; \ + echo "be read from package.json — pushing would move a floating tag"; \ + echo "rather than an identifiable build. Set DOCKER_TAG explicitly."; \ + exit 1; \ + } $(MAKE) build docker tag $(IMAGE_NAME):$(TAG) $(REGISTRY)/$(IMAGE_NAME):$(TAG) docker push $(REGISTRY)/$(IMAGE_NAME):$(TAG)