From e25c2703c1d02548f652a208d76b2c30c1b95184 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Sun, 6 Sep 2026 20:11:50 +0200 Subject: [PATCH] build: the oldest compiler this module admits is the one CI builds with The "go" line in go.mod was a floor of 1.26.5 while CI and the toolchain line both said 1.27.0. A floor is the only one of those the compiler enforces, so the gap was reachable: GOTOOLCHAIN=go1.26.8 built this module, the binary answered 0.3.0-rc1, wrote 0.3.0-rc1 into every manifest, and produced different bytes for png, docx and targz than the release of that name. Go 1.27 changed compress/flate, which is why - eleven of the fifty one pinned cases and all seven pinned standard library paths moved when this project went to 1.27.0. The diagnosis was already there and works: a manifest records the toolchain under tool.go, so two runs that disagree can be told apart. Nothing made anybody look. Raising the floor means Go itself refuses that build. The cost is stated rather than hidden, in go.mod and in the changelog: nobody on Go 1.26 can build this from source any more. Downloadable binaries and "go install" are unaffected. There is no toolchain directive left to compare against, and that is measured rather than overlooked. Go refuses a module whose toolchain directive is not newer than its go directive, so once the floor met the pin the build answered "updates to go.mod needed" until the directive came out. The thirty nine lines recording four toolchain decisions moved above the go line instead - the directive was never the valuable part. The pin now lives in GO_VERSION in the three workflows, and the new guard holds the floor against every one of them and against the sentence in README.md, which is the copy somebody reads before they have the repository. Bytes were checked rather than assumed, because go mod tidy moved three modules from the indirect block to the direct one: the module graph is identical at 69, the package set cmd/tfg links is identical at 200, go.sum is untouched, and the three byte stability guards are green. Two mutations, both caught. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 13 +++ README.md | 2 +- go.mod | 52 +++++++--- internal/guard/buildfloor_test.go | 160 ++++++++++++++++++++++++++++++ 4 files changed, 210 insertions(+), 17 deletions(-) create mode 100644 internal/guard/buildfloor_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 1543063..17a85a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -149,6 +149,19 @@ because it turns other people's test suites red. ### Changed +- **Building from source now needs Go 1.27.0.** It used to say 1.26.5, and that + sentence was true of compiling and false of the product. Go 1.27 changed + `compress/flate`, so a copy built on 1.26 answers the same version number, + writes that number into every manifest, and produces different bytes for PNG, + DOCX and TAR.GZ than the release of that name. The compiler now refuses that + build instead of producing one that quietly disagrees with everybody else's. + + If you are pinned to Go 1.26 you can no longer build from source. The + downloadable binaries are unaffected, and so is `go install`. + + A manifest already records which Go produced it, under `tool.go`, so two runs + that disagree can be told apart after the fact as well. + - **The `encryption` setting now says that a locked archive is a fixture rather than protection.** Nothing about the files changes - the sentence `tfg formats zip` prints, and the one the window shows beside the field, gained the two diff --git a/README.md b/README.md index 8ba4a86..c7dc375 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ Linux has no equivalent to sign them with. go install -tags noasm github.com/donislawdev/TestingFilesGenerator/cmd/tfg@latest ``` -**From source.** Needs Go 1.26.5 or newer, and nothing else: +**From source.** Needs Go 1.27.0 or newer, and nothing else: ``` git clone https://github.com/donislawdev/TestingFilesGenerator diff --git a/go.mod b/go.mod index 42e0ae5..68ec96a 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,38 @@ module github.com/donislawdev/TestingFilesGenerator // The compiler takes part in producing bytes, so its version is part of the -// byte stability contract (D11). This line is a minimum - the exact version -// used for tests and releases is pinned in the CI workflow, and the guard -// test in internal/guard reports any drift it causes. See docs/STACK.md. -go 1.26.5 +// byte stability contract (D11). This line is the floor: the oldest compiler +// the module admits, and the only one of the two below it that the compiler +// itself enforces. +// +// It is held equal to the toolchain line rather than left lower, and that is +// the whole point of it. A floor below the pin is a hole, because the toolchain +// line is consulted only when GOTOOLCHAIN allows a switch. Measured on +// 2026-09-06 with the floor at 1.26.5: GOTOOLCHAIN=go1.26.8 built this module, +// the binary answered 0.3.0-rc1, wrote 0.3.0-rc1 into every manifest, and +// produced different bytes for png, docx and targz. Same source, same version +// number, different files - which is the failure D11 exists to prevent. +// +// The cost is stated rather than hidden: nobody on Go 1.26 can build this from +// source any more. That was the owner's decision on 2026-09-06, on the argument +// that the alternative was not a working build but a build that lies about +// which version it is. Kept honest by TestTheBuildFloorIsThePinnedToolchainAndTheReadmeSaysSo, +// which also holds the sentence in README.md against this number. See +// docs/STACK.md. +go 1.27.0 -// What we actually build with, which is not the same statement as the line -// above. That one is a floor for anybody compiling this. This one says which -// toolchain produces our binaries, and Go fetches it rather than asking anyone -// to install it. +// How the line above got to the number it carries. There used to be a separate +// "toolchain" directive here saying which compiler produces our binaries, and +// it is gone rather than forgotten: measured on 2026-09-06, Go refuses to build +// a module whose toolchain directive is not newer than its go directive, and +// once the floor was raised to meet the pin the two were the same number. The +// build says "updates to go.mod needed" until the directive is taken out. The +// comment is kept because the directive was never the valuable part - these +// four decisions and what each of them cost were. +// +// What now carries the pin instead is GO_VERSION in the three CI workflows, and +// TestTheBuildFloorIsThePinnedToolchainAndTheReadmeSaysSo holds all of those +// against this line and against README.md. // // Raised to 1.26.6 on 2026-08-13 because govulncheck reported five standard // library vulnerabilities reachable from the window binary under 1.26.5 - among @@ -44,22 +67,21 @@ go 1.26.5 // closes a level zero stream went from five bytes to two. It measures the // framing now, so the next release moves the bytes again but does not stop the // format from being written. -toolchain go1.27.0 - -require github.com/goccy/go-yaml v1.19.2 require ( + fyne.io/fyne/v2 v2.8.1 github.com/gen2brain/gav1d v0.2.5 github.com/gen2brain/jxl v0.2.0 + github.com/goccy/go-yaml v1.19.2 + github.com/nicksnyder/go-i18n/v2 v2.6.1 + golang.org/x/image v0.45.0 golang.org/x/text v0.41.0 ) -require github.com/FyshOS/fancyfs v0.0.1 // indirect - require ( - fyne.io/fyne/v2 v2.8.1 fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4 // indirect github.com/BurntSushi/toml v1.6.0 // indirect + github.com/FyshOS/fancyfs v0.0.1 // indirect github.com/anthonynsimon/bild v0.14.0 // indirect github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -79,14 +101,12 @@ require ( github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect - github.com/nicksnyder/go-i18n/v2 v2.6.1 github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rymdport/portal v0.4.2 // indirect github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect github.com/stretchr/testify v1.11.1 // indirect github.com/yuin/goldmark v1.8.2 // indirect - golang.org/x/image v0.45.0 golang.org/x/net v0.57.0 // indirect golang.org/x/sys v0.47.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/internal/guard/buildfloor_test.go b/internal/guard/buildfloor_test.go new file mode 100644 index 0000000..85a98f4 --- /dev/null +++ b/internal/guard/buildfloor_test.go @@ -0,0 +1,160 @@ +package guard + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// The oldest compiler this module admits is the one it is actually built with. +// +// The "go" line in go.mod is a FLOOR: the oldest compiler the module admits, +// and the only statement of the three below that the compiler itself enforces. +// A floor lower than what CI runs is a hole, and it is not a tidiness question +// here, because bytes are the contract. The comment above the go line records +// what moved when this project went to 1.27.0 on 2026-09-01: Go 1.27 changed +// compress/flate, so every format that puts bytes through deflate produces +// different ones - eleven of the fifty one pinned cases moved, and all seven of +// the pinned standard library paths. +// +// Measured on 2026-09-06 against a build of this tree, which is what made this +// worth a guard rather than a note. With the floor at 1.26.5 a build under Go +// 1.26 succeeded, answered "0.3.0-rc1" to "tfg version", wrote "0.3.0-rc1" into +// every manifest, and produced different bytes from the release of that name +// for png, docx and targz. Same source, same version number, different files - +// which is the exact failure D11 and the version number exist to make +// diagnosable. The diagnosis was already in place and works: manifest.tool.go +// records the toolchain, so the two runs read "go1.27.0" and "go1.26.8" and +// somebody who thinks to look can tell. Nothing made them look. +// +// THERE IS NO TOOLCHAIN DIRECTIVE TO COMPARE AGAINST, and that is a measurement +// rather than an oversight. Go refuses to build a module whose toolchain +// directive is not newer than its go directive - once the floor was raised to +// meet the pin, the build answered "updates to go.mod needed" until the +// directive came out. So the pin now lives in GO_VERSION in the workflows, and +// this holds the floor against every one of them. +// +// The third copy is the one a person reads before they have the repository. +// README.md states the minimum in prose, and prose is the copy that rots, so it +// is held here too. Same arrangement as the build tags in +// TestTheInstallInstructionsCarryTheBuildTags and for the same reason: one file +// holds the fact, everything inside a checkout reads it, and the copies that +// have to live outside get a guard instead. +// +// Equality rather than "the floor is at least the pin", deliberately. Comparing +// versions means arithmetic on version strings, and arithmetic on version +// strings is precisely where this class of defect hides. Two strings either +// match or they do not, and that is checkable without a comparison anybody has +// to be right about. +func TestTheBuildFloorIsThePinnedToolchainAndTheReadmeSaysSo(t *testing.T) { + root := repoRoot(t) + floor := goModFloor(t, root) + + checked := 0 + for _, wf := range workflowFiles(t, root) { + raw, err := os.ReadFile(wf) + if err != nil { + t.Fatalf("reading %s: %v", filepath.Base(wf), err) + } + pin := regexp.MustCompile(`(?m)^\s*GO_VERSION:\s*"?([0-9][0-9.]*)"?\s*$`) + for _, m := range pin.FindAllStringSubmatch(string(raw), -1) { + checked++ + if m[1] == floor { + continue + } + t.Errorf("%s builds with Go %s and go.mod admits Go %s, so the compiler CI runs "+ + "is not the oldest one this module accepts.\n"+ + "What to do: make them the same number. A floor below what CI runs lets "+ + "somebody build a copy that answers the same version and produces "+ + "different bytes.", filepath.Base(wf), m[1], floor) + } + } + if checked == 0 { + t.Fatal("no workflow declared GO_VERSION, so this guard checked nothing against the " + + "floor. That is where the pin lives now that go.mod has no toolchain directive.") + } + + stated, at, text := readmeMinimumGo(t, root) + if stated != floor { + t.Errorf("README.md line %d tells somebody they need Go %s and go.mod admits Go %s:\n"+ + " %s\n"+ + "What to do: make the sentence name %s. It is read before anybody has the "+ + "repository, so it is the one copy nothing else can correct.", + at, stated, floor, strings.TrimSpace(text), floor) + } +} + +// goModFloor returns the version on the go directive. +func goModFloor(t *testing.T, root string) string { + t.Helper() + + raw, err := os.ReadFile(filepath.Join(root, "go.mod")) + if err != nil { + t.Fatalf("reading go.mod: %v", err) + } + for _, line := range strings.Split(string(raw), "\n") { + fields := strings.Fields(line) + if len(fields) == 2 && fields[0] == "go" { + return fields[1] + } + } + // Asserted rather than assumed. A guard that silently found no line would + // be green on a go.mod that lost it, which is the failure this file is + // about. + t.Fatal(`go.mod has no "go" line, so the build floor could not be read and this guard ` + + "checked nothing") + return "" +} + +// workflowFiles lists the CI definitions, which are where the pin lives. +func workflowFiles(t *testing.T, root string) []string { + t.Helper() + + found, err := filepath.Glob(filepath.Join(root, ".github", "workflows", "*.yml")) + if err != nil { + t.Fatalf("listing the workflows: %v", err) + } + if len(found) == 0 { + t.Fatal("no workflow files were found, so this guard could not read the pinned Go " + + "version from any of them") + } + return found +} + +// readmeMinimumGo returns the version README.md names as the minimum, the line +// it sits on and that line's text. +// +// Matched on the sentence rather than on a position, because a position moves +// the first time somebody adds a paragraph above it. +func readmeMinimumGo(t *testing.T, root string) (string, int, string) { + t.Helper() + + raw, err := os.ReadFile(filepath.Join(root, "README.md")) + if err != nil { + t.Fatalf("reading README.md: %v", err) + } + + want := regexp.MustCompile(`Needs Go ([0-9][0-9.]*)`) + found, at, text := "", 0, "" + for i, line := range strings.Split(string(raw), "\n") { + m := want.FindStringSubmatch(line) + if m == nil { + continue + } + // A second sentence naming a version is two answers to one question, + // and the guard would then check whichever came last. + if found != "" { + t.Fatalf("README.md names a minimum Go version twice, on lines %d and %d, so "+ + "there is no single sentence to hold against go.mod", at, i+1) + } + found, at, text = m[1], i+1, line + } + if found == "" { + t.Fatal(`README.md has no "Needs Go " sentence, so this guard checked ` + + "nothing. It is the only statement of the requirement somebody sees before " + + "they clone.") + } + return found, at, text +}