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:
@@ -0,0 +1,113 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user