Skip to content

build: stop tracking the SPA placeholder; degrade gracefully instead - #88

Merged
tibroc merged 1 commit into
mainfrom
build/stop-tracking-spa-placeholder
Aug 28, 2026
Merged

build: stop tracking the SPA placeholder; degrade gracefully instead#88
tibroc merged 1 commit into
mainfrom
build/stop-tracking-spa-placeholder

Conversation

@tibroc

@tibroc tibroc commented Aug 28, 2026

Copy link
Copy Markdown
Member

Problem

internal/web/web.go does //go:embed all:dist. A tracked placeholder
internal/web/dist/index.html existed so a fresh clone compiles without a
frontend build. But make embed overwrites it with Vite's real, content-hashed
output every build, so every make build produced a different index.html.
Committing that after a build churned git and repeatedly caused merge conflicts
between sibling PRs — make clean's git checkout -- internal/web/dist/index.html
band-aid was evidence the team already knew this.

Approach chosen: (b) — untrack the build output entirely

I evaluated both candidates in the task brief:

  • (a) stable (unhashed) Vite filenames + cache-header tuning would keep
    index.html byte-identical across builds, but it defeats the point of
    content-hashed long-term-cacheable assets. To avoid serving stale bundles to
    clients that bypass the service worker (first load, SW not yet installed,
    non-SW browsers), the Go file server would need real ETag/Last-Modified
    support — embed.FS files carry no real mtime, so that means adding a
    content-hash-based ETag layer to web.go just to recover safe caching. More
    moving parts, more places to get caching subtly wrong, for a workbox/PWA setup
    that already handles cache-busting via its own revision manifest.
  • (b) untrack the output, add a .gitkeep, degrade gracefully removes the
    problem at the root: nothing generated by a build is ever committed, so there
    is nothing to churn or conflict over. It needs no changes to caching headers
    or asset serving at all — production behavior (Docker/CI, which always run
    make web-build && make embed first) is untouched.

(b) is simpler, touches nothing caching-related, and matches the repo's "prefer
the boring, simple option" rule, so I went with it.

Changes

  • .gitignore: internal/web/dist/* stays ignored; the negation now keeps
    .gitkeep instead of index.html.
  • internal/web/dist/index.html untracked; internal/web/dist/.gitkeep added
    so go:embed all:dist still compiles with an otherwise-empty directory.
  • internal/web/web.go: SPAHandler no longer errors when index.html is
    missing from the embedded FS — it returns a handler that serves a plain
    "SPA not built, run make web-build" (503) response for every path instead,
    so router construction (and go build/go test) succeed with zero npm step.
  • internal/web/web_test.go: replaced the placeholder-dependent test with one
    asserting SPAHandler never errors and always returns 200 (built) or 503
    (not built), plus a dedicated test for the not-built path.
  • internal/server/router.go: added an optional Deps.SPA fs.FS override so
    router tests inject a fake built SPA instead of depending on this checkout's
    actual embedded content — several server tests were asserting 200 OK on the
    real embedded FS and would otherwise break/flake based on whether make embed
    had been run locally.
  • Makefile: clean drops the now-obsolete git checkout -- band-aid; it just
    clears everything in internal/web/dist except .gitkeep.
  • CLAUDE.md: added a line to "Commits and CI" — build output is never
    committed; a dirty internal/web/dist after building is a bug.
  • docs/specs/m2-ux-bug-batch.md: fixed a stale DoD line that told a future PR
    to commit the embedded build.

Verified locally

  • Fresh clone simulation (make clean, no npm step): go build ./... and
    go test -race ./internal/web/... pass (503 "not built" path exercised).
  • go vet ./..., gofmt -l, go tool golangci-lint run ./... — clean.
  • go test -race ./... — green, including internal/server (against real
    Postgres + mock OIDC running locally).
  • make build && git status --porcelain — empty (only intentional source
    changes staged; no dist churn).
  • web-ui: tsc --noEmit, npm run lint (0 errors, pre-existing warnings
    only, unrelated to this change), npm run test -- --run (223 tests) — all
    green.
  • make e2e (Playwright viewport matrix, against Postgres + mock OIDC + the
    built embedded binary): 115 passed, 6 pre-existing skips, 0 failures.
  • Re-ran go build/go test -race ./internal/web/... after make clean a
    second time post-e2e-build to confirm the degrade-gracefully path still
    works after a real build has happened once.

Not verified here

  • The actual GitHub Actions image job (Dockerfile build → ghcr.io publish) —
    didn't build/push an image locally per repo policy ("container images build
    in GitHub Actions... never build or push images from a local machine"). The
    Dockerfile is unchanged and its COPY --from=web /web/dist/ internal/web/dist/
    step still overlays the real build the same way it always did (now onto a
    .gitkeep instead of a placeholder index.html — harmless, .gitkeep isn't
    served by any route).
  • CI itself hasn't run on this PR yet; the ci.yml frontend/backend/e2e jobs
    mirror exactly what I ran locally (same make targets, same Postgres/mock-OIDC
    services), so I expect them green, but that's for the actual CI run to confirm.

The committed internal/web/dist/index.html existed only so go:embed had a
target on a fresh clone, but `make embed` overwrites it with a real,
content-hashed build every time — committing the result after a build churns
git and has repeatedly caused merge conflicts between sibling PRs.

Untrack the placeholder; a tracked internal/web/dist/.gitkeep keeps the
go:embed all:dist pattern compiling with nothing else in the directory.
SPAHandler no longer fails to construct when index.html is missing — it
serves a plain "SPA not built, run make web-build" response instead, so the
router (and go build/go test) work on a fresh clone with zero npm step.
Real deploys (make build, CI, Docker) always run the frontend build first and
are unaffected.

internal/server/Deps gains an SPA fs.FS override so router tests inject a
fake built SPA and no longer depend on this checkout's actual embed state.
@tibroc
tibroc merged commit 4511bca into main Aug 28, 2026
5 checks passed
@tibroc
tibroc deleted the build/stop-tracking-spa-placeholder branch August 28, 2026 12:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant