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