Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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*)"
]
}
}
57 changes: 57 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -38,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: |
Expand Down
29 changes: 29 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,39 @@ 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.

**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: `:<version>` 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
Expand Down
36 changes: 31 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 ""
Expand All @@ -24,12 +31,31 @@ 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, 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 tag $(IMAGE_NAME):latest $(REGISTRY)/$(IMAGE_NAME):latest
docker push $(REGISTRY)/$(IMAGE_NAME):$(TAG)
docker push $(REGISTRY)/$(IMAGE_NAME):latest

.PHONY: run
run:
Expand Down