Skip to content

fix(deploy): make a deployment match the manifest it publishes - #47

Merged
Sycatle merged 2 commits into
devfrom
fix/deploy-matches-the-published-manifest
Aug 25, 2026
Merged

fix(deploy): make a deployment match the manifest it publishes#47
Sycatle merged 2 commits into
devfrom
fix/deploy-matches-the-published-manifest

Conversation

@Sycatle

@Sycatle Sycatle commented Aug 25, 2026

Copy link
Copy Markdown
Owner

scripts/verify-web.sh was run against a deploy/ stack for the first time, using the manifest from the v0.1.0 release. It failed:

221 file(s) matched, 5 altered, 0 missing

The five were exactly the files Vite produces — index.html, two assets/index-*.js, the stylesheet, PdfViewer-*.js. The 221 copied verbatim from public/ matched byte for byte. So a deployment built with this repository's own deploy/ did not match the manifest this repository publishes, which empties the mechanism for anybody self-hosting.

Two causes, found by changing one factor at a time.

apps/web/dist was shipping inside the build context

.dockerignore patterns match paths relative to the context root unless they start with **/. dist/ excluded the one at the top and copied apps/web/dist straight in; node_modules/ did the same with 683 MiB. The context carried 683 files where the source tree has 457.

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.

A build whose output depends on whether somebody ran pnpm run build beforehand is not reproducible, and nothing said so. It is also why this reproduced on a developer machine and not in CI, where the tree is clean.

An empty VITE_MEDIA_URL is not the same as an absent one

Vite substitutes import.meta.env.VITE_* statically and distinguishes the two. 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.

ARG VITE_MEDIA_URL= put every deployment in the second case, so one running no media server still failed to match. That is backwards: docs/THREAT-MODEL.md § 4quinquies makes the trade "verifiable or calls", and a deployment configuring no calls was paying it anyway.

Measured on node 22.23.2 / pnpm 11.22.0, sha256(dist/index.html):

build hash
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

What was ruled out

Build path — the first hypothesis, and wrong. Two builds at /app and /a/much/deeper/build/directory/here, everything else equal, produced identical output. Worth recording because scripts/release.sh documents the opposite for the Rust binary (217 absolute paths, fixed with --remap-path-prefix), and the analogy does not carry to Vite.

Node and pnpm versions were identical throughout (22.23.2, 11.22.0).

What was run

  • scripts/verify-web.sh http://localhost:8099 WEB-SHA256SUMS against a deploy/ stack — 226 matched, 0 altered, 0 missing
  • gh attestation verify WEB-SHA256SUMS --repo Sycatle/whispee — passes, and binds the manifest to release.yml@refs/tags/v0.1.0 and commit 377e87e
  • Build context: 683 files → 457, matching the source tree
  • docker build -f deploy/Dockerfile.web — green

…nifest

`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.
…e image

`.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.
@Sycatle
Sycatle merged commit a68d2ec into dev Aug 25, 2026
5 checks passed
@Sycatle
Sycatle deleted the fix/deploy-matches-the-published-manifest branch August 25, 2026 17:35
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