Files
marketplace/cli/cmd/release/main.go
T
naudachu 01fb5a2703 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>
2026-08-12 01:01:27 +05:00

156 lines
5.8 KiB
Go

// 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
}