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
+107
View File
@@ -0,0 +1,107 @@
# AGENTS.md — internal/project (ROOT)
**One question: which directory is the project.** Everything that is a fact about
a project — the issue store, the request-payload scratchpad, the tracker config —
is resolved from the answer, and the answer is found by one walk written once.
This package **depends on nothing** but the standard library, and it is the only
one in the tree with no other package below it.
| file | what is in it |
|---|---|
| `project.go` | `Marker`, `Anchors`, `Parents`, `GitDirOf`, `MainWorktree`, `Root`, and the paths resolved from it — `StoreRoot`, `PayloadRoot`, `ConfigPath` — plus `NotFoundError` |
| `init.go` | `Init`: creates the marker, migrates an older layout in, gitignores `.kettle/`. `ClashError` is its refusal |
| `project_test.go` | the walk, including the worktree hop and the "no marker anywhere" answer |
## The walk
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`.
A marker, not a fixed number of `..` hops: how deep a caller sits below the root
is an implementation detail of the layout, and the layout is not a promise. Walking
up means every command sees one store from anywhere inside the project — including
from inside the store itself — while a `cd` into a *different* project correctly
answers with that project's store.
The worktree hop is one level of indirection, never two: a main checkout is not
itself a linked worktree, so it cannot chain and cannot cycle. Only a `.git` *file*
is a pointer; in an ordinary clone `.git` is a directory and there is nothing to
follow. A submodule's `.git` is a pointer too, but it points into
`<super>/.git/modules/…`, and `MainWorktree` refuses it on the `.git` basename
check — the tree it belongs to is already on the parent chain.
## Two rules that are not negotiable
**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. This is the whole reason the package exists
— the Python version resolved its store from `__file__` and wrote issues into a
versioned plugin cache.
**The marker is created by `kettle init`, never inferred.** `.git` was tried and is
in every clone, including this repository's own, which is how a plugin came to
resolve its store inside itself. No marker anywhere is an *answer*, not a fallback:
`NotFoundError` names the anchors the search began from — not the whole chain,
because an operator who sees the two places it started knows immediately whether it
started where they meant it to.
## Init, and the migration
`Init` is idempotent and every step announces itself, so `--dry-run` is the same
code path with the writes turned off:
- creates `.kettle/issues/` and `.kettle/payload/`;
- migrates an older store in, oldest layout first — `tmp/issues`, then
`.tea/issues`, and the same pair for `payload` — so a tree that skipped a
generation still lands in one place;
- adds `.kettle/` to `.gitignore`, unless some line already ignores it.
**Each migration is a move, never a copy.** Two stores is the state the marker
exists to prevent, and a store left behind at an old path is a store somebody will
edit by accident months later. When both sides hold a file of the same name it
stops with a `ClashError` naming up to five of them and changes nothing: two
versions of one issue, and which survives is not a decision a migration makes
quietly. The old `.tea` marker is removed only when the migration emptied it —
anything else parked in there is somebody's.
`.kettle/` is gitignored because an `origin: local` issue is the only copy of that
work and what goes into a shared history is the operator's call. Committing the
store is a legitimate choice; drop the line if the team makes it.
## Usage
```go
root := project.Root("") // "" when there is no project
store := project.StoreRoot("") // <root>/.kettle/issues
if store == "" {
return project.NotFoundError("") // names the directories it searched
}
```
A non-empty `start` overrides both anchors and exists so resolution can be
exercised against a scratch tree — which is what the test suite does, and why
every fixture also strips `CLAUDE_PROJECT_DIR`.
## What does not belong here
Anything that reads or writes an issue, a config file or a socket. This package
hands out **paths** and one answer about directories; the store is
[`issue`](../issue/AGENTS.md)'s, the config is
[`config`](../config/AGENTS.md)'s, and the scratchpad is filled by
[`gitea`](../gitea/AGENTS.md).
## Keeping this file true
- **Scope:** `project.go`, `init.go`, `project_test.go` — the walk, the marker, the
paths derived from it, and the migration.
- **Update it when** an anchor is added or reordered, the marker name changes, a
new path is resolved under the marker (the file table and the walk section both
name them), a legacy layout is added to or dropped from the migration list, or
the worktree rule changes.
- **Do not** document what any resolved path is *used for*; that belongs to the
package that uses it.