Skip to content

fix: refuse to deploy a dist that names files it never built - #57

Merged
sgardoll merged 2 commits into
mainfrom
fix/deploy-guard-missing-assets
Aug 14, 2026
Merged

fix: refuse to deploy a dist that names files it never built#57
sgardoll merged 2 commits into
mainfrom
fix/deploy-guard-missing-assets

Conversation

@sgardoll

Copy link
Copy Markdown
Owner

The failure

deploy_ftp.py mirrors dist/ and 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. 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.js and no such file was tracked.

Why nothing caught it

ensure_dist_committed checks the opposite direction — that every local file exists at HEAD. When the file is simply absent it isn't in local_files, so the check passes cleanly.

The state needs no uncommitted change to reach. dist/index.html is tracked, dist/assets/ is gitignored, so a fresh clone that skips npm run build is already in it.

The guard

ensure_dist_self_consistent() parses index.html, resolves every referenced path against dist/, and exits before any FTP connection is opened if one is missing:

Refusing to deploy: dist/index.html references files that are not built:
  assets/index-DItDCqU8.js
Uploading this would also prune the bundle currently serving the site,
taking production down. Rebuild first:
  npm run build

It is not skippable with --allow-dirty. That flag is for deploying a dist/ 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.mp4 and the bundle.

Verification

npm run test:deploy-guard — 6/6, including the real committed dist/ returning clean and a broken hash being caught. npm test 196/196. Both scripts compile.

No FTP connection was made in testing; the guard runs before connect, so it is exercised directly.

Note

DEPLOYMENT.md now states that npm run build is not optional and why.

🤖 Generated with Claude Code

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-apps

greptile-apps Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds a pre-FTP consistency check intended to prevent destructive deployment of an incomplete dist/, along with focused tests and deployment documentation.

  • Parses local references in dist/index.html and refuses deployment when matched files are absent.
  • Adds npm run test:deploy-guard with cases for missing bundles, URL schemes, query strings, and quoting styles.
  • Documents why rebuilding before deployment is mandatory.
  • Also commits two Python bytecode cache artifacts.

Confidence Score: 4/5

The 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 url(), but the guard scans only src= and href= attributes, allowing a partial dist to pass validation and proceed to destructive FTP pruning.

Files Needing Attention: scripts/deploy_ftp.py, scripts/test-deploy-guard.py

Important Files Changed

Filename Overview
scripts/deploy_ftp.py Adds the deployment guard, but its attribute-only parser misses the generated page's inline CSS font dependency.
scripts/test-deploy-guard.py Adds focused guard tests, though no case covers the existing CSS url() asset reference.
DEPLOYMENT.md Clearly documents the destructive pruning risk and mandatory build step.
package.json Adds a standalone command for running the deploy-guard test.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Start deploy] --> B{dist/index.html exists?}
    B -- No --> X[Refuse deployment]
    B -- Yes --> C[Extract src and href attributes]
    C --> D{Matched local file missing?}
    D -- Yes --> X
    D -- No --> E[Validate Git state unless allow-dirty]
    E --> F[Connect to FTP]
    F --> G[Upload local dist files]
    G --> H[Prune remote files without local counterparts]
    C -. Inline CSS url references are not extracted .-> I[Missing font remains undetected]
    I --> F
Loading
Prompt To Fix All With AI
### Issue 1
scripts/deploy_ftp.py:28-31
**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.

Reviews (1): Last reviewed commit: "fix: refuse to deploy a dist that names ..." | Re-trigger Greptile

Comment thread scripts/deploy_ftp.py
Comment on lines +28 to +31
ASSET_REFERENCE = re.compile(
r"""\b(?:src|href)\s*=\s*(?P<q>["'])(?P<path>[^"']+)(?P=q)""",
re.IGNORECASE,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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>
@sgardoll
sgardoll merged commit 708f528 into main Aug 14, 2026
4 of 5 checks passed
@sgardoll
sgardoll deleted the fix/deploy-guard-missing-assets branch August 14, 2026 00:23
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