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,43 @@
|
||||
# AGENTS.md — cmd/kettle
|
||||
|
||||
The binary's entry point, and all of it:
|
||||
|
||||
```go
|
||||
func main() { os.Exit(cmd.Main(os.Args[1:])) }
|
||||
```
|
||||
|
||||
One file, `main.go`, holding a package comment and that line. The sibling
|
||||
[`cmd/release`](../release/AGENTS.md) is the module's other binary — build
|
||||
infrastructure, deliberately not a `kettle` verb.
|
||||
|
||||
## Why it is empty
|
||||
|
||||
Everything a `main` usually accumulates — flag parsing, dispatch, usage text,
|
||||
error formatting, exit codes — is in [`internal/cmd`](../../internal/cmd/AGENTS.md),
|
||||
where it is **testable**. A `main` package cannot be imported, so anything written
|
||||
here can only be exercised by running the binary; the command tree is instead a
|
||||
library with one caller, and its tests run it as a subprocess *and* call into it
|
||||
directly where that is cheaper.
|
||||
|
||||
The exit status is the only thing this layer owns, and it owns it because
|
||||
`os.Exit` skips deferred functions: it has to happen after everything else is
|
||||
finished, at the outermost frame, and nowhere else in the tree may call it.
|
||||
|
||||
The version a build reports is **not** stamped here either. `-ldflags -X` names
|
||||
`internal/cmd.Version`, because that is where the `version` command reads it and
|
||||
where a test can build with the flag and read the answer back — a `-X` whose symbol
|
||||
path is one character wrong is silently ignored, and the binary goes on saying `dev`.
|
||||
|
||||
## Adding a command
|
||||
|
||||
Nothing here changes. A new command is a `register(&Command{…})` in an `init()`
|
||||
over in [`internal/cmd`](../../internal/cmd/AGENTS.md) — that is the whole point
|
||||
of a registry.
|
||||
|
||||
## Keeping this file true
|
||||
|
||||
- **Scope:** `main.go`, and the reason it stays this short.
|
||||
- **Update it when** this package grows a second file or a line that does anything
|
||||
but delegate — which should be treated as a design change and argued for, not
|
||||
documented after the fact.
|
||||
- **Do not** describe commands, flags or exit codes here.
|
||||
@@ -0,0 +1,113 @@
|
||||
# AGENTS.md — cmd/release
|
||||
|
||||
**The release tool: publishes a Gitea release for this repository, from this
|
||||
repository's own code.** Driven by `make release TAG=v1.2.3`, never by a user.
|
||||
|
||||
| file | what is in it |
|
||||
|---|---|
|
||||
| `main.go` | flags, argument validation, exit codes — everything that can fail before a socket is opened |
|
||||
| `publish.go` | `spec`, `receipt`, its own small SDK `client`, and the converge/upload logic |
|
||||
| `release_test.go` | the whole tool against a fake Gitea, including every refusal |
|
||||
|
||||
## Why it is not a `kettle` subcommand
|
||||
|
||||
`kettle`'s 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, 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
|
||||
|
||||
[`internal/gitea`](../../internal/gitea/AGENTS.md) is otherwise the one door for
|
||||
every request. Two reasons this one goes around it, both facts about where it 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.
|
||||
|
||||
What it does **not** reinvent is credentials or error vocabulary.
|
||||
[`internal/config`](../../internal/config/AGENTS.md) resolves the instance, the token
|
||||
and the repository exactly as `kettle` does — through `ResolveOutsideAProject`,
|
||||
which falls back to the environment when there is no marker and reads the project
|
||||
config when there is — and `gitea.Fail` turns an SDK `(response, error)` pair into
|
||||
the same `*APIError` a `kettle push` would report, so "the tracker said no" has one
|
||||
spelling in the tree.
|
||||
|
||||
## Idempotent end to end
|
||||
|
||||
A tag that already has a release **reuses** it, an asset whose name is already there
|
||||
is **replaced**, and a run repeated because the first died half way through converges
|
||||
on the same release with the same assets — not a second release with doubled
|
||||
attachments.
|
||||
|
||||
Reuse alone would only 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 supplied none is not asking for the release to be emptied.
|
||||
|
||||
A 404 from the release lookup is an **answer** — it is what "no release yet" looks
|
||||
like — and anything else is reported, because "the instance refused us" and "there is
|
||||
nothing there" must not both read as "create one".
|
||||
|
||||
The by-tag route is a lookup *through the tag*, and a draft need not have one, so a
|
||||
404 there is followed by a scan of the release listing before anything is created.
|
||||
Without it a retried `--draft` publish would file a second release for one tag —
|
||||
which is the failure this whole section exists to prevent, arriving through the one
|
||||
door that looks like the ordinary case.
|
||||
|
||||
## Order of operations
|
||||
|
||||
Everything that can be wrong in the arguments is reported **before a release exists
|
||||
to be half-published**:
|
||||
|
||||
1. `--tag` is required;
|
||||
2. every asset is stat'ed up front — a release that exists with half its assets on
|
||||
it, published by a run that then failed on a typo, is the failure this prevents;
|
||||
3. two files with one basename are refused, because an attachment is addressed by
|
||||
name and the second would silently replace the first while the receipt claimed
|
||||
both went up;
|
||||
4. notes are read from disk;
|
||||
5. only then does anything dial. The attachment listing is read once, before the
|
||||
first upload, so the names that matter are the ones that were there when the run
|
||||
started.
|
||||
|
||||
## Usage
|
||||
|
||||
Through the Makefile, which adds the three refusals that make a release
|
||||
reproducible — dirty tree, `TAG` that is not what `git describe` reports, tag not
|
||||
pushed to the remote:
|
||||
|
||||
```bash
|
||||
make release TAG=v1.2.3 [NOTES=notes.md] [TITLE="…"]
|
||||
```
|
||||
|
||||
Directly, when the Makefile is not what you want:
|
||||
|
||||
```bash
|
||||
KETTLE_URL=… KETTLE_TOKEN=… KETTLE_REPO=owner/name \
|
||||
go run ./cmd/release --tag v1.2.3 --notes-file notes.md dist/kettle_* dist/SHA256SUMS
|
||||
```
|
||||
|
||||
`--draft` and `--prerelease` are there; `--target` names the commitish a tag is
|
||||
created from when the tag does not exist yet.
|
||||
|
||||
## Keeping this file true
|
||||
|
||||
- **Scope:** `main.go`, `publish.go` and their test — the argument checks, the
|
||||
convergence rules, and the two decisions above about what this tool does *not*
|
||||
share with `kettle`.
|
||||
- **Update it when** a flag is added, the idempotency rules change, it starts or
|
||||
stops borrowing something from `internal/`, or the Makefile's refusals change.
|
||||
- **Do not** move any of this into `kettle`'s command registry without answering the
|
||||
first section — a verb here becomes documentation an agent loads.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,679 @@
|
||||
package main
|
||||
|
||||
// The publisher is tested against httptest, never against an instance: a test
|
||||
// that needs a server somewhere is a test nobody runs, and this is the one tool
|
||||
// in the tree whose mistakes are visible to everybody who downloads a binary.
|
||||
//
|
||||
// Every fixture points CLAUDE_PROJECT_DIR at an empty temp directory — no
|
||||
// `.kettle/` marker anywhere on the way up, which is the state a fresh clone is
|
||||
// in and the whole reason this tool resolves its configuration the way it does
|
||||
// — and KETTLE_CONFIG_HOME at another, so a run can neither read nor overwrite
|
||||
// the developer's own tokens.
|
||||
//
|
||||
// THE FAKE ANSWERS /api/v1/version, because building an SDK client is itself a
|
||||
// request: the SDK asks the instance what it is before it hands a client back,
|
||||
// and a fake that did not answer is a fake nothing can be built against.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
sdk "code.gitea.io/sdk/gitea"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
||||
)
|
||||
|
||||
// modernGitea is what the fake says it is: new enough for every route this
|
||||
// tool asks for.
|
||||
const modernGitea = "1.26.1"
|
||||
|
||||
// harmless points every fixture away from the machine it runs on.
|
||||
func harmless(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("CLAUDE_PROJECT_DIR", dir)
|
||||
t.Setenv(config.EnvHome, filepath.Join(dir, "config"))
|
||||
// An exported KETTLE_URL in the developer's shell would otherwise decide
|
||||
// what a test resolved to, and one of these tests is about resolving
|
||||
// nothing at all.
|
||||
for _, key := range []string{config.EnvURL, config.EnvToken, config.EnvRepo, config.EnvLogin} {
|
||||
t.Setenv(key, "")
|
||||
}
|
||||
}
|
||||
|
||||
func configFor(url string) *config.Resolved {
|
||||
return &config.Resolved{URL: url, Token: "s3cret", Owner: "acme", Repo: "widgets"}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// the fake tracker
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
type fake struct {
|
||||
mu sync.Mutex
|
||||
base string
|
||||
version string
|
||||
nextID int64
|
||||
releases []*sdk.Release
|
||||
assets map[int64][]*sdk.Attachment
|
||||
content map[int64][]byte
|
||||
requests []string
|
||||
|
||||
// hideDraftsFromTheTagRoute makes the by-tag lookup answer 404 for a draft,
|
||||
// which is what an instance does when the tag itself is not in git yet.
|
||||
hideDraftsFromTheTagRoute bool
|
||||
}
|
||||
|
||||
func newFake(t *testing.T) *fake {
|
||||
t.Helper()
|
||||
f := &fake{
|
||||
version: modernGitea,
|
||||
assets: map[int64][]*sdk.Attachment{},
|
||||
content: map[int64][]byte{},
|
||||
}
|
||||
srv := httptest.NewServer(f)
|
||||
t.Cleanup(srv.Close)
|
||||
f.base = srv.URL
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fake) url() string { return f.base }
|
||||
|
||||
func (f *fake) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.requests = append(f.requests, r.Method+" "+r.URL.Path)
|
||||
|
||||
if r.URL.Path == "/api/v1/version" {
|
||||
writeJSON(w, map[string]string{"version": f.version})
|
||||
return
|
||||
}
|
||||
rest, ok := strings.CutPrefix(r.URL.Path, "/api/v1/repos/acme/widgets/releases")
|
||||
if !ok {
|
||||
f.refuse(w, http.StatusNotFound, "the fake has no route for "+r.URL.Path)
|
||||
return
|
||||
}
|
||||
var parts []string
|
||||
if rest = strings.Trim(rest, "/"); rest != "" {
|
||||
parts = strings.Split(rest, "/")
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(parts) == 0 && r.Method == http.MethodGet:
|
||||
f.list(w, r)
|
||||
case len(parts) == 0 && r.Method == http.MethodPost:
|
||||
f.create(w, r)
|
||||
case len(parts) == 2 && parts[0] == "tags" && r.Method == http.MethodGet:
|
||||
f.byTag(w, parts[1])
|
||||
case len(parts) == 1 && r.Method == http.MethodPatch:
|
||||
f.edit(w, r, parts[0])
|
||||
case len(parts) == 2 && parts[1] == "assets" && r.Method == http.MethodGet:
|
||||
f.listAssets(w, parts[0])
|
||||
case len(parts) == 2 && parts[1] == "assets" && r.Method == http.MethodPost:
|
||||
f.addAsset(w, r, parts[0])
|
||||
case len(parts) == 3 && parts[1] == "assets" && r.Method == http.MethodDelete:
|
||||
f.dropAsset(w, parts[0], parts[2])
|
||||
default:
|
||||
f.refuse(w, http.StatusNotFound, "the fake has no route for "+r.Method+" "+r.URL.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fake) list(w http.ResponseWriter, r *http.Request) {
|
||||
if page := r.URL.Query().Get("page"); page != "" && page != "1" {
|
||||
writeJSON(w, []*sdk.Release{})
|
||||
return
|
||||
}
|
||||
writeJSON(w, f.releases)
|
||||
}
|
||||
|
||||
func (f *fake) create(w http.ResponseWriter, r *http.Request) {
|
||||
var opt sdk.CreateReleaseOption
|
||||
if err := json.NewDecoder(r.Body).Decode(&opt); err != nil {
|
||||
f.refuse(w, http.StatusUnprocessableEntity, err.Error())
|
||||
return
|
||||
}
|
||||
f.nextID++
|
||||
rel := &sdk.Release{
|
||||
ID: f.nextID,
|
||||
TagName: opt.TagName,
|
||||
Target: opt.Target,
|
||||
Title: opt.Title,
|
||||
Note: opt.Note,
|
||||
IsDraft: opt.IsDraft,
|
||||
IsPrerelease: opt.IsPrerelease,
|
||||
HTMLURL: f.base + "/acme/widgets/releases/tag/" + opt.TagName,
|
||||
}
|
||||
f.releases = append(f.releases, rel)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
writeJSON(w, rel)
|
||||
}
|
||||
|
||||
func (f *fake) byTag(w http.ResponseWriter, tag string) {
|
||||
for _, rel := range f.releases {
|
||||
if rel.TagName != tag {
|
||||
continue
|
||||
}
|
||||
if rel.IsDraft && f.hideDraftsFromTheTagRoute {
|
||||
break
|
||||
}
|
||||
writeJSON(w, rel)
|
||||
return
|
||||
}
|
||||
f.refuse(w, http.StatusNotFound, "release with tag '"+tag+"' not found")
|
||||
}
|
||||
|
||||
func (f *fake) edit(w http.ResponseWriter, r *http.Request, id string) {
|
||||
rel := f.release(id)
|
||||
if rel == nil {
|
||||
f.refuse(w, http.StatusNotFound, "no release "+id)
|
||||
return
|
||||
}
|
||||
var opt sdk.EditReleaseOption
|
||||
if err := json.NewDecoder(r.Body).Decode(&opt); err != nil {
|
||||
f.refuse(w, http.StatusUnprocessableEntity, err.Error())
|
||||
return
|
||||
}
|
||||
// Gitea's own semantics: an empty string leaves the field alone.
|
||||
if opt.Title != "" {
|
||||
rel.Title = opt.Title
|
||||
}
|
||||
if opt.Note != "" {
|
||||
rel.Note = opt.Note
|
||||
}
|
||||
if opt.IsDraft != nil {
|
||||
rel.IsDraft = *opt.IsDraft
|
||||
}
|
||||
if opt.IsPrerelease != nil {
|
||||
rel.IsPrerelease = *opt.IsPrerelease
|
||||
}
|
||||
writeJSON(w, rel)
|
||||
}
|
||||
|
||||
func (f *fake) listAssets(w http.ResponseWriter, id string) {
|
||||
rel := f.release(id)
|
||||
if rel == nil {
|
||||
f.refuse(w, http.StatusNotFound, "no release "+id)
|
||||
return
|
||||
}
|
||||
got := f.assets[rel.ID]
|
||||
if got == nil {
|
||||
got = []*sdk.Attachment{}
|
||||
}
|
||||
writeJSON(w, got)
|
||||
}
|
||||
|
||||
func (f *fake) addAsset(w http.ResponseWriter, r *http.Request, id string) {
|
||||
rel := f.release(id)
|
||||
if rel == nil {
|
||||
f.refuse(w, http.StatusNotFound, "no release "+id)
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("attachment")
|
||||
if err != nil {
|
||||
f.refuse(w, http.StatusUnprocessableEntity, "no attachment in the form: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
raw, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
f.refuse(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
f.nextID++
|
||||
a := &sdk.Attachment{
|
||||
ID: f.nextID,
|
||||
Name: header.Filename,
|
||||
Size: int64(len(raw)),
|
||||
DownloadURL: f.base + "/acme/widgets/releases/download/" + rel.TagName + "/" + header.Filename,
|
||||
}
|
||||
f.assets[rel.ID] = append(f.assets[rel.ID], a)
|
||||
f.content[a.ID] = raw
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
writeJSON(w, a)
|
||||
}
|
||||
|
||||
func (f *fake) dropAsset(w http.ResponseWriter, id, asset string) {
|
||||
rel := f.release(id)
|
||||
if rel == nil {
|
||||
f.refuse(w, http.StatusNotFound, "no release "+id)
|
||||
return
|
||||
}
|
||||
want, _ := strconv.ParseInt(asset, 10, 64)
|
||||
kept := make([]*sdk.Attachment, 0, len(f.assets[rel.ID]))
|
||||
for _, a := range f.assets[rel.ID] {
|
||||
if a.ID != want {
|
||||
kept = append(kept, a)
|
||||
}
|
||||
}
|
||||
f.assets[rel.ID] = kept
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (f *fake) release(id string) *sdk.Release {
|
||||
want, err := strconv.ParseInt(id, 10, 64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for _, rel := range f.releases {
|
||||
if rel.ID == want {
|
||||
return rel
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fake) refuse(w http.ResponseWriter, status int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"message": message})
|
||||
}
|
||||
|
||||
// assetNamed is what the tracker holds under this name, for the test that says
|
||||
// a replacement leaves exactly one.
|
||||
func (f *fake) assetNamed(name string) []*sdk.Attachment {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []*sdk.Attachment
|
||||
for _, batch := range f.assets {
|
||||
for _, a := range batch {
|
||||
if a.Name == name {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (f *fake) bytesOf(a *sdk.Attachment) string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return string(f.content[a.ID])
|
||||
}
|
||||
|
||||
func (f *fake) calls() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]string{}, f.requests...)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, dir, name, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// the tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// The ordinary case: a tag nobody has published yet, and two files that belong
|
||||
// on it.
|
||||
func TestItCreatesTheReleaseAndUploadsEveryAsset(t *testing.T) {
|
||||
harmless(t)
|
||||
f := newFake(t)
|
||||
dir := t.TempDir()
|
||||
binary := writeFile(t, dir, "kettle_v1.2.3_linux_amd64", "a binary, honestly")
|
||||
sums := writeFile(t, dir, "SHA256SUMS", "beef kettle_v1.2.3_linux_amd64\n")
|
||||
|
||||
got, err := publish(configFor(f.url()), spec{
|
||||
Tag: "v1.2.3",
|
||||
Notes: "what changed\n",
|
||||
Files: []string{binary, sums},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("publish: %v", err)
|
||||
}
|
||||
|
||||
if got.State != "created" {
|
||||
t.Errorf("state is %q, want created", got.State)
|
||||
}
|
||||
if len(f.releases) != 1 {
|
||||
t.Fatalf("the tracker holds %d release(s), want 1", len(f.releases))
|
||||
}
|
||||
rel := f.releases[0]
|
||||
if rel.TagName != "v1.2.3" || rel.Note != "what changed\n" {
|
||||
t.Errorf("the release is %+v", rel)
|
||||
}
|
||||
// Gitea refuses a release with no title, so the tag stands in for one.
|
||||
if rel.Title != "v1.2.3" {
|
||||
t.Errorf("title is %q, want the tag", rel.Title)
|
||||
}
|
||||
if len(got.Assets) != 2 {
|
||||
t.Fatalf("got %d asset(s), want 2", len(got.Assets))
|
||||
}
|
||||
for name, want := range map[string]string{
|
||||
"kettle_v1.2.3_linux_amd64": "a binary, honestly",
|
||||
"SHA256SUMS": "beef kettle_v1.2.3_linux_amd64\n",
|
||||
} {
|
||||
held := f.assetNamed(name)
|
||||
if len(held) != 1 {
|
||||
t.Fatalf("the tracker holds %d attachment(s) called %s, want 1", len(held), name)
|
||||
}
|
||||
if body := f.bytesOf(held[0]); body != want {
|
||||
t.Errorf("%s arrived as %q, want %q", name, body, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The receipt is the whole user experience of a tool nobody watches run.
|
||||
var out strings.Builder
|
||||
got.print(&out)
|
||||
for _, want := range []string{"created", "v1.2.3", "acme/widgets", rel.HTMLURL,
|
||||
"kettle_v1.2.3_linux_amd64", "SHA256SUMS", "2 asset(s)"} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Errorf("the receipt does not name %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A re-run is not a failure and not a second release. It is also not a no-op
|
||||
// when something changed: a retry that fixed the notes has to leave the fixed
|
||||
// notes behind.
|
||||
func TestARerunConvergesInsteadOfPublishingTwice(t *testing.T) {
|
||||
harmless(t)
|
||||
f := newFake(t)
|
||||
dir := t.TempDir()
|
||||
binary := writeFile(t, dir, "kettle_v2.0.0_darwin_arm64", "one")
|
||||
|
||||
first, err := publish(configFor(f.url()), spec{Tag: "v2.0.0", Notes: "first go", Files: []string{binary}})
|
||||
if err != nil {
|
||||
t.Fatalf("the first publish: %v", err)
|
||||
}
|
||||
again, err := publish(configFor(f.url()), spec{Tag: "v2.0.0", Notes: "first go", Files: []string{binary}})
|
||||
if err != nil {
|
||||
t.Fatalf("the second publish: %v", err)
|
||||
}
|
||||
|
||||
if len(f.releases) != 1 {
|
||||
t.Fatalf("a re-run left %d releases for one tag", len(f.releases))
|
||||
}
|
||||
if again.State != "reused" {
|
||||
t.Errorf("state is %q, want reused — nothing had changed", again.State)
|
||||
}
|
||||
if again.Release.ID != first.Release.ID {
|
||||
t.Errorf("the re-run published a different release (%d, was %d)", again.Release.ID, first.Release.ID)
|
||||
}
|
||||
if held := f.assetNamed("kettle_v2.0.0_darwin_arm64"); len(held) != 1 {
|
||||
t.Errorf("the tracker holds %d copies of the one asset", len(held))
|
||||
}
|
||||
|
||||
// And the corrected notes actually land.
|
||||
fixed, err := publish(configFor(f.url()), spec{Tag: "v2.0.0", Notes: "second go", Files: []string{binary}})
|
||||
if err != nil {
|
||||
t.Fatalf("the third publish: %v", err)
|
||||
}
|
||||
if fixed.State != "updated" {
|
||||
t.Errorf("state is %q, want updated — the notes changed", fixed.State)
|
||||
}
|
||||
if f.releases[0].Note != "second go" {
|
||||
t.Errorf("the notes are %q, want the corrected ones", f.releases[0].Note)
|
||||
}
|
||||
if len(f.releases) != 1 {
|
||||
t.Errorf("converging forked the release: %d of them", len(f.releases))
|
||||
}
|
||||
}
|
||||
|
||||
// Two attachments with one name is the silent failure: the download URL names
|
||||
// the file, so the second copy is not addressable and nobody notices which one
|
||||
// people got.
|
||||
func TestAnAssetOfTheSameNameIsReplacedRatherThanDoubled(t *testing.T) {
|
||||
harmless(t)
|
||||
f := newFake(t)
|
||||
dir := t.TempDir()
|
||||
|
||||
path := writeFile(t, dir, "kettle_v3.0.0_linux_arm64", "the first build")
|
||||
if _, err := publish(configFor(f.url()), spec{Tag: "v3.0.0", Files: []string{path}}); err != nil {
|
||||
t.Fatalf("the first publish: %v", err)
|
||||
}
|
||||
// Same name, different bytes — a rebuild after a fix, which is exactly when
|
||||
// somebody re-runs this.
|
||||
writeFile(t, dir, "kettle_v3.0.0_linux_arm64", "the second build")
|
||||
got, err := publish(configFor(f.url()), spec{Tag: "v3.0.0", Files: []string{path}})
|
||||
if err != nil {
|
||||
t.Fatalf("the second publish: %v", err)
|
||||
}
|
||||
|
||||
held := f.assetNamed("kettle_v3.0.0_linux_arm64")
|
||||
if len(held) != 1 {
|
||||
t.Fatalf("the release carries %d attachments of that name, want 1", len(held))
|
||||
}
|
||||
if body := f.bytesOf(held[0]); body != "the second build" {
|
||||
t.Errorf("the asset is %q — the replacement did not take", body)
|
||||
}
|
||||
if len(got.Assets) != 1 || !got.Assets[0].Replaced {
|
||||
t.Errorf("the receipt does not report a replacement: %+v", got.Assets)
|
||||
}
|
||||
var out strings.Builder
|
||||
got.print(&out)
|
||||
if !strings.Contains(out.String(), "replaced") {
|
||||
t.Errorf("the receipt does not say it replaced anything:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A draft has no git tag behind it, so the by-tag route can answer 404 for a
|
||||
// release that is plainly there. A publish that believed it would file a second
|
||||
// release every time it was retried.
|
||||
func TestADraftIsFoundEvenWhenTheTagRouteHidesIt(t *testing.T) {
|
||||
harmless(t)
|
||||
f := newFake(t)
|
||||
f.hideDraftsFromTheTagRoute = true
|
||||
|
||||
s := spec{Tag: "v4.0.0", Draft: true}
|
||||
if _, err := publish(configFor(f.url()), s); err != nil {
|
||||
t.Fatalf("the first publish: %v", err)
|
||||
}
|
||||
again, err := publish(configFor(f.url()), s)
|
||||
if err != nil {
|
||||
t.Fatalf("the second publish: %v", err)
|
||||
}
|
||||
if len(f.releases) != 1 {
|
||||
t.Fatalf("a retried draft published %d releases for one tag", len(f.releases))
|
||||
}
|
||||
if again.State != "reused" {
|
||||
t.Errorf("state is %q, want reused", again.State)
|
||||
}
|
||||
if !f.releases[0].IsDraft {
|
||||
t.Error("the release stopped being a draft")
|
||||
}
|
||||
}
|
||||
|
||||
// A half-filled configuration is refused before anything is dialled, naming the
|
||||
// variable or the command that supplies what is missing. "401 Unauthorized"
|
||||
// names nothing anybody can act on.
|
||||
func TestAHalfFilledConfigurationIsRefusedBeforeItDials(t *testing.T) {
|
||||
harmless(t)
|
||||
f := newFake(t)
|
||||
|
||||
for _, tc := range []struct {
|
||||
what string
|
||||
cfg *config.Resolved
|
||||
want string
|
||||
}{
|
||||
{"no url", &config.Resolved{Token: "t", Owner: "a", Repo: "b"}, config.EnvURL},
|
||||
{"no token", &config.Resolved{URL: f.url(), Owner: "a", Repo: "b"}, config.EnvToken},
|
||||
{"no repo", &config.Resolved{URL: f.url(), Token: "t"}, config.EnvRepo},
|
||||
{"nothing at all", &config.Resolved{}, config.EnvURL},
|
||||
} {
|
||||
_, err := publish(tc.cfg, spec{Tag: "v0.0.1"})
|
||||
if err == nil {
|
||||
t.Errorf("%s: accepted", tc.what)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("%s: the refusal does not name the fix (%q): %v", tc.what, tc.want, err)
|
||||
}
|
||||
}
|
||||
if calls := f.calls(); len(calls) != 0 {
|
||||
t.Errorf("a request went out for a configuration that was refused: %v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// End to end through main's own argument handling, with the credentials in the
|
||||
// environment and no project anywhere on the way up — which is the state a
|
||||
// clone is in, and the reason this resolves configuration the way it does.
|
||||
func TestRunPublishesFromTheEnvironmentWithNoProjectInSight(t *testing.T) {
|
||||
harmless(t)
|
||||
f := newFake(t)
|
||||
dir := t.TempDir()
|
||||
binary := writeFile(t, dir, "kettle_v5.0.0_darwin_amd64", "mach-o, trust me")
|
||||
notes := writeFile(t, dir, "NOTES.md", "## v5.0.0\n\nIt does the thing.\n")
|
||||
|
||||
t.Setenv(config.EnvURL, f.url())
|
||||
t.Setenv(config.EnvToken, "s3cret")
|
||||
t.Setenv(config.EnvRepo, "acme/widgets")
|
||||
|
||||
var stdout, stderr strings.Builder
|
||||
code := run([]string{"--tag", "v5.0.0", "--title", "kettle v5.0.0", "--notes-file", notes, binary},
|
||||
&stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit = %d, want 0\nstdout:\n%s\nstderr:\n%s", code, stdout.String(), stderr.String())
|
||||
}
|
||||
if len(f.releases) != 1 || f.releases[0].Title != "kettle v5.0.0" {
|
||||
t.Fatalf("the tracker holds %+v", f.releases)
|
||||
}
|
||||
if !strings.Contains(f.releases[0].Note, "It does the thing.") {
|
||||
t.Errorf("the notes file did not arrive: %q", f.releases[0].Note)
|
||||
}
|
||||
for _, want := range []string{"created", "uploaded", "kettle_v5.0.0_darwin_amd64", f.releases[0].HTMLURL} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Errorf("the receipt does not name %q:\n%s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
// A token in a receipt is a token in a terminal scrollback and a pasted
|
||||
// bug report.
|
||||
if strings.Contains(stdout.String()+stderr.String(), "s3cret") {
|
||||
t.Errorf("the run printed the token:\n%s%s", stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Everything a person can get wrong in the arguments is reported before a
|
||||
// release exists to be half-published.
|
||||
func TestRunRefusesBadArgumentsWithoutTouchingTheTracker(t *testing.T) {
|
||||
harmless(t)
|
||||
f := newFake(t)
|
||||
dir := t.TempDir()
|
||||
here := writeFile(t, dir, "kettle_v6.0.0_linux_amd64", "x")
|
||||
elsewhere := filepath.Join(t.TempDir(), "kettle_v6.0.0_linux_amd64")
|
||||
if err := os.WriteFile(elsewhere, []byte("y"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv(config.EnvURL, f.url())
|
||||
t.Setenv(config.EnvToken, "s3cret")
|
||||
t.Setenv(config.EnvRepo, "acme/widgets")
|
||||
|
||||
for _, tc := range []struct {
|
||||
what string
|
||||
argv []string
|
||||
want string
|
||||
}{
|
||||
{"no tag", []string{here}, "--tag is required"},
|
||||
{"a file that is not there", []string{"--tag", "v6.0.0", filepath.Join(dir, "absent")}, "cannot upload"},
|
||||
{"a directory", []string{"--tag", "v6.0.0", dir}, "it is a directory"},
|
||||
{"two files with one name", []string{"--tag", "v6.0.0", here, elsewhere}, "would replace the first"},
|
||||
{"notes that are not there", []string{"--tag", "v6.0.0", "--notes-file", filepath.Join(dir, "absent.md")}, "reading the notes"},
|
||||
} {
|
||||
var stdout, stderr strings.Builder
|
||||
if code := run(tc.argv, &stdout, &stderr); code != 2 {
|
||||
t.Errorf("%s: exit = %d, want 2\n%s%s", tc.what, code, stdout.String(), stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), tc.want) {
|
||||
t.Errorf("%s: stderr does not say %q:\n%s", tc.what, tc.want, stderr.String())
|
||||
}
|
||||
}
|
||||
if calls := f.calls(); len(calls) != 0 {
|
||||
t.Errorf("a refused run still talked to the tracker: %v", calls)
|
||||
}
|
||||
if len(f.releases) != 0 {
|
||||
t.Errorf("a refused run created %d release(s)", len(f.releases))
|
||||
}
|
||||
}
|
||||
|
||||
// A failure carries the status and what the server said, in the transport's own
|
||||
// error type, because "500" on its own has never helped anybody.
|
||||
func TestAFailureNamesTheStatusAndWhatTheServerSaid(t *testing.T) {
|
||||
harmless(t)
|
||||
// A token that is not allowed to write releases is the failure somebody
|
||||
// will actually meet: reads are fine, the create is refused.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/api/v1/version":
|
||||
writeJSON(w, map[string]string{"version": modernGitea})
|
||||
case r.Method == http.MethodPost:
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = io.WriteString(w, `{"message":"token does not have at least one of required scope(s): [write:repository]"}`)
|
||||
case strings.Contains(r.URL.Path, "/releases/tags/"):
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = io.WriteString(w, `{"message":"release with tag 'v7.0.0' not found"}`)
|
||||
default:
|
||||
writeJSON(w, []*sdk.Release{})
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
_, err := publish(configFor(srv.URL), spec{Tag: "v7.0.0"})
|
||||
if err == nil {
|
||||
t.Fatal("a 403 published a release")
|
||||
}
|
||||
for _, want := range []string{"403", "write:repository", "v7.0.0"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the failure does not mention %q:\n%v", want, err)
|
||||
}
|
||||
}
|
||||
if strings.Contains(err.Error(), "s3cret") {
|
||||
t.Errorf("the failure quotes the token:\n%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A tool nobody watches run has to be readable when somebody finally does.
|
||||
func TestTheReceiptIsAligned(t *testing.T) {
|
||||
r := &receipt{
|
||||
Repo: "acme/widgets",
|
||||
State: "created",
|
||||
Release: &sdk.Release{TagName: "v1.0.0", HTMLURL: "https://git.example.com/acme/widgets/releases/tag/v1.0.0"},
|
||||
Assets: []asset{
|
||||
{Name: "kettle_v1.0.0_darwin_arm64", URL: "https://git.example.com/a"},
|
||||
{Name: "SHA256SUMS", URL: "https://git.example.com/b", Replaced: true},
|
||||
},
|
||||
}
|
||||
var out strings.Builder
|
||||
r.print(&out)
|
||||
|
||||
// One line for the release, one per asset, the URL, and the summary.
|
||||
lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
|
||||
if len(lines) != 5 {
|
||||
t.Fatalf("the receipt is %d line(s):\n%s", len(lines), out.String())
|
||||
}
|
||||
// The URLs line up, which is what makes a column of them scannable.
|
||||
first := strings.Index(lines[1], "https://")
|
||||
if second := strings.Index(lines[2], "https://"); first != second {
|
||||
t.Errorf("the asset URLs do not line up (%d vs %d):\n%s", first, second, out.String())
|
||||
}
|
||||
if !strings.HasPrefix(lines[2], "replaced") {
|
||||
t.Errorf("a replaced asset is not called one:\n%s", out.String())
|
||||
}
|
||||
// The URL a person opens is on its own line, not buried in a summary.
|
||||
if !strings.HasPrefix(lines[3], "release ") || !strings.HasSuffix(lines[3], "/releases/tag/v1.0.0") {
|
||||
t.Errorf("the release URL is not on its own line:\n%s", out.String())
|
||||
}
|
||||
if want := fmt.Sprintf("%d asset(s): 1 uploaded, 1 replaced", 2); !strings.Contains(lines[4], want) {
|
||||
t.Errorf("the summary does not read %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user