A version number is pushed once, and every build addresses its CSS differently
gates / gates (push) Failing after 44s
gates / gates (push) Failing after 44s
MEASURED FIRST (audits/app-page-2026-10-15b/P0/VERDICT.md). The tab bar rendered unstyled through
the public address for one reason, and it was not the box: exactly one guest runs 0.315.0 and it
runs the registry's current digest (8887343011d6, identical to the registry's). The floor is still
0.314.0, so nothing else could have taken it.
What it was, in four measured facts:
1. the stylesheet URL is /static/style.css?v={{.Version}} -> ?v=0.315.0 for all THREE different
0.315.0 builds (e66d466, fe0228c, db52f5a), the first of which had no .app-tabs rules at all;
2. the origin sends NO validator at all (ETag present = 0, Last-Modified present = 0), so a
cached copy cannot be revalidated - it can only expire;
3. Cloudflare caches that URL (cf-cache-status HIT, age climbing) and rewrites the browser TTL
from the origin's 3600 to 14400 - four hours;
4. the session's first request returned cf-cache-status EXPIRED, which only happens when the edge
holds an entry that has aged out.
Origin, edge and box now agree byte-for-byte. The stale object itself is gone - that first probe
forced its revalidation - and the REPORT says so rather than claiming a HIT nobody can reproduce.
THE FIX IS IN TWO PARTS, NEITHER OF WHICH IS PURGING A CACHE:
build.sh refuses to push a version that already exists, before building, for --push and
--multiarch. A registry that cannot be asked is a refusal too: proving the tag is new is the point,
and "I could not ask" is not that proof. Local builds are untouched - reusing a version while
iterating is normal. scripts/test_build_push_once.sh drives the real build.sh with a fake docker on
PATH over six cases; red-proved twice (check deleted -> a present tag builds and pushes; an
unreachable registry treated as absent -> two cases fail).
assetTag() now hashes version + GitCommit + BuildTime, and EVERY shell's stylesheet link uses it
(seven of the nine asked for ?v={{.Version}}; only the two stranger-facing share pages already had
it). It stays opaque and 8 hex, because the share pages are reached by a capability URL and naming
the build to whoever holds one is a gift to somebody hunting a known fault. Cache-Control is
unchanged: the new address is the fix, not a shorter cache.
The parity harness normalises the asset tag on BOTH sides of the comparison, as it already does for
the per-session CSRF token, so all 148 fixtures stay byte-identical on disk and stop depending on
which build rendered them. The same normalisation is applied at the second comparison site in
i18n_wiring_test.go, which had the same assumption.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
This commit is contained in:
@@ -641,16 +641,26 @@ func renderI18nCase(t *testing.T, s *Server, lang string, c i18nCase) string {
|
||||
return normaliseI18nRender(buf.String())
|
||||
}
|
||||
|
||||
// assetTagRe matches the stylesheet's cache-buster. Since v0.316.0 that is a PER-BUILD tag, so it
|
||||
// differs between two builds of the same source — which is the whole point of it (a cache must not
|
||||
// be able to serve a previous build's CSS under an address the new build still asks for). A parity
|
||||
// fixture pins COPY, not which build rendered it, so the tag is blanked on both sides of every
|
||||
// comparison, exactly as a per-session CSRF token is. Without this, every fixture in the directory
|
||||
// would churn on every build and the whole set would stop meaning anything.
|
||||
var assetTagRe = regexp.MustCompile(`(/static/style\.css\?v=)[^"']*`)
|
||||
|
||||
// csrfValueRe matches a CSRF field's value. A session CSRF token is random per session, so it can
|
||||
// never be a fixture value; it is blanked on BOTH sides of every parity comparison, exactly as
|
||||
// relative ages are. What the fixture still pins is that the field is THERE and which form it is in —
|
||||
// which is the part that says whether the page posts to the household switch or to /lang.
|
||||
var csrfValueRe = regexp.MustCompile(`(name="_csrf" value=")[^"]*"`)
|
||||
|
||||
// normaliseI18nRender removes the two things that legitimately differ between two renders of the same
|
||||
// page: a relative age off the wall clock, and a per-session CSRF token.
|
||||
// normaliseI18nRender removes the three things that legitimately differ between two renders of the
|
||||
// same page: a relative age off the wall clock, a per-session CSRF token, and the per-build asset
|
||||
// tag. None of them is copy; all three would otherwise make the fixtures unmaintainable.
|
||||
func normaliseI18nRender(s string) string {
|
||||
s = relativeAgeRe.ReplaceAllString(s, "# $1")
|
||||
s = assetTagRe.ReplaceAllString(s, "${1}ASSET")
|
||||
return csrfValueRe.ReplaceAllString(s, "${1}CSRF\"")
|
||||
}
|
||||
|
||||
@@ -699,11 +709,17 @@ func TestI18nParity(t *testing.T) {
|
||||
t.Logf("%s: fixture WRITTEN", c.name)
|
||||
continue
|
||||
}
|
||||
want, err := os.ReadFile(p)
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: no parity fixture (%v) — fixtures are captured from UNCONVERTED templates, never regenerated to make a conversion pass", c.name, err)
|
||||
}
|
||||
if got != string(want) {
|
||||
// BOTH sides are normalised, and the function is idempotent (a normalised render passes
|
||||
// through it unchanged). It has to be both: v0.316.0 moved every shell's stylesheet from
|
||||
// `?v=<version>` to a per-build tag, and normalising only the fresh render would have
|
||||
// convicted all 148 fixtures for a difference that is not copy. Normalising both leaves the
|
||||
// stored bytes untouched and keeps the fixtures meaningful across builds.
|
||||
want := normaliseI18nRender(string(raw))
|
||||
if got != want {
|
||||
gl, wl := strings.Split(got, "\n"), strings.Split(string(want), "\n")
|
||||
for i := 0; i < len(gl) || i < len(wl); i++ {
|
||||
var g, w string
|
||||
|
||||
Reference in New Issue
Block a user