01fb5a2703
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>
114 lines
5.3 KiB
Markdown
114 lines
5.3 KiB
Markdown
# AGENTS.md — cmd/release
|
|
|
|
**The release tool: publishes a Gitea release for this repository, from this
|
|
repository's own code.** Driven by `make release TAG=v1.2.3`, never by a user.
|
|
|
|
| file | what is in it |
|
|
|---|---|
|
|
| `main.go` | flags, argument validation, exit codes — everything that can fail before a socket is opened |
|
|
| `publish.go` | `spec`, `receipt`, its own small SDK `client`, and the converge/upload logic |
|
|
| `release_test.go` | the whole tool against a fake Gitea, including every refusal |
|
|
|
|
## Why it is not a `kettle` subcommand
|
|
|
|
`kettle`'s command tree is not just a menu: it is what `kettle gen skills`
|
|
generates the plugin's SKILL.md files from. A verb added there arrives in the
|
|
documentation an agent loads and in the reference an operator reads, and **"publish
|
|
a release" is not something either of them does.** Publishing is build
|
|
infrastructure — it runs once, on a tag, by the person cutting it — and the thing
|
|
users install should not carry it.
|
|
|
|
It is still this module's code, built on the same Gitea SDK, and that is the point:
|
|
the release is published by the repository it is a release *of*, with nothing to
|
|
trust that is not in this tree and no third-party tool between a tag and what people
|
|
download.
|
|
|
|
## Why it does not use internal/gitea
|
|
|
|
[`internal/gitea`](../../internal/gitea/AGENTS.md) is otherwise the one door for
|
|
every request. Two reasons this one goes around it, both facts about where it runs
|
|
rather than preferences:
|
|
|
|
- that transport files every request body under `.kettle/payload/`, a path resolved
|
|
from the project marker — and the marker is gitignored, so a fresh clone has none
|
|
and a build tool has no business creating one;
|
|
- **an asset upload's request body IS the binary.** Filing a 20 MB multipart body as
|
|
JSON in a scratchpad helps nobody.
|
|
|
|
What it does **not** reinvent is credentials or error vocabulary.
|
|
[`internal/config`](../../internal/config/AGENTS.md) resolves the instance, the token
|
|
and the repository exactly as `kettle` does — through `ResolveOutsideAProject`,
|
|
which falls back to the environment when there is no marker and reads the project
|
|
config when there is — and `gitea.Fail` turns an SDK `(response, error)` pair into
|
|
the same `*APIError` a `kettle push` would report, so "the tracker said no" has one
|
|
spelling in the tree.
|
|
|
|
## Idempotent end to end
|
|
|
|
A tag that already has a release **reuses** it, an asset whose name is already there
|
|
is **replaced**, and a run repeated because the first died half way through converges
|
|
on the same release with the same assets — not a second release with doubled
|
|
attachments.
|
|
|
|
Reuse alone would only make a re-run *not fail*; it would not make it **converge**. A
|
|
second run with corrected notes has to leave the release holding the corrected notes,
|
|
or the retry that fixed the mistake published the mistake again. Empty notes mean
|
|
"leave what is there", not "clear them": `--notes-file` is how notes are supplied,
|
|
and a run that supplied none is not asking for the release to be emptied.
|
|
|
|
A 404 from the release lookup is an **answer** — it is what "no release yet" looks
|
|
like — and anything else is reported, because "the instance refused us" and "there is
|
|
nothing there" must not both read as "create one".
|
|
|
|
The by-tag route is a lookup *through the tag*, and a draft need not have one, so a
|
|
404 there is followed by a scan of the release listing before anything is created.
|
|
Without it a retried `--draft` publish would file a second release for one tag —
|
|
which is the failure this whole section exists to prevent, arriving through the one
|
|
door that looks like the ordinary case.
|
|
|
|
## Order of operations
|
|
|
|
Everything that can be wrong in the arguments is reported **before a release exists
|
|
to be half-published**:
|
|
|
|
1. `--tag` is required;
|
|
2. every asset is stat'ed up front — a release that exists with half its assets on
|
|
it, published by a run that then failed on a typo, is the failure this prevents;
|
|
3. two files with one basename are refused, because an attachment is addressed by
|
|
name and the second would silently replace the first while the receipt claimed
|
|
both went up;
|
|
4. notes are read from disk;
|
|
5. only then does anything dial. The attachment listing is read once, before the
|
|
first upload, so the names that matter are the ones that were there when the run
|
|
started.
|
|
|
|
## Usage
|
|
|
|
Through the Makefile, which adds the three refusals that make a release
|
|
reproducible — dirty tree, `TAG` that is not what `git describe` reports, tag not
|
|
pushed to the remote:
|
|
|
|
```bash
|
|
make release TAG=v1.2.3 [NOTES=notes.md] [TITLE="…"]
|
|
```
|
|
|
|
Directly, when the Makefile is not what you want:
|
|
|
|
```bash
|
|
KETTLE_URL=… KETTLE_TOKEN=… KETTLE_REPO=owner/name \
|
|
go run ./cmd/release --tag v1.2.3 --notes-file notes.md dist/kettle_* dist/SHA256SUMS
|
|
```
|
|
|
|
`--draft` and `--prerelease` are there; `--target` names the commitish a tag is
|
|
created from when the tag does not exist yet.
|
|
|
|
## Keeping this file true
|
|
|
|
- **Scope:** `main.go`, `publish.go` and their test — the argument checks, the
|
|
convergence rules, and the two decisions above about what this tool does *not*
|
|
share with `kettle`.
|
|
- **Update it when** a flag is added, the idempotency rules change, it starts or
|
|
stops borrowing something from `internal/`, or the Makefile's refusals change.
|
|
- **Do not** move any of this into `kettle`'s command registry without answering the
|
|
first section — a verb here becomes documentation an agent loads.
|