From e3cd73ed54b76271025fe54c6af21df81a99cab2 Mon Sep 17 00:00:00 2001 From: Somansh Reddy Satish Date: Tue, 18 Aug 2026 07:07:15 +0000 Subject: [PATCH 1/2] ci: read the Go version from go.mod instead of six literal pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same version string was written in seven places: the go directive in go.mod, four setup-go pins in ci.yml, and one each in release-stable.yml and dev-release.yml. Nothing kept them in agreement except remembering to grep, and the previous patch bump had to touch all six workflow spots by hand. That drift is not cosmetic. setup-go exports GOTOOLCHAIN=local, so the toolchain it installs cannot upgrade itself to satisfy go.mod: a pin that falls behind the go directive fails the build outright, and one that runs ahead of it builds against a newer stdlib than the module declares. Every setup-go step now reads go-version-file: go.mod, leaving the directive as the only place the version exists. Adds a test over the workflow files asserting no step reintroduces a literal go-version, since the whole benefit disappears the first time someone re-pins one by hand. It fails if a literal pin appears, if go-version-file names anything other than go.mod, and if the sweep matches no setup-go steps at all — that last case because an empty match set would otherwise pass while checking nothing. It reads both .yml and .yaml, since this repo already spells one config .goreleaser.yaml and a .yaml workflow would otherwise land outside the sweep. Verified setup-go resolves the directive as intended by replaying its own parser (installer.ts parseGoVersionFile) against this go.mod: no toolchain directive present, go directive 1.25.13, resolved 1.25.13. Resolution is exact rather than floating only because the directive carries a patch component — setup-go matches its input with semver.satisfies, under which "1.25.13" admits only that version while a two-component "1.26" admits any 1.26.x. Behaviour is therefore unchanged from the literal pins this replaces; whether the repo wants to guarantee that by requiring a patch component is a policy question left open here. Note for whoever bumps next: the two release workflows are workflow_dispatch only, so no automatic run exercises them. This PR's CI covers the four ci.yml jobs; the release paths are covered by the test above rather than by execution. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 13 +++-- .github/workflows/dev-release.yml | 3 +- .github/workflows/release-stable.yml | 3 +- cmd/heygen/go_version_pin_test.go | 82 ++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 cmd/heygen/go_version_pin_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2670d4e..6d7702f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,9 +20,14 @@ jobs: run: git config --global core.autocrlf false - uses: actions/checkout@v7 + # Single source of truth for the Go version: go.mod's `go` directive. + # setup-go exports GOTOOLCHAIN=local, so the installed toolchain cannot + # upgrade itself to satisfy go.mod — a literal pin here that drifts below + # that directive is a hard build failure, not a silent downgrade. Reading + # the directive means the two cannot disagree. - uses: actions/setup-go@v6 with: - go-version: "1.25.13" + go-version-file: go.mod - name: Build run: make build @@ -37,7 +42,7 @@ jobs: - uses: actions/setup-go@v6 with: - go-version: "1.25.13" + go-version-file: go.mod - uses: golangci/golangci-lint-action@v9 with: @@ -80,7 +85,7 @@ jobs: - uses: actions/setup-go@v6 with: - go-version: "1.25.13" + go-version-file: go.mod - name: Install govulncheck run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 @@ -121,7 +126,7 @@ jobs: - uses: actions/setup-go@v6 with: - go-version: "1.25.13" + go-version-file: go.mod - uses: goreleaser/goreleaser-action@v7 with: diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index 947236b..7dcc920 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -25,9 +25,10 @@ jobs: fetch-depth: 0 ref: main + # Go version comes from go.mod's `go` directive; see ci.yml for why it lives there. - uses: actions/setup-go@v6 with: - go-version: "1.25.13" + go-version-file: go.mod - name: Run tests run: make test diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index 894d05e..a118ffe 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -21,9 +21,10 @@ jobs: fetch-depth: 0 ref: main + # Go version comes from go.mod's `go` directive; see ci.yml for why it lives there. - uses: actions/setup-go@v6 with: - go-version: "1.25.13" + go-version-file: go.mod - name: Validate version shell: bash diff --git a/cmd/heygen/go_version_pin_test.go b/cmd/heygen/go_version_pin_test.go new file mode 100644 index 0000000..c749575 --- /dev/null +++ b/cmd/heygen/go_version_pin_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "os" + "path/filepath" + "regexp" + "testing" + + "gopkg.in/yaml.v3" +) + +// The Go version has exactly one home: the `go` directive in go.mod. Every +// setup-go step reads it via go-version-file rather than restating it. +// +// This is not just DRY. setup-go exports GOTOOLCHAIN=local, so the toolchain it +// installs cannot upgrade itself to satisfy go.mod: a literal pin that drifts +// BELOW the directive fails the build outright, and one that drifts above it +// builds against a newer stdlib than the module declares. Before this was +// centralized the same version string was repeated in seven places, held in +// agreement only by whoever remembered to grep. +// +// A literal go-version is what this guards against, so the check is on the +// workflow files rather than on go.mod. +// +// Scope: direct actions/setup-go steps in top-level workflow files. A reusable +// workflow or composite action could install Go without a step this sees. None +// exists today; if one is added, this needs to follow those references. +func TestWorkflowsTakeGoVersionFromGoMod(t *testing.T) { + dir := filepath.Join("..", "..", ".github", "workflows") + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read workflows dir: %v", err) + } + + // Assert the sweep found something: an empty match set would pass silently + // and look identical to a clean bill. + steps := 0 + + for _, e := range entries { + // Both extensions: this repo already spells one config .goreleaser.yaml, + // so a .yaml workflow is a plausible way to land outside this sweep. + if ext := filepath.Ext(e.Name()); e.IsDir() || (ext != ".yml" && ext != ".yaml") { + continue + } + path := filepath.Join(dir, e.Name()) + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + + var wf struct { + Jobs map[string]struct { + Steps []struct { + Uses string `yaml:"uses"` + With map[string]string `yaml:"with"` + } `yaml:"steps"` + } `yaml:"jobs"` + } + if err := yaml.Unmarshal(raw, &wf); err != nil { + t.Fatalf("parse %s: %v", path, err) + } + + for job, j := range wf.Jobs { + for _, st := range j.Steps { + if !regexp.MustCompile(`actions/setup-go@`).MatchString(st.Uses) { + continue + } + steps++ + if v, ok := st.With["go-version"]; ok { + t.Errorf("%s job %q pins go-version: %q — use go-version-file: go.mod so the version has one home", e.Name(), job, v) + } + if got := st.With["go-version-file"]; got != "go.mod" { + t.Errorf("%s job %q has go-version-file: %q, want %q", e.Name(), job, got, "go.mod") + } + } + } + } + + if steps == 0 { + t.Fatal("found no actions/setup-go steps; the check read nothing and would pass regardless of the pins") + } +} From b2911e8f1762486d5ad386164a0b3b52db6c7c28 Mon Sep 17 00:00:00 2001 From: Somansh Reddy Satish Date: Tue, 18 Aug 2026 15:47:30 +0000 Subject: [PATCH 2/2] ci: enforce the pin policy in go.mod, not just the workflows Review of the previous commit found the guard did not actually enforce what the change claims. It read the workflow files and never opened go.mod, so both ways of breaking the single-source contract left it green. Two edits did that. A two-component `go 1.26` directive turns setup-go's exact match into a range, so every job takes the latest patch and a rebuilt tag can link a different stdlib. Adding a `toolchain` directive is worse: setup-go prefers it over the `go` directive whenever GOTOOLCHAIN is not already "local", and it is not, because setup-go resolves the version before exporting that variable. A `toolchain go1.24.0` line would have silently reinstated the stdlib the parent commit exists to remove, with go.mod still reading 1.25.13. Settles the question the previous commit left open: this repo wants a reproducible pin, not floating patches, because releases ship checksummed binaries and rebuilding a tag must produce the same one. TestGoModPinsAnExactToolchain requires a three-component directive and rejects a toolchain directive. Both were mutation-pinned. A missing `go` directive is guarded too, though only defensively: dropping it makes the module unbuildable long before any test runs. Documents the rule in RELEASE.md, which AGENTS.md requires for a change to how releases are built, and the toolchain source is exactly that. The previous commit judged no doc update was owed by reading only the "triggers, versioning, channels" line and missing the broader clause below it. Co-Authored-By: Claude Opus 5 (1M context) --- RELEASE.md | 20 +++++++++++++ cmd/heygen/go_version_pin_test.go | 47 ++++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/RELEASE.md b/RELEASE.md index 55711be..7c01d26 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -16,6 +16,26 @@ For install instructions, see [README.md](./README.md). - Built from a tagged commit via GoReleaser - Intended for milestone cuts and broader distribution later +## Go toolchain + +Release artifacts are built with the Go version in `go.mod`'s `go` directive. Every `setup-go` step, +in CI and in both release workflows, resolves its toolchain from that directive via +`go-version-file`, so it is the only place the version is written. To move the toolchain, edit that +one line. + +Two properties of the directive are load-bearing for releases, and `cmd/heygen/go_version_pin_test.go` +enforces both: + +- **It must carry a patch component.** `setup-go` matches its input as a semver range, so + `go 1.25.13` installs exactly that patch while a two-component `go 1.26` installs the latest + `1.26.x` available at build time. The second form makes a release non-reproducible: rebuilding the + same tag later can link a different stdlib. +- **`go.mod` must not contain a `toolchain` directive.** `setup-go` prefers it over the `go` + directive, so a `toolchain` line silently decides what ships while `go` still appears to. Go + tooling adds that line on its own sometimes; remove it and bump `go` instead. + +A patch bump is therefore a one-line change to `go.mod`, and no workflow needs editing. + ## How to Cut a Dev Release 1. Make sure `main` is in a good state. diff --git a/cmd/heygen/go_version_pin_test.go b/cmd/heygen/go_version_pin_test.go index c749575..2c9e452 100644 --- a/cmd/heygen/go_version_pin_test.go +++ b/cmd/heygen/go_version_pin_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "regexp" + "strings" "testing" "gopkg.in/yaml.v3" @@ -67,7 +68,7 @@ func TestWorkflowsTakeGoVersionFromGoMod(t *testing.T) { } steps++ if v, ok := st.With["go-version"]; ok { - t.Errorf("%s job %q pins go-version: %q — use go-version-file: go.mod so the version has one home", e.Name(), job, v) + t.Errorf("%s job %q pins go-version: %q; use go-version-file: go.mod so the version has one home", e.Name(), job, v) } if got := st.With["go-version-file"]; got != "go.mod" { t.Errorf("%s job %q has go-version-file: %q, want %q", e.Name(), job, got, "go.mod") @@ -80,3 +81,47 @@ func TestWorkflowsTakeGoVersionFromGoMod(t *testing.T) { t.Fatal("found no actions/setup-go steps; the check read nothing and would pass regardless of the pins") } } + +// The workflows above read go.mod, so go.mod is now what decides which +// toolchain CI and the release builds install. That makes two properties of the +// file load-bearing, and neither is visible from reading a workflow. +// +// First, setup-go matches its input with semver.satisfies, so the number of +// components decides pinned versus floating: `go 1.25.13` admits only that +// patch, while `go 1.26` admits any 1.26.x. The repo's choice is a reproducible +// pin, so the directive must carry a patch component. +// +// Second, setup-go prefers a `toolchain` directive over the `go` directive +// unless GOTOOLCHAIN is already "local". It is not: setup-go resolves the +// version before it exports that variable, so a toolchain line silently wins +// and go.mod stops being a single source while still appearing to be one. Go +// tooling can add that line on its own, which is why this is a test and not a +// convention. Bump the `go` directive instead. +// +// Requiring three components also rejects an exact prerelease pin like +// `go 1.26rc1`. That is intended: releases ship binaries to users, so a +// prerelease toolchain is not something to build them with. +// +// Out of scope: a workflow that sets GOTOOLCHAIN itself would override +// selection after setup-go runs. Nothing does, and unlike the two cases above +// that would be a deliberate act rather than drift. +func TestGoModPinsAnExactToolchain(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "..", "go.mod")) + if err != nil { + t.Fatalf("read go.mod: %v", err) + } + + if m := regexp.MustCompile(`(?m)^toolchain\s+(\S+)`).FindSubmatch(raw); m != nil { + t.Errorf("go.mod has a toolchain directive (%s); setup-go would install that instead of the go directive; remove it and bump `go` instead", m[1]) + } + + m := regexp.MustCompile(`(?m)^go\s+(\S+)`).FindSubmatch(raw) + if m == nil { + t.Fatal("go.mod has no go directive; every setup-go step resolves its version from it") + } + version := string(m[1]) + + if parts := strings.Split(version, "."); len(parts) != 3 { + t.Errorf("go directive is %q, want major.minor.patch. setup-go treats %q as a range and would install the latest matching patch", version, version) + } +}