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..ccd6e39 --- /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) // mutate-exempt: any status but 0 fails the step; run is what the tests drive + } +} + +// 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 + if err != nil { + 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/release/cmd/releasename/main_test.go b/internal/release/cmd/releasename/main_test.go new file mode 100644 index 0000000..63f9f7f --- /dev/null +++ b/internal/release/cmd/releasename/main_test.go @@ -0,0 +1,78 @@ +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) { + 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") { + 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..8bc3da1 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,114 @@ 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) // 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) + } + 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) { + 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 recorded != "" { + return 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..e383b06 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,157 @@ 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) + } + }) + } +} + +// 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) + } +} 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) {