fix: refuse to deploy a dist that names files it never built - #57
Conversation
The FTP mirror prunes any remote file with no local counterpart, so deploying a dist/index.html that references a bundle nobody built is worse than a no-op: it uploads the broken HTML and deletes the bundle currently serving production, leaving the site down until someone rebuilds and redeploys. Nothing caught this. ensure_dist_committed checks the opposite direction - every local file is committed at HEAD - which passes cleanly when the file is simply absent. And the state needs no uncommitted change to reach: dist/index.html is tracked while dist/assets/ is gitignored, so a fresh clone that skips `npm run build` has the HTML and none of the JS it names. ensure_dist_self_consistent parses index.html and fails when a referenced file is missing from disk, before any FTP connection is opened. Deliberately not skippable with --allow-dirty: that flag exists for a dist git cannot reproduce, never for one that is internally broken. Reference matching skips anything carrying a URI scheme, a protocol-relative host, or a bare anchor, and strips query strings, so external CDN scripts and data: URIs cannot trip it. Relative and root-relative paths are both checked. scripts/test-deploy-guard.py covers all six cases (npm run test:deploy-guard). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Greptile SummaryThe PR adds a pre-FTP consistency check intended to prevent destructive deployment of an incomplete
Confidence Score: 4/5The unchecked inline CSS asset path should be fixed before merging because an allowed dirty deployment can still prune a production dependency that the new guard promises to validate. The generated page contains a deploy-owned font reference using CSS Files Needing Attention: scripts/deploy_ftp.py, scripts/test-deploy-guard.py Important Files Changed
|
| ASSET_REFERENCE = re.compile( | ||
| r"""\b(?:src|href)\s*=\s*(?P<q>["'])(?P<path>[^"']+)(?P=q)""", | ||
| re.IGNORECASE, | ||
| ) |
There was a problem hiding this comment.
Inline CSS assets bypass guard
When --allow-dirty deploys a dist containing its JavaScript bundle but missing the generated font, the guard ignores the existing src: url('/assets/Delight-VF-CnVpVuQk.ttf') reference because it scans only src= and href= attributes, allowing FTP pruning to remove the production font and break the site's intended typography.
Prompt To Fix With AI
This is a comment left during a code review.
Path: scripts/deploy_ftp.py
Line: 28-31
Comment:
**Inline CSS assets bypass guard**
When `--allow-dirty` deploys a dist containing its JavaScript bundle but missing the generated font, the guard ignores the existing `src: url('/assets/Delight-VF-CnVpVuQk.ttf')` reference because it scans only `src=` and `href=` attributes, allowing FTP pruning to remove the production font and break the site's intended typography.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.The page carries its stylesheet inline, so the built font is reachable only
through `src: url('/assets/Delight-VF-<hash>.ttf')` - a colon, not an `=`, and
therefore invisible to the attribute pattern. A dist missing the font passed
the guard, and the mirror then pruned the font off production while the guard
stayed silent. Same prune mechanism as the bundle, quieter symptom: the site
renders in a fallback face rather than not at all.
Matching url() reuses the existing skip rules, which already cover the two
other forms in the page: url(#gem-grad) SVG fragments are caught by the anchor
rule, and url("data:image/svg+xml,...") by the scheme rule. Quotes are
optional in CSS url(), so both spellings are matched.
Guard test now 9/9, including a missing font, an unquoted url(), and the
fragment/data: forms staying ignored. Verified against the real dist: clean as
built, and both the font and the bundle reported when either is absent.
Caught by review of #57.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The failure
deploy_ftp.pymirrorsdist/and prunes any remote file with no local counterpart. So deploying adist/index.htmlthat references a bundle nobody built is worse than a no-op — it uploads the broken HTML and deletes the bundle currently serving production. The site stays down until someone rebuilds and redeploys.Surfaced by the cloud review on #56, where exactly this drift had landed on the branch: the HTML named
index-DItDCqU8.jsand no such file was tracked.Why nothing caught it
ensure_dist_committedchecks the opposite direction — that every local file exists at HEAD. When the file is simply absent it isn't inlocal_files, so the check passes cleanly.The state needs no uncommitted change to reach.
dist/index.htmlis tracked,dist/assets/is gitignored, so a fresh clone that skipsnpm run buildis already in it.The guard
ensure_dist_self_consistent()parsesindex.html, resolves every referenced path againstdist/, and exits before any FTP connection is opened if one is missing:It is not skippable with
--allow-dirty. That flag is for deploying adist/git cannot reproduce; it was never meant to cover one that is internally broken.Matching skips anything with a URI scheme (
https:,data:,mailto:), protocol-relative hosts, and bare anchors, and strips query strings — so the CDN scripts and data URIs already in the page can't trip it. Both relative and root-relative paths are checked. Calibrated against the real file, whose only local references are/favicon-brand-v2.svg,/CCC.mp4and the bundle.Verification
npm run test:deploy-guard— 6/6, including the real committeddist/returning clean and a broken hash being caught.npm test196/196. Both scripts compile.No FTP connection was made in testing; the guard runs before connect, so it is exercised directly.
Note
DEPLOYMENT.mdnow states thatnpm run buildis not optional and why.🤖 Generated with Claude Code