Fetch Alpine.js at build time under a verified hash (closes #145) #164
Reference in New Issue
Block a user
Delete Branch "issue-145-unvendor-alpine"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #145.
static/js/alpine.min.jswas a committed minified bundle: unreviewable, referenced by no hash, and forbidden byREPO_POLICIES.mdon both counts (no build artifacts in version control; every external reference pinned by content hash).Option taken: 1 (build-time fetch, verified sha256)
No blocker was hit, so option 2 was not needed. Option 3 (drop Alpine) is left as #163 per the direction on the issue.
Alpine 3.14.9, from
https://registry.npmjs.org/alpinejs/-/alpinejs-3.14.9.tgz— the publisher of record; jsDelivr and unpkg serve mirrors of this same tarball, verified identical while picking the source.alpinejs-3.14.9.tgz97dad7c0c81e659cfc8e7700055da9770f8186187cb9a8a76efb57e00d5ce52apackage/dist/cdn.min.js(shipped)3ed1eed252488921df65e363d6715deb04d7f92aaedb9e52199fdf73cb1e0ad3The shipped hash is byte-identical to the blob that was committed, so the asset served does not change — this is a supply-chain change, not a behavioral one.
How the pin is enforced, not merely recorded
script/fetch-assetsdownloads, checks the tarball sha256, extracts, checks the extracted file's sha256, and only then installs. Both hashes are hardcoded. Nothing is installed on mismatch.static/vendor.sha256records the sha256 of every installed asset and is the single source of truth: the fetch script verifies against it, so the hash cannot drift into a second copy nothing reads.static/vendor_test.gore-hashes the bytesgo:embedactually put in the binary against that manifest. A build that skipped, cached around, or subverted the fetch cannot produce a greenmake test.internal/server/static_assets_test.gofetches every/s/scripttemplates/base.htmlloads through the real router, so the page is proven to still get its JavaScript..gitignorekeeps the artifact out of the repo;.dockerignorekeeps a host copy out of the build context, so the image can only obtain it by fetching and verifying.make bootstrapruns the fetch for local development,make assetsruns it on demand, and the Dockerfile builder stage runs it beforemake test/make build.Verification failure modes, demonstrated
All three run against a throwaway copy of the tree, since a live verification step is the whole point of the issue.
Expected hash corrupted by one hex digit:
URL repointed at a different version (3.14.8), hashes untouched:
In both cases
static/js/still held onlyapp.jsafterwards: the bad bytes were never installed.Asset tampered with after a good fetch, caught by
make teston the embedded bytes:Asset absent entirely (fresh clone) fails both new tests with actionable messages —
js/alpine.min.js is listed in vendor.sha256 but is not embeddedandbase.html loads /s/js/alpine.min.js but the server does not serve it. This fired for real during the rebase of this branch, which dropped the untracked artifact from the working tree;make assetsfixed it.The asset is present in the runtime image and served at the same URL
curlagainst the built runtime image (started--rm, removed afterwards):and
/pages/loginfrom that same container still emits<script defer src="/s/js/alpine.min.js". Same URL, same bytes, hash equal to the pin.Gate evidence
make check(with an isolated lint cache, see below): exit 0,0 issues.docker build --no-cache-filter=lint --no-cache-filter=builder .: exit 0, and the checks demonstrably executed rather than replaying:#21 [lint 8/8] RUN make lint—0 issues.,DONE 56.9smake test— 11 packages with real per-package durations (internal/delivery 4.404s,internal/handlers 3.670s,static 1.027s, ...), zero(cached)markers in the whole log#29 [builder 8/11] RUN script/fetch-assets→fetching Alpine.js 3.14.9 from https://registry.npmjs.org/...→installed static/js/alpine.min.js (3ed1eed...)Note for reviewers: a plain host
make linton this machine reports ~18 phantom issues under../rev-130-clawbot-merge/..., a path that does not exist and is outside the module — stale entries in the shared~/.cache/golangci-lint, the skew described in #106 and #109. With an isolated lint cache the host reports0 issues., matching the Docker run exactly. None of the phantom findings touch this branch.Also observed, out of scope and not filed:
make lintwarns that thegomodguardlinter is deprecated since v2.12.0 in favour ofgomodguard_v2. The fix is a.golangci.ymledit, which is yours alone to make.TODO.mduntouched, as instructed.PASS
Independent review. DoD of #145 met: blob gone from the tree, pin enforced (not merely recorded), page still served.
Pin verified independently — I re-downloaded
alpinejs-3.14.9.tgzfromregistry.npmjs.org: sha25697dad7c0...5ce52amatches;package/dist/cdn.min.jssha2563ed1eed2...1e0ad3, 44758 bytes, matchesstatic/vendor.sha256and is byte-identical to the blob atnext:static/js/alpine.min.js. Served asset unchanged.Negative controls I reproduced myself (not taken on report):
script/fetch-assetsexit 1,static/js/left holding onlyapp.jsstatic/js/cleanmake testFAIL,vendor_test.go:49, real durations, zero(cached)is listed in vendor.sha256 but is not embedded,base.html loads /s/js/alpine.min.js but the server does not serve it)js/ghost.js) → exit 1; manifest with no entry for the asset → exit 1.dockerignoreverified with a host copy actually present in the context (the author's own build ran before the host copy existed, so it did not prove this):COPY . .landsstatic/js/withapp.jsonly. A stale or tampered host copy cannot be baked in.Runtime: image built and run
--rm,/s/js/alpine.min.js→ 200, 44758 bytes, served sha256 equals the pin,version:"3.14.9";/pages/loginemits<script defer src="/s/js/alpine.min.js">. Container and images removed.Gate:
docker build --no-cache-filter=lint --no-cache-filter=builder .exit 0.make fmt-checkDONE 2.0s,make lintDONE 60.8s →0 issues.,script/fetch-assetsgenuinely fetched in-image (#29, not cached),make test11 packages with real durations, zero(cached)in the whole log. CI green onb8272e1(Successful in 3m29s). Merges clean againstnext@279effb. Single commit, title ends(closes #145), basenext,TODO.mduntouched, no attribution trailers, shellcheck--shell=shclean onscript/fetch-assetsandscript/bootstrap.One non-blocking gap (not grounds to hold this, but worth closing):
static/static.gouses//go:embed css js— directory patterns. A missingstatic/js/alpine.min.jsis therefore not a compile error, so on a tree that never ran the fetch,make buildexits 0 and produces a binary whose admin UI silently has no Alpine (I ran it:make buildexit 0, binary emitted,/s/js/alpine.min.jsabsent). Same formake run,make dev, and a barego build. Every gated path is safe — the Dockerfile fetches beforemake test/make build, andmake check/CI fail loudly — so this is local-dev ergonomics only, and the failure is a dead UI rather than an unverified asset. Acceptable fix: name the files in the embed (//go:embed css js/app.js js/alpine.min.js), which turns absence into a compile error on every build path, or makebuilddepend onassetsin theMakefile.Disclosure: I did not run
make linton the host. Lint evidence is the uncached Dockerlintstage only, per the shared-host cache-bleed rule (#106, #109).make checkwas verified asscript/test(host, exit 0) +script/fmt-check(host, exit 0) +make lintin that container.clawbot referenced this pull request2026-08-17 23:42:35 +02:00