Skip to content

Commit e807b99

Browse files
committed
test: guard the deployment contract for entrypoint count and bundle contents
1 parent 84f6bb0 commit e807b99

3 files changed

Lines changed: 132 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,16 +54,26 @@ jobs:
5454
pip install -r requirements-dev.txt
5555
5656
- name: Lint
57-
run: ruff check app scripts tests
57+
run: ruff check app api scripts tests
5858

5959
- name: Format check
60-
run: ruff format --check app scripts tests
60+
run: ruff format --check app api scripts tests
6161

6262
- name: Verify the Vercel bundle stays importable without local extras
6363
# Guards the actual deployment contract: if anything in the import graph
6464
# starts requiring torch, Vercel breaks at runtime, not at build time.
6565
run: python -c "from app.main import create_app; create_app()"
6666

67+
- name: Verify the serverless entrypoint loads
68+
# Executed the way Vercel does it — from another directory, with the
69+
# repo root not already on sys.path.
70+
run: cd /tmp && python -c "
71+
import importlib.util;
72+
spec = importlib.util.spec_from_file_location('e', '$GITHUB_WORKSPACE/api/index.py');
73+
m = importlib.util.module_from_spec(spec);
74+
spec.loader.exec_module(m);
75+
print('entrypoint ok:', len(m.app.routes), 'routes')"
76+
6777
- name: Migrate
6878
run: alembic upgrade head
6979

requirements-dev.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,5 @@ pytest==8.3.4
1212
pytest-asyncio==0.25.0
1313
anyio==4.7.0
1414
ruff==0.16.0
15+
# Used by the deployment-contract test to evaluate .vercelignore patterns.
16+
pathspec==0.12.1

tests/test_deployment_contract.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""Guards on how the app is packaged for deployment.
2+
3+
Both of these encode bugs that reached production. Neither was visible to any
4+
other test, and both presented as an opaque `FUNCTION_INVOCATION_FAILED` with a
5+
build that reported success.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import ast
11+
import subprocess
12+
import sys
13+
from pathlib import Path
14+
15+
import pytest
16+
17+
ROOT = Path(__file__).resolve().parents[1]
18+
19+
20+
def module_level_asgi_apps() -> list[str]:
21+
"""Files assigning a module-level `app`, which is what Vercel's FastAPI
22+
preset scans for when choosing an entrypoint."""
23+
found: list[str] = []
24+
for path in list(ROOT.glob("app/**/*.py")) + list(ROOT.glob("api/**/*.py")):
25+
tree = ast.parse(path.read_text())
26+
for node in tree.body: # module level only
27+
targets = (
28+
node.targets
29+
if isinstance(node, ast.Assign)
30+
else [node.target]
31+
if isinstance(node, ast.AnnAssign)
32+
else []
33+
)
34+
if any(isinstance(t, ast.Name) and t.id == "app" for t in targets):
35+
found.append(str(path.relative_to(ROOT)))
36+
return sorted(found)
37+
38+
39+
def test_there_is_exactly_one_asgi_entrypoint():
40+
"""Two module-level `app` instances meant Vercel's preset built
41+
`app/main.py` as the function while dependencies were installed for the
42+
entrypoint declared in vercel.json. The deployed function had no fastapi and
43+
died at cold start.
44+
45+
Anything needing an instance should use the factory:
46+
`uvicorn app.main:create_app --factory`.
47+
"""
48+
apps = module_level_asgi_apps()
49+
assert apps == ["api/index.py"], (
50+
f"expected exactly one ASGI entrypoint at api/index.py, found {apps}. "
51+
"A second one makes the deployed entrypoint ambiguous."
52+
)
53+
54+
55+
def test_vercelignore_patterns_are_anchored():
56+
""".vercelignore uses gitignore semantics, so an unanchored directory name
57+
matches at every depth. `evals/` once stripped `app/evals/` — a package the
58+
request path imports — out of the bundle.
59+
"""
60+
unanchored = [
61+
line
62+
for raw in (ROOT / ".vercelignore").read_text().splitlines()
63+
if (line := raw.strip()) and not line.startswith(("#", "/", "!"))
64+
]
65+
assert not unanchored, (
66+
f"unanchored .vercelignore patterns match at every depth: {unanchored}. "
67+
"Prefix each with '/' so it only matches at the repo root."
68+
)
69+
70+
71+
def test_nothing_the_runtime_imports_is_excluded_from_the_bundle():
72+
pathspec = pytest.importorskip("pathspec", reason="pathspec not installed")
73+
74+
lines = [
75+
line
76+
for raw in (ROOT / ".vercelignore").read_text().splitlines()
77+
if (line := raw.strip()) and not line.startswith("#")
78+
]
79+
spec = pathspec.PathSpec.from_lines("gitwildmatch", lines)
80+
81+
tracked = subprocess.run(
82+
["git", "ls-files"], cwd=ROOT, capture_output=True, text=True, check=True
83+
).stdout.split()
84+
85+
# Everything under app/ and api/ is runtime code, plus the two non-Python
86+
# assets that are easy to lose because nothing imports them by path.
87+
required = [
88+
f
89+
for f in tracked
90+
if f.startswith(("app/", "api/"))
91+
or f in ("requirements.txt", "vercel.json", ".python-version")
92+
]
93+
excluded = [f for f in required if spec.match_file(f)]
94+
95+
assert not excluded, f"runtime files excluded from the Vercel bundle: {excluded}"
96+
97+
98+
@pytest.mark.skipif(sys.platform == "win32", reason="posix path assumptions")
99+
def test_the_entrypoint_loads_without_the_repo_root_on_syspath():
100+
"""Vercel executes api/index.py from another directory. If the sys.path
101+
bootstrap in that file regresses, `app` becomes unimportable in production
102+
while every local run still works."""
103+
result = subprocess.run(
104+
[
105+
sys.executable,
106+
"-c",
107+
"import importlib.util,sys;"
108+
f"spec=importlib.util.spec_from_file_location('e', r'{ROOT / 'api' / 'index.py'}');"
109+
"m=importlib.util.module_from_spec(spec);"
110+
"spec.loader.exec_module(m);"
111+
"print(len(m.app.routes))",
112+
],
113+
cwd="/tmp",
114+
capture_output=True,
115+
text=True,
116+
)
117+
assert result.returncode == 0, result.stderr
118+
assert int(result.stdout.strip()) > 0

0 commit comments

Comments
 (0)