From c6a86244c6112e230051d28728feae9bc8dd3e86 Mon Sep 17 00:00:00 2001 From: Stuart Date: Fri, 14 Aug 2026 10:11:32 +1000 Subject: [PATCH 1/2] fix: refuse to deploy a dist that names files it never built 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 --- DEPLOYMENT.md | 10 ++ package.json | 1 + .../__pycache__/deploy_ftp.cpython-313.pyc | Bin 0 -> 15644 bytes .../test-deploy-guard.cpython-313.pyc | Bin 0 -> 4091 bytes scripts/deploy_ftp.py | 62 +++++++++++ scripts/test-deploy-guard.py | 97 ++++++++++++++++++ 6 files changed, 170 insertions(+) create mode 100644 scripts/__pycache__/deploy_ftp.cpython-313.pyc create mode 100644 scripts/__pycache__/test-deploy-guard.cpython-313.pyc create mode 100644 scripts/test-deploy-guard.py diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 86e6310..e78c055 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -29,6 +29,16 @@ sit on the server forever. It never lists or deletes a remote directory that has no local counterpart. Use `--dry-run` to preview without changing the server. +Because pruning deletes remote files, `npm run build` is not optional. The +script refuses to run when `dist/index.html` names a file that was never built: +uploading it would replace the live page with one whose bundle 404s *and* prune +the bundle currently serving the site, so the site would stay down until +someone rebuilt. `dist/index.html` is tracked while `dist/assets/` is +gitignored, so a fresh clone is already in that state until it builds. This +check is not skippable with `--allow-dirty` — that flag covers a `dist/` git +cannot reproduce, not one that is internally broken. `npm run test:deploy-guard` +exercises it. + ## FlutterFlow custom-class deploys FlutterFlow's VS Code extension supports editing existing standalone Custom Code diff --git a/package.json b/package.json index 2603fc2..55b7926 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "preview": "vite preview", "test": "node --test src/*.test.js", "test:proxies": "php scripts/test-proxy-allowlist.php", + "test:deploy-guard": "python3 scripts/test-deploy-guard.py", "test:key-storage": "npm run build && node scripts/test-key-storage.mjs", "verify:buildship-mcp": "node scripts/verify-buildship-mcp.mjs", "audit:widget": "node scripts/audit-ff-widget.mjs" diff --git a/scripts/__pycache__/deploy_ftp.cpython-313.pyc b/scripts/__pycache__/deploy_ftp.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..23777c84f5a1d57c06e62397b6b1ea317bcc3640 GIT binary patch literal 15644 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 literal 0 HcmV?d00001 diff --git a/scripts/deploy_ftp.py b/scripts/deploy_ftp.py index ff9dc18..0eb2105 100755 --- a/scripts/deploy_ftp.py +++ b/scripts/deploy_ftp.py @@ -18,10 +18,19 @@ import argparse import ftplib import os +import re import subprocess import sys from pathlib import Path +# src=/href= on any tag, single or double quoted. Only used to find the built +# assets index.html depends on, so attribute order and tag name don't matter. +ASSET_REFERENCE = re.compile( + r"""\b(?:src|href)\s*=\s*(?P["'])(?P[^"']+)(?P=q)""", + re.IGNORECASE, +) +URI_SCHEME = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*:") + HOST = "ftp.connectio.com.au" PORT = 21 USER = "opencode_upload@connectio.com.au" @@ -213,6 +222,58 @@ def ensure_dist_committed(allow_dirty): ) +def missing_referenced_assets(index_html): + """Returns the root-relative paths index.html points at that aren't on disk. + + Only root-relative references are checked - external URLs, protocol-relative + hosts, data: URIs and in-page anchors are somebody else's problem. + """ + missing = [] + for match in ASSET_REFERENCE.finditer(index_html): + ref = match.group("path").strip() + # Anything carrying a scheme (http:, data:, mailto:), a protocol-relative + # host, or a bare in-page anchor is not a file this deploy ships. + if ref.startswith(("#", "//")) or URI_SCHEME.match(ref): + continue + rel = ref.split("?", 1)[0].split("#", 1)[0].lstrip("/") + if not rel: + continue + if not (DIST / rel).is_file(): + missing.append(rel) + return sorted(set(missing)) + + +def ensure_dist_self_consistent(): + """Refuse to deploy a dist/ whose index.html points at files that aren't built. + + The mirror prunes any remote file with no local counterpart, so an + index.html referencing a bundle that was never built is worse than a no-op: + it uploads the broken HTML *and* deletes the working bundle currently + serving production, leaving the site down until someone rebuilds. + + That state is reachable without any uncommitted change - 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. Deliberately not + skippable by --allow-dirty: that flag is for deploying a dist git cannot + reproduce, never for one that is internally broken. + """ + index_html = DIST / "index.html" + if not index_html.is_file(): + sys.exit(f"Refusing to deploy: {index_html} does not exist. Run `npm run build` first.") + + missing = missing_referenced_assets(index_html.read_text(encoding="utf-8")) + if missing: + listed = "\n".join(f" {rel}" for rel in missing) + sys.exit( + "Refusing to deploy: dist/index.html references files that are not " + "built:\n" + f"{listed}\n" + "Uploading this would also prune the bundle currently serving the " + "site, taking production down. Rebuild first:\n" + " npm run build" + ) + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( @@ -228,6 +289,7 @@ def main(): if not DIST.is_dir(): sys.exit(f"{DIST} does not exist. Run `npm run build` first.") + ensure_dist_self_consistent() ensure_dist_committed(args.allow_dirty) local_files, local_dirs = local_files_and_dirs(DIST) diff --git a/scripts/test-deploy-guard.py b/scripts/test-deploy-guard.py new file mode 100644 index 0000000..ab5bd15 --- /dev/null +++ b/scripts/test-deploy-guard.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Checks the deploy guard that keeps a half-built dist/ off production. + +The FTP mirror prunes any remote file with no local counterpart, so deploying a +dist/index.html that names a bundle nobody built uploads the broken HTML *and* +deletes the bundle currently serving the site. dist/index.html is tracked while +dist/assets/ is gitignored, so a fresh clone reaches that state just by +skipping `npm run build`. + +Run: python3 scripts/test-deploy-guard.py +""" +import importlib.util +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def load_deploy_module(): + spec = importlib.util.spec_from_file_location( + "deploy_ftp", REPO_ROOT / "scripts" / "deploy_ftp.py", + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def main(): + deploy = load_deploy_module() + built_html = (deploy.DIST / "index.html").read_text(encoding="utf-8") + + cases = [ + ( + "the committed dist references only files that exist", + built_html, + [], + ), + ( + "a bundle hash with no built file is caught", + built_html.replace("assets/index-", "assets/index-absent-"), + None, # asserted below - the real hash is not known here + ), + ( + "external, protocol-relative, data and anchor refs are ignored", + '' + '' + '' + '' + 'mail', + [], + ), + ( + "a query string does not hide a file that exists", + '', + [], + ), + ( + "a relative reference is checked too", + '', + ["assets/never-built.js"], + ), + ( + "single-quoted attributes are checked", + "", + ["assets/never-built-2.js"], + ), + ] + + # Resolve the real bundle name for the query-string case. + real_bundle = next( + (p.name for p in (deploy.DIST / "assets").glob("index-*.js")), None, + ) + if real_bundle is None: + sys.exit("No built bundle in dist/assets/. Run `npm run build` first.") + + failures = [] + for name, document, expected in cases: + document = document.replace("PLACEHOLDER", real_bundle) + actual = deploy.missing_referenced_assets(document) + + if expected is None: + ok = len(actual) == 1 and actual[0].startswith("assets/index-absent-") + else: + ok = actual == expected + + print(f"{'PASS' if ok else 'FAIL'} {name}") + if not ok: + failures.append(f"{name}: expected {expected}, got {actual}") + + if failures: + print("\n" + "\n".join(failures)) + sys.exit(1) + print(f"\n{len(cases)}/{len(cases)} passed") + + +if __name__ == "__main__": + main() From 67fcb515b0031ef25769a7dbf6067fe78abe81cf Mon Sep 17 00:00:00 2001 From: Stuart Date: Fri, 14 Aug 2026 10:17:50 +1000 Subject: [PATCH 2/2] fix: check CSS url() references, not just src=/href= The page carries its stylesheet inline, so the built font is reachable only through `src: url('/assets/Delight-VF-.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 --- .../__pycache__/deploy_ftp.cpython-313.pyc | Bin 15644 -> 16012 bytes .../test-deploy-guard.cpython-313.pyc | Bin 4091 -> 4584 bytes scripts/deploy_ftp.py | 24 ++++++++++++++---- scripts/test-deploy-guard.py | 19 ++++++++++++++ 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/scripts/__pycache__/deploy_ftp.cpython-313.pyc b/scripts/__pycache__/deploy_ftp.cpython-313.pyc index 23777c84f5a1d57c06e62397b6b1ea317bcc3640..5062db562670cd5cc1923801c5a52c9019de49f4 100644 GIT binary patch delta 1125 zcmZuxU2NM_6u#GSQaeqXCQX(!bm2A?iJh$rEsS+EX+d3RRTk97jWj9lGN*A9qtsom zoza#7g=(A7_*r|Y_|ZTWFP+2-$buK(2~9%cg=r6)BE%C&h^IgiNRyDb9z^hfAI|yw zo$q|dK3Dejjn`i2KIrOl1A1LK`{J{$W82+fcw;;9?)K#eogSIZ`-$&p@QCX;v?T!G zE~x`|OC0t}JnoU)*e7!_Ap6_(e1gnIBg1*&Z~1@Y|8Y1d2c^!TPC&-sL|i%)0eC=; zNukHj0HAOH$OAEO{2vnps!Wv;!`6H7VaLdbTyEgHEXYPun3zqkju&Hzg$dEd=M}Ry zUOXF1hzmz-BC{$|m27>@-gLkb>kaOA24<~Gom&jdTYUE_3(MB?{x2NxqBVMGhK1YK zR^(F%KekTx|H;A6tP>9vp(o4$unST^hbcg=#|3r{7FqH^Jl-=0b*8}dg6XdS&>aN_ zQAUDf*2C|(`&peWu;f}aKu{vYd)nI|=Mw?)gy0Uex1><>+&lmUHt!>Ifhf6_=pbKG z(MJhC?4?X^s-wMO-C1z9`zm(dTL2UTZR>)QE)(QKJHim`Bj0$Uf!s|1IspR32iltA zVRBB0dX*`9rg_zDVBJ7wjl3f~^3vqW1r-~pykgeS^nC6#QcM$T<%X#mNHFkXCRM}g zQbt6IUO|(Rgyvh$rsj7*F?(^k_RyWboif$s!Kr4EE9br{j ztSRNXN(FeOQLWt<`s#F^ijSe}8cn)VckhTHh)7tBM+Hq^tT!r}UUl0;2aOBWp(<7? znyQRPF0F4vnK%bH=(5fmuqVof#h7TsFYL0=jlrO0s6fQUaCpMI61pKR(!8a6?z2Y7v( zQ!U2fb$!Q&@9+b+_<_CTZGNcbpwth1 zhATvGe+yfGBpD|RTlZ3Kc-WrOClv!iXTORv5;bGAZ##(yXkCR@obh23i~4Z zg4SCQ2GD zZZZjouCIyHBi9>?=4vb#J-1;`9q%H>sFs+bI%1CUq#~*(+Y-DBB`hJV5)g_sz+u73 zMBK2#*<9|w!GDq16ZWVOG$2GAQG*l4aIcuy8LjLbKq$o{ln`Agv_%ln5HrO1s+4!Q znN`}kB^C#iO@4#HJ<7V_9D@%kC(Pp-99O^LiOE3O-ROI^mqjuIb}bJ8m3CPHy*xg{}; z+KPw+roBO21$(P)upk(1FA*{z5yiKc^Wr;h`0AX64-qLpLFAm6AC^QiHkJ<(DVxe? zjziGb8agCrPyQFBInq&yNb;bR6H`PI2ZyD>V^UfSiqd3WB62D#!m6(d3&8u`(C^=e z%~15$z%zf?&y|FXoR%h&!}-x{NjI9wuaH}7Azgy4R_iM8-5P|!M%we44>($~mvSWv>d1YE0DDCvom zN+k9>v!OGyLY8-}n7~^D#dsKgB`^JRby3 zJi2xF_VXiqp_fds-#L1nb#RrwVV-61ARfZQ0?c{w2p+{_Z4|~40pz?wK=a%Txb<$} zXq0|s!t8j)X|dK%zcEAbD1GHyhR3MaKMN=5IscW&Wd1S$3ozBG0c02e1PXl^(I@_u z!0~GkPlW)E(J%f5_C$eoaGVP4j4y!`YvXi)9im(8SvX1e*b$hZ@7OVzboYT&);9|~ zO%aemI^AIVK6vVOoS=$g7>XvNa#b^tqM;>>k)^AtG*S#yB9^>i+N<-1xvffz=Uq~! zOiI#D7gZE>JAZM`&`Wu@C@B!bkW53+q?)8>ik709)23NYrssC&g-#QDw8yFcNwN{q zkfq(Rs-`6V2OcFtz2*W>#*z;zOjPJI*}`qGYF!I zNF*l7cV|I#2`REx)g2SXu8C=~Q&v>9{qUG9RWhU1o3dWDG_id-)$XKJ zh%5<4O+L9*QBTFQOGdON$Fs{0ay7*UFQA9@&^-V2!XV&tO>Vu( z$M?BJi)8}A76*8t$*nf|U{0}&B9m1KG a<^c@%(0=WWyk+_xTxxj%6V(TuAMy)^sN#bF delta 276 zcmaE%{9B&yGcPX}0}!ys)@7ygY~mPG6V5vy~$TtmI~^#{Y)K7#^7v^azXv`j8q7{(4| zf~f%NmB%5jV9W|rrNEHRpsBc7nd33jhI?FiuE#G1eTV-Ch2G=h> Vn=kSIU}SS-WHkKXG5La^1OPo!G`j!* diff --git a/scripts/deploy_ftp.py b/scripts/deploy_ftp.py index 0eb2105..ed659fe 100755 --- a/scripts/deploy_ftp.py +++ b/scripts/deploy_ftp.py @@ -29,6 +29,13 @@ r"""\b(?:src|href)\s*=\s*(?P["'])(?P[^"']+)(?P=q)""", re.IGNORECASE, ) +# The page carries its stylesheet inline, so the built font arrives as a CSS +# `src: url(...)` - a colon, not an `=`, and therefore invisible to the +# attribute pattern above. Quotes are optional in CSS url(). +CSS_URL_REFERENCE = re.compile( + r"""\burl\(\s*(?P["']?)(?P[^"')]+)(?P=q)\s*\)""", + re.IGNORECASE, +) URI_SCHEME = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*:") HOST = "ftp.connectio.com.au" @@ -223,16 +230,23 @@ def ensure_dist_committed(allow_dirty): def missing_referenced_assets(index_html): - """Returns the root-relative paths index.html points at that aren't on disk. + """Returns the paths index.html points at that aren't on disk. - Only root-relative references are checked - external URLs, protocol-relative - hosts, data: URIs and in-page anchors are somebody else's problem. + Covers both HTML attributes (src=/href=) and CSS url() in the inline + stylesheet - the built font is only reachable through the latter. External + URLs, protocol-relative hosts, data: URIs and in-page anchors (including + url(#svg-gradient)) are somebody else's problem. """ missing = [] - for match in ASSET_REFERENCE.finditer(index_html): + matches = [ + *ASSET_REFERENCE.finditer(index_html), + *CSS_URL_REFERENCE.finditer(index_html), + ] + for match in matches: ref = match.group("path").strip() # Anything carrying a scheme (http:, data:, mailto:), a protocol-relative - # host, or a bare in-page anchor is not a file this deploy ships. + # host, or a bare in-page anchor is not a file this deploy ships. The + # anchor case also covers CSS's url(#gradient-id) SVG fragments. if ref.startswith(("#", "//")) or URI_SCHEME.match(ref): continue rel = ref.split("?", 1)[0].split("#", 1)[0].lstrip("/") diff --git a/scripts/test-deploy-guard.py b/scripts/test-deploy-guard.py index ab5bd15..495e3b8 100644 --- a/scripts/test-deploy-guard.py +++ b/scripts/test-deploy-guard.py @@ -64,6 +64,25 @@ def main(): "", ["assets/never-built-2.js"], ), + # The stylesheet is inline, so the built font is reachable only through + # CSS url() - a colon, not an `=`. Missing it would let the mirror prune + # the production font while the guard stayed silent. + ( + "a missing font in CSS url() is caught", + "", + ["assets/never-built.ttf"], + ), + ( + "an unquoted CSS url() is checked", + "", + ["assets/never-built-3.ttf"], + ), + ( + "SVG fragment and data: url() are ignored", + "", + [], + ), ] # Resolve the real bundle name for the query-string case.