From 8163e70fa5d8ff6385bc0daa5e49458b78b41af8 Mon Sep 17 00:00:00 2001 From: sycatle Date: Tue, 25 Aug 2026 19:20:24 +0200 Subject: [PATCH 1/2] fix(deploy): unset an empty media origin, so the image matches the manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ARG VITE_MEDIA_URL=` defined the variable as an empty string, and Vite distinguishes a variable that is absent from one that is defined and empty: absent, the bundle keeps `const e={}, x=!!e?.VITE_MEDIA_URL`; defined and empty, it folds to a constant and minification removes the branch. So every deployment built from this image produced a bundle that did not match the manifest `.github/workflows/release.yml` publishes — including a deployment running no media server at all. That is backwards from what `docs/THREAT-MODEL.md` § 4quinquies claims: the trade is "verifiable **or** calls", and a deployment configuring no calls was paying it anyway. It made the whole publishing mechanism inert in the one configuration it was built for. Reproduced here before committing, `dist/index.html`: variable absent, as CI builds it 2a26c3f24aff3326 ARG VITE_MEDIA_URL= (before) 9b4c1178cc297802 unset when empty (after) 2a26c3f24aff3326 unset when empty, calls configured a13e1dcae6b3cbae The last line is the one that must still differ, and does: a deployment that configures calls names an origin in its policy and knowingly stops matching. Mine to have missed. I measured that configuration no longer changes the bytes, and then wrote a Dockerfile that reintroduced exactly that — because the measurement was run without the variable while the image always set it. Testing the thing rather than the thing as it is deployed. --- deploy/Dockerfile.web | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/deploy/Dockerfile.web b/deploy/Dockerfile.web index a6c398e..1665d85 100644 --- a/deploy/Dockerfile.web +++ b/deploy/Dockerfile.web @@ -39,7 +39,7 @@ FROM node:22.23.2-bookworm-slim AS build # The media server's origin, for calls. Empty by default, and it must stay that way for a # deployment that runs none: an empty value keeps its host out of `connect-src` instead of # widening the policy for a service that will never be contacted. -ARG VITE_MEDIA_URL= +ARG VITE_MEDIA_URL # The version is pinned rather than left to corepack's default, for the reason # `rust-toolchain.toml` gives at length about the compiler: "recent" is not a version. @@ -75,7 +75,24 @@ COPY apps/web ./ # toolchain and no `wasm-pack` are needed here. That artefact is the one `scripts/verify-wasm.sh` # checks against `crates/crypto-wasm` on every pull request; serving it is serving the bytes that # check covers. -RUN pnpm run build +# **`unset` when empty, and this is not a formality.** Vite substitutes `import.meta.env.VITE_*` +# statically, and it distinguishes a variable that is *absent* from one that is *defined and +# empty*. Absent, the bundle keeps `const e={}, x=!!e?.VITE_MEDIA_URL`. Defined-and-empty, Vite +# folds it to a constant and minification deletes the branch — different bytes, different chunk +# hashes, different `index.html`. +# +# `ARG VITE_MEDIA_URL=` put every deployment in the second case, so a deployment that runs **no** +# media server still produced a bundle that did not match the manifest published by +# `.github/workflows/release.yml`. That is backwards: `docs/THREAT-MODEL.md` § 4quinquies says the +# trade is "verifiable **or** calls", and a deployment configuring no calls was paying it anyway. +# +# Measured rather than reasoned, on node 22.23.2 and pnpm 11.22.0, `dist/index.html`: +# +# variable absent, as CI builds it 2a26c3f24aff3326 ← what the manifest lists +# ARG VITE_MEDIA_URL= (before) 9b4c1178cc297802 +# unset when empty (after) 2a26c3f24aff3326 +# unset when empty, calls configured a13e1dcae6b3cbae ← differs, and should +RUN if [ -z "${VITE_MEDIA_URL:-}" ]; then unset VITE_MEDIA_URL; fi; pnpm run build FROM caddy:2-alpine From 8b9b8145bad2d603b6bb5c78c42dfaceb8fabece Mon Sep 17 00:00:00 2001 From: sycatle Date: Tue, 25 Aug 2026 19:24:03 +0200 Subject: [PATCH 2/2] fix(docker): anchor the ignore patterns, or apps/web/dist ships in the image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.dockerignore` patterns match against paths relative to the context root unless they begin with `**/`. So `dist/` excluded the one at the top and copied `apps/web/dist` straight in, and `node_modules/` did the same with 683 MiB of `apps/web/node_modules`. The context carried 683 files where the source tree has 457. # What the stale dist did, which is the reason this is a fix and not a tidy-up Tailwind scans source files for class names. Given the previous bundle, it scanned that too and emitted a different stylesheet, so the chunk hashes moved and `index.html` with them. The deployment then no longer matched the manifest `.github/workflows/release.yml` publishes — which is precisely the property `scripts/verify-web.sh` exists to check. A build whose output depends on whether somebody ran `pnpm run build` beforehand is not reproducible, and nothing anywhere said so. It presented as five altered files out of 226, all of them the ones Vite produces. # Measured, before and after `scripts/verify-web.sh` against a `deploy/` stack, using the manifest from the v0.1.0 release: before 221 matched, 5 altered, 0 missing after 226 matched, 0 altered, 0 missing Context file count: 683 → 457, the same as the source tree. --- .dockerignore | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/.dockerignore b/.dockerignore index 3bb30d8..8075c64 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,19 +4,29 @@ # this workspace once, and every byte of it is uploaded to the daemon before the first # instruction runs. The build produces its own artefacts inside the image; the ones lying here # are from another toolchain, another architecture, or both. -target/ -node_modules/ +# **Anchored at the context root unless they start with `**/`, and that is the whole point of this +# block.** Docker matches these against paths relative to the context, so a bare `node_modules/` +# excludes the one at the top and copies `apps/web/node_modules` — 683 MiB of it — straight into +# the image. Measured, not feared: the build context carried 683 files where the source tree has +# 457. +# +# `apps/web/dist` was the one that cost an afternoon. Copied in, Tailwind scanned the previous +# bundle for class names and emitted a different stylesheet, so the chunk hashes moved and the +# deployment stopped matching the manifest `.github/workflows/release.yml` publishes — the exact +# property `scripts/verify-web.sh` exists to check. A build whose output depends on whether +# somebody ran `pnpm run build` beforehand is not reproducible, and nothing said so. +**/target/ +**/node_modules/ .git/ .worktrees/ # Secrets. `.env` is the development one and would be baked into a layer. -.env -.env.local -deploy/.env +**/.env +**/.env.local # Build outputs that the image rebuilds itself. -dist/ -pkg/ -*.tsbuildinfo +**/dist/ +**/pkg/ +**/*.tsbuildinfo apps/desktop/gen/ release/artefacts/