Skip to content

Commit a08791c

Browse files
committed
ci: add patch-set integrity linter, SDK unit tests, and CI pipeline
Adds the first build-free safety net for the repo: - tools/check_patches.py: enforces series-sync, numbering, single-surface, well-formed diffs, the uxr- switch prefix, and no brand string literals. - sdk/python/tests/test_sdk.py: 21 tests for platform resolution, persona flag mapping, and SHA256SUMS verification (no network / no browser). - .github/workflows/ci.yml: runs the linter, pytest on 3.9/3.12, shellcheck --severity=error, and node --check on every push and PR.
1 parent bbca8d1 commit a08791c

3 files changed

Lines changed: 392 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
name: ci
2+
3+
# Fast, build-free gates that run on every push and PR. None of these compile Chromium
4+
# (that is a multi-hour job) — they guard the patch set's invariants and the SDK logic,
5+
# which is where cheap mistakes actually happen.
6+
on:
7+
push:
8+
branches: [main]
9+
pull_request:
10+
11+
permissions:
12+
contents: read
13+
14+
concurrency:
15+
group: ci-${{ github.ref }}
16+
cancel-in-progress: true
17+
18+
jobs:
19+
patch-integrity:
20+
name: patch-set integrity
21+
runs-on: ubuntu-latest
22+
steps:
23+
- uses: actions/checkout@v4
24+
- uses: actions/setup-python@v5
25+
with:
26+
python-version: "3.12"
27+
- name: Lint the patch series
28+
run: python tools/check_patches.py
29+
30+
sdk-python:
31+
name: python sdk tests
32+
runs-on: ubuntu-latest
33+
strategy:
34+
fail-fast: false
35+
matrix:
36+
python-version: ["3.9", "3.12"]
37+
steps:
38+
- uses: actions/checkout@v4
39+
- uses: actions/setup-python@v5
40+
with:
41+
python-version: ${{ matrix.python-version }}
42+
- name: Install pytest
43+
run: python -m pip install --upgrade pip pytest
44+
- name: Import smoke test
45+
run: python -c "import sys; sys.path.insert(0, 'sdk/python'); import tilion_fortress; print(tilion_fortress.__version__)"
46+
- name: Run unit tests
47+
run: python -m pytest sdk/python/tests -q
48+
49+
shell:
50+
name: shellcheck
51+
runs-on: ubuntu-latest
52+
steps:
53+
- uses: actions/checkout@v4
54+
- name: Shellcheck build + packaging scripts
55+
# --severity=error: fail only on real bugs, not pre-existing style nits.
56+
run: |
57+
shopt -s globstar nullglob
58+
shellcheck --severity=error build/**/*.sh packaging/**/*.sh
59+
60+
node-sdk:
61+
name: node sdk syntax
62+
runs-on: ubuntu-latest
63+
steps:
64+
- uses: actions/checkout@v4
65+
- uses: actions/setup-node@v4
66+
with:
67+
node-version: "20"
68+
- name: Syntax-check the CLI + library
69+
run: |
70+
node --check sdk/node/index.js
71+
node --check sdk/node/cli.js

