Skip to content

Commit f77d1fc

Browse files
donislawdevclaude
andcommitted
build: the tool is built with Go 1.27 now, and six formats change bytes
Raises the toolchain in go.mod and GO_VERSION in both workflows from 1.26.7 to 1.27.0, rewrites the eleven pinned generator cases and all seven pinned standard library paths that moved, and bumps two tools that cannot read Go 1.27 at all. This is a breaking change under D11. Sizes are unchanged, every size that worked still works, and the same sizes are reachable - what moved is the compressed data. Affected: targz, png, docx, xlsx, pptx, ico when it holds a png, and zip when compression is asked for. zip left alone is untouched because its default stores rather than compresses. The other seventeen formats are byte for byte what they were. The cause is compress/flate, measured with a probe that imports none of this project: all thirty two combinations of input and level differ between the two releases. At level zero the change is framing rather than algorithm - the block closing a stream went from five bytes to two, a constant three - and above it the compressor itself behaves differently. The reason for moving is not that 1.27 is better. This machine builds other projects that are already on it, and GOTOOLCHAIN belongs to the account rather than to a project, so the two were taking it in turns and a guard went red on formats nobody had touched. Staying meant a check before every command, forever. Decision by the owner. TAR.GZ could not be produced at all under 1.27 before this. Its size is arithmetic rather than a measurement, because compressing twice to learn a length would make a preview cost what a run costs, and that arithmetic carried the gzip framing as three constants. It measures them now, at first use, from four compressions, and checks the model against a third block and against the empty stream before trusting it. A later release can move these bytes again but can no longer stop the format being written. That repair is byte neutral under 1.26.7 - nine of nine against a binary from main - so every byte that moved here belongs to the compiler and not to it. Two tools had to move with the compiler, and neither was a finding about this code. staticcheck v0.7.0 refuses the standard library outright ("export data version 4"), and govulncheck v1.1.4 PANICS, which reads like a security result on a gate named for vulnerabilities. Both are at their newest, v0.8.1 and v1.7.0, and both are clean - govulncheck finds no vulnerability in 1.27.0, which was worth knowing for a release with no patches behind it yet. Two minimums moved and the site says so: png 73 to 74 B, with 75-82 and 85 now the sizes it cannot produce, and targz 1052 to 1049 B, next reachable 1051, gap at 1050. Measured with the probe that exists for that table rather than derived, because two numbers in it were once wrong from being derived. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent a531708 commit f77d1fc

12 files changed

Lines changed: 278 additions & 68 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ env:
2626
# The exact toolchain used for tests and releases. go.mod declares a
2727
# minimum - this is the pin. Raising it can change generated bytes, so the
2828
# byte stability guard has to be green before it moves.
29-
GO_VERSION: "1.26.7"
29+
GO_VERSION: "1.27.0"
3030

3131
jobs:
3232
test:
@@ -324,7 +324,7 @@ jobs:
324324
# what makes it worth having: a scanner that lists every advisory
325325
# touching the module graph produces noise, and noise gets switched off.
326326
# Measured before switching it on, 2026-08-02: no vulnerabilities found.
327-
run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./...
327+
run: go run golang.org/x/vuln/cmd/govulncheck@v1.7.0 ./...
328328

329329
staticcheck:
330330
name: staticcheck
@@ -367,7 +367,7 @@ jobs:
367367
# both of them the word "Pillow" at the start of an error string, which
368368
# is the name of the library that refused the image rather than a
369369
# sentence. Zero findings with the config in place.
370-
run: go run honnef.co/go/tools/cmd/staticcheck@v0.7.0 ./...
370+
run: go run honnef.co/go/tools/cmd/staticcheck@v0.8.1 ./...
371371

372372
lint:
373373
name: linters

‎.github/workflows/release.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ concurrency:
2929
env:
3030
# The same pin CI carries. A release built on a different toolchain than the
3131
# one the byte stability guards ran under is a release nobody measured.
32-
GO_VERSION: "1.26.7"
32+
GO_VERSION: "1.27.0"
3333

