Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Binary file added scripts/__pycache__/deploy_ftp.cpython-313.pyc
Binary file not shown.
Binary file not shown.
76 changes: 76 additions & 0 deletions scripts/deploy_ftp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<q>["'])(?P<path>[^"']+)(?P=q)""",
re.IGNORECASE,
)
Comment on lines +28 to +31

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 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<q>["']?)(?P<path>[^"')]+)(?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"
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down
116 changes: 116 additions & 0 deletions scripts/test-deploy-guard.py
Original file line number Diff line number Diff line change
@@ -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",
'<a href="#top"></a>'
'<script src="https://cdn.tailwindcss.com"></script>'
'<link href="//fonts.gstatic.com/x.css">'
'<img src="data:image/png;base64,AA">'
'<a href="mailto:someone@example.com">mail</a>',
[],
),
(
"a query string does not hide a file that exists",
'<script src="/assets/PLACEHOLDER?v=2"></script>',
[],
),
(
"a relative reference is checked too",
'<script src="assets/never-built.js"></script>',
["assets/never-built.js"],
),
(
"single-quoted attributes are checked",
"<script src='/assets/never-built-2.js'></script>",
["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",
"<style>@font-face{font-family:D;src:url('/assets/never-built.ttf');}</style>",
["assets/never-built.ttf"],
),
(
"an unquoted CSS url() is checked",
"<style>@font-face{src:url(/assets/never-built-3.ttf);}</style>",
["assets/never-built-3.ttf"],
),
(
"SVG fragment and data: url() are ignored",
"<style>.a{fill:url(#gem-grad)}"
".b{background:url(\"data:image/svg+xml,%3Csvg%3E%3C/svg%3E\")}</style>",
[],
),
]

# 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()
Loading