Files
marketplace/cli/cmd/release/publish.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

359 lines
11 KiB
Go

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
}