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
+43
View File
@@ -0,0 +1,43 @@
# AGENTS.md — cmd/kettle
The binary's entry point, and all of it:
```go
func main() { os.Exit(cmd.Main(os.Args[1:])) }
```
One file, `main.go`, holding a package comment and that line. The sibling
[`cmd/release`](../release/AGENTS.md) is the module's other binary — build
infrastructure, deliberately not a `kettle` verb.
## Why it is empty
Everything a `main` usually accumulates — flag parsing, dispatch, usage text,
error formatting, exit codes — is in [`internal/cmd`](../../internal/cmd/AGENTS.md),
where it is **testable**. A `main` package cannot be imported, so anything written
here can only be exercised by running the binary; the command tree is instead a
library with one caller, and its tests run it as a subprocess *and* call into it
directly where that is cheaper.
The exit status is the only thing this layer owns, and it owns it because
`os.Exit` skips deferred functions: it has to happen after everything else is
finished, at the outermost frame, and nowhere else in the tree may call it.
The version a build reports is **not** stamped here either. `-ldflags -X` names
`internal/cmd.Version`, because that is where the `version` command reads it and
where a test can build with the flag and read the answer back — a `-X` whose symbol
path is one character wrong is silently ignored, and the binary goes on saying `dev`.
## Adding a command
Nothing here changes. A new command is a `register(&Command{…})` in an `init()`
over in [`internal/cmd`](../../internal/cmd/AGENTS.md) — that is the whole point
of a registry.
## Keeping this file true
- **Scope:** `main.go`, and the reason it stays this short.
- **Update it when** this package grows a second file or a line that does anything
but delegate — which should be treated as a design change and argued for, not
documented after the fact.
- **Do not** describe commands, flags or exit codes here.
+113
View File
@@ -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.
+155
View File
@@ -0,0 +1,155 @@
// Command release publishes a Gitea release for this repository, from this
// repository's own code.
//
// DELIBERATELY NOT A `kettle` SUBCOMMAND, and not for tidiness. `kettle` is a
// tool for issues, and its 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 the binary uses,
// 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, which is otherwise the one door for every
// request. Two reasons, both facts about where this 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 and would double the memory
// this uses for no reader's benefit.
//
// What it does not reinvent is credentials. internal/config resolves the
// instance, the token and the repository exactly as kettle does, environment
// first, and refuses a half-filled configuration by naming what is missing.
// The error vocabulary is internal/gitea's too — a failure here carries the
// status and quotes what the server said, in the same words a `kettle push`
// would use.
//
// IDEMPOTENT END TO END. A tag that already has a release reuses it, an asset
// whose name is already there replaces it, and a run that is repeated because
// the first one died half way through converges on the same release with the
// same assets instead of a second release and doubled attachments.
package main
import (
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
)
const usage = `usage: release --tag <tag> [flags] [<file>…]
Publish a Gitea release for the repository this configuration points at, and
upload each named file as an asset. Re-running it is safe: an existing release
for the tag is reused and an asset of the same name is replaced, never doubled.
Credentials resolve the way kettle's do — ` + config.EnvURL + `, ` + config.EnvToken + ` and
` + config.EnvRepo + `, or, when this is run from inside an initialized project, that
project's .kettle/config.yaml and the machine's login file.
flags:
`
func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) }
// run is main with its edges handed in, so a test can drive the whole tool.
func run(argv []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("release", flag.ContinueOnError)
fs.SetOutput(stderr)
fs.Usage = func() {
fmt.Fprint(stderr, usage)
fs.PrintDefaults()
}
tag := fs.String("tag", "", "the tag to publish, e.g. v1.2.3 (required)")
title := fs.String("title", "", "release title (default: the tag)")
notesFile := fs.String("notes-file", "", "file holding the release notes; empty leaves an existing release's notes alone")
target := fs.String("target", "", "commitish a tag is created from when the tag does not exist yet (default: the default branch)")
draft := fs.Bool("draft", false, "publish as a draft")
prerelease := fs.Bool("prerelease", false, "mark as a prerelease")
if err := fs.Parse(argv); err != nil {
return 2 // flag has already said what it did not like
}
s := spec{
Tag: strings.TrimSpace(*tag),
Title: strings.TrimSpace(*title),
Target: strings.TrimSpace(*target),
Draft: *draft,
Prerelease: *prerelease,
Files: fs.Args(),
}
if s.Tag == "" {
fmt.Fprintln(stderr, "release: --tag is required — the tag this release is for")
fs.Usage()
return 2
}
if err := checkFiles(s.Files); err != nil {
fmt.Fprintf(stderr, "release: %v\n", err)
return 2
}
if *notesFile != "" {
raw, err := os.ReadFile(*notesFile)
if err != nil {
fmt.Fprintf(stderr, "release: reading the notes: %v\n", err)
return 2
}
s.Notes = string(raw)
}
// Nothing above this line dials, and that is the order it is written in:
// every mistake a person can make in the arguments is reported before a
// release exists to be half-published.
cfg, err := config.ResolveOutsideAProject("")
if err != nil {
fmt.Fprintf(stderr, "release: %v\n", err)
return 1
}
got, err := publish(cfg, s)
if err != nil {
fmt.Fprintf(stderr, "release: %v\n", err)
return 1
}
got.print(stdout)
return 0
}
// checkFiles refuses what would fail later, before anything is created.
//
// Every asset is stat'ed up front because the alternative is a release that
// exists with half its assets on it, published by a run that then failed on a
// typo. Two files with one basename are refused for the same reason from the
// other direction: an attachment is addressed by name, so the second would
// replace the first and the receipt would claim both went up.
func checkFiles(files []string) error {
seen := map[string]string{}
for _, path := range files {
fi, err := os.Stat(path)
if err != nil {
return fmt.Errorf("cannot upload %s: %w", path, err)
}
if fi.IsDir() {
return fmt.Errorf("cannot upload %s: it is a directory", path)
}
name := filepath.Base(path)
if first, ok := seen[name]; ok {
return fmt.Errorf("%s and %s are both %q — an asset is addressed by name, so the second would replace the first",
first, path, name)
}
seen[name] = path
}
return nil
}
+358
View File
@@ -0,0 +1,358 @@
package main
import (
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"unicode/utf8"
sdk "code.gitea.io/sdk/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
)
const (
// userAgent names this tool in the instance's log. An admin looking at a
// burst of requests should be able to tell a release from a push.
userAgent = "kettle-release"
// requestTimeout bounds one call. Generous next to the transport's 30s
// because one of these calls is an upload: a 20 MB binary over a domestic
// connection is minutes, and a run that gives up half way through its
// assets is exactly the mess this tool exists to avoid.
requestTimeout = 10 * time.Minute
// pageLimit and maxPages bound the two listings this makes. A repository
// with a runaway number of releases must not turn one publish into an
// unbounded read.
pageLimit = 50
maxPages = 20
)
// spec is what was asked for: one release, and the files that belong on it.
type spec struct {
Tag string
Title string
Notes string
Target string
Draft bool
Prerelease bool
Files []string
}
// title defaults to the tag, because Gitea refuses a release without one and
// "v1.2.3" is what a person would have typed anyway.
func (s spec) title() string {
if s.Title != "" {
return s.Title
}
return s.Tag
}
// asset is one file that ended up on the release.
type asset struct {
Name string
URL string
// Replaced records that an attachment of this name was already there and
// was removed to make room. Two assets with one name is the failure mode a
// retried publish has, and it is silent: the download URL is by name.
Replaced bool
}
// receipt is what happened, in the words the run will print.
type receipt struct {
Repo string
Release *sdk.Release
// State is "created", "updated" or "reused" — which of the three a re-run
// hit is the whole question an operator has about idempotency.
State string
Assets []asset
}
// publish makes the tracker say what the spec says, and reports what it did.
func publish(cfg *config.Resolved, s spec) (*receipt, error) {
c, err := newClient(cfg)
if err != nil {
return nil, err
}
rel, state, err := c.releaseFor(s)
if err != nil {
return nil, err
}
assets, err := c.uploadAll(rel, s.Files)
if err != nil {
return nil, err
}
return &receipt{Repo: c.slug(), Release: rel, State: state, Assets: assets}, nil
}
// print writes the receipt: what happened to the release, every asset that
// ended up on it, and the URL a person opens.
func (r *receipt) print(w io.Writer) {
fmt.Fprintf(w, "%-9s release %s in %s\n", r.State, r.Release.TagName, r.Repo)
width := 0
for _, a := range r.Assets {
if n := utf8.RuneCountInString(a.Name); n > width {
width = n
}
}
uploaded, replaced := 0, 0
for _, a := range r.Assets {
verb := "uploaded"
uploaded++
if a.Replaced {
verb, replaced = "replaced", replaced+1
}
fmt.Fprintf(w, "%-9s %-*s %s\n", verb, width, a.Name, a.URL)
}
if url := r.Release.HTMLURL; url != "" {
fmt.Fprintf(w, "%-9s %s\n", "release", url)
}
fmt.Fprintf(w, "%d asset(s): %d uploaded, %d replaced — draft: %s, prerelease: %s\n",
len(r.Assets), uploaded-replaced, replaced,
yesNo(r.Release.IsDraft), yesNo(r.Release.IsPrerelease))
}
func yesNo(b bool) string {
if b {
return "yes"
}
return "no"
}
// client is one repository on one instance.
type client struct {
api *sdk.Client
owner, name string
}
func (c *client) slug() string { return c.owner + "/" + c.name }
// newClient refuses a half-filled configuration before it dials, the same way
// gitea.New does and for the same reason: building a client is itself a
// request — the SDK asks the instance for its version before it hands one back
// — and a missing token reported as a connection failure sends whoever is
// reading it to the wrong place.
func newClient(cfg *config.Resolved) (*client, error) {
if cfg == nil {
return nil, errors.New("no resolved configuration — call config.ResolveOutsideAProject first")
}
if err := cfg.Complete(); err != nil {
return nil, err
}
base := strings.TrimRight(cfg.URL, "/")
api, err := sdk.NewClient(base,
sdk.SetToken(cfg.Token),
sdk.SetHTTPClient(&http.Client{Timeout: requestTimeout}),
sdk.SetUserAgent(userAgent))
if err != nil {
if errors.Is(err, &sdk.ErrUnknownVersion{}) {
return nil, fmt.Errorf("%s did not answer with a version this can read (%w)"+
" — check that %s points at a Gitea instance", base, err, config.EnvURL)
}
return nil, fmt.Errorf("cannot reach the Gitea instance at %s: %w", base, err)
}
return &client{api: api, owner: cfg.Owner, name: cfg.Repo}, nil
}
// releaseFor is the release this tag should have, created or brought into line.
func (c *client) releaseFor(s spec) (*sdk.Release, string, error) {
found, err := c.find(s.Tag)
if err != nil {
return nil, "", err
}
if found == nil {
rel, err := c.create(s)
return rel, "created", err
}
rel, changed, err := c.converge(found, s)
if err != nil {
return nil, "", err
}
if changed {
return rel, "updated", nil
}
return rel, "reused", nil
}
// find is the release for this tag, or nil when the repository has none.
//
// A 404 is an answer here and not a failure — it is what "no release yet"
// looks like, which is the ordinary case the first time a tag is published.
// Anything else is reported, because "the instance refused us" and "there is
// nothing there" must not both read as "create one".
func (c *client) find(tag string) (*sdk.Release, error) {
got, resp, err := c.api.GetReleaseByTag(c.owner, c.name, tag)
if err == nil {
return got, nil
}
if failed := gitea.Fail(resp, err); !gitea.StatusIs(failed, http.StatusNotFound) {
return nil, fmt.Errorf("looking for a release on %s: %w", tag, failed)
}
// The by-tag route is a lookup through the tag, and a draft need not have
// one — so a draft this tool created on an earlier run can answer 404 to
// the question "is it already there?". Scanning the listing is what keeps a
// retried `--draft` publish from filing a second release for one tag.
return c.scan(tag)
}
// scan walks the release listing for this tag.
func (c *client) scan(tag string) (*sdk.Release, error) {
for page := 1; page <= maxPages; page++ {
batch, resp, err := c.api.ListReleases(c.owner, c.name, sdk.ListReleasesOptions{
ListOptions: sdk.ListOptions{Page: page, PageSize: pageLimit},
})
if err := gitea.Fail(resp, err); err != nil {
return nil, fmt.Errorf("listing releases: %w", err)
}
for _, rel := range batch {
if rel.TagName == tag {
return rel, nil
}
}
if len(batch) < pageLimit {
return nil, nil // a short page is the last one
}
}
return nil, nil
}
func (c *client) create(s spec) (*sdk.Release, error) {
got, resp, err := c.api.CreateRelease(c.owner, c.name, sdk.CreateReleaseOption{
TagName: s.Tag,
Target: s.Target,
Title: s.title(),
Note: s.Notes,
IsDraft: s.Draft,
IsPrerelease: s.Prerelease,
})
if err := gitea.Fail(resp, err); err != nil {
return nil, fmt.Errorf("creating the release for %s: %w", s.Tag, err)
}
if got == nil || got.ID == 0 {
return nil, fmt.Errorf("creating the release for %s: the tracker's answer carries no id", s.Tag)
}
return got, nil
}
// converge edits an existing release until it says what the spec says, and
// reports whether anything had to change.
//
// Reuse alone would be enough to 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 did not supply any is not a run asking
// for the release to be emptied.
func (c *client) converge(rel *sdk.Release, s spec) (*sdk.Release, bool, error) {
note := s.Notes
if note == "" {
note = rel.Note
}
if rel.Title == s.title() && rel.Note == note &&
rel.IsDraft == s.Draft && rel.IsPrerelease == s.Prerelease {
return rel, false, nil
}
draft, prerelease := s.Draft, s.Prerelease
got, resp, err := c.api.EditRelease(c.owner, c.name, rel.ID, sdk.EditReleaseOption{
TagName: rel.TagName,
Title: s.title(),
Note: note,
IsDraft: &draft,
IsPrerelease: &prerelease,
})
if err := gitea.Fail(resp, err); err != nil {
return nil, false, fmt.Errorf("updating the release for %s: %w", rel.TagName, err)
}
if got == nil || got.ID == 0 {
return nil, false, fmt.Errorf("updating the release for %s: the tracker's answer carries no id", rel.TagName)
}
return got, true, nil
}
// uploadAll puts every named file on the release, replacing an attachment that
// already carries that name.
//
// The listing is read once, before the first upload, because the names that
// matter are the ones that were there when the run started: the files being
// uploaded are checked for duplicate basenames up front, so nothing this run
// adds can collide with something else this run adds.
func (c *client) uploadAll(rel *sdk.Release, files []string) ([]asset, error) {
if len(files) == 0 {
return nil, nil
}
existing, err := c.attachments(rel.ID)
if err != nil {
return nil, err
}
byName := map[string][]*sdk.Attachment{}
for _, a := range existing {
byName[a.Name] = append(byName[a.Name], a)
}
out := make([]asset, 0, len(files))
for _, path := range files {
name := filepath.Base(path)
replaced := false
// Removed before the upload rather than after it. Gitea will happily
// hold two attachments with one name, and the download URL names the
// file — so the state to avoid at all costs is the ambiguous one, not
// the momentarily absent one.
for _, old := range byName[name] {
if resp, err := c.api.DeleteReleaseAttachment(c.owner, c.name, rel.ID, old.ID); err != nil {
return out, fmt.Errorf("removing the old %s: %w", name, gitea.Fail(resp, err))
}
replaced = true
}
got, err := c.upload(rel.ID, path, name)
if err != nil {
return out, err
}
out = append(out, asset{Name: name, URL: got.DownloadURL, Replaced: replaced})
}
return out, nil
}
func (c *client) attachments(release int64) ([]*sdk.Attachment, error) {
var out []*sdk.Attachment
for page := 1; page <= maxPages; page++ {
batch, resp, err := c.api.ListReleaseAttachments(c.owner, c.name, release,
sdk.ListReleaseAttachmentsOptions{ListOptions: sdk.ListOptions{Page: page, PageSize: pageLimit}})
if err := gitea.Fail(resp, err); err != nil {
return nil, fmt.Errorf("listing the release's assets: %w", err)
}
out = append(out, batch...)
if len(batch) < pageLimit {
return out, nil
}
}
return out, nil
}
func (c *client) upload(release int64, path, name string) (*sdk.Attachment, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("uploading %s: %w", name, err)
}
defer f.Close()
got, resp, err := c.api.CreateReleaseAttachment(c.owner, c.name, release, f, name)
if err := gitea.Fail(resp, err); err != nil {
return nil, fmt.Errorf("uploading %s: %w", name, err)
}
if got == nil {
return nil, fmt.Errorf("uploading %s: the tracker's answer carries no attachment", name)
}
return got, nil
}
+679
View File
@@ -0,0 +1,679 @@
package main
// The publisher is tested against httptest, never against an instance: a test
// that needs a server somewhere is a test nobody runs, and this is the one tool
// in the tree whose mistakes are visible to everybody who downloads a binary.
//
// Every fixture points CLAUDE_PROJECT_DIR at an empty temp directory — no
// `.kettle/` marker anywhere on the way up, which is the state a fresh clone is
// in and the whole reason this tool resolves its configuration the way it does
// — and KETTLE_CONFIG_HOME at another, so a run can neither read nor overwrite
// the developer's own tokens.
//
// THE FAKE ANSWERS /api/v1/version, because building an SDK client is itself a
// request: the SDK asks the instance what it is before it hands a client back,
// and a fake that did not answer is a fake nothing can be built against.
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
sdk "code.gitea.io/sdk/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
)
// modernGitea is what the fake says it is: new enough for every route this
// tool asks for.
const modernGitea = "1.26.1"
// harmless points every fixture away from the machine it runs on.
func harmless(t *testing.T) {
t.Helper()
dir := t.TempDir()
t.Setenv("CLAUDE_PROJECT_DIR", dir)
t.Setenv(config.EnvHome, filepath.Join(dir, "config"))
// An exported KETTLE_URL in the developer's shell would otherwise decide
// what a test resolved to, and one of these tests is about resolving
// nothing at all.
for _, key := range []string{config.EnvURL, config.EnvToken, config.EnvRepo, config.EnvLogin} {
t.Setenv(key, "")
}
}
func configFor(url string) *config.Resolved {
return &config.Resolved{URL: url, Token: "s3cret", Owner: "acme", Repo: "widgets"}
}
// --------------------------------------------------------------------------
// the fake tracker
// --------------------------------------------------------------------------
type fake struct {
mu sync.Mutex
base string
version string
nextID int64
releases []*sdk.Release
assets map[int64][]*sdk.Attachment
content map[int64][]byte
requests []string
// hideDraftsFromTheTagRoute makes the by-tag lookup answer 404 for a draft,
// which is what an instance does when the tag itself is not in git yet.
hideDraftsFromTheTagRoute bool
}
func newFake(t *testing.T) *fake {
t.Helper()
f := &fake{
version: modernGitea,
assets: map[int64][]*sdk.Attachment{},
content: map[int64][]byte{},
}
srv := httptest.NewServer(f)
t.Cleanup(srv.Close)
f.base = srv.URL
return f
}
func (f *fake) url() string { return f.base }
func (f *fake) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
f.requests = append(f.requests, r.Method+" "+r.URL.Path)
if r.URL.Path == "/api/v1/version" {
writeJSON(w, map[string]string{"version": f.version})
return
}
rest, ok := strings.CutPrefix(r.URL.Path, "/api/v1/repos/acme/widgets/releases")
if !ok {
f.refuse(w, http.StatusNotFound, "the fake has no route for "+r.URL.Path)
return
}
var parts []string
if rest = strings.Trim(rest, "/"); rest != "" {
parts = strings.Split(rest, "/")
}
switch {
case len(parts) == 0 && r.Method == http.MethodGet:
f.list(w, r)
case len(parts) == 0 && r.Method == http.MethodPost:
f.create(w, r)
case len(parts) == 2 && parts[0] == "tags" && r.Method == http.MethodGet:
f.byTag(w, parts[1])
case len(parts) == 1 && r.Method == http.MethodPatch:
f.edit(w, r, parts[0])
case len(parts) == 2 && parts[1] == "assets" && r.Method == http.MethodGet:
f.listAssets(w, parts[0])
case len(parts) == 2 && parts[1] == "assets" && r.Method == http.MethodPost:
f.addAsset(w, r, parts[0])
case len(parts) == 3 && parts[1] == "assets" && r.Method == http.MethodDelete:
f.dropAsset(w, parts[0], parts[2])
default:
f.refuse(w, http.StatusNotFound, "the fake has no route for "+r.Method+" "+r.URL.Path)
}
}
func (f *fake) list(w http.ResponseWriter, r *http.Request) {
if page := r.URL.Query().Get("page"); page != "" && page != "1" {
writeJSON(w, []*sdk.Release{})
return
}
writeJSON(w, f.releases)
}
func (f *fake) create(w http.ResponseWriter, r *http.Request) {
var opt sdk.CreateReleaseOption
if err := json.NewDecoder(r.Body).Decode(&opt); err != nil {
f.refuse(w, http.StatusUnprocessableEntity, err.Error())
return
}
f.nextID++
rel := &sdk.Release{
ID: f.nextID,
TagName: opt.TagName,
Target: opt.Target,
Title: opt.Title,
Note: opt.Note,
IsDraft: opt.IsDraft,
IsPrerelease: opt.IsPrerelease,
HTMLURL: f.base + "/acme/widgets/releases/tag/" + opt.TagName,
}
f.releases = append(f.releases, rel)
w.WriteHeader(http.StatusCreated)
writeJSON(w, rel)
}
func (f *fake) byTag(w http.ResponseWriter, tag string) {
for _, rel := range f.releases {
if rel.TagName != tag {
continue
}
if rel.IsDraft && f.hideDraftsFromTheTagRoute {
break
}
writeJSON(w, rel)
return
}
f.refuse(w, http.StatusNotFound, "release with tag '"+tag+"' not found")
}
func (f *fake) edit(w http.ResponseWriter, r *http.Request, id string) {
rel := f.release(id)
if rel == nil {
f.refuse(w, http.StatusNotFound, "no release "+id)
return
}
var opt sdk.EditReleaseOption
if err := json.NewDecoder(r.Body).Decode(&opt); err != nil {
f.refuse(w, http.StatusUnprocessableEntity, err.Error())
return
}
// Gitea's own semantics: an empty string leaves the field alone.
if opt.Title != "" {
rel.Title = opt.Title
}
if opt.Note != "" {
rel.Note = opt.Note
}
if opt.IsDraft != nil {
rel.IsDraft = *opt.IsDraft
}
if opt.IsPrerelease != nil {
rel.IsPrerelease = *opt.IsPrerelease
}
writeJSON(w, rel)
}
func (f *fake) listAssets(w http.ResponseWriter, id string) {
rel := f.release(id)
if rel == nil {
f.refuse(w, http.StatusNotFound, "no release "+id)
return
}
got := f.assets[rel.ID]
if got == nil {
got = []*sdk.Attachment{}
}
writeJSON(w, got)
}
func (f *fake) addAsset(w http.ResponseWriter, r *http.Request, id string) {
rel := f.release(id)
if rel == nil {
f.refuse(w, http.StatusNotFound, "no release "+id)
return
}
file, header, err := r.FormFile("attachment")
if err != nil {
f.refuse(w, http.StatusUnprocessableEntity, "no attachment in the form: "+err.Error())
return
}
defer file.Close()
raw, err := io.ReadAll(file)
if err != nil {
f.refuse(w, http.StatusInternalServerError, err.Error())
return
}
f.nextID++
a := &sdk.Attachment{
ID: f.nextID,
Name: header.Filename,
Size: int64(len(raw)),
DownloadURL: f.base + "/acme/widgets/releases/download/" + rel.TagName + "/" + header.Filename,
}
f.assets[rel.ID] = append(f.assets[rel.ID], a)
f.content[a.ID] = raw
w.WriteHeader(http.StatusCreated)
writeJSON(w, a)
}
func (f *fake) dropAsset(w http.ResponseWriter, id, asset string) {
rel := f.release(id)
if rel == nil {
f.refuse(w, http.StatusNotFound, "no release "+id)
return
}
want, _ := strconv.ParseInt(asset, 10, 64)
kept := make([]*sdk.Attachment, 0, len(f.assets[rel.ID]))
for _, a := range f.assets[rel.ID] {
if a.ID != want {
kept = append(kept, a)
}
}
f.assets[rel.ID] = kept
w.WriteHeader(http.StatusNoContent)
}
func (f *fake) release(id string) *sdk.Release {
want, err := strconv.ParseInt(id, 10, 64)
if err != nil {
return nil
}
for _, rel := range f.releases {
if rel.ID == want {
return rel
}
}
return nil
}
func (f *fake) refuse(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"message": message})
}
// assetNamed is what the tracker holds under this name, for the test that says
// a replacement leaves exactly one.
func (f *fake) assetNamed(name string) []*sdk.Attachment {
f.mu.Lock()
defer f.mu.Unlock()
var out []*sdk.Attachment
for _, batch := range f.assets {
for _, a := range batch {
if a.Name == name {
out = append(out, a)
}
}
}
return out
}
func (f *fake) bytesOf(a *sdk.Attachment) string {
f.mu.Lock()
defer f.mu.Unlock()
return string(f.content[a.ID])
}
func (f *fake) calls() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string{}, f.requests...)
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func writeFile(t *testing.T, dir, name, content string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
// --------------------------------------------------------------------------
// the tests
// --------------------------------------------------------------------------
// The ordinary case: a tag nobody has published yet, and two files that belong
// on it.
func TestItCreatesTheReleaseAndUploadsEveryAsset(t *testing.T) {
harmless(t)
f := newFake(t)
dir := t.TempDir()
binary := writeFile(t, dir, "kettle_v1.2.3_linux_amd64", "a binary, honestly")
sums := writeFile(t, dir, "SHA256SUMS", "beef kettle_v1.2.3_linux_amd64\n")
got, err := publish(configFor(f.url()), spec{
Tag: "v1.2.3",
Notes: "what changed\n",
Files: []string{binary, sums},
})
if err != nil {
t.Fatalf("publish: %v", err)
}
if got.State != "created" {
t.Errorf("state is %q, want created", got.State)
}
if len(f.releases) != 1 {
t.Fatalf("the tracker holds %d release(s), want 1", len(f.releases))
}
rel := f.releases[0]
if rel.TagName != "v1.2.3" || rel.Note != "what changed\n" {
t.Errorf("the release is %+v", rel)
}
// Gitea refuses a release with no title, so the tag stands in for one.
if rel.Title != "v1.2.3" {
t.Errorf("title is %q, want the tag", rel.Title)
}
if len(got.Assets) != 2 {
t.Fatalf("got %d asset(s), want 2", len(got.Assets))
}
for name, want := range map[string]string{
"kettle_v1.2.3_linux_amd64": "a binary, honestly",
"SHA256SUMS": "beef kettle_v1.2.3_linux_amd64\n",
} {
held := f.assetNamed(name)
if len(held) != 1 {
t.Fatalf("the tracker holds %d attachment(s) called %s, want 1", len(held), name)
}
if body := f.bytesOf(held[0]); body != want {
t.Errorf("%s arrived as %q, want %q", name, body, want)
}
}
// The receipt is the whole user experience of a tool nobody watches run.
var out strings.Builder
got.print(&out)
for _, want := range []string{"created", "v1.2.3", "acme/widgets", rel.HTMLURL,
"kettle_v1.2.3_linux_amd64", "SHA256SUMS", "2 asset(s)"} {
if !strings.Contains(out.String(), want) {
t.Errorf("the receipt does not name %q:\n%s", want, out.String())
}
}
}
// A re-run is not a failure and not a second release. It is also not a no-op
// when something changed: a retry that fixed the notes has to leave the fixed
// notes behind.
func TestARerunConvergesInsteadOfPublishingTwice(t *testing.T) {
harmless(t)
f := newFake(t)
dir := t.TempDir()
binary := writeFile(t, dir, "kettle_v2.0.0_darwin_arm64", "one")
first, err := publish(configFor(f.url()), spec{Tag: "v2.0.0", Notes: "first go", Files: []string{binary}})
if err != nil {
t.Fatalf("the first publish: %v", err)
}
again, err := publish(configFor(f.url()), spec{Tag: "v2.0.0", Notes: "first go", Files: []string{binary}})
if err != nil {
t.Fatalf("the second publish: %v", err)
}
if len(f.releases) != 1 {
t.Fatalf("a re-run left %d releases for one tag", len(f.releases))
}
if again.State != "reused" {
t.Errorf("state is %q, want reused — nothing had changed", again.State)
}
if again.Release.ID != first.Release.ID {
t.Errorf("the re-run published a different release (%d, was %d)", again.Release.ID, first.Release.ID)
}
if held := f.assetNamed("kettle_v2.0.0_darwin_arm64"); len(held) != 1 {
t.Errorf("the tracker holds %d copies of the one asset", len(held))
}
// And the corrected notes actually land.
fixed, err := publish(configFor(f.url()), spec{Tag: "v2.0.0", Notes: "second go", Files: []string{binary}})
if err != nil {
t.Fatalf("the third publish: %v", err)
}
if fixed.State != "updated" {
t.Errorf("state is %q, want updated — the notes changed", fixed.State)
}
if f.releases[0].Note != "second go" {
t.Errorf("the notes are %q, want the corrected ones", f.releases[0].Note)
}
if len(f.releases) != 1 {
t.Errorf("converging forked the release: %d of them", len(f.releases))
}
}
// Two attachments with one name is the silent failure: the download URL names
// the file, so the second copy is not addressable and nobody notices which one
// people got.
func TestAnAssetOfTheSameNameIsReplacedRatherThanDoubled(t *testing.T) {
harmless(t)
f := newFake(t)
dir := t.TempDir()
path := writeFile(t, dir, "kettle_v3.0.0_linux_arm64", "the first build")
if _, err := publish(configFor(f.url()), spec{Tag: "v3.0.0", Files: []string{path}}); err != nil {
t.Fatalf("the first publish: %v", err)
}
// Same name, different bytes — a rebuild after a fix, which is exactly when
// somebody re-runs this.
writeFile(t, dir, "kettle_v3.0.0_linux_arm64", "the second build")
got, err := publish(configFor(f.url()), spec{Tag: "v3.0.0", Files: []string{path}})
if err != nil {
t.Fatalf("the second publish: %v", err)
}
held := f.assetNamed("kettle_v3.0.0_linux_arm64")
if len(held) != 1 {
t.Fatalf("the release carries %d attachments of that name, want 1", len(held))
}
if body := f.bytesOf(held[0]); body != "the second build" {
t.Errorf("the asset is %q — the replacement did not take", body)
}
if len(got.Assets) != 1 || !got.Assets[0].Replaced {
t.Errorf("the receipt does not report a replacement: %+v", got.Assets)
}
var out strings.Builder
got.print(&out)
if !strings.Contains(out.String(), "replaced") {
t.Errorf("the receipt does not say it replaced anything:\n%s", out.String())
}
}
// A draft has no git tag behind it, so the by-tag route can answer 404 for a
// release that is plainly there. A publish that believed it would file a second
// release every time it was retried.
func TestADraftIsFoundEvenWhenTheTagRouteHidesIt(t *testing.T) {
harmless(t)
f := newFake(t)
f.hideDraftsFromTheTagRoute = true
s := spec{Tag: "v4.0.0", Draft: true}
if _, err := publish(configFor(f.url()), s); err != nil {
t.Fatalf("the first publish: %v", err)
}
again, err := publish(configFor(f.url()), s)
if err != nil {
t.Fatalf("the second publish: %v", err)
}
if len(f.releases) != 1 {
t.Fatalf("a retried draft published %d releases for one tag", len(f.releases))
}
if again.State != "reused" {
t.Errorf("state is %q, want reused", again.State)
}
if !f.releases[0].IsDraft {
t.Error("the release stopped being a draft")
}
}
// A half-filled configuration is refused before anything is dialled, naming the
// variable or the command that supplies what is missing. "401 Unauthorized"
// names nothing anybody can act on.
func TestAHalfFilledConfigurationIsRefusedBeforeItDials(t *testing.T) {
harmless(t)
f := newFake(t)
for _, tc := range []struct {
what string
cfg *config.Resolved
want string
}{
{"no url", &config.Resolved{Token: "t", Owner: "a", Repo: "b"}, config.EnvURL},
{"no token", &config.Resolved{URL: f.url(), Owner: "a", Repo: "b"}, config.EnvToken},
{"no repo", &config.Resolved{URL: f.url(), Token: "t"}, config.EnvRepo},
{"nothing at all", &config.Resolved{}, config.EnvURL},
} {
_, err := publish(tc.cfg, spec{Tag: "v0.0.1"})
if err == nil {
t.Errorf("%s: accepted", tc.what)
continue
}
if !strings.Contains(err.Error(), tc.want) {
t.Errorf("%s: the refusal does not name the fix (%q): %v", tc.what, tc.want, err)
}
}
if calls := f.calls(); len(calls) != 0 {
t.Errorf("a request went out for a configuration that was refused: %v", calls)
}
}
// End to end through main's own argument handling, with the credentials in the
// environment and no project anywhere on the way up — which is the state a
// clone is in, and the reason this resolves configuration the way it does.
func TestRunPublishesFromTheEnvironmentWithNoProjectInSight(t *testing.T) {
harmless(t)
f := newFake(t)
dir := t.TempDir()
binary := writeFile(t, dir, "kettle_v5.0.0_darwin_amd64", "mach-o, trust me")
notes := writeFile(t, dir, "NOTES.md", "## v5.0.0\n\nIt does the thing.\n")
t.Setenv(config.EnvURL, f.url())
t.Setenv(config.EnvToken, "s3cret")
t.Setenv(config.EnvRepo, "acme/widgets")
var stdout, stderr strings.Builder
code := run([]string{"--tag", "v5.0.0", "--title", "kettle v5.0.0", "--notes-file", notes, binary},
&stdout, &stderr)
if code != 0 {
t.Fatalf("exit = %d, want 0\nstdout:\n%s\nstderr:\n%s", code, stdout.String(), stderr.String())
}
if len(f.releases) != 1 || f.releases[0].Title != "kettle v5.0.0" {
t.Fatalf("the tracker holds %+v", f.releases)
}
if !strings.Contains(f.releases[0].Note, "It does the thing.") {
t.Errorf("the notes file did not arrive: %q", f.releases[0].Note)
}
for _, want := range []string{"created", "uploaded", "kettle_v5.0.0_darwin_amd64", f.releases[0].HTMLURL} {
if !strings.Contains(stdout.String(), want) {
t.Errorf("the receipt does not name %q:\n%s", want, stdout.String())
}
}
// A token in a receipt is a token in a terminal scrollback and a pasted
// bug report.
if strings.Contains(stdout.String()+stderr.String(), "s3cret") {
t.Errorf("the run printed the token:\n%s%s", stdout.String(), stderr.String())
}
}
// Everything a person can get wrong in the arguments is reported before a
// release exists to be half-published.
func TestRunRefusesBadArgumentsWithoutTouchingTheTracker(t *testing.T) {
harmless(t)
f := newFake(t)
dir := t.TempDir()
here := writeFile(t, dir, "kettle_v6.0.0_linux_amd64", "x")
elsewhere := filepath.Join(t.TempDir(), "kettle_v6.0.0_linux_amd64")
if err := os.WriteFile(elsewhere, []byte("y"), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv(config.EnvURL, f.url())
t.Setenv(config.EnvToken, "s3cret")
t.Setenv(config.EnvRepo, "acme/widgets")
for _, tc := range []struct {
what string
argv []string
want string
}{
{"no tag", []string{here}, "--tag is required"},
{"a file that is not there", []string{"--tag", "v6.0.0", filepath.Join(dir, "absent")}, "cannot upload"},
{"a directory", []string{"--tag", "v6.0.0", dir}, "it is a directory"},
{"two files with one name", []string{"--tag", "v6.0.0", here, elsewhere}, "would replace the first"},
{"notes that are not there", []string{"--tag", "v6.0.0", "--notes-file", filepath.Join(dir, "absent.md")}, "reading the notes"},
} {
var stdout, stderr strings.Builder
if code := run(tc.argv, &stdout, &stderr); code != 2 {
t.Errorf("%s: exit = %d, want 2\n%s%s", tc.what, code, stdout.String(), stderr.String())
}
if !strings.Contains(stderr.String(), tc.want) {
t.Errorf("%s: stderr does not say %q:\n%s", tc.what, tc.want, stderr.String())
}
}
if calls := f.calls(); len(calls) != 0 {
t.Errorf("a refused run still talked to the tracker: %v", calls)
}
if len(f.releases) != 0 {
t.Errorf("a refused run created %d release(s)", len(f.releases))
}
}
// A failure carries the status and what the server said, in the transport's own
// error type, because "500" on its own has never helped anybody.
func TestAFailureNamesTheStatusAndWhatTheServerSaid(t *testing.T) {
harmless(t)
// A token that is not allowed to write releases is the failure somebody
// will actually meet: reads are fine, the create is refused.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/version":
writeJSON(w, map[string]string{"version": modernGitea})
case r.Method == http.MethodPost:
w.WriteHeader(http.StatusForbidden)
_, _ = io.WriteString(w, `{"message":"token does not have at least one of required scope(s): [write:repository]"}`)
case strings.Contains(r.URL.Path, "/releases/tags/"):
w.WriteHeader(http.StatusNotFound)
_, _ = io.WriteString(w, `{"message":"release with tag 'v7.0.0' not found"}`)
default:
writeJSON(w, []*sdk.Release{})
}
}))
t.Cleanup(srv.Close)
_, err := publish(configFor(srv.URL), spec{Tag: "v7.0.0"})
if err == nil {
t.Fatal("a 403 published a release")
}
for _, want := range []string{"403", "write:repository", "v7.0.0"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the failure does not mention %q:\n%v", want, err)
}
}
if strings.Contains(err.Error(), "s3cret") {
t.Errorf("the failure quotes the token:\n%v", err)
}
}
// A tool nobody watches run has to be readable when somebody finally does.
func TestTheReceiptIsAligned(t *testing.T) {
r := &receipt{
Repo: "acme/widgets",
State: "created",
Release: &sdk.Release{TagName: "v1.0.0", HTMLURL: "https://git.example.com/acme/widgets/releases/tag/v1.0.0"},
Assets: []asset{
{Name: "kettle_v1.0.0_darwin_arm64", URL: "https://git.example.com/a"},
{Name: "SHA256SUMS", URL: "https://git.example.com/b", Replaced: true},
},
}
var out strings.Builder
r.print(&out)
// One line for the release, one per asset, the URL, and the summary.
lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
if len(lines) != 5 {
t.Fatalf("the receipt is %d line(s):\n%s", len(lines), out.String())
}
// The URLs line up, which is what makes a column of them scannable.
first := strings.Index(lines[1], "https://")
if second := strings.Index(lines[2], "https://"); first != second {
t.Errorf("the asset URLs do not line up (%d vs %d):\n%s", first, second, out.String())
}
if !strings.HasPrefix(lines[2], "replaced") {
t.Errorf("a replaced asset is not called one:\n%s", out.String())
}
// The URL a person opens is on its own line, not buried in a summary.
if !strings.HasPrefix(lines[3], "release ") || !strings.HasSuffix(lines[3], "/releases/tag/v1.0.0") {
t.Errorf("the release URL is not on its own line:\n%s", out.String())
}
if want := fmt.Sprintf("%d asset(s): 1 uploaded, 1 replaced", 2); !strings.Contains(lines[4], want) {
t.Errorf("the summary does not read %q:\n%s", want, out.String())
}
}