From a4266befd0efd8c6aec6647008fa9695f32df57a Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Tue, 29 Sep 2026 09:48:29 -0300 Subject: [PATCH 1/2] versions: Validate, Newest and Resolve are this module's own No repository that takes go-tools calls them - spin, spin-ami, spin-machine, shipset and spin-pr-gotools vet against this tree through a go.work - and each export is a promise to all five that nobody holds. Parse and Set still validate, Bump and Check still resolve, and Status.Newest stays: cmd/versions prints it. Co-Authored-By: Claude Opus 5.5 (1M context) --- versions/upstream/upstream.go | 24 ++++++++++++------------ versions/upstream/upstream_test.go | 8 ++++---- versions/versions.go | 8 ++++---- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/versions/upstream/upstream.go b/versions/upstream/upstream.go index 6807812..9a54033 100644 --- a/versions/upstream/upstream.go +++ b/versions/upstream/upstream.go @@ -28,9 +28,9 @@ import ( // errFollows is an entry whose newest version is another's to say: a person reads it there. var errFollows = errors.New("versions: follows another") -// Newest is the version check says e could be at: the same for an image tracked by its digest, +// newest is the version check says e could be at: the same for an image tracked by its digest, // or a branch, whose pin is what moves. -func Newest(ctx context.Context, e versions.Entry) (string, error) { +func newest(ctx context.Context, e versions.Entry) (string, error) { kind, arg, _ := strings.Cut(e.Track, " ") switch kind { case "github-release": @@ -82,9 +82,9 @@ func Newest(ctx context.Context, e versions.Entry) (string, error) { return "", fmt.Errorf("versions: %s tracks %q, which check does not know", e.Name, e.Track) } -// Resolve is the pin of e at version: an image's digest, a tag's or a branch's commit, a +// resolve is the pin of e at version: an image's digest, a tag's or a branch's commit, a // download's sha256. -func Resolve(ctx context.Context, e versions.Entry, version string) (string, error) { +func resolve(ctx context.Context, e versions.Entry, version string) (string, error) { e.Version = version switch e.Kind { case versions.Image: @@ -132,11 +132,11 @@ func Bump(ctx context.Context, v *versions.Versions, name, version string) (vers return versions.Entry{}, nil, fmt.Errorf("versions: %s's archive is what a build's git writes of the commit, so it is bumped by hand: see its note", name) } if version == "" { - if version, err = Newest(ctx, e); err != nil { + if version, err = newest(ctx, e); err != nil { return versions.Entry{}, nil, fmt.Errorf("%w; name the version", err) } } - pin, err := Resolve(ctx, e, version) + pin, err := resolve(ctx, e, version) if err != nil { return versions.Entry{}, nil, err } @@ -149,7 +149,7 @@ func Bump(ctx context.Context, v *versions.Versions, name, version string) (vers } // Pinned is e's download, refused past limit bytes, and unless its SHA-256 is e's pin: the check -// beside the Resolve that wrote the pin. +// beside the resolve that wrote the pin. func Pinned(ctx context.Context, e versions.Entry, limit int64) ([]byte, error) { raw, err := fetch.Bytes(ctx, e.URL(), limit) if err != nil { @@ -398,19 +398,19 @@ func Check(ctx context.Context, v *versions.Versions) []Status { var out []Status for _, e := range v.Entries { st := Status{Entry: e} - newest, err := Newest(ctx, e) + latest, err := newest(ctx, e) switch { case errors.Is(err, errFollows): st.Note = "follows " + strings.TrimPrefix(e.Track, "follows ") + ": " + e.Note case err != nil: st.Note = err.Error() default: - st.Newest = newest - st.Behind = newest != e.Version + st.Newest = latest + st.Behind = latest != e.Version // A file pinned to a commit of a busy branch: the branch moves several times a day and // the file almost never. Behind is the file changing, not the commit. if st.Behind && e.Kind == versions.Download && strings.HasPrefix(e.Track, "commit ") { - pin, err := Resolve(ctx, e, newest) + pin, err := resolve(ctx, e, latest) switch { case err != nil: st.Note = err.Error() @@ -419,7 +419,7 @@ func Check(ctx context.Context, v *versions.Versions) []Status { } } if !st.Behind && (e.Kind == versions.Image || e.Kind == versions.Git) { - pin, err := Resolve(ctx, e, newest) + pin, err := resolve(ctx, e, latest) if err != nil { st.Note = err.Error() } else if pin != e.Pin { diff --git a/versions/upstream/upstream_test.go b/versions/upstream/upstream_test.go index 05d2919..5fd9997 100644 --- a/versions/upstream/upstream_test.go +++ b/versions/upstream/upstream_test.go @@ -49,11 +49,11 @@ func TestTheNewestIsWhatThePageLinksTo(t *testing.T) { })) t.Cleanup(srv.Close) e := versions.Entry{Name: "icons", Source: "https://cdn/{version}/Icon-package_{version}.zip", Track: "page " + srv.URL} - if got, err := Newest(t.Context(), e); err != nil || got != "07312026.abc" { - t.Errorf("Newest = %q, %v", got, err) + if got, err := newest(t.Context(), e); err != nil || got != "07312026.abc" { + t.Errorf("newest = %q, %v", got, err) } e.Source = "https://cdn/Other_{version}.zip" - if _, err := Newest(t.Context(), e); err == nil { + if _, err := newest(t.Context(), e); err == nil { t.Error("a page that links to no such file said a version") } } @@ -142,7 +142,7 @@ func TestACommitTrackedFileIsBehindOnlyWhenItChanged(t *testing.T) { defer srv.Close() e := versions.Entry{Name: "check-config", Kind: versions.Download, Source: srv.URL + "/{version}/check-config.sh", Version: first, Track: "commit " + repo + " master"} - pin, err := Resolve(t.Context(), e, first) + pin, err := resolve(t.Context(), e, first) if err != nil { t.Fatal(err) } diff --git a/versions/versions.go b/versions/versions.go index 957d683..d8df914 100644 --- a/versions/versions.go +++ b/versions/versions.go @@ -63,8 +63,8 @@ var ( sum = regexp.MustCompile(`^[0-9a-f]{64}$`) ) -// Validate is the entry whole and its pin in its kind's form. -func (e Entry) Validate() error { +// validate is the entry whole and its pin in its kind's form. +func (e Entry) validate() error { var errs []error check := func(ok bool, format string, args ...any) { if !ok { @@ -169,7 +169,7 @@ func Parse(raw []byte) (*Versions, error) { errs = append(errs, fmt.Errorf("versions: %s is there twice", e.Name)) } seen[e.Name] = true - errs = append(errs, e.Validate()) + errs = append(errs, e.validate()) } if err := errors.Join(errs...); err != nil { return nil, err @@ -197,7 +197,7 @@ func (v *Versions) Set(name, version, pin string) ([]byte, error) { return nil, err } e.Version, e.Pin = version, pin - if err := e.Validate(); err != nil { + if err := e.validate(); err != nil { return nil, err } lines := strings.SplitAfter(string(v.raw), "\n") From 578d20f8046d5de936581e6c9d087288ee7344ee Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Tue, 29 Sep 2026 09:50:29 -0300 Subject: [PATCH 2/2] versions: the tests that hold newest, resolve and Check Co-Authored-By: Claude Opus 5.5 (1M context) --- versions/upstream/check_test.go | 209 ++++++++++++++++++++++++++++++++ versions/versions.go | 2 +- versions/versions_test.go | 23 ++++ 3 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 versions/upstream/check_test.go diff --git a/versions/upstream/check_test.go b/versions/upstream/check_test.go new file mode 100644 index 0000000..f07e03a --- /dev/null +++ b/versions/upstream/check_test.go @@ -0,0 +1,209 @@ +package upstream + +import ( + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/spin-stack/go-tools/versions" +) + +// gitRepo is a repository with a lightweight tag v1.0.0 on its first commit, an annotated tag +// v1.2.0 and a branch topic on its second, and master one commit past them. +type gitRepo struct { + dir, first, second, head string +} + +func newGitRepo(t *testing.T) gitRepo { + t.Helper() + r := gitRepo{dir: t.TempDir()} + git := func(args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", r.dir, "-c", "user.name=t", "-c", "user.email=t@t"}, args...)...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + return strings.TrimSpace(string(out)) + } + git("init", "-q", "-b", "master") + git("commit", "-q", "--allow-empty", "-m", "one") + r.first = git("rev-parse", "HEAD") + git("tag", "v1.0.0") + git("commit", "-q", "--allow-empty", "-m", "two") + r.second = git("rev-parse", "HEAD") + git("tag", "-a", "-m", "annotated", "v1.2.0") + git("branch", "topic") + git("commit", "-q", "--allow-empty", "-m", "three") + r.head = git("rev-parse", "HEAD") + return r +} + +// A git pin is a commit: an annotated tag's is the commit it points at, not the tag's own object, +// and a branch's is its head. +func TestAGitPinIsTheCommit(t *testing.T) { + r := newGitRepo(t) + e := versions.Entry{Name: "x", Kind: versions.Git, Source: r.dir} + for version, want := range map[string]string{"v1.0.0": r.first, "v1.2.0": r.second, "topic": r.second, "master": r.head} { + if got, err := resolve(t.Context(), e, version); err != nil || got != want { + t.Errorf("%s: pin %q, %v; want %q", version, got, err, want) + } + } + if got, err := resolve(t.Context(), e, "v9"); err == nil { + t.Errorf("a version the repository does not have was pinned at %q", got) + } + e.Source = filepath.Join(r.dir, "nothing") + if got, err := resolve(t.Context(), e, "master"); err == nil { + t.Errorf("a repository that is not there answered %q", got) + } +} + +// A download's pin is the SHA-256 of the file at the version asked, not the one pinned. +func TestADownloadsPinIsTheSumAtTheVersionAsked(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/2/f" { + http.NotFound(w, r) + return + } + _, _ = w.Write([]byte("two")) + })) + t.Cleanup(srv.Close) + e := versions.Entry{Name: "f", Kind: versions.Download, Source: srv.URL + "/{version}/f", Version: "1"} + sum := sha256.Sum256([]byte("two")) + if got, err := resolve(t.Context(), e, "2"); err != nil || got != hex.EncodeToString(sum[:]) { + t.Errorf("pin %q, %v", got, err) + } +} + +// docker is a stand-in for the docker CLI that prints the digest in digestFile, or fails when +// there is none. +func docker(t *testing.T) (digestFile string) { + t.Helper() + dir := t.TempDir() + digestFile = filepath.Join(dir, "digest") + script := "#!/bin/sh\nexec cat " + digestFile + "\n" + if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o755); err != nil { //nolint:gosec // an executable is the point + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + return digestFile +} + +// An image's pin is the digest its tag names now, as docker reads it. +func TestAnImagesPinIsItsDigest(t *testing.T) { + digestFile := docker(t) + e := versions.Entry{Name: "go", Kind: versions.Image, Source: "golang"} + if got, err := resolve(t.Context(), e, "1.27"); err == nil { + t.Errorf("docker failed and the pin is %q", got) + } + if err := os.WriteFile(digestFile, []byte("sha256:abc\n"), 0o600); err != nil { + t.Fatal(err) + } + if got, err := resolve(t.Context(), e, "1.27"); err != nil || got != "sha256:abc" { + t.Errorf("pin %q, %v", got, err) + } +} + +// Each track finds the newest its own way; these are the ones that need no network. +func TestTheNewestIsWhatTheTrackFinds(t *testing.T) { + r := newGitRepo(t) + for _, tc := range []struct { + name string + entry versions.Entry + want string // "" is an error + }{ + {"a git entry by its tags", versions.Entry{Kind: versions.Git, Version: "v1.0.0", Track: "tags " + r.dir}, "v1.2.0"}, + {"a tags track whose repository is not there", versions.Entry{Kind: versions.Git, Version: "v1.0.0", Track: "tags " + filepath.Join(r.dir, "nothing")}, ""}, + {"a branch is its own newest", versions.Entry{Version: "master", Track: "branch"}, "master"}, + {"a commit is its branch's head", versions.Entry{Track: "commit " + r.dir + " master"}, r.head}, + // Neither is GitHub, so the error says which repository was asked. + {"a release of the repository the track names", versions.Entry{Source: "https://example.com/a", Track: "github-release https://example.com/b"}, ""}, + {"a PyPI package whose name is no URL", versions.Entry{Source: "a\x7f", Track: "pypi"}, ""}, + {"a track check does not know", versions.Entry{Track: "rumour"}, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := newest(t.Context(), tc.entry) + if tc.want == "" && err == nil { + t.Fatalf("newest %q, want an error", got) + } + if tc.want != "" && (err != nil || got != tc.want) { + t.Errorf("newest %q, %v; want %q", got, err, tc.want) + } + }) + } + _, err := newest(t.Context(), versions.Entry{Source: "https://example.com/a", Track: "github-release https://example.com/b"}) + if !strings.Contains(err.Error(), "https://example.com/b") { + t.Errorf("the track's repository is not the one asked: %v", err) + } +} + +// Check says where each entry stands: at its newest, behind it, behind at the same version because +// the name points elsewhere now, or a note saying why it cannot tell. +func TestCheckSaysWhereEachEntryStands(t *testing.T) { + r := newGitRepo(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("v1.2.0's file")) + })) + t.Cleanup(srv.Close) + sum := sha256.Sum256([]byte("v1.2.0's file")) + missing := httptest.NewServer(http.NotFoundHandler()) + t.Cleanup(missing.Close) + digestFile := docker(t) + if err := os.WriteFile(digestFile, []byte("sha256:new\n"), 0o600); err != nil { + t.Fatal(err) + } + + git := versions.Entry{Name: "g", Kind: versions.Git, Source: r.dir, Version: "master", Track: "branch"} + moved, gone := git, git + moved.Name, moved.Pin = "moved", r.second + gone.Name, gone.Source = "gone", filepath.Join(r.dir, "nothing") + git.Pin = r.head + image := versions.Entry{Name: "i", Kind: versions.Image, Source: "golang", Version: "1.27", Pin: "sha256:old", Track: "digest"} + // Behind by its version, and its file at that version is the same bytes: still behind, since + // only a file tracked by a commit is excused. + download := versions.Entry{Name: "d", Kind: versions.Download, Source: srv.URL + "/{version}", Version: "v1.0.0", + Pin: hex.EncodeToString(sum[:]), Track: "tags " + r.dir} + current := download + current.Name, current.Version, current.Pin = "current", "v1.2.0", strings.Repeat("0", 64) + // A file tracked by a commit is at its branch's head, behind it with the same bytes, or behind + // it where the file cannot be read; and a git entry tracked by a commit is not a file. + atHead := versions.Entry{Name: "at-head", Kind: versions.Download, Source: srv.URL + "/{version}", Version: r.head, + Pin: hex.EncodeToString(sum[:]), Track: "commit " + r.dir + " master"} + unread := atHead + unread.Name, unread.Version, unread.Source = "unread", r.first, missing.URL+"/{version}" + gitByCommit := versions.Entry{Name: "git-by-commit", Kind: versions.Git, Source: r.dir, Version: r.first, Pin: r.head, + Track: "commit " + r.dir + " master"} + follows := versions.Entry{Name: "f", Track: "follows spin", Note: "spin says"} + unknown := versions.Entry{Name: "u", Track: "rumour"} + + got := Check(t.Context(), &versions.Versions{Entries: []versions.Entry{git, moved, gone, image, download, current, atHead, unread, gitByCommit, follows, unknown}}) + want := []struct { + newest string + behind bool + note string + }{ + {"master", false, ""}, + {"master", true, "master is now " + r.head}, + {"master", false, "ls-remote"}, + {"1.27", true, "1.27 is now sha256:new"}, + {"v1.2.0", true, ""}, + {"v1.2.0", false, ""}, + {r.head, false, ""}, + {r.head, true, "404"}, + {r.head, true, ""}, + {"", false, "follows spin: spin says"}, + {"", false, "which check does not know"}, + } + for i, w := range want { + st := got[i] + if st.Newest != w.newest || st.Behind != w.behind || !strings.Contains(st.Note, w.note) || (w.note == "" && st.Note != "") { + t.Errorf("%s: %+v, want newest %q behind %v note %q", st.Entry.Name, st, w.newest, w.behind, w.note) + } + } +} diff --git a/versions/versions.go b/versions/versions.go index d8df914..35daecd 100644 --- a/versions/versions.go +++ b/versions/versions.go @@ -210,7 +210,7 @@ func (v *Versions) Set(name, version, pin string) ([]byte, error) { if n := field(item, "name"); n == nil || n.Value != name { continue } - for _, kv := range [][2]string{{"version", version}, {"pin", pin}} { + for _, kv := range [][2]string{{"version", version}, {"pin", pin}} { // mutate-exempt: the pair's length; nothing reads past kv[1] n := field(item, kv[0]) if n == nil { continue diff --git a/versions/versions_test.go b/versions/versions_test.go index 377d11f..f8439ba 100644 --- a/versions/versions_test.go +++ b/versions/versions_test.go @@ -167,3 +167,26 @@ func TestABumpKeepsHowTheValueIsWritten(t *testing.T) { }) } } + +// An entry is refused when a field is not in its kind's form, each for its own reason: a download +// has an https source with {version} in it, and a module a package path, not a URL. +func TestAnEntryOutOfItsKindsFormIsRefused(t *testing.T) { + for _, tc := range []struct{ name, entry, want string }{ + {"a download over http", "kind: download\n source: http://x/{version}.tgz\n pin: " + strings.Repeat("a", 64), + "a download whose source is not an https URL"}, + {"a download with no version in its source", "kind: download\n source: https://x/latest.tgz\n pin: " + strings.Repeat("a", 64), + "a download whose source is not an https URL"}, + {"a module named by a URL", "kind: module\n source: https://x/y", "a module with no package path"}, + {"a module with no source", "kind: module", "a module with no package path"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := Parse([]byte("- name: a\n " + tc.entry + "\n version: v1\n track: tags x\n")) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("err = %v, want %q", err, tc.want) + } + }) + } + if _, err := Parse([]byte("- name: a\n kind: module\n source: example.com/x\n version: v1\n track: tags x\n")); err != nil { + t.Errorf("a module by its package path: %v", err) + } +}