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 0000000..5062db5 Binary files /dev/null and b/scripts/__pycache__/deploy_ftp.cpython-313.pyc differ diff --git a/scripts/__pycache__/test-deploy-guard.cpython-313.pyc b/scripts/__pycache__/test-deploy-guard.cpython-313.pyc new file mode 100644 index 0000000..9584bcf Binary files /dev/null and b/scripts/__pycache__/test-deploy-guard.cpython-313.pyc differ diff --git a/scripts/deploy_ftp.py b/scripts/deploy_ftp.py index ff9dc18..ed659fe 100755 --- a/scripts/deploy_ftp.py +++ b/scripts/deploy_ftp.py @@ -18,10 +18,26 @@ 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, +) +# 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" PORT = 21 USER = "opencode_upload@connectio.com.au" @@ -213,6 +229,65 @@ def ensure_dist_committed(allow_dirty): ) +def missing_referenced_assets(index_html): + """Returns the paths index.html points at that aren't on disk. + + 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 = [] + 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. 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("/") + 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 +303,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..495e3b8 --- /dev/null +++ b/scripts/test-deploy-guard.py @@ -0,0 +1,116 @@ +#!/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"], + ), + # 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. + 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()