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,187 @@
|
||||
# AGENTS.md — internal/cmd
|
||||
|
||||
**The command tree: flags, receipts, exit codes.** The only package that may import
|
||||
every layer below it, and the only one that prints.
|
||||
|
||||
`cmd/kettle` is four lines around `cmd.Main(os.Args[1:])` — everything a `main`
|
||||
usually accumulates lives here instead, because a `main` package cannot be imported
|
||||
and therefore cannot be tested.
|
||||
|
||||
## Commands are values
|
||||
|
||||
Each command is one `register(&Command{…})` in an `init()`, carrying the metadata a
|
||||
human needs — `Short`, `Long`, `Examples`, `Args`, `Group` — **in the same struct
|
||||
that carries the code**. That is what lets the plugin's SKILL.md files be generated
|
||||
from this list: a command whose flags changed cannot ship with documentation that
|
||||
says otherwise.
|
||||
|
||||
```go
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "tree",
|
||||
Group: GroupIssue,
|
||||
Args: "[<id>…]",
|
||||
Short: "draw the dependency graph of the local store",
|
||||
Long: `…`,
|
||||
Examples: []Example{{"kettle tree", "every root (nothing depends on it)"}},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
out := storeFlag(fs)
|
||||
depth := fs.Int("depth", 6, "maximum depth")
|
||||
return func(args []string) error { … }
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**`Setup` registers flags and returns the runner**, closing over them. Splitting it
|
||||
that way is what lets `Command.Flags()` walk a command's flags without running
|
||||
anything — which is how the doc generator reads them.
|
||||
|
||||
**The tree is flat.** `kettle new`, not `kettle issue new`: an agent pays for every
|
||||
token of every invocation, and the grouping that matters for reading is carried in
|
||||
`Group` and only shows up in the docs. Three groups, in presentation order:
|
||||
`project`, `issue`, `sync`.
|
||||
|
||||
| file | what is in it |
|
||||
|---|---|
|
||||
| `command.go` | `Command`, the registry, `Main`, help rendering, `SilentError`, `Fail`, and `permute` |
|
||||
| `flags.go` | `storeFlag`/`storeRoot`, `wasSet`, the repeatable `stringList` |
|
||||
| `sync.go` | `syncStart`/`syncStartExisting`, `commentsSidecarPath` — the shared opening of every tracker command |
|
||||
| `gen.go` | `kettle gen skills`: the generated region in the plugin's SKILL.md files |
|
||||
| `init.go` `auth.go` `config.go` `version.go` | group `project`. `version.go` also holds `Version`, the string a release build stamps in with `-ldflags -X` |
|
||||
| `new.go` `check.go` `ac.go` `tree.go` `index.go` `evict.go` | group `issue` — no network in any of them |
|
||||
| `pull.go` `push.go` `remote.go` `comment.go` `close.go` `labels.go` `evict_sync.go` | group `sync` |
|
||||
| `cli_test.go` | builds the binary in `TestMain`, runs it as a subprocess |
|
||||
| `sync_pull_test.go` `sync_write_test.go` | the tracker halves, against fake servers |
|
||||
| `gen_test.go` | the generator: determinism, the region splice, the missing-marker refusal |
|
||||
|
||||
## Three conventions every command follows
|
||||
|
||||
**Flags may come after positionals.** The standard `flag` package stops parsing at
|
||||
the first non-flag argument, so `kettle ac <id> --check 3` would hand `--check` to
|
||||
the command as a positional and tick nothing. `permute` moves flags forward, using
|
||||
the `FlagSet` to know whether a flag swallows the next argument; `--` ends the
|
||||
permutation. Every other CLI an operator uses interleaves the two, and a tool that
|
||||
silently ignores a flag because of where it was typed is worse than one that rejects
|
||||
it.
|
||||
|
||||
**Exit codes are three.** `0` fine, `2` for a usage problem (unknown command,
|
||||
unparseable flags), `1` for an ordinary failure — printed as `kettle <name>: <err>`
|
||||
by `Main`, which is why no command prefixes its own errors. `SilentError{Code: 1}`
|
||||
is for a command that has already said everything it has to say: `check` and
|
||||
`gen --check` use it, because findings went to stdout and a second copy on stderr
|
||||
would be noise.
|
||||
|
||||
**The store is resolved before a socket is opened.** `syncStart` does that in one
|
||||
place: a command that dialled first would report a network problem for a project
|
||||
that was never initialized, and an operator would go looking at the wrong thing.
|
||||
`syncStartExisting` adds `RequireStore` for the commands that read the store rather
|
||||
than create it — `push`, `comment`, `close`, `sync-evict` — because a missing store
|
||||
is a mistake to report, not a directory to conjure.
|
||||
|
||||
**There is no `--login` and no `--repo`** on any sync command bar `labels`. Which
|
||||
login a project runs under is a fact about the project, stated once by
|
||||
`kettle init`. That the two could disagree is what the Python version needed a
|
||||
`PreToolUse` hook to police.
|
||||
|
||||
`--out` is the one flag almost every command has, and an explicit one is used
|
||||
**exactly as typed**: a relative `--out` stays relative to the working directory,
|
||||
because that is what the operator asked for.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
kettle help # the tree, grouped
|
||||
kettle help push # one command in full: flags, defaults, examples
|
||||
|
||||
kettle init --login noodles --repo owner/name
|
||||
kettle new --type task --title "Wire sqlc into the appclick repo layer"
|
||||
kettle ac wire-sqlc-appclick --check 3
|
||||
kettle check --strict # exit 1 on any error; --strict counts warnings too
|
||||
|
||||
kettle pull 42 # the issue and everything blocking it, any state
|
||||
kettle push --update wire-sqlc-appclick
|
||||
kettle sync-evict --dry-run
|
||||
```
|
||||
|
||||
Every command's own `Long` text is the reference — it is what
|
||||
`kettle help <name>` prints and what the generator writes into the plugin. **Do not
|
||||
restate a flag table here**; it would be a third copy of something already in two
|
||||
places, one of them mechanically checked.
|
||||
|
||||
## push and pull, the two halves of one rule
|
||||
|
||||
The rule is that **the store holds what has not left this machine.** Both halves are
|
||||
worth reading in full before either file is touched.
|
||||
|
||||
`push` (`push.go`) deletes `<id>.md` and every sidecar under that slug — on create
|
||||
and on `--update` alike, one rule with no exception, because a `PATCH` is a push and
|
||||
two rules would put back exactly the question this removes ("is my copy the fresh
|
||||
one?"). The deletion is the **last** thing that happens, and only after all three of:
|
||||
|
||||
1. the call came back without an error and with a 2xx,
|
||||
2. the answer carries a plausible number — on `--update`, the very number that was
|
||||
`PATCH`ed,
|
||||
3. the number → slug ledger has been written.
|
||||
|
||||
Network down, non-2xx, an answer that does not confirm the write: the file stays and
|
||||
the run stops. Get the ordering wrong and a slug is lost at exactly the moment the
|
||||
local copy stops being the record, which is why the ledger is written *before*
|
||||
anything is deleted. A never-pushed `origin: local` issue is never touched by any of
|
||||
it.
|
||||
|
||||
Dependencies go first, in topological order, so a blocker has its number before the
|
||||
issue that names it. An `--update` can take one extra request with it, because
|
||||
Gitea's edit endpoint carries no labels — when the answer's label set and the
|
||||
issue's disagree the whole set goes up in a `PUT`, and a warning on stderr says
|
||||
which names moved.
|
||||
|
||||
`pull` (`pull.go`) is how a pushed issue comes back. Three sources answer "what is
|
||||
this issue called here", in this order: the ledger (the only one that knows what is
|
||||
on disk *right now*, so it wins), the `<!-- kettle:id … -->` marker in the
|
||||
tracker-side body, then the slugified title. A marker is taken at its word only when
|
||||
the slug is free; a name already in use is a collision, not an identity, and is
|
||||
uniquified.
|
||||
|
||||
Two ways to name what to pull, and they are **not the same operation**: a key is an
|
||||
*address* and fetches an issue in any state, while a filter is a *query* and leaves
|
||||
closed issues out. `--limit` is on the **write**, not the selection — it counts what
|
||||
lands in the store, which is why a filtered pull can enumerate far more than it
|
||||
keeps and says so. Blockers come down too, recursively to `--depth`, and are outside
|
||||
the limit: a blocker is followed because a stored issue named it. A pull **overwrites
|
||||
the body** — it is a fetch, not a merge — with checkbox state the one exception.
|
||||
|
||||
## The generator
|
||||
|
||||
`gen.go` writes the plugin's SKILL.md command reference from this registry.
|
||||
|
||||
**It owns a region, not a file.** Everything between `<!-- kettle:gen -->` and
|
||||
`<!-- /kettle:gen -->` is replaced on every run; every byte outside comes back
|
||||
exactly as it was, which matters most for `description:`, the prose that decides
|
||||
whether an agent loads the skill at all and the one thing here no generator can
|
||||
write. A file with **no** markers is reported and left alone, never overwritten —
|
||||
clobbering somebody's prose because they forgot a marker is the failure this design
|
||||
exists to prevent.
|
||||
|
||||
The output is deterministic to the byte — no timestamps, no map iteration — so
|
||||
regenerating something unchanged produces no diff. `--check` is that property made
|
||||
useful: it writes nothing and exits 1 when anything on disk differs, which is what a
|
||||
pre-commit hook or a CI step calls, and it wins over `--dry-run`.
|
||||
|
||||
One file per **group**, so adding a group here adds a skill directory over there;
|
||||
name one only when it is a subject somebody would load on its own. A command with no
|
||||
`Group` is in no skill and the run says so. A `Long` or `Example` that spells a
|
||||
region marker out in full is a hard error — the generated block would end inside
|
||||
itself.
|
||||
|
||||
## Keeping this file true
|
||||
|
||||
- **Scope:** the shape of the command tree — the registry, the shared helpers, the
|
||||
three conventions, the round trip, the generator. The file table names every
|
||||
source file in this directory.
|
||||
- **Update it when** a command file is added or removed, a group is added, a shared
|
||||
helper in `flags.go`/`sync.go` changes, an exit code gains a meaning, or the
|
||||
push/pull ordering guarantees change.
|
||||
- **Do not** copy a flag list or a command's `Long` text here. `kettle help <name>`
|
||||
and the generated SKILL.md blocks are the two places that exist for it, and a
|
||||
third would be the one that drifts.
|
||||
@@ -491,6 +491,44 @@ func TestAMissingLoginIsExplained(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The version is "dev" until a build stamps it, and the STAMPING is what is
|
||||
// tested here rather than the printing.
|
||||
//
|
||||
// A `-X` whose symbol path is one character wrong is not an error: the linker
|
||||
// ignores it and the binary goes on reporting "dev" for the rest of its life,
|
||||
// which is discovered by an operator holding a release that will not say what
|
||||
// it is. So this builds with the flag the Makefile uses and reads the answer
|
||||
// back out of the binary.
|
||||
func TestVersionSaysDevUntilABuildStampsIt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
r := mustRun(t, dir, "version")
|
||||
if !strings.Contains(r.stdout, "dev") || !strings.Contains(r.stdout, "built") {
|
||||
t.Errorf("a build from source must say what it is:\n%s", r.out())
|
||||
}
|
||||
// A version needs no project: it is a fact about the binary, and the
|
||||
// question is asked most often by somebody whose project is not resolving.
|
||||
if short := mustRun(t, dir, "version", "--short"); strings.TrimSpace(short.stdout) != "dev" {
|
||||
t.Errorf("--short printed %q, want dev", short.stdout)
|
||||
}
|
||||
|
||||
const stamp = "v9.9.9-from-the-test"
|
||||
stamped := filepath.Join(t.TempDir(), "kettle")
|
||||
build := exec.Command("go", "build",
|
||||
"-ldflags", "-X git.noodles.cam/claude-skills/marketplace/cli/internal/cmd.Version="+stamp,
|
||||
"-o", stamped, "../../cmd/kettle")
|
||||
if out, err := build.CombinedOutput(); err != nil {
|
||||
t.Fatalf("building a stamped binary: %v\n%s", err, out)
|
||||
}
|
||||
out, err := exec.Command(stamped, "version", "--short").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("running the stamped binary: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(string(out)); got != stamp {
|
||||
t.Errorf("the stamped binary reports %q, want %q — the -X symbol path is wrong", got, stamp)
|
||||
}
|
||||
}
|
||||
|
||||
func closeIssue(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
setField(t, path, "state", "closed")
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
)
|
||||
|
||||
// Version is what this binary was built as. It is stamped at link time and
|
||||
// defaults to something honest.
|
||||
//
|
||||
// "dev" is the truth for a build from a working tree: a binary somebody built
|
||||
// out of a checkout is not a release and must not claim to be one. A release
|
||||
// build says otherwise by naming this variable:
|
||||
//
|
||||
// go build -ldflags "-X git.noodles.cam/claude-skills/marketplace/cli/internal/cmd.Version=v1.2.3" ./cmd/kettle
|
||||
//
|
||||
// which is what `make build`, `make dist` and `make release` do. The path is
|
||||
// exercised by a test that builds with the flag and reads the answer back,
|
||||
// because a -X whose symbol path is one character wrong is silently ignored and
|
||||
// the binary goes on reporting "dev".
|
||||
var Version = "dev"
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "version",
|
||||
Group: GroupProject,
|
||||
Short: "print the version this binary was built as",
|
||||
Long: `A binary that cannot say what it is, is a support problem: an operator with an
|
||||
old copy on PATH and a skill written against a newer one has no way to see the
|
||||
mismatch, and neither does anybody reading their transcript.
|
||||
|
||||
The version is stamped at link time. A build from source says "dev" and means
|
||||
it — that is not a placeholder to be edited, it is the answer for a binary that
|
||||
came out of somebody's working tree rather than off a tag.
|
||||
|
||||
The commit is reported when the build recorded one, which ` + "`go build`" + ` does from
|
||||
git and a build from an unpacked tarball cannot. A tree with uncommitted
|
||||
changes in it says so beside the commit.`,
|
||||
Examples: []Example{
|
||||
{"kettle version", "the version, the toolchain and the commit"},
|
||||
{"kettle version --short", "just the version, for a script"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
short := fs.Bool("short", false, "print the version alone, with nothing around it")
|
||||
|
||||
return func(args []string) error {
|
||||
if len(args) > 0 {
|
||||
return Fail("version takes no arguments")
|
||||
}
|
||||
if *short {
|
||||
fmt.Println(Version)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("kettle %s\n", Version)
|
||||
fmt.Printf("built %s %s/%s\n", runtime.Version(), runtime.GOOS, runtime.GOARCH)
|
||||
if rev := revision(); rev != "" {
|
||||
fmt.Printf("commit %s\n", rev)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// revision is the commit this binary was built from, or "" when the build
|
||||
// recorded none.
|
||||
//
|
||||
// `go build` stamps it out of git; a build from an unpacked tarball has no
|
||||
// repository to ask, and there is nothing to report rather than something to
|
||||
// invent. A dirty tree is named as one: the commit is then a lower bound on
|
||||
// what is in the binary and not a description of it.
|
||||
func revision() string {
|
||||
info, ok := debug.ReadBuildInfo()
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
var rev string
|
||||
var dirty bool
|
||||
for _, s := range info.Settings {
|
||||
switch s.Key {
|
||||
case "vcs.revision":
|
||||
rev = s.Value
|
||||
case "vcs.modified":
|
||||
dirty = s.Value == "true"
|
||||
}
|
||||
}
|
||||
if rev != "" && dirty {
|
||||
rev += " (with uncommitted changes)"
|
||||
}
|
||||
return rev
|
||||
}
|
||||
Reference in New Issue
Block a user