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 +}