feat: publish releases with this repository's own SDK code

There is no CI: the instance has no act_runner and none is planned, so releases
are cut by hand. That makes `make check` the only thing standing between a
mistake and the tracker, and it is one command: gofmt, vet, the suite with the
cache defeated, `go mod verify`, a vendored build, and `kettle gen skills
--check`. The last one is the invariant worth having — the plugin's SKILL.md
command reference is generated from the binary's registry, so a flag that
changed cannot ship with documentation that recommends the old one.

`cli/cmd/release` publishes to Gitea using the same SDK the binary already
vendors, which is a pleasing thing to be able to say: nothing third-party
handles the artifacts. It is a second binary rather than a `kettle` subcommand
on purpose — `kettle`'s command tree is what generates the plugin's skills, so a
verb there ships to every operator, and publishing a release is build
infrastructure. It is idempotent end to end: an existing release for the tag is
reused, an asset of the same name is replaced rather than doubled, and a retried
run converges instead of duplicating.

`make release` refuses three things, each with its own message: a dirty working
tree, a TAG that is not what `git describe` reports, and a tag the remote does
not have. A release built from uncommitted code is unreproducible and nobody
finds out until they need to reproduce it.

`kettle version` reports the stamp, the toolchain and the VCS revision. The
default is `dev`, and a hand build says so and means it — a binary out of
somebody's working tree is not a release and must not claim to be one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
naudachu
2026-08-12 01:01:27 +05:00
parent ec0a1893b7
commit 01fb5a2703
27 changed files with 3385 additions and 266 deletions
+188
View File
@@ -0,0 +1,188 @@
# The gate, and the release, for a repository with no CI.
#
# There is no act_runner on the instance this lives on and none is planned, so
# NOTHING RUNS ON A PUSH. `make check` is the only thing standing between a
# mistake and the tracker, and it is on whoever is committing to run it — the
# same five steps a workflow would have run, in one command, exiting non-zero
# the moment one of them fails.
#
# `make release` is the other half: a release is cut by hand, from a developer's
# machine, by this module's own code (cmd/release) talking to Gitea's API. It
# refuses to publish a dirty tree or a tag that is not the version it is about
# to stamp into the binaries, because a release built from uncommitted code is
# unreproducible and nobody finds out until they need to reproduce it.
SHELL := /bin/sh
# Sequential on purpose: `check` builds the binary and then asks it whether the
# plugin's documentation still matches, and -j would let the second start first.
.NOTPARALLEL:
MODULE := git.noodles.cam/claude-skills/marketplace/cli
SKILLS := ../plugins/kettle/skills
DIST := dist
BIN := $(DIST)/kettle
REMOTE ?= origin
# Where `make install` puts the binary. The plugin's skills expect `kettle` on
# PATH and say so when it is not; override for a Go-style layout:
# make install BINDIR=$(go env GOPATH)/bin
BINDIR ?= $(HOME)/.local/bin
# VERSION is what a binary reports for `kettle version`. It is derived from git
# rather than kept in a file: a number somebody has to remember to bump is a
# number that will be wrong. A tree that is not on a tag says so, and a dirty
# tree says that too — "v0.2.0-4-g1a2b3c4-dirty" is the honest answer, and it is
# exactly what you want to see in a bug report.
VERSION ?= $(shell git describe --tags --dirty --always 2>/dev/null || echo dev)
LDFLAGS := -X $(MODULE)/internal/cmd.Version=$(VERSION)
PLATFORMS := darwin/amd64 darwin/arm64 linux/amd64 linux/arm64
.PHONY: help check fmt vet test verify build gen-check dist release install clean
help:
@echo 'kettle — there is no CI here; these are what a person runs.'
@echo
@echo ' make check the gate: fmt, vet, test, modules, build, docs'
@echo ' make build dist/kettle, version stamped'
@echo ' make install [BINDIR=…] build straight onto your PATH'
@echo ' make dist cross-compile every platform + SHA256SUMS'
@echo ' make release TAG=v1.2.3 [NOTES=notes.md] [TITLE="…"]'
@echo ' dist, then publish it to Gitea'
@echo ' make clean remove dist/'
@echo
@echo " version $(VERSION)"
@echo " bindir $(BINDIR)"
# ---------------------------------------------------------------------------
# the gate
# ---------------------------------------------------------------------------
check: fmt vet test verify build gen-check
@echo 'check all clear — $(VERSION)'
# gofmt reports rather than rewrites: a formatting change made silently by a
# build is a change nobody reviewed. cmd and internal, never the module cache.
fmt:
@out=`gofmt -l cmd internal`; \
if [ -n "$$out" ]; then \
echo 'gofmt these files are not formatted:'; \
echo "$$out" | sed 's/^/ /'; \
echo ' run: gofmt -w cmd internal'; \
exit 1; \
fi; \
echo 'gofmt clean'
vet:
@echo 'vet go vet ./...'
@go vet ./...
# -count=1 defeats the test cache. A gate that can pass because it passed an
# hour ago on different code is not a gate.
test:
@echo 'test go test -count=1 ./...'
@go test -count=1 ./...
# Two checks, because they answer different questions. `go mod verify` says the
# module cache matches go.sum; the vendored build says the committed vendor/ is
# complete and is what actually compiles. A vendor/ that has drifted from go.mod
# fails nothing until somebody builds on a machine with a cold cache.
verify:
@echo 'modules go mod verify'
@go mod verify
@echo 'vendor go build -mod=vendor ./...'
@go build -mod=vendor ./...
build:
@mkdir -p $(DIST)
@go build -trimpath -ldflags '$(LDFLAGS)' -o $(BIN) ./cmd/kettle
@echo 'build $(BIN) — $(VERSION)'
# The documentation invariant: everything the plugin's SKILL.md files say about
# a kettle command is generated from the registry the binary is built from, and
# this exits 1 the moment the two disagree. Run `kettle gen skills --out …`
# without --check to fix it.
gen-check: build
@echo 'docs gen skills --check'
@$(BIN) gen skills --out $(SKILLS) --check
# ---------------------------------------------------------------------------
# building for other people
# ---------------------------------------------------------------------------
install:
@mkdir -p $(BINDIR)
@go build -trimpath -ldflags '$(LDFLAGS)' -o $(BINDIR)/kettle ./cmd/kettle
@echo 'installed $(BINDIR)/kettle — $(VERSION)'
@case ":$$PATH:" in \
*":$(BINDIR):"*) ;; \
*) echo "note: $(BINDIR) is not on your PATH, so the plugin's skills will not find it" ;; \
esac
# CGO_ENABLED=0 because these binaries are downloaded by people whose machines
# are not this one: a build that links against the host's libc is a build that
# runs on the host.
dist:
@rm -f $(DIST)/kettle_* $(DIST)/SHA256SUMS
@mkdir -p $(DIST)
@for p in $(PLATFORMS); do \
os=$${p%/*}; arch=$${p#*/}; \
out=$(DIST)/kettle_$(VERSION)_$${os}_$${arch}; \
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch \
go build -trimpath -ldflags '$(LDFLAGS)' -o $$out ./cmd/kettle || exit 1; \
echo "dist $$out"; \
done
@cd $(DIST) && \
if command -v sha256sum >/dev/null 2>&1; then \
sha256sum kettle_$(VERSION)_* > SHA256SUMS; \
else \
shasum -a 256 kettle_$(VERSION)_* > SHA256SUMS; \
fi
@echo 'dist $(DIST)/SHA256SUMS'
# ---------------------------------------------------------------------------
# cutting one
# ---------------------------------------------------------------------------
# Published by cmd/release, which is this module's own code against Gitea's own
# API — no third-party action, nothing between the tag and what people download
# that is not in this repository.
#
# The three refusals are the point of doing it here rather than by hand:
#
# dirty tree what shipped would not be what is committed, and nobody could
# rebuild it;
# wrong tag TAG must be the version `git describe` reports, which is only
# true when the tag exists and HEAD is standing on it — so this
# also catches "I forgot to tag" and "I tagged, then committed";
# unpushed tag a release naming a tag the server does not have is a download
# page pointing at a commit nobody else can fetch.
release:
@test -n '$(TAG)' || { echo 'usage: make release TAG=v1.2.3 [NOTES=notes.md] [TITLE="…"]'; exit 2; }
@if [ -n "`git status --porcelain`" ]; then \
echo 'refusing: the working tree is dirty — a release built from uncommitted code cannot be rebuilt'; \
git status --short | sed 's/^/ /'; \
exit 1; \
fi
@if [ '$(VERSION)' != '$(TAG)' ]; then \
echo 'refusing: TAG is $(TAG) but this commit describes as $(VERSION)'; \
echo ' the binaries would be stamped $(VERSION) and the release would claim $(TAG).'; \
echo ' tag this commit first: git tag -a $(TAG) -m $(TAG)'; \
exit 1; \
fi
@if ! git ls-remote --exit-code --tags $(REMOTE) 'refs/tags/$(TAG)' >/dev/null 2>&1; then \
echo 'refusing: $(TAG) is not on $(REMOTE) — push it first: git push $(REMOTE) $(TAG)'; \
echo ' (another remote? make release TAG=$(TAG) REMOTE=…)'; \
exit 1; \
fi
@$(MAKE) dist
@echo 'release publishing $(TAG) with cmd/release'
@go run ./cmd/release --tag '$(TAG)' \
$(if $(TITLE),--title '$(TITLE)') \
$(if $(NOTES),--notes-file '$(NOTES)') \
$(DIST)/kettle_$(VERSION)_* $(DIST)/SHA256SUMS
clean:
@rm -rf $(DIST)
@echo 'clean $(DIST) removed'