Commit 2538f3fdc6

2538f3fdc63b4c6dd660ebcd120381b954b1d310

parent: 11c4aee7c5

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-20 06:50 UTC

httpd: stamp the stylesheet hash on its URL

/static/style.css was served max-age=86400, must-revalidate at an
unversioned URL. must-revalidate only forces a check once freshness
expires, so for a day after a deploy a browser rendered the new markup
against its old stylesheet, and the ETag was never consulted.

Seen on a phone minutes after v1.31.0: the cached sheet predated !440,
so .railopt, .railmore and .raildrop had no rules. Every rail icon
showed instead of collapsing to five, and the More menu's drop rendered
as a line of inline links across the rail.

The hash that was already the ETag is now also the ?v= the layout stamps
on the link, so a deploy changes the URL. That URL names bytes that
cannot change and is served immutable; the bare URL keeps its lifetime.

Closes #239
internal/httpd/anchors_test.go +42
@@ -7,6 +7,8 @@ import (
77 "testing"
88
99 "gitbay.org/gitbay/internal/config"
10 "gitbay.org/gitbay/internal/store"
11 "gitbay.org/gitbay/internal/web"
1012)
1113
1214// Markdown headings carry ids, so a README section can be deep-linked
@@ -38,3 +40,43 @@ func TestStylesheetRevalidates(t *testing.T) {
3840 t.Fatalf("revalidation: %d with %d bytes", second.Code, second.Body.Len())
3941 }
4042}
43
44// The layout stamps the served bytes' hash on the stylesheet URL, so a
45// deploy changes that URL and a browser cannot answer it from a copy of
46// the old sheet. A URL carrying the hash names bytes that cannot change
47// and is served without revalidation; the bare URL keeps its lifetime
48// (#239).
49func TestStylesheetURLCarriesTheBuildHash(t *testing.T) {
50 var sb strings.Builder
51 var base basePage
52 base.Viewer = "alice"
53 err := web.Render(&sb, "dashboard.html", struct {
54 basePage
55 Tab string
56 Pins []pinnedRow
57 Reviews []store.DashboardItem
58 Assigned []store.DashboardItem
59 MRs []store.DashboardItem
60 Issues []store.DashboardItem
61 Feed []feedLine
62 }{base, "dashboard", nil, nil, nil, nil, nil, nil})
63 if err != nil {
64 t.Fatal(err)
65 }
66 want := `href="/static/style.css?v=` + stylesheetHash + `"`
67 if !strings.Contains(sb.String(), want) {
68 t.Errorf("the page does not link %s", want)
69 }
70
71 s := New(config.Default(), nil)
72 versioned := httptest.NewRecorder()
73 s.stylesheet(versioned, httptest.NewRequest("GET", "/static/style.css?v="+stylesheetHash, nil))
74 if cc := versioned.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") {
75 t.Errorf("versioned URL: Cache-Control = %q, want immutable", cc)
76 }
77 bare := httptest.NewRecorder()
78 s.stylesheet(bare, httptest.NewRequest("GET", "/static/style.css", nil))
79 if cc := bare.Header().Get("Cache-Control"); !strings.Contains(cc, "must-revalidate") {
80 t.Errorf("bare URL: Cache-Control = %q, want must-revalidate", cc)
81 }
82}
internal/httpd/web.go +19 −6
@@ -64,19 +64,32 @@ func (s *Server) siteName() string {
6464 return strings.TrimSuffix(h, "/")
6565}
6666
67// stylesheetETag is the hash of what stylesheet serves, computed once:
68// a browser revalidates with If-None-Match and gets a 304 until a deploy
69// changes the bytes (#132).
70var stylesheetETag = func() string {
67// stylesheetHash is the hash of what stylesheet serves, computed once. It
68// is the ETag, so a browser revalidating with If-None-Match gets a 304
69// until a deploy changes the bytes (#132), and it is the ?v= the layout
70// stamps on the URL, so a deploy the browser has not fetched yet cannot be
71// answered from its cache (#239).
72var stylesheetHash = func() string {
7173 h := sha256.New()
7274 h.Write(styleCSS)
7375 h.Write(chromaCSS)
74 return `"` + hex.EncodeToString(h.Sum(nil))[:16] + `"`
76 return hex.EncodeToString(h.Sum(nil))[:16]
7577}()
7678
79var stylesheetETag = `"` + stylesheetHash + `"`
80
81func init() { web.StyleVersion = stylesheetHash }
82
7783func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) {
7884 w.Header().Set("ETag", stylesheetETag)
79 w.Header().Set("Cache-Control", "public, max-age=86400, must-revalidate")
85 // A URL carrying this build's hash names bytes that cannot change, so
86 // it never needs revalidating. The bare URL still can, and keeps the
87 // policy it had.
88 if r.URL.Query().Get("v") == stylesheetHash {
89 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
90 } else {
91 w.Header().Set("Cache-Control", "public, max-age=86400, must-revalidate")
92 }
8093 if r.Header.Get("If-None-Match") == stylesheetETag {
8194 w.WriteHeader(http.StatusNotModified)
8295 return
internal/web/templates/layout.html +1 −1
@@ -4,7 +4,7 @@
44<meta charset="utf-8">
55<meta name="viewport" content="width=device-width, initial-scale=1">
66<title>{{template "title" .}}</title>
7<link rel="stylesheet" href="/static/style.css">
7<link rel="stylesheet" href="/static/style.css?v={{styleVersion}}">
88<link rel="icon" href="/favicon.svg" type="image/svg+xml">
99{{with field . "Feed"}}<link rel="alternate" type="application/atom+xml" href="{{.}}">
1010{{end}}</head>
internal/web/web.go +6
@@ -62,9 +62,15 @@ var fullVersion = sync.OnceValue(func() string {
6262 return ""
6363})
6464
65// StyleVersion identifies the bytes /static/style.css serves. The httpd
66// package sets it; the layout stamps it on the stylesheet URL so a deploy
67// changes that URL and a browser holding a cached copy cannot miss it.
68var StyleVersion string
69
6570var funcs = template.FuncMap{
6671 "gitbayVersion": func() string { return version() },
6772 "gitbayCommit": func() string { return fullVersion() },
73 "styleVersion": func() string { return StyleVersion },
6874 "join": strings.Join,
6975 // paragraphs splits plain text on blank lines for safe rich display.
7076 "paragraphs": func(s string) []string {