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,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