01fb5a2703
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>
359 lines
11 KiB
Go
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
|
|
}
|