From 3c8d2b02ddb7ddcd56263507ad6bfb1bc5179f59 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Mon, 9 Nov 2026 18:38:29 -0300 Subject: [PATCH 1/3] release: v. by release.yml, and go tool task runs the latest Releases are named as spin-stack names releases (internal/release, as spin's releaseversion): v., the day in UTC and its number. release.yml makes one of main each time CI passes on it - the commit that passed - builds, attests, and publishes last, with a checksums.txt; a commit released already is that release, not a second one. cmd/task takes go-tools from the latest release, held to the sha256 its checksums give, asking which is the latest at most every ten minutes and going on with the last one offline (internal/toolcache's Latest). No versions.yaml pins go-tools, so the gate asks only for task. Co-Authored-By: Claude Opus 5.5 --- .github/taskgate-allow.txt | 2 +- .github/workflows/ci.yml | 41 ------- .github/workflows/release.yml | 78 +++++++++++++ README.md | 6 +- Taskfile.yml | 32 ++--- cmd/task/main.go | 37 +++--- cmd/task/main_test.go | 85 ++++++-------- internal/release/cmd/releasename/main.go | 62 ++++++++++ internal/release/cmd/releasename/main_test.go | 70 +++++++++++ internal/release/release.go | 48 ++++++++ internal/release/release_test.go | 47 ++++++++ internal/toolcache/toolcache.go | 109 +++++++++++++++++ internal/toolcache/toolcache_test.go | 110 ++++++++++++++++++ site/src/content/docs/start.md | 43 +++---- site/src/content/docs/versions/reference.md | 2 +- versions/gate/gate.go | 31 +---- versions/gate/gate_test.go | 33 ++---- versions/upstream/upstream.go | 2 +- 18 files changed, 637 insertions(+), 201 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 internal/release/cmd/releasename/main.go create mode 100644 internal/release/cmd/releasename/main_test.go create mode 100644 internal/release/release.go create mode 100644 internal/release/release_test.go diff --git a/.github/taskgate-allow.txt b/.github/taskgate-allow.txt index 99bb2f8..3db2579 100644 --- a/.github/taskgate-allow.txt +++ b/.github/taskgate-allow.txt @@ -8,4 +8,4 @@ # would be in the module graph of every repository that imports it. .github/workflows/ci.yml/gate/Task's pin # read before Task is installed .github/workflows/pages.yml/build/Task's pin # read before Task is installed -.github/workflows/ci.yml/release/Task's pin # read before Task is installed +.github/workflows/release.yml/release/Task's pin # read before Task is installed diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e31eb36..1e5d480 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,44 +111,3 @@ jobs: archive: false retention-days: 7 if-no-files-found: ignore - - # Main's commit, once the gate has passed on it, as the binaries a repository's `go tool task` - # runs (cmd/task): every command for each platform, attested as built here from this commit, and - # published as the release bin- that a versions.yaml pins by sha256. - release: - name: Release - needs: gate - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: write - id-token: write - attestations: write - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - go-version-file: go.mod - - - name: Task's pin - run: go run ./cmd/versions env task >> "$GITHUB_ENV" - - - uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - with: - version: ${{ env.TASK_VERSION }} - checksum: ${{ env.TASK_SHA256 }} - - - name: The binaries - run: task release:build - - - name: Their provenance - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 - with: - subject-path: _output/release/*.tar.gz - - - name: The release - env: - GH_TOKEN: ${{ github.token }} - run: task release:publish diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ac5200a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,78 @@ +# go-tools' release, named as spin-stack names releases: v., the day in UTC and which +# release of that day (internal/release). Made of main each time CI passes on it - the commit that +# passed, not whatever main is when this starts - and published last: a release is its files, built +# and attested here, then the tag. What a repository's `go tool task` (cmd/task) runs is the latest. +name: Release + +on: + workflow_run: + workflows: [CI] + types: [completed] + branches: [main] + +# One at a time: a release is named at the start and tagged at the end, so two at once would name +# the same one. +concurrency: + group: release + cancel-in-progress: false + +permissions: + contents: read + +env: + GITHUB_TOKEN: ${{ github.token }} + +jobs: + release: + name: Release + if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + id-token: write + attestations: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.workflow_run.head_sha }} + # Every tag, which the next release's name is counted from. + fetch-depth: 0 + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + + - name: Task's pin + run: go run ./cmd/versions env task >> "$GITHUB_ENV" + + - uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 + with: + version: ${{ env.TASK_VERSION }} + checksum: ${{ env.TASK_SHA256 }} + + # The release this commit is: the one already tagged at it when CI was run again, the next + # name today otherwise. + - name: Its name + id: name + env: + SHA: ${{ github.event.workflow_run.head_sha }} + run: task release:name + + - name: The binaries + if: steps.name.outputs.new == 'true' + run: task release:build + + - name: Their provenance + if: steps.name.outputs.new == 'true' + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: _output/release/*.tar.gz + + - name: The release + if: steps.name.outputs.new == 'true' + env: + GH_TOKEN: ${{ github.token }} + SHA: ${{ github.event.workflow_run.head_sha }} + VERSION: ${{ steps.name.outputs.version }} + run: task release:publish diff --git a/README.md b/README.md index 10deb76..db17af2 100644 --- a/README.md +++ b/README.md @@ -21,17 +21,17 @@ their `go.mod`; each fails CI with a specific finding and says how to resolve it | [`linkweight`](https://spin-stack.github.io/go-tools/linkweight/) | what each binary links and why: the bytes every module puts in it, the function that stops the linker dropping unused methods, the import whose cut drops the most, and a capability - exec, listen, mount, setuid - no line of the allowlist explains | | [`proven`](https://spin-stack.github.io/go-tools/proven/) | what CI already proved about a commit, by its tree: the run that answers for it, what a run on main is of, whether it may be released, each test's time, which failed tests are flaky | | [`exposure`](https://spin-stack.github.io/go-tools/exposure/) | what a repository would show if made public: secrets and private values in every ref's commits, pull request heads included, and in its issues, comments and releases | -| [`task`](https://spin-stack.github.io/go-tools/start/) | not a check: Task at the version `versions.yaml` pins, with every other go-tools command on its `PATH`, both downloaded as released binaries - the one command a repository's `go.mod` takes, so neither Task's modules nor the tools' are compiled or cached | +| [`task`](https://spin-stack.github.io/go-tools/start/) | not a check: Task at the version `versions.yaml` pins, with every other go-tools command on its `PATH`, the tools at their latest release - both downloaded as binaries; the one command a repository's `go.mod` takes, so neither Task's modules nor the tools' are compiled or cached | | [`gencache`](https://spin-stack.github.io/go-tools/gencache/) | not a check: `go generate` with each mockgen directive answered from a cache when nothing its mock is made from has changed | | [`ghatrace`](https://spin-stack.github.io/go-tools/ghatrace/) | not a check: where a GitHub Actions run's time went - the critical path, each job's wait for a runner, its steps and the spans inside them - as the run's summary, an HTML page and a Perfetto trace, with nothing hosted | | [`provenance`, `sbom`, `attestgate`, `trustroot`](https://spin-stack.github.io/go-tools/supply-chain/) | releases published without SLSA provenance or an SBOM | ```sh -go get -tool github.com/spin-stack/go-tools/cmd/task # and task + go-tools in versions.yaml +go get -tool github.com/spin-stack/go-tools/cmd/task # and task in versions.yaml go tool task mutate # a task that runs `mutate -base origin/main` ``` -[Getting started](https://spin-stack.github.io/go-tools/start/) has the two `versions.yaml` entries. +[Getting started](https://spin-stack.github.io/go-tools/start/) has the `versions.yaml` entry. `versions`, `versions/upstream`, `versions/gate`, `provenance`, `attestgate`, `sbom`, `ghatrace` and `trustroot` are also packages another repository imports; the rest is `internal/`. diff --git a/Taskfile.yml b/Taskfile.yml index 278859d..848cb2d 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -137,23 +137,27 @@ tasks: cmd: >- tar --sort=name --owner=0 --group=0 --numeric-owner --mtime="@$(git show -s --format=%ct HEAD)" -C {{.OUT}}/release/{{replace "/" "_" .ITEM}} -czf {{.OUT}}/release/go-tools_{{replace "/" "_" .ITEM}}.tar.gz . + # What cmd/task holds a download of the latest release to, no versions.yaml pinning it. + - cd {{.OUT}}/release && sha256sum go-tools_*.tar.gz > checksums.txt + + release:name: + desc: The release SHA is - released already, or the next v. today - as version= and new= for $GITHUB_OUTPUT. + requires: + vars: [SHA] + cmds: + - go run ./internal/release/cmd/releasename {{.SHA}} >> "${GITHUB_OUTPUT:-/dev/stdout}" release:publish: - desc: Publish _output/release's archives as the GitHub release bin-, the version a versions.yaml pins (GH_TOKEN, GITHUB_SHA). - # Named by the commit's UTC time, which is also the time in the pseudo-version a go.mod requires - # this commit at: versions/gate holds a repository's go-tools entry to its go.mod's. A tag of - # that shape is no module version, so `go get` never takes it for one. A run that is retried - # publishes the release it made the first time, and fails rather than replace another's files. + desc: Publish _output/release as the GitHub release VERSION of SHA, which tags it (GH_TOKEN). + # The tag is made by the release, last: a release that failed to publish is no tag that names a + # commit with no files. + requires: + vars: [SHA, VERSION] cmds: - - | - set -euo pipefail - tag="bin-$(TZ=UTC git show -s --format=%cd --date=format-local:%Y%m%d.%H%M%S "${GITHUB_SHA}")" - if gh release view "$tag" >/dev/null 2>&1; then - echo "$tag is published already" - exit 0 - fi - gh release create "$tag" {{.OUT}}/release/*.tar.gz --target "${GITHUB_SHA}" --title "$tag" \ - --notes "Every go-tools command at ${GITHUB_SHA}, for a repository's versions.yaml: \`go tool versions bump go-tools\`." + - >- + gh release create {{.VERSION}} {{.OUT}}/release/*.tar.gz {{.OUT}}/release/checksums.txt + --target {{.SHA}} --title {{.VERSION}} + --notes "Every go-tools command at {{.SHA}}, attested as built by release.yml. A repository's \`go tool task\` runs the latest." site: desc: Build the documentation site (site/, Astro Starlight) into site/dist; task site:dev serves it with live reload. diff --git a/cmd/task/main.go b/cmd/task/main.go index c95a031..03857fe 100644 --- a/cmd/task/main.go +++ b/cmd/task/main.go @@ -6,10 +6,11 @@ // // A repository takes this one command in its go.mod instead of Task's and go-tools' own: compiling // those is about 450 modules and a minute or more on a runner, for programs their authors already -// release. This one links versions.yaml's reader and a download, and it puts versions.yaml's `task` -// entry - and its `go-tools` entry, when it has one - in the user's cache, each held to its sha256 -// and unpacked once by it (internal/toolcache), then becomes Task: the same process, so a signal, -// the terminal and the exit status are Task's. +// release. This one links versions.yaml's reader and a download. It puts versions.yaml's `task` +// in the user's cache, held to its sha256, and go-tools' latest release, held to the sha256 its +// checksums give - every repository runs the newest commands main has passed its gate with, and +// none bumps them - each unpacked once (internal/toolcache). Then it becomes Task: the same +// process, so a signal, the terminal and the exit status are Task's. package main import ( @@ -25,18 +26,23 @@ import ( "github.com/spin-stack/go-tools/versions" ) +// goTools is where go-tools' releases are, made by its release.yml of main each time its gate +// passes. +const goTools = "https://github.com/spin-stack/go-tools" + func main() { - err := run(context.Background(), os.Args[1:], syscall.Exec) // mutate-exempt: the process's own arguments; run is what the test drives - fmt.Fprintln(os.Stderr, "task:", err) //nolint:errcheck // the error is the exit status's reason; stderr failing changes no status - os.Exit(2) // mutate-exempt: main is reached only when the exec failed, which no test makes it do; run is what the tests drive + err := run(context.Background(), os.Args[1:], goTools, syscall.Exec) // mutate-exempt: the process's own arguments; run is what the test drives + fmt.Fprintln(os.Stderr, "task:", err) //nolint:errcheck // the error is the exit status's reason; stderr failing changes no status + os.Exit(2) // mutate-exempt: main is reached only when the exec failed, which no test makes it do; run is what the tests drive } // execFunc replaces the process with argv[0]'s program: syscall.Exec, and a recorder in a test. type execFunc func(path string, argv []string, env []string) error -// run finds the repository's versions.yaml, installs what it pins, and execs Task with args. It -// returns only when that fails: on success the process is Task's. -func run(ctx context.Context, args []string, exec execFunc) error { +// run finds the repository's versions.yaml, installs the Task it pins and the latest release of +// the tools at base, and execs Task with args. It returns only when that fails: on success the +// process is Task's. +func run(ctx context.Context, args []string, base string, exec execFunc) error { wd, err := os.Getwd() if err != nil { return err @@ -62,15 +68,12 @@ func run(ctx context.Context, args []string, exec execFunc) error { if err != nil { return err } - path := os.Getenv("PATH") - if tools, err := v.Get("go-tools"); err == nil { - dir, err := toolcache.Install(ctx, tools, root) - if err != nil { - return err - } - path = dir + string(os.PathListSeparator) + path + tools, err := toolcache.Latest(ctx, base, "go-tools", root) + if err != nil { + return err } bin := filepath.Join(taskDir, "task") + path := tools + string(os.PathListSeparator) + os.Getenv("PATH") return exec(bin, append([]string{bin}, args...), withPath(os.Environ(), path)) } diff --git a/cmd/task/main_test.go b/cmd/task/main_test.go index efc3023..a5ce03a 100644 --- a/cmd/task/main_test.go +++ b/cmd/task/main_test.go @@ -36,9 +36,10 @@ func archive(t *testing.T, name string) ([]byte, string) { return buf.Bytes(), hex.EncodeToString(s[:]) } -// repository is a directory whose versions.yaml pins task, and go-tools when withTools, at a TLS -// server the default client trusts for the test; the user cache is a directory of the test's. -func repository(t *testing.T, withTools bool) string { +// repository is a directory whose versions.yaml pins task at a TLS server the default client +// trusts for the test, and the base URL of go-tools' releases there - its latest, v20261110.01, +// listed in its checksums. The user's cache is a directory of the test's. +func repository(t *testing.T) (repo, base string) { t.Helper() platform := runtime.GOOS + "_" + runtime.GOARCH taskArchive, taskPin := archive(t, "task") @@ -47,7 +48,11 @@ func repository(t *testing.T, withTools bool) string { switch r.URL.Path { case "/task/v3.54.0/task_" + platform + ".tar.gz": _, _ = w.Write(taskArchive) - case "/tools/bin-1.2/go-tools_" + platform + ".tar.gz": + case "/go-tools/releases/latest": + http.Redirect(w, r, "/go-tools/releases/tag/v20261110.01", http.StatusFound) + case "/go-tools/releases/download/v20261110.01/checksums.txt": + _, _ = w.Write([]byte(toolsPin + " go-tools_" + platform + ".tar.gz\n")) + case "/go-tools/releases/download/v20261110.01/go-tools_" + platform + ".tar.gz": _, _ = w.Write(toolsArchive) default: http.NotFound(w, r) @@ -58,42 +63,31 @@ func repository(t *testing.T, withTools bool) string { http.DefaultClient.Transport = srv.Client().Transport t.Cleanup(func() { http.DefaultClient.Transport = before }) - p := runtime.GOOS + "/" + runtime.GOARCH file := `- name: task kind: download source: ` + srv.URL + `/task/v{version}/task_{os}_{arch}.tar.gz version: 3.54.0 pins: - ` + p + `: ` + taskPin + ` + ` + runtime.GOOS + "/" + runtime.GOARCH + `: ` + taskPin + ` track: github-release https://github.com/go-task/task ` - if withTools { - file += `- name: go-tools - kind: download - source: ` + srv.URL + `/tools/bin-{version}/go-tools_{os}_{arch}.tar.gz - version: "1.2" - pins: - ` + p + `: ` + toolsPin + ` - track: github-release https://github.com/spin-stack/go-tools bin- -` - } - repo := t.TempDir() + repo = t.TempDir() if err := os.WriteFile(filepath.Join(repo, "versions.yaml"), []byte(file), 0o600); err != nil { t.Fatal(err) } t.Setenv("XDG_CACHE_HOME", t.TempDir()) t.Setenv("HOME", t.TempDir()) - return repo + return repo, srv.URL + "/go-tools" } type call struct { - path string - argv, env []string - calledWith bool + path string + argv, env []string + called bool } func (c *call) exec(path string, argv, env []string) error { - c.path, c.argv, c.env, c.calledWith = path, argv, env, true + c.path, c.argv, c.env, c.called = path, argv, env, true return nil } @@ -108,9 +102,9 @@ func pathOf(env []string) []string { } // From anywhere under the repository, `go tool task` is the pinned Task, given the arguments it -// was given, with go-tools' commands first on a PATH that is there once. -func TestTaskIsThePinnedTaskWithTheToolsOnItsPath(t *testing.T) { - repo := repository(t, true) +// was given, with the latest go-tools first on a PATH that is there once. +func TestTaskIsThePinnedTaskWithTheLatestToolsOnItsPath(t *testing.T) { + repo, base := repository(t) sub := filepath.Join(repo, "internal", "x") if err := os.MkdirAll(sub, 0o750); err != nil { t.Fatal(err) @@ -118,7 +112,7 @@ func TestTaskIsThePinnedTaskWithTheToolsOnItsPath(t *testing.T) { t.Chdir(sub) t.Setenv("PATH", "/usr/bin") var c call - if err := run(t.Context(), []string{"lint", "--", "-v"}, c.exec); err != nil { + if err := run(t.Context(), []string{"lint", "--", "-v"}, base, c.exec); err != nil { t.Fatal(err) } if filepath.Base(c.path) != "task" || !slices.Equal(c.argv, []string{c.path, "lint", "--", "-v"}) { @@ -137,28 +131,16 @@ func TestTaskIsThePinnedTaskWithTheToolsOnItsPath(t *testing.T) { } } -// A repository that pins no go-tools still runs its Task, with the caller's PATH as it was. -func TestTaskWithoutGoToolsKeepsThePath(t *testing.T) { - t.Chdir(repository(t, false)) - t.Setenv("PATH", "/usr/bin") - var c call - if err := run(t.Context(), nil, c.exec); err != nil { - t.Fatal(err) - } - if paths := pathOf(c.env); !slices.Equal(paths, []string{"/usr/bin"}) { - t.Errorf("PATH = %v", paths) - } -} - // What `go tool task` cannot run is said, and nothing is exec'd: no versions.yaml above, one that -// pins no Task, one that does not read. +// pins no Task, one that does not read, no go-tools release to be had. func TestWhatCannotRunTaskIsSaid(t *testing.T) { + _, base := repository(t) for _, tc := range []struct { - name, file, says string + name, file, base, says string }{ - {"no versions.yaml", "", "no versions.yaml here or above"}, - {"no task in it", "- name: go\n kind: date\n version: \"1\"\n track: today\n", "runs the Task it pins"}, - {"a file that does not read", "- name: [\n", "versions"}, + {"no versions.yaml", "", base, "no versions.yaml here or above"}, + {"no task in it", "- name: go\n kind: date\n version: \"1\"\n track: today\n", base, "runs the Task it pins"}, + {"a file that does not read", "- name: [\n", base, "versions"}, } { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() @@ -169,10 +151,19 @@ func TestWhatCannotRunTaskIsSaid(t *testing.T) { } t.Chdir(dir) var c call - err := run(t.Context(), nil, c.exec) - if err == nil || !strings.Contains(err.Error(), tc.says) || c.calledWith { - t.Errorf("err = %v, exec'd %v; want %q", err, c.calledWith, tc.says) + err := run(t.Context(), nil, tc.base, c.exec) + if err == nil || !strings.Contains(err.Error(), tc.says) || c.called { + t.Errorf("err = %v, exec'd %v; want %q", err, c.called, tc.says) } }) } + t.Run("no go-tools release", func(t *testing.T) { + repo, base := repository(t) + t.Chdir(repo) + var c call + err := run(t.Context(), nil, base+"/none", c.exec) + if err == nil || !strings.Contains(err.Error(), "the latest release of") || c.called { + t.Errorf("err = %v, exec'd %v", err, c.called) + } + }) } diff --git a/internal/release/cmd/releasename/main.go b/internal/release/cmd/releasename/main.go new file mode 100644 index 0000000..d2a3d3d --- /dev/null +++ b/internal/release/cmd/releasename/main.go @@ -0,0 +1,62 @@ +// Command releasename says what release.yml makes of a commit, as lines for $GITHUB_OUTPUT: +// `version=` and `new=false` when it is released already, the next +// name today and `new=true` otherwise. +// +// go run ./internal/release/cmd/releasename +// +// The tags are the clone's: release.yml fetches them all before asking. +package main + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + "time" + + "github.com/spin-stack/go-tools/internal/release" +) + +func main() { + if err := run(os.Args[1:], os.Stdout, git, time.Now()); err != nil { // mutate-exempt: the process's own arguments; run is what the test drives + fmt.Fprintln(os.Stderr, "releasename:", err) //nolint:errcheck // the exit status says it + os.Exit(1) + } +} + +// gitFunc is a git command's output, one line a word: the clone's, and a test's own. +type gitFunc func(args ...string) ([]string, error) + +func run(args []string, w io.Writer, g gitFunc, now time.Time) error { + if len(args) != 1 { + return errors.New("releasename ") + } + at, err := g("tag", "--points-at", args[0]) + if err != nil { + return err + } + if v := release.Of(at); v != "" { + _, err := fmt.Fprintf(w, "version=%s\nnew=false\n", v) + return err + } + all, err := g("tag", "--list") + if err != nil { + return err + } + _, err = fmt.Fprintf(w, "version=%s\nnew=true\n", release.Next(all, now)) + return err +} + +func git(args ...string) ([]string, error) { + out, err := exec.Command("git", args...).Output() //nolint:gosec // git, with the commit release.yml names: a tag listing, not a command line + var exit *exec.ExitError + if errors.As(err, &exit) { + return nil, fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, exit.Stderr) + } + if err != nil { + return nil, err + } + return strings.Fields(string(out)), nil +} diff --git a/internal/release/cmd/releasename/main_test.go b/internal/release/cmd/releasename/main_test.go new file mode 100644 index 0000000..b6ea80b --- /dev/null +++ b/internal/release/cmd/releasename/main_test.go @@ -0,0 +1,70 @@ +package main + +import ( + "bytes" + "errors" + "slices" + "strings" + "testing" + "time" +) + +func fakeGit(atCommit, all []string, failOn string) gitFunc { + return func(args ...string) ([]string, error) { + if slices.Contains(args, failOn) { + return nil, errors.New("git failed") + } + if slices.Contains(args, "--points-at") { + return atCommit, nil + } + return all, nil + } +} + +// A commit released already is that release; one that is not is the day's next. +func TestACommitIsItsReleaseOrTheNextOne(t *testing.T) { + now := time.Date(2026, 11, 10, 12, 0, 0, 0, time.UTC) + for _, tc := range []struct { + name string + at, all []string + want string + }{ + {"released", []string{"v20261110.02"}, []string{"v20261110.01", "v20261110.02", "v20261110.03"}, "version=v20261110.02\nnew=false\n"}, + {"not yet", nil, []string{"v20261110.01", "v20261110.02"}, "version=v20261110.03\nnew=true\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + var out bytes.Buffer + if err := run([]string{"abc"}, &out, fakeGit(tc.at, tc.all, ""), now); err != nil { + t.Fatal(err) + } + if out.String() != tc.want { + t.Errorf("said %q, want %q", out.String(), tc.want) + } + }) + } +} + +// No commit, or a git that fails, is no name. +func TestNoCommitOrNoGitIsNoName(t *testing.T) { + var out bytes.Buffer + if err := run(nil, &out, fakeGit(nil, nil, ""), time.Now()); err == nil || out.Len() != 0 { + t.Errorf("no commit: %v, %q", err, out.String()) + } + for _, failOn := range []string{"--points-at", "--list"} { + out.Reset() + if err := run([]string{"abc"}, &out, fakeGit(nil, nil, failOn), time.Now()); err == nil || out.Len() != 0 { + t.Errorf("git failing on %s: %v, %q", failOn, err, out.String()) + } + } +} + +// git's own output is read a word a line, and its failure says what it said. +func TestGitIsTheClonesGit(t *testing.T) { + tags, err := git("tag", "--list", "no-such-tag-*") + if err != nil || len(tags) != 0 { + t.Errorf("tags = %v, %v", tags, err) + } + if _, err := git("no-such-command"); err == nil || !strings.Contains(err.Error(), "git no-such-command") { + t.Errorf("err = %v", err) + } +} diff --git a/internal/release/release.go b/internal/release/release.go new file mode 100644 index 0000000..c05a49e --- /dev/null +++ b/internal/release/release.go @@ -0,0 +1,48 @@ +// Package release names go-tools' releases as spin-stack names its own: v., the +// day in UTC and which release of that day (spin's hack/releaseversion and docs/releases.md). A +// release is made of main each time its gate passes, so a day can have several. +// +// A name of that form is no module version - semver is three numbers - so `go get` never takes +// one of these tags for a version of the module, and a repository's go.mod goes on naming a +// commit. +package release + +import ( + "fmt" + "regexp" + "strconv" + "time" +) + +// name is a release's name, and nothing that only resembles one: two digits of sequence, never +// one, so v20261109.1 and v20261109.01 are not two spellings of one release. +var name = regexp.MustCompile(`^v(\d{8})\.(\d{2})$`) + +// Next is the name of the next release on now's day, given every tag there is. +func Next(tags []string, now time.Time) string { + day := now.UTC().Format("20060102") + next := 1 + for _, t := range tags { + m := name.FindStringSubmatch(t) + if m == nil || m[1] != day { + continue + } + if seq, _ := strconv.Atoi(m[2]); seq >= next { + next = seq + 1 + } + } + return fmt.Sprintf("v%s.%02d", day, next) +} + +// Of is the release among tags, the ones that point at a commit, that names it; "" when the +// commit is not released. A run asked again of a commit it released answers that release rather +// than making a second one of the same bytes. +func Of(tags []string) string { + best := "" + for _, t := range tags { + if name.MatchString(t) && t > best { + best = t + } + } + return best +} diff --git a/internal/release/release_test.go b/internal/release/release_test.go new file mode 100644 index 0000000..a78a129 --- /dev/null +++ b/internal/release/release_test.go @@ -0,0 +1,47 @@ +package release + +import ( + "testing" + "time" +) + +// The next release is the day's next number, whatever else is tagged: another day's, a name that +// only resembles one, a prerelease. +func TestTheNextReleaseIsTheDaysNextNumber(t *testing.T) { + now := time.Date(2026, 11, 9, 23, 30, 0, 0, time.FixedZone("ART", -3*3600)) // 2026-11-10 in UTC + for _, tc := range []struct { + name string + tags []string + want string + }{ + {"no release yet", nil, "v20261110.01"}, + {"another day's", []string{"v20261109.07"}, "v20261110.01"}, + {"the day's", []string{"v20261110.01", "v20261110.02", "v20261109.09"}, "v20261110.03"}, + {"out of order", []string{"v20261110.09", "v20261110.02"}, "v20261110.10"}, + {"not a release's name", []string{"v20261110.3", "v20261110.04-rc1", "bin-20261110.212154", "v1.2.3"}, "v20261110.01"}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := Next(tc.tags, now); got != tc.want { + t.Errorf("Next = %s, want %s", got, tc.want) + } + }) + } +} + +// A commit's release is the release tag pointing at it, the latest when it has two; none when no +// tag at it is a release's name. +func TestACommitsReleaseIsTheReleaseTagAtIt(t *testing.T) { + for _, tc := range []struct { + tags []string + want string + }{ + {nil, ""}, + {[]string{"bin-20261109.212154", "v1.0.0"}, ""}, + {[]string{"v20261109.01"}, "v20261109.01"}, + {[]string{"v20261109.01", "v20261110.02", "v20261110.1"}, "v20261110.02"}, + } { + if got := Of(tc.tags); got != tc.want { + t.Errorf("Of(%v) = %q, want %q", tc.tags, got, tc.want) + } + } +} diff --git a/internal/toolcache/toolcache.go b/internal/toolcache/toolcache.go index 123ab79..0f1bf0f 100644 --- a/internal/toolcache/toolcache.go +++ b/internal/toolcache/toolcache.go @@ -16,10 +16,12 @@ import ( "fmt" "io" "io/fs" + "net/http" "os" "path/filepath" "runtime" "strings" + "time" "github.com/spin-stack/go-tools/internal/fetch" "github.com/spin-stack/go-tools/versions" @@ -81,6 +83,113 @@ func install(ctx context.Context, e versions.Entry, root, goos, goarch string) ( return "", fmt.Errorf("toolcache: %w", renamed) } +// Checksums is the file every release of a repository Latest reads lists its archives in, as +// sha256sum writes them. +const Checksums = "checksums.txt" + +// fresh is how long a release's name, once asked, is taken as the latest without asking again: a +// Taskfile runs `go tool task` many times a minute, and a new release can wait that long. +const fresh = 10 * time.Minute + +// Latest is the directory the latest release of the repository at base (https://github.com//) +// is unpacked in under root: its archive __.tar.gz, held to the sha256 the +// release's checksums file gives it. The name of the latest release is asked at most every ten +// minutes, and a machine that cannot ask goes on with the last one it had. +func Latest(ctx context.Context, base, name, root string) (string, error) { + return latest(ctx, base, name, root, runtime.GOOS, runtime.GOARCH, time.Now()) +} + +func latest(ctx context.Context, base, name, root, goos, goarch string, now time.Time) (string, error) { + tag, err := latestTag(ctx, base, filepath.Join(root, name, "latest"), now) + if err != nil { + return "", err + } + // A release installed before is its directory, by the sha256 recorded when it was: no network, + // which is what lets a machine that cannot reach GitHub go on with the release it has. + installed := filepath.Join(root, name, tag+"."+goos+"_"+goarch) + if pin, err := os.ReadFile(installed); err == nil { //nolint:gosec // this cache's own file + if dir := filepath.Join(root, name, string(pin)); exists(dir) { + return dir, nil + } + } + archive := name + "_" + goos + "_" + goarch + ".tar.gz" + sums, err := fetch.Bytes(ctx, base+"/releases/download/"+tag+"/"+Checksums, 1<<20) + if err != nil { + return "", fmt.Errorf("toolcache: %s %s: %w", name, tag, err) + } + pin := "" + for line := range strings.Lines(string(sums)) { + if sum, file, ok := strings.Cut(strings.TrimSpace(line), " "); ok && file == archive { + pin = sum + } + } + if pin == "" { + return "", fmt.Errorf("toolcache: %s %s lists no %s", name, tag, archive) + } + dir, err := install(ctx, versions.Entry{Name: name, Kind: versions.Download, Version: tag, + Source: base + "/releases/download/{version}/" + name + "_{os}_{arch}.tar.gz", + Pins: map[string]string{goos + "/" + goarch: pin}}, root, goos, goarch) + if err != nil { + return "", err + } + if err := os.WriteFile(installed, []byte(pin), 0o600); err != nil { + return "", fmt.Errorf("toolcache: %w", err) + } + return dir, nil +} + +func exists(p string) bool { + _, err := os.Stat(p) + return err == nil +} + +// latestTag is the name of base's latest release: the one stamp records when it was written less +// than fresh ago, else asked of GitHub - where /releases/latest redirects to the release, which no +// API quota counts - and recorded; the one stamp records, however old, when GitHub cannot answer. +func latestTag(ctx context.Context, base, stamp string, now time.Time) (string, error) { + recorded, _ := os.ReadFile(stamp) //nolint:gosec // this cache's own file + if st, err := os.Stat(stamp); err == nil && len(recorded) > 0 && now.Sub(st.ModTime()) < fresh { + return string(recorded), nil + } + tag, err := askLatest(ctx, base) + if err != nil { + if len(recorded) > 0 { + return string(recorded), nil + } + return "", fmt.Errorf("toolcache: the latest release of %s: %w", base, err) + } + if err := os.MkdirAll(filepath.Dir(stamp), 0o750); err != nil { + return "", fmt.Errorf("toolcache: %w", err) + } + if err := os.WriteFile(stamp, []byte(tag), 0o600); err != nil { + return "", fmt.Errorf("toolcache: %w", err) + } + return tag, nil +} + +// askLatest is the tag base's /releases/latest redirects to. +func askLatest(ctx context.Context, base string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodHead, base+"/releases/latest", nil) + if err != nil { + return "", err + } + client := &http.Client{ + Transport: http.DefaultClient.Transport, + Timeout: 30 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } + resp, err := client.Do(req) + if err != nil { + return "", err + } + _ = resp.Body.Close() // a HEAD's: nothing in it + _, tag, ok := strings.Cut(resp.Header.Get("Location"), "/releases/tag/") + if resp.StatusCode != http.StatusFound || !ok || tag == "" { + return "", fmt.Errorf("%s/releases/latest answered %s, not a release", base, resp.Status) + } + return tag, nil +} + // unpack writes the regular files and directories of the gzipped tar at archive into dir, each // with its mode's permission bits. A name that would land outside dir is refused, and a link // is not followed or made: what the archive holds is programs, and a link is a way out of dir. diff --git a/internal/toolcache/toolcache_test.go b/internal/toolcache/toolcache_test.go index 7c92f2e..2581e72 100644 --- a/internal/toolcache/toolcache_test.go +++ b/internal/toolcache/toolcache_test.go @@ -13,6 +13,7 @@ import ( "strings" "sync/atomic" "testing" + "time" "github.com/spin-stack/go-tools/versions" ) @@ -196,3 +197,112 @@ func TestAnInstallThatLosesTheRaceAnswersTheWinnersDirectory(t *testing.T) { t.Errorf("beside it: %v", left) } } + +// github plays a repository's releases: /releases/latest redirects to *latest, each release in +// releases lists its archives in checksums.txt, and asked counts the questions for the latest. +type github struct { + latest *string + releases map[string][]byte // tag -> linux/amd64 archive + asked atomic.Int32 + down atomic.Bool +} + +func (g *github) serve(t *testing.T) string { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if g.down.Load() { + http.Error(w, "down", http.StatusServiceUnavailable) + return + } + if r.URL.Path == "/releases/latest" { + g.asked.Add(1) + http.Redirect(w, r, "/releases/tag/"+*g.latest, http.StatusFound) + return + } + for tag, body := range g.releases { + s := sha256.Sum256(body) + switch r.URL.Path { + case "/releases/download/" + tag + "/checksums.txt": + _, _ = w.Write([]byte(hex.EncodeToString(s[:]) + " go-tools_linux_amd64.tar.gz\n" + strings.Repeat("0", 64) + " go-tools_darwin_arm64.tar.gz\n")) + return + case "/releases/download/" + tag + "/go-tools_linux_amd64.tar.gz": + _, _ = w.Write(body) + return + } + } + http.NotFound(w, r) + })) + t.Cleanup(srv.Close) + return srv.URL +} + +// The latest release is installed held to the sha256 its checksums give, asked for at most every +// ten minutes, and a machine that cannot ask goes on with the last release it had. +func TestTheLatestReleaseIsAskedForAtMostEveryTenMinutes(t *testing.T) { + one, _ := tarball(t, member{name: "mutate", body: "one", mode: 0o755, kind: tar.TypeReg}) + two, _ := tarball(t, member{name: "mutate", body: "two", mode: 0o755, kind: tar.TypeReg}) + tag := "v20261110.01" + g := &github{latest: &tag, releases: map[string][]byte{"v20261110.01": one, "v20261110.02": two}} + base := g.serve(t) + root := t.TempDir() + now := time.Date(2026, 11, 10, 12, 0, 0, 0, time.UTC) + holds := func(at time.Time, want string) { + t.Helper() + dir, err := latest(t.Context(), base, "go-tools", root, "linux", "amd64", at) + if err != nil { + t.Fatal(err) + } + if got, _ := os.ReadFile(filepath.Join(dir, "mutate")); string(got) != want { + t.Errorf("at %s: mutate is %q, want %q", at.Format(time.TimeOnly), got, want) + } + } + holds(now, "one") + // A stamp is written at the machine's own time: it is the clock latest is given that is read + // against it here, so the test moves that clock rather than the file's. + stamp := filepath.Join(root, "go-tools", "latest") + written := time.Now() + tag = "v20261110.02" + holds(written.Add(9*time.Minute), "one") + if g.asked.Load() != 1 { + t.Errorf("asked %d times within ten minutes", g.asked.Load()) + } + holds(written.Add(11*time.Minute), "two") + if g.asked.Load() != 2 { + t.Errorf("asked %d times past ten minutes", g.asked.Load()) + } + if got, _ := os.ReadFile(stamp); string(got) != "v20261110.02" { + t.Errorf("the stamp says %q", got) + } + g.down.Store(true) + holds(time.Now().Add(time.Hour), "two") +} + +// What the latest release cannot be is said: GitHub down with nothing asked before, a redirect +// that is not to a release, a release whose checksums do not list this platform's archive. +func TestALatestReleaseThatCannotBeHadIsSaid(t *testing.T) { + archive, _ := tarball(t, member{name: "mutate", body: "x", mode: 0o755, kind: tar.TypeReg}) + for _, tc := range []struct { + name, tag, goos string + down bool + says string + }{ + {"github down", "v20261110.01", "linux", true, "the latest release of"}, + {"no release", "", "linux", false, "not a release"}, + {"a release without it", "v20261110.01", "windows", false, "lists no go-tools_windows_amd64.tar.gz"}, + {"a release that is not there", "v20261110.09", "linux", false, "404"}, + } { + t.Run(tc.name, func(t *testing.T) { + tag := tc.tag + g := &github{latest: &tag, releases: map[string][]byte{"v20261110.01": archive}} + g.down.Store(tc.down) + base := g.serve(t) + if tc.name == "no release" { + base += "/nothing" + } + _, err := latest(t.Context(), base, "go-tools", t.TempDir(), tc.goos, "amd64", time.Now()) + if err == nil || !strings.Contains(err.Error(), tc.says) { + t.Errorf("err = %v, want %q", err, tc.says) + } + }) + } +} diff --git a/site/src/content/docs/start.md b/site/src/content/docs/start.md index bab7677..9211175 100644 --- a/site/src/content/docs/start.md +++ b/site/src/content/docs/start.md @@ -3,10 +3,10 @@ title: Getting started description: Add go-tools to a repository as Go tools, run the gates from a Taskfile, and wire them into CI. --- -Every tool here is a command under `cmd/`, released as binaries for main's head each time the gate -passes on a push to it (`bin-`), and a -repository runs them through Task at the versions its `versions.yaml` pins. There is nothing to -install globally, and nothing to compile. +Every tool here is a command under `cmd/`, released as binaries of main each time its gate passes +(`v.`, named as spin-stack names its releases, and attested as built by +`release.yml`). A repository runs the latest of them through Task, at the version its +`versions.yaml` pins. There is nothing to install globally, and nothing to compile. ## Take Task and the tools @@ -16,7 +16,7 @@ One command in `go.mod`: go get -tool github.com/spin-stack/go-tools/cmd/task ``` -and two entries in `versions.yaml`, each pinned by the sha256 of every platform's archive: +and Task in `versions.yaml`, pinned by the sha256 of every platform's archive: ```yaml title="versions.yaml" - name: task @@ -25,33 +25,20 @@ and two entries in `versions.yaml`, each pinned by the sha256 of every platform' version: 3.54.0 pins: linux/amd64: 680859dbb4d881a9c72d4d9a8f510825450849af8567deacd7302c01124416fb - darwin/arm64: ... + darwin/arm64: 12a69fcf37bb8d5bc9150c44f9a936775b73e59ff044c2c245232f198c135637 track: github-release https://github.com/go-task/task -- name: go-tools - kind: download - source: https://github.com/spin-stack/go-tools/releases/download/bin-{version}/go-tools_{os}_{arch}.tar.gz - version: "20261109.193146" - pins: - linux/amd64: ... - track: github-release https://github.com/spin-stack/go-tools bin- ``` -`go tool task lint` then downloads both for this machine, checks them against the pins, unpacks -them once into the user's cache, and runs Task with every go-tools command on its `PATH`: a -Taskfile says `mutate -base origin/main`. The first run on a machine takes a second or two; Task's -and the tools' own modules - about 450 for Task and the tools together - are in no `go.mod` and no -CI cache. - -Moving to a newer go-tools is a bump of both halves to the same commit, which `versions/gate` -checks: the packages a repository imports from `go.mod`, the commands from `versions.yaml`. - -```sh -go get github.com/spin-stack/go-tools@ -versions bump go-tools -``` +`go tool task lint` then puts that Task and go-tools' latest release for this machine in the +user's cache - Task held to its pin, the tools to the sha256 their release's `checksums.txt` gives +- unpacks each once, and runs Task with every go-tools command on its `PATH`: a Taskfile says +`mutate -base origin/main`. The first run on a machine takes a second or two. Which release is the +latest is asked at most every ten minutes, and a machine that cannot ask goes on with the one it +has. Task's and the tools' own modules - about 450 together - are in no `go.mod` and no CI cache. -`versions` is one of the commands on Task's `PATH`, so the second line is a task's: the -spin-stack repositories have `task bump NAME=go-tools VERSION=...`. +No repository bumps the tools: a release of go-tools is in every repository's next run. What a +repository imports of go-tools as a library - `versions`, `provenance` - is `go.mod`'s, moved by +`go get github.com/spin-stack/go-tools@` as any dependency is. ## Run them where developers already run checks diff --git a/site/src/content/docs/versions/reference.md b/site/src/content/docs/versions/reference.md index 8dae789..29ca416 100644 --- a/site/src/content/docs/versions/reference.md +++ b/site/src/content/docs/versions/reference.md @@ -33,7 +33,7 @@ description: Every kind of entry versions.yaml takes, the build arguments each b | track | the newest is | |---|---| -| `github-release [repo] [prefix]` | the repository's latest release (`source`'s by default), with a `v` only when the version has one; with a prefix, a release is `` (go-tools' `bin-20261109.193146`) | +| `github-release [repo] [prefix]` | the repository's latest release (`source`'s by default), with a `v` only when the version has one; with a prefix, a release is `` | | `tags ` | the highest release-numbered tag of a git repository; for a download, the highest whose file is published | | `commit ` | the commit a branch is at, for a download whose version is a commit: behind only when the file changed | | `go` | the newest stable Go, spelled as the tag is (`1.27.1-trixie`) | diff --git a/versions/gate/gate.go b/versions/gate/gate.go index 3926510..32ee617 100644 --- a/versions/gate/gate.go +++ b/versions/gate/gate.go @@ -106,40 +106,21 @@ func (g Gate) Check() error { } // TaskCommand is the go-tools command a go.mod takes as `go tool task`: Task at versions.yaml's -// `task`, with versions.yaml's `go-tools` on its PATH. +// `task`, with go-tools' latest release on its PATH. const TaskCommand = "github.com/spin-stack/go-tools/cmd/task" -// goTools is go.mod's go-tools requirement at a commit: v0.0.0--, whose time is -// the name of that commit's release (bin-YYYYMMDD.hhmmss). -var goTools = regexp.MustCompile(`(?m)^\s*(?:require\s+)?github\.com/spin-stack/go-tools v0\.0\.0-(\d{8})(\d{6})-[0-9a-f]{12}\b`) - // taskCommand is what a go.mod that takes go-tools' task command owes versions.yaml: the `task` it -// runs, and the `go-tools` it puts on Task's PATH at the commit go.mod's library is - one commit for -// the packages a repository imports and the commands its Taskfile runs. Both entries are read by -// the command, which no line of the repository spells as a read, so they are marked used here. +// runs. The command reads that entry, which no line of the repository spells as a read, so it is +// marked used here. go-tools itself is its latest release, which no versions.yaml pins. func taskCommand(v *versions.Versions, gomod string, used map[string]bool) []error { if !slices.ContainsFunc(strings.Fields(gomod), func(f string) bool { return f == TaskCommand }) { return nil } - var errs []error - if _, err := v.Get("task"); err != nil { - errs = append(errs, fmt.Errorf("go.mod takes %s, which runs the Task %s pins: %w", TaskCommand, versions.File, err)) - } used["task"] = true - tools, err := v.Get("go-tools") - if err != nil { - return errs - } - used["go-tools"] = true - m := goTools.FindStringSubmatch(gomod) - if m == nil { - return errs - } - if want := m[1] + "." + m[2]; tools.Version != want { - errs = append(errs, fmt.Errorf("go.mod requires go-tools at %s and %s's go-tools is %s: one commit for the packages and the commands (versions bump go-tools %s)", - want, versions.File, tools.Version, want)) + if _, err := v.Get("task"); err != nil { + return []error{fmt.Errorf("go.mod takes %s, which runs the Task %s pins: %w", TaskCommand, versions.File, err)} } - return errs + return nil } // pins is what the file rel breaks of the gate by what it writes: a digest or a commit, or a diff --git a/versions/gate/gate_test.go b/versions/gate/gate_test.go index 412a9d6..83cbf92 100644 --- a/versions/gate/gate_test.go +++ b/versions/gate/gate_test.go @@ -139,34 +139,21 @@ func TestAGateOutsideARepositorySaysWhatGitSaid(t *testing.T) { } } -// A go.mod that takes go-tools' task command reads versions.yaml's task and go-tools through it, -// and holds go-tools to the commit the go.mod requires; one without it reads neither. -func TestTheTaskCommandReadsTaskAndGoToolsAtGoModsCommit(t *testing.T) { - pins := func(tools string) string { - file := "- name: task\n kind: download\n source: https://x/v{version}/task_{os}_{arch}.tar.gz\n version: 3.54.0\n pins:\n linux/amd64: " + - strings.Repeat("a", 64) + "\n track: github-release https://github.com/go-task/task\n" - if tools != "" { - file += "- name: go-tools\n kind: download\n source: https://x/bin-{version}/go-tools_{os}_{arch}.tar.gz\n version: \"" + tools + - "\"\n pins:\n linux/amd64: " + strings.Repeat("b", 64) + "\n track: github-release https://github.com/spin-stack/go-tools bin-\n" - } - return file - } - const tool = "\ntool (\n\tgithub.com/spin-stack/go-tools/cmd/task\n)\n" - const require = "module x\n\ngo 1.27\n\nrequire github.com/spin-stack/go-tools v0.0.0-20261109193146-29e3e436a905\n" +// A go.mod that takes go-tools' task command reads versions.yaml's task through it, and owes it +// one; a go.mod without it reads no task. +func TestTheTaskCommandReadsTask(t *testing.T) { + const task = "- name: task\n kind: download\n source: https://x/v{version}/task_{os}_{arch}.tar.gz\n version: 3.54.0\n pins:\n linux/amd64: " + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n track: github-release https://github.com/go-task/task\n" + const tool = "module x\n\ntool (\n\tgithub.com/spin-stack/go-tools/cmd/task\n)\n" for _, tc := range []struct { name, gomod, file string says []string }{ - {"the command, and go-tools at go.mod's commit", require + tool, pins("20261109.193146"), nil}, - {"the command, and go-tools at another commit", require + tool, pins("20261108.000000"), - []string{"go.mod requires go-tools at 20261109.193146 and versions.yaml's go-tools is 20261108.000000"}}, - {"the command, with go-tools not required as a library", "module x\n" + tool, pins("20261108.000000"), nil}, - {"the command and no go-tools", "module x\n" + tool, pins(""), nil}, - {"the command and no task", "module x\n" + tool, "- name: go\n kind: date\n version: \"1\"\n track: today\n", + {"the command and its task", tool, task, nil}, + {"the command on a tool line of its own", "module x\n\ntool " + TaskCommand + "\n", task, nil}, + {"the command and no task", tool, "- name: go\n kind: date\n version: \"1\"\n track: today\n", []string{"go.mod takes " + TaskCommand + ", which runs the Task versions.yaml pins", "pins go, and nothing reads it"}}, - {"the command on a tool line of its own", "module x\n\ntool " + TaskCommand + "\n", pins(""), nil}, - {"no command: nothing reads them", "module x\n", pins("20261109.193146"), - []string{"pins task, and nothing reads it", "pins go-tools, and nothing reads it"}}, + {"no command: nothing reads the task", "module x\n", task, []string{"pins task, and nothing reads it"}}, } { t.Run(tc.name, func(t *testing.T) { err := Gate{Root: repo(t, map[string]string{versions.File: tc.file, "go.mod": tc.gomod})}.Check() diff --git a/versions/upstream/upstream.go b/versions/upstream/upstream.go index 640fe9a..8c24451 100644 --- a/versions/upstream/upstream.go +++ b/versions/upstream/upstream.go @@ -309,7 +309,7 @@ func sha256Of(ctx context.Context, url string) (string, error) { } // releaseVersion is a release's tag as the pin spells a version: prefix taken off, for a -// repository whose releases are named prefix (go-tools' bin-20261109.193146), and the v +// repository whose releases are named prefix (cli-1.4.2 beside a library's tags), and the v // its tags have only when the pin has one, since a download's URL may add it. A tag that does not // start with prefix is no release of the kind the entry pins. func releaseVersion(tag, prefix string, withV bool) (string, error) { From 95ed429eb6f8608bbfba645ed7fdb1e85c8e5d12 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Mon, 9 Nov 2026 18:40:04 -0300 Subject: [PATCH 2/3] toolcache, releasename: a test or a reason for each edit mutate made that nothing refused Co-Authored-By: Claude Opus 5.5 --- internal/release/cmd/releasename/main.go | 12 +++---- internal/toolcache/toolcache.go | 13 +++---- internal/toolcache/toolcache_test.go | 45 ++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 12 deletions(-) diff --git a/internal/release/cmd/releasename/main.go b/internal/release/cmd/releasename/main.go index d2a3d3d..ccd6e39 100644 --- a/internal/release/cmd/releasename/main.go +++ b/internal/release/cmd/releasename/main.go @@ -22,7 +22,7 @@ import ( func main() { if err := run(os.Args[1:], os.Stdout, git, time.Now()); err != nil { // mutate-exempt: the process's own arguments; run is what the test drives fmt.Fprintln(os.Stderr, "releasename:", err) //nolint:errcheck // the exit status says it - os.Exit(1) + os.Exit(1) // mutate-exempt: any status but 0 fails the step; run is what the tests drive } } @@ -51,12 +51,12 @@ func run(args []string, w io.Writer, g gitFunc, now time.Time) error { func git(args ...string) ([]string, error) { out, err := exec.Command("git", args...).Output() //nolint:gosec // git, with the commit release.yml names: a tag listing, not a command line - var exit *exec.ExitError - if errors.As(err, &exit) { - return nil, fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, exit.Stderr) - } if err != nil { - return nil, err + var said []byte + if exit := (*exec.ExitError)(nil); errors.As(err, &exit) { + said = exit.Stderr + } + return nil, fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, said) } return strings.Fields(string(out)), nil } diff --git a/internal/toolcache/toolcache.go b/internal/toolcache/toolcache.go index 0f1bf0f..8bc3da1 100644 --- a/internal/toolcache/toolcache.go +++ b/internal/toolcache/toolcache.go @@ -113,7 +113,7 @@ func latest(ctx context.Context, base, name, root, goos, goarch string, now time } } archive := name + "_" + goos + "_" + goarch + ".tar.gz" - sums, err := fetch.Bytes(ctx, base+"/releases/download/"+tag+"/"+Checksums, 1<<20) + sums, err := fetch.Bytes(ctx, base+"/releases/download/"+tag+"/"+Checksums, 1<<20) // mutate-exempt: a bound, not a size: a release's checksums are four lines if err != nil { return "", fmt.Errorf("toolcache: %s %s: %w", name, tag, err) } @@ -147,14 +147,15 @@ func exists(p string) bool { // than fresh ago, else asked of GitHub - where /releases/latest redirects to the release, which no // API quota counts - and recorded; the one stamp records, however old, when GitHub cannot answer. func latestTag(ctx context.Context, base, stamp string, now time.Time) (string, error) { - recorded, _ := os.ReadFile(stamp) //nolint:gosec // this cache's own file - if st, err := os.Stat(stamp); err == nil && len(recorded) > 0 && now.Sub(st.ModTime()) < fresh { - return string(recorded), nil + raw, _ := os.ReadFile(stamp) //nolint:gosec // this cache's own file + recorded := string(raw) + if st, err := os.Stat(stamp); recorded != "" && err == nil && now.Sub(st.ModTime()) < fresh { + return recorded, nil } tag, err := askLatest(ctx, base) if err != nil { - if len(recorded) > 0 { - return string(recorded), nil + if recorded != "" { + return recorded, nil } return "", fmt.Errorf("toolcache: the latest release of %s: %w", base, err) } diff --git a/internal/toolcache/toolcache_test.go b/internal/toolcache/toolcache_test.go index 2581e72..e383b06 100644 --- a/internal/toolcache/toolcache_test.go +++ b/internal/toolcache/toolcache_test.go @@ -306,3 +306,48 @@ func TestALatestReleaseThatCannotBeHadIsSaid(t *testing.T) { }) } } + +// A stamp that says nothing is no release, however fresh: the latest is asked for. +func TestAnEmptyStampIsAskedPast(t *testing.T) { + archive, _ := tarball(t, member{name: "mutate", body: "x", mode: 0o755, kind: tar.TypeReg}) + tag := "v20261110.01" + g := &github{latest: &tag, releases: map[string][]byte{tag: archive}} + base := g.serve(t) + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "go-tools"), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "go-tools", "latest"), nil, 0o600); err != nil { + t.Fatal(err) + } + if _, err := latest(t.Context(), base, "go-tools", root, "linux", "amd64", time.Now()); err != nil || g.asked.Load() != 1 { + t.Errorf("err = %v, asked %d times", err, g.asked.Load()) + } +} + +// Only a redirect to a release is the latest release: one elsewhere, one to a release with no +// name, and a release's address in an answer that is no redirect are not. +func TestOnlyARedirectToAReleaseIsTheLatest(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/elsewhere/releases/latest": + http.Redirect(w, r, "/elsewhere/login", http.StatusFound) + case "/unnamed/releases/latest": + http.Redirect(w, r, "/unnamed/releases/tag/", http.StatusFound) + case "/not-a-redirect/releases/latest": + w.Header().Set("Location", "/not-a-redirect/releases/tag/v20261110.01") + w.WriteHeader(http.StatusOK) + case "/good/releases/latest": + http.Redirect(w, r, "/good/releases/tag/v20261110.01", http.StatusFound) + } + })) + t.Cleanup(srv.Close) + for _, repo := range []string{"elsewhere", "unnamed", "not-a-redirect"} { + if tag, err := askLatest(t.Context(), srv.URL+"/"+repo); err == nil { + t.Errorf("%s: %q is the latest release", repo, tag) + } + } + if tag, err := askLatest(t.Context(), srv.URL+"/good"); err != nil || tag != "v20261110.01" { + t.Errorf("good: %q, %v", tag, err) + } +} From 93427c222f1f027b790ff174a75f0cc6803e8161 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Mon, 9 Nov 2026 18:41:07 -0300 Subject: [PATCH 3/3] releasename: git's test in a repository of its own Co-Authored-By: Claude Opus 5.5 --- internal/release/cmd/releasename/main_test.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/internal/release/cmd/releasename/main_test.go b/internal/release/cmd/releasename/main_test.go index b6ea80b..63f9f7f 100644 --- a/internal/release/cmd/releasename/main_test.go +++ b/internal/release/cmd/releasename/main_test.go @@ -60,8 +60,16 @@ func TestNoCommitOrNoGitIsNoName(t *testing.T) { // git's own output is read a word a line, and its failure says what it said. func TestGitIsTheClonesGit(t *testing.T) { - tags, err := git("tag", "--list", "no-such-tag-*") - if err != nil || len(tags) != 0 { + dir := t.TempDir() + t.Chdir(dir) + for _, args := range [][]string{{"init", "-q"}, {"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "x"}, + {"tag", "v20261110.01"}, {"tag", "v20261110.02"}} { + if _, err := git(args...); err != nil { + t.Fatal(err) + } + } + tags, err := git("tag", "--list") + if err != nil || !slices.Equal(tags, []string{"v20261110.01", "v20261110.02"}) { t.Errorf("tags = %v, %v", tags, err) } if _, err := git("no-such-command"); err == nil || !strings.Contains(err.Error(), "git no-such-command") {