Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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*)"
]
}
}
13 changes: 13 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 Down
21 changes: 21 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: `:<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
28 changes: 23 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,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."; \

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — the message was promising something the pipeline did not enforce. Fixed by making it true rather than by softening the wording.

298bd9d adds a Require a signed tag on a signed commit step that reads verification state from GitHub's API and refuses to publish unless both the tag object and the commit it points at are verified signatures. Lightweight tags are rejected outright, since they carry no signature at all.

Both objects are checked deliberately: a signed tag on an unsigned commit still leaves the artifact that actually ships without provenance. Verification comes from the API rather than git tag -v so trust stays anchored to the keys registered to the org's accounts, instead of a keyring the runner imports — which a workflow edit could quietly widen.

Simulated against real history before committing: v0.0.1 and v0.0.2 both pass, and the unsigned v0.0.1-20260728 preview snapshot is rejected as lightweight — so this is also defence-in-depth behind the pre-release skip added in this PR.

Your suppressed comment on line 50 was right too, and is fixed in the same commit: TAG falls back to latest when DOCKER_TAG is unset and the version cannot be read from package.json, so make push could still move a floating tag despite the comment above it saying otherwise. It now refuses when TAG resolves to latest.

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:
Expand Down