3434
jobs:
3535
check:

‎CHANGELOG.md‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,40 @@ because it turns other people's test suites red.
1616

1717
### Breaking
1818

19+
- **Six formats have different bytes, because the tool is built with Go 1.27
20+
now.** Sizes are unchanged. Every size that worked before still works, every
21+
reader that took these files still takes them, and the same sizes are
22+
reachable. What changed is the compressed data inside them.
23+
24+
Affected: `targz`, `png`, `docx`, `xlsx`, `pptx`, `ico` when it holds a png,
25+
and `zip` when you ask for compression. `zip` left alone is untouched,
26+
because its default stores rather than compresses. The other seventeen
27+
formats are byte for byte what they were.
28+
29+
Go 1.27 changed `compress/flate`, which is what all of those run through.
30+
Below the default compression level the change is only how a stream is
31+
closed, and above it the compressor itself behaves differently.
32+
33+
Two minimums moved with it. The smallest `png` is **74 B** rather than 73,
34+
and sizes 75 to 82 and 85 are the ones it cannot produce. The smallest
35+
`targz` is **1049 B** rather than 1052, the next size up is 1051, and 1050 is
36+
the one it cannot produce. `tfg formats` prints the current numbers.
37+
38+
**A suite pinning hashes for those formats will go red once and then stay
39+
green.** There is no switch back: staying on the old compiler was not a
40+
choice this tool can offer, since the compiler comes from whoever builds it.
41+
42+
- **`.tar.gz` could not be produced at all under Go 1.27 until this release.**
43+
Every size was refused with an error saying the generator produced three
44+
bytes fewer than planned. The size of a `.tar.gz` is worked out rather than
45+
measured - compressing twice to learn a length would make a preview cost what
46+
the run costs - and that arithmetic carried a number that turned out to
47+
describe one release of Go.
48+
49+
It measures that number now, at first use, and checks its own answer before
50+
trusting it. A later Go release can move these bytes again, but it can no
51+
longer stop the format from being written.
52+
1953
- **A generated `.tar.gz` has different bytes, because a lot of them could
2054
not be opened by a Go program.** Sizes are unchanged, every size that
2155
worked before still works, and every reader that took these files still

‎go.mod‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,23 @@ go 1.26.5
2828
// here" and "green there" have to mean the same compiler. The byte stability
2929
// guards were run under 1.26.7 before this line moved and none of them
3030
// shifted, so D11 holds and no major version is owed.
31-
toolchain go1.26.7
31+
//
32+
// Raised to 1.27.0 on 2026-09-01, and this one IS owed a major version. Go
33+
// 1.27 changed compress/flate, so every format that puts bytes through deflate
34+
// produces different ones - eleven of the fifty one pinned cases moved, and all
35+
// seven of the pinned standard library paths. Sizes are unchanged and the same
36+
// sizes are reachable. Decision by the owner, and the reason was not that the
37+
// release is better: this machine builds other projects that are already on
38+
// 1.27, a toolchain setting belongs to the account rather than to a project, so
39+
// the two were taking it in turns. Staying meant a check before every command
40+
// forever. Written up in docs/GO-127-MIGRATION.md.
41+
//
42+
// TAR.GZ could not be produced at all under 1.27 until this move, because its
43+
// size arithmetic carried the gzip framing as a constant and the block that
44+
// closes a level zero stream went from five bytes to two. It measures the
45+
// framing now, so the next release moves the bytes again but does not stop the
46+
// format from being written.
47+
toolchain go1.27.0
3248

3349
require github.com/goccy/go-yaml v1.19.2
3450

