build: stop tracking the SPA placeholder; degrade gracefully instead - #88
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
internal/web/web.godoes//go:embed all:dist. A tracked placeholderinternal/web/dist/index.htmlexisted so a fresh clone compiles without afrontend build. But
make embedoverwrites it with Vite's real, content-hashedoutput every build, so every
make buildproduced a differentindex.html.Committing that after a build churned git and repeatedly caused merge conflicts
between sibling PRs —
make clean'sgit checkout -- internal/web/dist/index.htmlband-aid was evidence the team already knew this.
Approach chosen: (b) — untrack the build output entirely
I evaluated both candidates in the task brief:
index.htmlbyte-identical across builds, but it defeats the point ofcontent-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.FSfiles carry no real mtime, so that means adding acontent-hash-based ETag layer to
web.gojust to recover safe caching. Moremoving parts, more places to get caching subtly wrong, for a workbox/PWA setup
that already handles cache-busting via its own revision manifest.
.gitkeep, degrade gracefully removes theproblem 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 embedfirst) 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.gitkeepinstead ofindex.html.internal/web/dist/index.htmluntracked;internal/web/dist/.gitkeepaddedso
go:embed all:diststill compiles with an otherwise-empty directory.internal/web/web.go:SPAHandlerno longer errors whenindex.htmlismissing 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 oneasserting
SPAHandlernever 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 optionalDeps.SPA fs.FSoverride sorouter tests inject a fake built SPA instead of depending on this checkout's
actual embedded content — several server tests were asserting
200 OKon thereal embedded FS and would otherwise break/flake based on whether
make embedhad been run locally.
Makefile:cleandrops the now-obsoletegit checkout --band-aid; it justclears everything in
internal/web/distexcept.gitkeep.CLAUDE.md: added a line to "Commits and CI" — build output is nevercommitted; a dirty
internal/web/distafter building is a bug.docs/specs/m2-ux-bug-batch.md: fixed a stale DoD line that told a future PRto commit the embedded build.
Verified locally
make clean, no npm step):go build ./...andgo test -race ./internal/web/...pass (503 "not built" path exercised).go vet ./...,gofmt -l,go tool golangci-lint run ./...— clean.go test -race ./...— green, includinginternal/server(against realPostgres + mock OIDC running locally).
make build && git status --porcelain— empty (only intentional sourcechanges staged; no dist churn).
web-ui:tsc --noEmit,npm run lint(0 errors, pre-existing warningsonly, unrelated to this change),
npm run test -- --run(223 tests) — allgreen.
make e2e(Playwright viewport matrix, against Postgres + mock OIDC + thebuilt embedded binary): 115 passed, 6 pre-existing skips, 0 failures.
go build/go test -race ./internal/web/...aftermake cleanasecond time post-e2e-build to confirm the degrade-gracefully path still
works after a real build has happened once.
Not verified here
imagejob (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
.gitkeepinstead of a placeholderindex.html— harmless,.gitkeepisn'tserved by any route).
ci.ymlfrontend/backend/e2e jobsmirror exactly what I ran locally (same
maketargets, same Postgres/mock-OIDCservices), so I expect them green, but that's for the actual CI run to confirm.