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,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
|
||||
}
|
||||
Reference in New Issue
Block a user