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
+145 -239
View File
@@ -1,11 +1,15 @@
# AGENTS.md — the kettle CLI
`kettle` is a globally installed binary. It owns everything that used to be a
Python script under `plugins/tea/skills/*/scripts/`: what an issue is, where the
store lives, who this machine is, and how issues move to and from Gitea.
`kettle` is a globally installed binary. It owns everything mechanical about
issues: what an issue is, where the store lives, who this machine is, and how
issues move to and from Gitea. It replaced a set of Python scripts that used to
ship inside the plugin.
The plugin keeps what only a plugin can carry — the rules an operator states and
a binary cannot enforce. Everything mechanical is here.
a binary cannot enforce. Everything else is here.
**This file is the binary's map.** Each package documents its own rules in its own
directory; nothing below is repeated there and nothing there is repeated here.
## Why a binary
@@ -20,47 +24,17 @@ Three failures in the Python version were failures of *runtime*, not of logic:
convention checked by grep.
A compiled binary answers all three by construction. There is one walk
(`internal/project`), it is imported rather than re-derived, and the layering
rule is a build graph a test walks.
## Two dependencies
`gopkg.in/yaml.v3` and `code.gitea.io/sdk/gitea`, both vendored, and that is the
whole list — eight modules once the SDK's own are counted, 2.3 MB of `vendor/`.
The CLI still has no cobra: commands are values in a registry, which is what
lets the plugin's SKILL.md files be generated from the same struct that holds
the code.
`vendor/` is committed, so a build needs no network. **`go.mod` says `go 1.26`**,
which the SDK requires; that is now the minimum for anybody building this binary.
The transport was hand-rolled `net/http` against the REST API and is now the
official SDK. What that bought:
- **the payload shapes are one vocabulary, maintained by the people who maintain
the server.** They used to be ours, in `internal/wire`, and every field Gitea
learned was a field somebody here had to notice.
- **the server's version, for free.** The SDK negotiates it when a client is
built, which is what lets the transport answer "does this instance have issue
dependencies?" from the version instead of guessing from a status code — see
`dependenciesSince`.
What it cost is written down where it happened: the shapes come with an HTTP
client attached (see the layering note below), a run makes one extra request
before it does anything (the version handshake), and Gitea's issue **edit**
endpoint carries no labels, so an issue whose labels changed needs a second call
`push` makes it and says so.
The SDK is imported as `sdk` everywhere, so one type has one spelling across the
tree. `internal/gitea` is the package named `gitea` and the SDK is `sdk` inside
it; the same alias holds in `internal/mapping` and `internal/cmd`.
([`internal/project`](internal/project/AGENTS.md)), it is imported rather than
re-derived, and the layering rule is a build graph a test walks.
## Layers
Knowledge flows one way. The arrow means "imports".
Knowledge flows one way. The arrow means "imports"; follow a name to that
package's own AGENTS.md.
```
cmd/kettle thin main; exit status only
cmd/release build infrastructure, not a kettle verb — see its own file
internal/cmd the command tree: flags, receipts, exit codes
│ │ │
@@ -86,185 +60,92 @@ internal/project ROOT which directory is the project, and every path
depends on nothing
```
Both the transport and the bridge also import `code.gitea.io/sdk/gitea`, which
is where the payloads now live. The arrow that used to point at `internal/wire`
for them points there instead.
| package | layer | what its own file opens with |
|---|---|---|
| [`cmd/kettle`](cmd/kettle/AGENTS.md) | entry point | `os.Exit(cmd.Main(os.Args[1:]))`, and why there is nothing else in it |
| [`cmd/release`](cmd/release/AGENTS.md) | build tool | why publishing a release is not a `kettle` verb, and why it goes around the transport |
| [`internal/cmd`](internal/cmd/AGENTS.md) | commands | the registry every command is a value in, and the generator that writes the plugin's docs from it |
| [`internal/config`](internal/config/AGENTS.md) | configuration | two files, and why the tokens are not in the one inside the repository |
| [`internal/gitea`](internal/gitea/AGENTS.md) | transport | the SDK, the payload scratchpad, the ledger, the dependency endpoint |
| [`internal/mapping`](internal/mapping/AGENTS.md) | bridge | md ↔ payload, the id marker, label colours, the checkbox merge |
| [`internal/wire`](internal/wire/AGENTS.md) | addresses | `42`, `#42`, `owner/repo#42`, a URL — four spellings of one thing |
| [`internal/issue`](internal/issue/AGENTS.md) | domain | the format, the taxonomy, the store, eviction — all of it offline |
| [`internal/project`](internal/project/AGENTS.md) | root | the walk, and every path resolved from its answer |
Read it bottom-up and each layer knows strictly less about trackers than the one
above it. Four rules hold the line, seven tests between them, and each fails on
a real mistake rather than on a naming convention:
The rules that hold the layers apart, and the seven tests that fail when one
breaks, are in [`internal/AGENTS.md`](internal/AGENTS.md). Read the diagram
bottom-up: each layer knows strictly less about trackers than the one above it.
- `internal/issue` may import `internal/project` and the standard library, and
nothing else. One test walks `go list -deps` and fails on any path with a dot
in its first element — which is also what keeps yaml AND the SDK out of the
domain — and another names `net/http`, `net`, `os/exec` and `encoding/json`,
standard library the first test would not catch. **Untouched by the migration
to the SDK, and that is the point: the domain did not notice it happened.**
- `internal/wire` imports only the standard library, checked the same two ways.
- `internal/gitea` must not import `internal/issue` **or** `internal/mapping`:
the transport knows numbers, logins, HTTP and JSON, and none of what they mean.
- `internal/mapping` reaches for nothing but the domain and the SDK — checked on
its DIRECT imports, with `os`, `net/http` and `internal/gitea` named — and a
second test greps its sources for `time.Now`.
## Dependencies, and building
**That fourth rule was stronger before the SDK, and the trade is deliberate.**
The payload shapes lived in `internal/wire`, a package that imported the
standard library and nothing else, so "the bridge cannot reach a transport" was
a fact about the import graph: there was nothing in its dependency closure that
could open a socket. `code.gitea.io/sdk/gitea` is a client and a set of types in
one package, so importing the types imports the client, and a test that walked
the closure would now be asserting something false. What is still true, and what
the test now says, is that **mapping performs no I/O** — no `os`, no `net/http`,
no transport, no configuration, no clock. `time` is allowed where it was not,
because the SDK hands over a `time.Time` and somebody has to format it back into
the string an issue file holds; the clock itself is still the caller's, and the
grep for `time.Now` is what says so.
`wire` used to exist because Go needs the JSON shapes to be one type — the
transport and the bridge were written in parallel and each invented its own
`Issue`, `Label`, `Milestone` and `Comment`. The SDK settles that argument for
the shapes. **What survives is addressing**, which the SDK has no answer for at
all: it takes an owner, a name and an `int64`, and never parses. `42`, `#42`,
`owner/repo#42` and an issue URL are four spellings of one address, all four are
what somebody has in hand, and `wire.Key` is what the ledger is keyed by and
what the `gitea:` metadata field holds. So `wire` keeps `Repo`, `Key`, their
parsing and their tests, and lost the payloads.
If a tracker concept — an issue number, a login, an HTTP call, a label colour —
shows up in `internal/issue`, it is in the wrong place.
## The walk
`internal/project` answers one question and everything else reads the answer.
Anchors, first hit wins: `$CLAUDE_PROJECT_DIR`, then the working directory. Each
is searched up its parent chain for a `.kettle/` marker, and then — only if that
found nothing — up the parent chain of the **main working tree of any linked
worktree** met on the way, reached by reading `gitdir:` out of a `.git` *file*
and following `commondir`.
Nothing here resolves from the executable's own location. Where an installation
keeps its files is a fact about the installation; whose issues a tree has is a
fact about the tree, and a binary installed in one place and pointed at another
must answer from the one it was pointed at.
**The marker is created by `kettle init`, never inferred.** `.git` was tried and
is in every clone. No marker anywhere is an answer, not a fallback: the command
reports which directories it searched and stops.
## Configuration, and where secrets are not
Two files, and the split is the whole design.
`<project>/.kettle/config.yaml` — written by `kettle init`, says which tracker
repository the issues belong to and which login to reach it under. It pins a
login by **name**. The name is worth nothing on its own, which is what makes it
safe to keep in a file inside a working tree.
`~/.config/kettle/logins.yaml` (`$KETTLE_CONFIG_HOME` or `$XDG_CONFIG_HOME`
override it) — one file per machine, mode 0600, holding the tokens. Managed with
`kettle auth`, which reads the token from standard input by default because an
argument is in the shell history the moment it is typed. Nothing prints a token
back: `kettle config` shows `(set)`.
A token in a file inside a working tree ends up in a commit. Not always, not
immediately, and not by anyone careless — but a project config is exactly the
file somebody eventually decides to share, and a secret that has ever been
pushed has to be rotated.
`KETTLE_LOGIN`, `KETTLE_REPO`, `KETTLE_URL` and `KETTLE_TOKEN` each override the
file they shadow, for CI and for anyone who would rather have no token on disk.
Unknown keys in either file are an **error**, not a silent drop: an older binary
reading a newer config would otherwise delete the setting it did not recognize
the next time it wrote the file.
`init` gitignores `.kettle/` wholesale. An `origin: local` issue is the only copy
of that work, and what goes into a shared history is the operator's call, not
this binary's — drop the line if the team decides otherwise.
## The store
`<project root>/.kettle/issues`, one flat markdown file per issue, named by its
slug, one metadata field per line so plain grep works without a parser.
It holds two kinds of file and only one of them is a store. An `origin: local`
issue lives here and nowhere else — that file **is** the issue. Anything with a
tracker origin is a working copy, deleted the moment a push confirms the tracker
is up to date. Eviction makes the same trade one step earlier and asks the file
instead of the tracker, which is why it lives in the domain.
`.kettle/payload/` is a sibling, never a child: request bodies are debris of the
transport, and a scratchpad inside a store makes `ls .kettle/issues` lie about
what exists. It is written by an `http.RoundTripper` installed on the SDK's
client, so **every** request with a body is filed and not only the ones a call
site remembered to name — a name only decides what the file is called. A run
that sends nothing, which includes every read-only command, still leaves no
directory: the first write creates it. The dump is the same JSON the wire
carried, re-indented and with `<`, `>` and `&` left alone, because the SDK
marshals with encoding/json's escaping and a dump nobody can read is a dump
nobody reads.
`kettle init` migrates older layouts in, oldest first — `tmp/issues` and then
`.tea/issues` — and each is a **move**. A store left behind at an old path is a
store somebody will edit by accident months later. It refuses to pick a winner
when both sides hold a file of the same name.
## Tests
`gopkg.in/yaml.v3` and `code.gitea.io/sdk/gitea` — eight modules once the SDK's
own are counted. No cobra: commands are values in a registry, which is what lets
the plugin's SKILL.md files be generated from the same struct that holds the code.
```bash
cd cli && go test ./...
make install # build straight onto your PATH, version stamped
make install BINDIR=$(go env GOPATH)/bin
go build -o ~/.local/bin/kettle ./cmd/kettle # the same thing, unstamped
go install git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest
```
`internal/cmd` builds the binary once in `TestMain` and runs it as a subprocess
against a throwaway project in a temp directory — the same discipline the Python
suite arrived at, for the same reason. **The binary is never run in the
directory it was built in**, because that is exactly the arrangement that hid
the `__file__` bug: a tool is installed in one place and used on projects in
another, and a test that collapses the two proves nothing about resolution.
**`make` is the build, and it is also the CI.** There is no act_runner on the
instance this lives on and none is planned, so nothing runs on a push: `make check`
— fmt, vet, test, `go mod verify`, build, `gen skills --check` is the only thing
standing between a mistake and the tracker, and it is on whoever is committing to run
it. `make help` lists the rest; `make dist` cross-compiles four platforms with a
`SHA256SUMS`, and `make release TAG=v1.2.3` publishes them through
[`cmd/release`](cmd/release/AGENTS.md) after refusing a dirty tree, a `TAG` that is
not what `git describe` reports, and a tag the remote does not have.
Every fixture strips `CLAUDE_PROJECT_DIR`. It is the first anchor of the walk,
so the harness's own value would point every fixture at this repository.
Anything touching credentials sets `KETTLE_CONFIG_HOME` at a temp directory, so
a test run can neither read nor overwrite the developer's own tokens.
A hand build says `dev` for `kettle version` and means it: a binary out of somebody's
working tree is not a release and must not claim to be one. The version is stamped at
link time into `internal/cmd.Version`, derived from `git describe` rather than kept in
a file — a number somebody has to remember to bump is a number that will be wrong.
**Every fake tracker answers `/api/v1/version`**, because building a client asks
for it: the SDK will not hand one back until the instance has said what it is,
and that answer is what the dependency gate is decided on later. The fakes say
1.26.1. One says 1.19.4, and that is a test — an instance too old for the issue
dependency endpoints is answered from its version, with no request made.
**`go.mod` says `go 1.26`**, which the SDK requires, and **`vendor/` is
committed** — 281 files, 2.3 MB, which is the price of knowing exactly what
compiled.
Be precise about what that buys, because it is easy to overclaim: vendoring
pins the *contents* of every dependency in this repository's own history, so a
dependency that is retagged, yanked or unreachable cannot change what this
binary is built from. It does **not** by itself give a network-free build.
`go 1.26` in `go.mod` means `GOTOOLCHAIN=auto` fetches a toolchain over the
network on any machine whose local Go is older, which is most of them. If an
air-gapped build is ever a requirement, pin the toolchain too — vendoring alone
will not get you there.
`make check` runs both halves: `go mod verify` says the module cache matches
`go.sum`, and `go build -mod=vendor ./...` says the committed tree is complete
and is what actually compiles. A `vendor/` that has drifted from `go.mod` fails
nothing until somebody builds with a cold cache, which is exactly when nobody
wants to find out.
The transport is the official SDK rather than hand-rolled `net/http`. What that
bought: the payload shapes are one vocabulary maintained by the people who
maintain the server, and the instance's version arrives for free, which is what
lets the transport answer "does this instance have issue dependencies?" from the
version instead of guessing from a status code. What it cost is written down where
it happened — see [`internal/gitea`](internal/gitea/AGENTS.md) and
[`internal/mapping`](internal/mapping/AGENTS.md).
The SDK is imported as `sdk` everywhere, so one type has one spelling across the
tree.
## The round trip
`push` and `pull` are the two halves of one rule, and the rule is that **the
store holds what has not left this machine.**
store holds what has not left this machine.** A successful push deletes the local
file — on `--update` too, one rule with no exception — and only after the tracker
confirms the write and the number → slug ledger is written. A pull is how the copy
comes back, under the same slug, on a machine that has never seen it.
A successful push deletes `<id>.md` and every sidecar under that slug, on create
and on `--update` alike, and prints the number and URL the issue now lives at.
An `--update` can take one extra request with it: Gitea's edit endpoint carries
no labels, so when the answer's label set and the issue's disagree — a label
added or removed locally, or one a create silently dropped — the whole set goes
up in a PUT, and a warning on stderr says which names moved.
The deletion happens **only after a confirmed tracker response and only after the
number -> slug ledger has been written** — network down, non-2xx, or an answer
that does not carry the right number, and the file stays where it is while the
run stops. A never-pushed `origin: local` issue is never touched by any of it.
The slug survives that round trip two ways over, and a test proves both: it goes
up in the body as `<!-- kettle:id … -->`, and it is indexed by number in
`.kettle/issues/.remote.json`. A rename in the web UI, a lost ledger, a fresh
clone, another machine — the file comes back under the same name and every
`depends:` pointing at it still resolves. The marker is written in that spelling
and read in both it and the older `<!-- tea:id … -->`, because issues pushed
before the rename are still in the tracker.
Pull by number fetches an issue in **any** state: a number is an address, not a
query, and `42`, `#42`, `owner/repo#42` and a URL all name one. Only filter mode
leaves closed issues out. A pull returns the unit of work rather than one row of
it — blockers come down with it unless `--no-deps` says otherwise — and it
overwrites the body, because it is a fetch and not a merge. The one exception is
checkbox state, which is the local half of the work and is merged rather than
clobbered.
The mechanics of each half live with the commands that implement them, in
[`internal/cmd`](internal/cmd/AGENTS.md); the ledger that survives the deletion is
[`internal/gitea`](internal/gitea/AGENTS.md)'s, and the marker that survives a lost
ledger is [`internal/mapping`](internal/mapping/AGENTS.md)'s.
## No guard hook
@@ -276,45 +157,70 @@ for the transport and the guard to disagree — the failure the hook existed to
catch is not expressible any more.
There is also no `--login` and no `--repo` on any sync command bar `labels`.
Which login a project runs under, and which repository its issues belong to, are
facts about the project, stated once by `kettle init`. A cross-repository address
is still an address: `kettle pull owner/repo#42` re-points the client for that
one call — `Client.For(repo)`, which is bookkeeping and not a second connection,
because the SDK takes the owner and the name per call. The credentials, the
negotiated version and the scratchpad come along.
A cross-repository address is still an address: `kettle pull owner/repo#42`
re-points the client for that one call, which is bookkeeping and not a second
connection.
## Issue dependencies
## Tests
The one endpoint with a story. `depends:` becomes a native Gitea link, which is
what makes the tracker show the blocking panel and refuse to close a blocked
issue first, and it is read back the same way on a pull.
```bash
make check # fmt, vet, test, go mod verify, build, docs — the whole gate
go test ./... # just the tests
```
- **Reading** goes through the SDK (`ListIssueDependencies`).
- **Writing does not.** Gitea's own `IssueMeta` is `{index, owner, repo}` and has
been since the endpoint existed; the SDK's is `{index}`, which can only ever
link inside one repository, and a `depends:` entry is allowed to live
somewhere else. So one hand-rolled POST survives, through the same HTTP client
as everything else — same payload dump, same `*APIError`.
- **The version gates both.** The routes are absent from Gitea 1.19 and present
in 1.20, checked against the release tags themselves, so an instance older
than that is answered from the version it already gave us rather than from a
404 — which on an old server is also what "no such issue" looks like.
- **A tracker that answers with a status still means "no dependencies here"**,
because an instance that HAS the endpoint can still have them turned off for a
repository, and a pull must bring the issue back either way. A dead connection
is not that answer and never was.
Three disciplines every test follows:
- **`internal/cmd` builds the binary once in `TestMain` and runs it as a
subprocess** against a throwaway project in a temp directory. The binary is
never run in the directory it was built in, because that is exactly the
arrangement that hid the `__file__` bug: a tool is installed in one place and
used on projects in another, and a test that collapses the two proves nothing
about resolution.
- **Every fixture strips `CLAUDE_PROJECT_DIR`**, the first anchor of the walk, or
the harness's own value would point every fixture at this repository. Anything
touching credentials sets `KETTLE_CONFIG_HOME` at a temp directory, so a run can
neither read nor overwrite the developer's own tokens.
- **Every fake tracker answers `/api/v1/version`**, because building an SDK client
asks for it. The fakes say 1.26.1; one says 1.19.4, and that one is a test — an
instance too old for the dependency endpoints is answered from its version with
no request made.
## Documentation that is generated
```bash
kettle gen skills --out ../plugins/kettle/skills # rewrite the blocks
kettle gen skills --out ../plugins/kettle/skills --check # exit 1 if stale
```
Everything between `<!-- kettle:gen -->` and `<!-- /kettle:gen -->` in the
plugin's SKILL.md files comes from the command registry, so a renamed flag cannot
ship with documentation that recommends the old one. `--check` is what a
pre-commit hook or a CI step calls. The generator is
[`internal/cmd`](internal/cmd/AGENTS.md)'s, and the seam between command groups
and plugin skills is [the plugin's](../plugins/kettle/AGENTS.md).
Note the asymmetry, because it is easy to get backwards: **a SKILL.md command
block is generated and must never be hand-edited; every AGENTS.md in this tree is
hand-written and must never be generated.** One is a flag table, the other is a
reason.
## Status
Done and tested: all seven packages, and the commands `init`, `auth`, `config`,
`new`, `check`, `ac`, `tree`, `index`, `evict`, `pull`, `push`, `remote`,
`comment`, `close`, `labels`, `sync-evict`. 99 tests.
Done and tested: every package in the table above, and the commands `init`, `auth`,
`config`, `gen`, `version`, `new`, `check`, `ac`, `tree`, `index`, `evict`, `pull`,
`push`, `remote`, `comment`, `close`, `labels`, `sync-evict` — plus `cmd/release`,
against a fake Gitea.
The plugin is rewired: it lives at `plugins/kettle`, ships no Python and no guard
hook, and its command reference is generated from this registry
`kettle gen skills --out plugins/kettle/skills`, with `--check` as the invariant.
The generator writes one file per GROUP (`project`, `issue`, `sync`); the plugin
also carries `init`, `auth` and `use`, which hold procedure rather than flags and
point at the generated `project` block. Adding a group here adds a skill
directory there, so name one only when it is a subject somebody would load on its
own.
The plugin is rewired: it lives at `plugins/kettle`, ships no Python domain code
and no guard hook, and its command reference is generated from this registry.
## Keeping this file true
- **Scope:** the binary as a whole — why it exists, what it depends on, how it is
built and tested, and where each package's own document is. Files: `go.mod`,
`go.sum`, `Makefile`, and the shape of the tree.
- **Update it when** a package is added or removed (the diagram and the table both
name every one), a dependency changes, a `make` target is added or its meaning
changes, or a cross-package contract like the round trip changes.
- **Do not** describe a package's internals here. That is what the files it links to
are for, and a summary that drifts is worse than a link.