// 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 scaffold` writes a project's skills and commands 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 [flags] […] 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 }