sdk/python/tests/test_sdk.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""
2+
Unit tests for the tilion_fortress SDK.
3+
4+
These cover the pure, release-critical logic that decides *which* bundle a user gets and
5+
whether it is trusted — the platform resolver, the persona->flag mapping, and the
6+
SHA256SUMS parser — with no network and no browser launch. A regression here silently
7+
ships the wrong binary or skips checksum verification, so it is worth gating in CI.
8+
9+
Run: pytest sdk/python/tests -q
10+
"""
11+
from __future__ import annotations
12+
import sys
13+
from pathlib import Path
14+
15+
import pytest
16+
17+
# Make `import tilion_fortress` work when tests run from the repo root or from sdk/python.
18+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
19+
import tilion_fortress as tf # noqa: E402
20+
21+
22+
# --------------------------------------------------------------------------- platform
23+
@pytest.mark.parametrize("sysname,machine,expected", [
24+
("Linux", "x86_64", "linux-x64"),
25+
("Linux", "amd64", "linux-x64"),
26+
("Windows", "AMD64", "win-x64"),
27+
("Windows", "x86_64", "win-x64"),
28+
("Darwin", "arm64", "mac-arm64"),
29+
("Darwin", "aarch64","mac-arm64"),
30+
("Darwin", "x86_64", "mac-x64"),
31+
("Linux", "aarch64", None), # no arm64 Linux bundle yet -> unsupported
32+
("Linux", "armv7l", None),
33+
("FreeBSD", "amd64", None),
34+
])
35+
def test_resolve_platform(monkeypatch, sysname, machine, expected):
36+
monkeypatch.setattr(tf.platform, "system", lambda: sysname)
37+
monkeypatch.setattr(tf.platform, "machine", lambda: machine)
38+
assert tf.resolve_platform() == expected
39+
40+
41+
def test_every_resolvable_platform_has_an_asset():
42+
# Any key resolve_platform() can return must exist in the _ASSETS table, and every
43+
# launcher path must live under the bundle dir so extraction lands where _download expects.
44+
resolvable = {"linux-x64", "win-x64", "mac-arm64", "mac-x64"}
45+
assert resolvable <= set(tf._ASSETS)
46+
for plat, (asset, kind, launcher) in tf._ASSETS.items():
47+
assert asset.startswith("tilion-fortress-") and plat in asset
48+
assert kind in ("tar", "zip")
49+
assert launcher.startswith("tilion-fortress/")
50+
51+
52+
# --------------------------------------------------------------------------- persona
53+
def test_persona_args_empty():
54+
assert tf._persona_args(None) == []
55+
assert tf._persona_args({}) == []
56+
57+
58+
def test_persona_args_known_keys_map_to_uxr_flags():
59+
args = tf._persona_args({"timezone": "America/New_York", "hw_concurrency": 16})
60+
assert "--uxr-timezone=America/New_York" in args
61+
assert "--uxr-hw-concurrency=16" in args
62+
63+
64+
def test_persona_args_unknown_key_falls_back_to_uxr_prefix():
65+
# Unknown keys still become --uxr-<key-with-dashes>, never a bare or branded flag.
66+
args = tf._persona_args({"some_new_surface": "v"})
67+
assert args == ["--uxr-some-new-surface=v"]
68+
69+
70+
def test_persona_args_are_all_uxr_prefixed():
71+
persona = {"platform": "Win32", "timezone": "UTC", "webgl_renderer": "ANGLE",
72+
"device_memory": 8, "screen_width": 1920, "canvas_seed": 42, "weird_key": "x"}
73+
for a in tf._persona_args(persona):
74+
assert a.startswith("--uxr-"), a
75+
76+
77+
# --------------------------------------------------------------------------- checksums
78+
def test_sha256_matches_hashlib(tmp_path):
79+
import hashlib
80+
f = tmp_path / "blob.bin"
81+
data = b"fortress" * 4096
82+
f.write_bytes(data)
83+
assert tf._sha256(f) == hashlib.sha256(data).hexdigest()
84+
85+
86+
class _FakeResp:
87+
def __init__(self, body: bytes):
88+
self._body = body
89+
def read(self) -> bytes:
90+
return self._body
91+
def __enter__(self):
92+
return self
93+
def __exit__(self, *exc):
94+
return False
95+
96+
97+
def test_expected_sha_parses_matching_asset(monkeypatch):
98+
asset = tf._ASSETS["linux-x64"][0]
99+
body = (
100+
f"aa11bb22 {asset}\n"
101+
f"deadbeef tilion-fortress-win-x64.zip\n"
102+
).encode()
103+
monkeypatch.setattr(tf.urllib.request, "urlopen", lambda *a, **k: _FakeResp(body))
104+
assert tf._expected_sha(asset) == "aa11bb22"
105+
106+
107+
def test_expected_sha_handles_starred_binary_marker(monkeypatch):
108+
# `sha256sum` writes "<hash> *<file>" in binary mode; the parser strips the leading '*'.
109+
asset = tf._ASSETS["linux-x64"][0]
110+
body = f"cafef00d *{asset}\n".encode()
111+
monkeypatch.setattr(tf.urllib.request, "urlopen", lambda *a, **k: _FakeResp(body))
112+
assert tf._expected_sha(asset) == "cafef00d"
113+
114+
115+
def test_expected_sha_returns_none_when_absent(monkeypatch):
116+
body = b"aa11bb22 some-other-asset.tar.gz\n"
117+
monkeypatch.setattr(tf.urllib.request, "urlopen", lambda *a, **k: _FakeResp(body))
118+
assert tf._expected_sha(tf._ASSETS["linux-x64"][0]) is None
119+
120+
121+
def test_expected_sha_swallows_network_error(monkeypatch):
122+
def boom(*a, **k):
123+
raise OSError("network down")
124+
monkeypatch.setattr(tf.urllib.request, "urlopen", boom)
125+
# Must degrade to None (caller then warns + skips), never raise.
126+
assert tf._expected_sha("anything") is None
127+
128+
129+
# --------------------------------------------------------------------------- release wiring
130+
def test_version_and_tag_are_coherent():
131+
import re
132+
assert re.fullmatch(r"\d+\.\d+\.\d+.*", tf.__version__)
133+
assert re.fullmatch(r"v\d+\.\d+\.\d+\.\d+", tf._TAG)
134+
assert tf._REPO == "tiliondev/fortress"
135+
assert tf._DOCKER_IMAGE.startswith("tilion/fortress")

tools/check_patches.py

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
#!/usr/bin/env python3
2+
"""
3+
check_patches.py - integrity linter for the Fortress patch set.
4+
5+
Fortress is a set of source patches applied to a pinned Chromium checkout
6+
(see build/apply-patches.sh + patches/series). Several invariants are load-bearing
7+
but were only ever enforced by human review. This linter enforces them mechanically
8+
so CI can gate every PR:
9+
10+
1. series-sync - patches/series lists exactly the patches/*.patch files, once each,
11+
in ascending numeric order. A patch that is not in series is silently
12+
skipped by apply-patches.sh; an entry with no file breaks the build.
13+
2. numbering - files are NNNN-*.patch, contiguous from 0001, no gaps, no duplicates.
14+
3. single-surface - each patch touches exactly ONE file (one `diff --git`). The project
15+
rule is one patch per file so rebases stay legible.
16+
4. well-formed - each patch has a `diff --git` header, ---/+++ file headers, and >=1 hunk.
17+
5. uxr-only - any command-line switch a patch introduces uses the de-branded `uxr-`
18+
prefix. A `--fortress-*` / `--tilion-*` switch would bake a brand token
19+
into the binary and is forbidden.
20+
6. no-brand - no added line introduces a quoted string literal containing a brand
21+
token (tilion/tillion/fortress/phoron/swarm). Such a literal ships in
22+
the binary's string table and is fingerprintable. (Comments are fine.)
23+
24+
Exit code 0 if every check passes, 1 otherwise. Pure standard library; no build tree needed.
25+
26+
python tools/check_patches.py # from the repo root
27+
python tools/check_patches.py --verbose
28+
"""
29+
from __future__ import annotations
30+
import argparse
31+
import re
32+
import sys
33+
from pathlib import Path
34+
35+
REPO = Path(__file__).resolve().parent.parent
36+
PATCHES = REPO / "patches"
37+
SERIES = PATCHES / "series"
38+
39+
# Brand tokens that must never appear as a compiled-in string literal.
40+
BRAND_RE = re.compile(r'"[^"\n]*(tilion|tillion|fortress|phoron|swarm)[^"\n]*"', re.IGNORECASE)
41+
# Command-line switch lookups a patch may add.
42+
SWITCH_RE = re.compile(r'(?:HasSwitch|GetSwitchValueASCII|GetSwitchValueNative)\(\s*"([^"]+)"')
43+
PATCH_NAME_RE = re.compile(r"^(\d{4})-.*\.patch$")
44+
45+
46+
class Report:
47+
def __init__(self) -> None:
48+
self.failures: list[str] = []
49+
50+
def check(self, name: str, ok: bool, detail: str = "") -> None:
51+
mark = "PASS" if ok else "FAIL"
52+
line = f" [{mark}] {name}"
53+
if detail:
54+
line += f" - {detail}"
55+
print(line)
56+
if not ok:
57+
self.failures.append(name)
58+
59+
60+
def _patch_files() -> list[Path]:
61+
return sorted(p for p in PATCHES.glob("*.patch"))
62+
63+
64+
def _strip_comment(added: str) -> str:
65+
"""Drop a trailing // line comment so comments never trip the string checks."""
66+
i = added.find("//")
67+
return added[:i] if i != -1 else added
68+
69+
70+
def check_series_sync(rep: Report, verbose: bool) -> None:
71+
if not SERIES.exists():
72+
rep.check("series-sync", False, "patches/series is missing")
73+
return
74+
listed = [ln.strip() for ln in SERIES.read_text().splitlines()
75+
if ln.strip() and not ln.strip().startswith("#")]
76+
listed_names = [Path(x).name for x in listed]
77+
actual = [p.name for p in _patch_files()]
78+
79+
dupes = sorted({n for n in listed_names if listed_names.count(n) > 1})
80+
missing_file = [n for n in listed_names if n not in actual] # in series, no file
81+
missing_entry = [n for n in actual if n not in listed_names] # file, not in series
82+
ordered = listed_names == sorted(listed_names)
83+
84+
ok = not dupes and not missing_file and not missing_entry and ordered
85+
detail = f"{len(listed_names)} entries / {len(actual)} files"
86+
if dupes:
87+
detail = f"duplicate series entries: {dupes}"
88+
elif missing_file:
89+
detail = f"series lists patches with no file: {missing_file}"
90+
elif missing_entry:
91+
detail = f"patch files not listed in series (would be skipped by apply-patches.sh): {missing_entry}"
92+
elif not ordered:
93+
detail = "series is not in ascending order"
94+
rep.check("series-sync", ok, detail)
95+
96+
97+
def check_numbering(rep: Report, verbose: bool) -> None:
98+
nums: list[int] = []
99+
bad_names: list[str] = []
100+
for p in _patch_files():
101+
m = PATCH_NAME_RE.match(p.name)
102+
if not m:
103+
bad_names.append(p.name)
104+
else:
105+
nums.append(int(m.group(1)))
106+
dupes = sorted({n for n in nums if nums.count(n) > 1})
107+
expected = list(range(1, len(nums) + 1)) if nums else []
108+
gaps = sorted(set(expected) - set(nums))
109+
ok = not bad_names and not dupes and not gaps
110+
if bad_names:
111+
detail = f"non-conforming names: {bad_names}"
112+
elif dupes:
113+
detail = f"duplicate numbers: {[f'{n:04d}' for n in dupes]}"
114+
elif gaps:
115+
detail = f"missing numbers: {[f'{n:04d}' for n in gaps]}"
116+
else:
117+
detail = f"0001..{max(nums):04d} contiguous" if nums else "no patches"
118+
rep.check("numbering", ok, detail)
119+
120+
121+
def check_bodies(rep: Report, verbose: bool) -> None:
122+
"""single-surface + well-formed + uxr-only + no-brand, per patch."""
123+
multi_file: list[str] = []
124+
malformed: list[str] = []
125+
bad_switch: list[str] = []
126+
brand_hits: list[str] = []
127+
128+
for p in _patch_files():
129+
text = p.read_text(encoding="utf-8", errors="replace")
130+
lines = text.splitlines()
131+
diff_headers = [ln for ln in lines if ln.startswith("diff --git ")]
132+
has_minus = any(ln.startswith("--- ") for ln in lines)
133+
has_plus = any(ln.startswith("+++ ") for ln in lines)
134+
has_hunk = any(ln.startswith("@@") for ln in lines)
135+
136+
if len(diff_headers) != 1:
137+
multi_file.append(f"{p.name} ({len(diff_headers)} files)")
138+
if not (diff_headers and has_minus and has_plus and has_hunk):
139+
malformed.append(p.name)
140+
141+
for ln in lines:
142+
if not ln.startswith("+") or ln.startswith("+++"):
143+
continue
144+
added = _strip_comment(ln[1:])
145+
for sw in SWITCH_RE.findall(added):
146+
if not sw.startswith("uxr-"):
147+
bad_switch.append(f"{p.name}: --{sw}")
148+
if BRAND_RE.search(added):
149+
brand_hits.append(f"{p.name}: {ln.strip()[:70]}")
150+
151+
rep.check("single-surface", not multi_file,
152+
f"all patches touch one file" if not multi_file else f"multi-file: {multi_file}")
153+
rep.check("well-formed", not malformed,
154+
"all patches parse" if not malformed else f"malformed: {malformed}")
155+
rep.check("uxr-only-switches", not bad_switch,
156+
"all switches use the uxr- prefix" if not bad_switch else f"non-uxr: {bad_switch}")
157+
rep.check("no-brand-literals", not brand_hits,
158+
"no brand string literals in added code" if not brand_hits
159+
else f"brand literal would ship in binary: {brand_hits}")
160+
161+
162+
def main() -> int:
163+
ap = argparse.ArgumentParser(description="Integrity linter for the Fortress patch set.")
164+
ap.add_argument("-v", "--verbose", action="store_true")
165+
args = ap.parse_args()
166+
167+
if not PATCHES.is_dir():
168+
print(f"error: {PATCHES} not found (run from the repo root)", file=sys.stderr)
169+
return 1
170+
171+
print(f"Fortress patch-set linter - {len(_patch_files())} patches in {PATCHES.relative_to(REPO)}/")
172+
rep = Report()
173+
check_series_sync(rep, args.verbose)
174+
check_numbering(rep, args.verbose)
175+
check_bodies(rep, args.verbose)
176+
177+
print("-" * 60)
178+
if rep.failures:
179+
print(f"FAILED: {len(rep.failures)} check(s): {', '.join(rep.failures)}")
180+
return 1
181+
print("OK: all patch-set checks passed")
182+
return 0
183+
184+
185+
if __name__ == "__main__":
186+
raise SystemExit(main())

0 commit comments

Comments
 (0)