‎internal/format/targz/framing.go‎

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// What gzip costs on top of the bytes it carries, measured rather than
2+
// written down.
3+
//
4+
// This file exists because a number that was written down turned out to be a
5+
// fact about one Go release. The size of a TAR.GZ is arithmetic - it has to
6+
// be, because the bytes pass through deflate and building the archive to
7+
// measure it would make a preview cost what the run costs. The arithmetic
8+
// needs to know what the gzip stream adds, and until 2026-09-01 that was three
9+
// constants in targz.go.
10+
//
11+
// Go 1.27.0 changed one of them. The block that closes a level zero stream
12+
// went from a five byte empty STORED block to a two byte one, so every archive
13+
// came out three bytes short of its plan and the format refused to write
14+
// anything at all - measured on every size tried, from 64 kB to 10 MB. The
15+
// engine was right to refuse. The arithmetic was describing Go 1.26.
16+
//
17+
// Bumping the constant would have worked until the next release. Measuring
18+
// asks the library that is actually linked, so it holds for the one after that
19+
// too. The model is the same under both releases and only the constants move:
20+
//
21+
// overhead(n) = base + perBlock * ceil(n / storeBlock)
22+
//
23+
// Measured 2026-09-01, level zero: perBlock is 5 under both, base is 23 under
24+
// go1.26.7 and 20 under go1.27.0.
25+
//
26+
// One honest limit, because it would otherwise look like this file is proven
27+
// and it is only half proven. Replacing the measurement with today's two
28+
// constants written down would pass every test in this repository, today, on
29+
// this compiler - the mutation runner was pointed at exactly that and it
30+
// cannot go red. What the measurement buys is the NEXT release, and no test
31+
// that runs today can demonstrate that. The mutations here cover the
32+
// arithmetic being wrong; they cannot cover it being right for the wrong
33+
// reason. That is why this comment is long: it is the only thing standing
34+
// between a later reader and a tidy simplification back to the bug.
35+
package targz
36+
37+
import (
38+
"bytes"
39+
"compress/gzip"
40+
"fmt"
41+
"io"
42+
"sync"
43+
)
44+
45+
// framing is what a level zero gzip stream costs beyond its content.
46+
type framing struct {
47+
// base is the header, the trailer, and the block that closes the stream.
48+
base int64
49+
// perBlock is what each stored block of content costs.
50+
perBlock int64
51+
}
52+
53+
// gzipFraming measures the framing once and hands back the same answer after.
54+
//
55+
// Lazy rather than at init because the answer is only needed when a size is
56+
// being worked out, and a package that measures something on every program
57+
// start makes every command pay for the one that needs it.
58+
var measuredFraming = sync.OnceValues(measureFraming)
59+
60+
// measureFraming works the two constants out from three compressions, and then
61+
// checks the model against a fourth.
62+
//
63+
// Two points settle the line and the third says whether it is a line at all.
64+
// Without that check a change to the BLOCK SIZE - rather than to the cost of a
65+
// block - would be read as a change to the constants, and the arithmetic would
66+
// be quietly wrong instead of loudly refused. That is the failure this whole
67+
// file exists to stop happening a second time, so it is worth one more
68+
// compression of a buffer that is already in memory.
69+
func measureFraming() (framing, error) {
70+
one, err := storedOverhead(storeBlock)
71+
if err != nil {
72+
return framing{}, err
73+
}
74+
two, err := storedOverhead(2 * storeBlock)
75+
if err != nil {
76+
return framing{}, err
77+
}
78+
79+
f := framing{perBlock: two - one}
80+
f.base = one - f.perBlock
81+
82+
// Two independent checks the two points above cannot make on their own: a
83+
// third multiple of the block, and the empty stream, which is base alone.
84+
three, err := storedOverhead(3 * storeBlock)
85+
if err != nil {
86+
return framing{}, err
87+
}
88+
empty, err := storedOverhead(0)
89+
if err != nil {
90+
return framing{}, err
91+
}
92+
if want := f.base + 3*f.perBlock; three != want {
93+
return framing{}, fmt.Errorf(
94+
"targz: this build of Go frames a gzip stream in a shape this tool does not understand. "+
95+
"Three blocks of content cost %d B where the two measured before them predict %d B, "+
96+
"so the size of an archive cannot be worked out without building it",
97+
three, want)
98+
}
99+
if empty != f.base {
100+
return framing{}, fmt.Errorf(
101+
"targz: this build of Go frames an empty gzip stream at %d B where the measurement says %d B, "+
102+
"so the size of an archive cannot be worked out without building it",
103+
empty, f.base)
104+
}
105+
return f, nil
106+
}
107+
108+
// storedOverhead is what gzip adds to n bytes at compression level zero.
109+
//
110+
// The content is zeros, and that is safe precisely because the level is zero:
111+
// stored blocks carry their input unchanged, so the framing does not depend on
112+
// what is in them. At any other level it would.
113+
func storedOverhead(n int64) (int64, error) {
114+
var out bytes.Buffer
115+
w, err := gzip.NewWriterLevel(&out, gzip.NoCompression)
116+
if err != nil {
117+
return 0, err
118+
}
119+
if n > 0 {
120+
if _, err := io.CopyN(w, zeros{}, n); err != nil {
121+
return 0, err
122+
}
123+
}
124+
if err := w.Close(); err != nil {
125+
return 0, err
126+
}
127+
return int64(out.Len()) - n, nil
128+
}
129+
130+
// zeros is an endless run of zero bytes, so the measurement allocates one
131+
// small buffer rather than the megabyte it reads.
132+
type zeros struct{}
133+
134+
func (zeros) Read(p []byte) (int, error) {
135+
for i := range p {
136+
p[i] = 0
137+
}
138+
return len(p), nil
139+
}

‎internal/format/targz/size.go‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,19 @@ func tarLength(m memo) int64 {
7979
}
8080

8181
// gzipFixed is the whole file except the comment.
82+
//
83+
// The framing comes from framing.go, which asks the gzip that is linked rather
84+
// than reading a number written here. If that measurement refused, this uses a
85+
// zero framing and the sizes are nonsense - which is safe only because Plan
86+
// returns the refusal before anything is written or announced. Nothing acts on
87+
// a size worked out from a framing this tool did not understand.
8288
func gzipFixed(tarLen int64) int64 {
89+
f, _ := measuredFraming()
8390
blocks := tarLen / storeBlock
8491
if tarLen%storeBlock != 0 {
8592
blocks++
8693
}
87-
return gzipFraming + storeBlockCost*(blocks+1) + tarLen
94+
return f.base + f.perBlock*blocks + tarLen
8895
}
8996

9097
// commentCost is what a comment adds to the file: its bytes plus the zero that

‎internal/format/targz/targz.go‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,14 +72,16 @@ const (
7272
// storeBlock is the largest run of bytes gzip emits as one stored block at
7373
// compression level zero. It decides the framing overhead, so it decides
7474
// the arithmetic in size.go.
75+
//
76+
// This one stays written down because it is a fact about DEFLATE - a
77+
// stored block carries a sixteen bit length - rather than about a release
78+
// of Go. What each block COSTS, and what the header, trailer and closing
79+
// block cost, moved to framing.go and are measured, because those did turn
80+
// out to be facts about a release. framing.go checks this number too: if a
81+
// build ever framed at a different block size, the model would stop
82+
// predicting a third block and the format refuses rather than guessing.
7583
storeBlock = 65535
7684

77-
// gzipFraming is the fixed ten byte header plus the eight byte trailer.
78-
gzipFraming = 18
79-
80-
// storeBlockCost is what each stored block costs on top of its content.
81-
storeBlockCost = 5
82-
8385
writeChunk = 32 * 1024
8486
)
8587

@@ -188,6 +190,13 @@ type memo struct {
188190
}
189191

190192
func (generator) Plan(r format.Request) (format.Plan, error) {
193+
// Before anything else, because every size below is worked out from this
194+
// and a framing this tool does not understand has to be a refusal rather
195+
// than an archive of the wrong length. See framing.go.
196+
if _, err := measuredFraming(); err != nil {
197+
return format.Plan{}, err
198+
}
199+
191200
groups, err := archive.Groups("targz", r)
192201
if err != nil {
193202
return format.Plan{}, err

0 commit comments

Comments
 (0)