The macOS runner uses Bash 3.2, which rejects parameter case conversion. Normalize the GHCR owner with portable tr and verify mixed-case input. The macOS runner can clear setgid from a directory when chmod applies 2770. Compare scratch permissions with the native chmod result while preserving all bits the host accepts. Tests: scripts/run_tests.sh tests/scripts/test_termux_build_driver.py tests/test_scratch_dir.py Shell: bash -n scripts/termux/build_builder_image.sh
223 lines
9.5 KiB
Python
223 lines
9.5 KiB
Python
"""Exercise the Termux sequencing boundary with real child executables."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
import pytest
|
|
from ruamel.yaml import YAML
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
DRIVER = ROOT / "scripts/termux/build.py"
|
|
pytestmark = pytest.mark.platforms("posix")
|
|
|
|
|
|
@pytest.fixture
|
|
def build_fixture(tmp_path):
|
|
repo = tmp_path / "source with spaces"
|
|
payload = tmp_path / "prepared payload"
|
|
out = tmp_path / "deb output"
|
|
tools = tmp_path / "tools"
|
|
tools.mkdir()
|
|
payload.mkdir()
|
|
for name in ("scripts/build/node-deps.mjs", "scripts/build/tui.mjs", "scripts/termux/build_deb.sh"):
|
|
script = repo / name
|
|
script.parent.mkdir(parents=True, exist_ok=True)
|
|
script.touch()
|
|
log = tmp_path / "calls.jsonl"
|
|
recorder = f"""#!{sys.executable}
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
args = sys.argv[1:]
|
|
assert Path(args[0]).is_file(), args
|
|
stage = Path(args[0]).name
|
|
with Path(os.environ['CALL_LOG']).open('a') as stream:
|
|
stream.write(json.dumps({{'tool': Path(sys.argv[0]).name, 'args': args,
|
|
'cwd': os.getcwd(), 'image': os.environ.get('TERMUX_BUILDER_IMAGE')}}) + '\\n')
|
|
if stage == os.environ.get('FAIL_STAGE'):
|
|
sys.exit(37)
|
|
if stage == 'tui.mjs':
|
|
product = Path(args[args.index('--out') + 1])
|
|
(product / 'dist').mkdir(parents=True, exist_ok=True)
|
|
(product / 'dist/entry.js').write_text('fixture TUI product')
|
|
(product / 'package.json').write_text('{{"type":"module"}}')
|
|
if stage == 'build_deb.sh':
|
|
product = Path(args[args.index('--tui-product') + 1])
|
|
assert (product / 'dist/entry.js').read_text() == 'fixture TUI product'
|
|
destination = Path(args[args.index('--out') + 1])
|
|
destination.mkdir(parents=True)
|
|
(destination / 'assembly-received-product').write_text(str(product))
|
|
"""
|
|
for name in ("node", "bash"):
|
|
tool = tools / name
|
|
tool.write_text(recorder, encoding="utf-8")
|
|
tool.chmod(0o755)
|
|
env = dict(os.environ, PATH=f"{tools}{os.pathsep}{os.environ['PATH']}",
|
|
CALL_LOG=str(log), TERMUX_BUILDER_IMAGE="fixture-pinned-image")
|
|
return repo, payload, out, log, env
|
|
|
|
|
|
def invoke(build_fixture, *identity, extra=()):
|
|
repo, payload, out, _, env = build_fixture
|
|
return subprocess.run(
|
|
[sys.executable, str(DRIVER), "--repo", str(repo), "--payload", str(payload),
|
|
"--out", str(out), *identity, *extra],
|
|
cwd=repo.parent, env=env, capture_output=True, text=True, timeout=30,
|
|
)
|
|
|
|
|
|
def recorded_calls(log):
|
|
return [json.loads(line) for line in log.read_text().splitlines()] if log.exists() else []
|
|
|
|
|
|
@pytest.mark.parametrize("identity", [("--tag", "v1.2.3"), ("--commit", "a" * 40)])
|
|
def test_driver_prepares_only_tui_then_passes_product_to_deb(build_fixture, identity):
|
|
repo, payload, out, log, _ = build_fixture
|
|
result = invoke(build_fixture, *identity)
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
product = repo / ".build/termux/tui"
|
|
assert recorded_calls(log) == [
|
|
{"tool": "node", "args": [str(repo / "scripts/build/node-deps.mjs"),
|
|
"--source", str(repo), "--workspace", "ui-tui"],
|
|
"cwd": str(repo), "image": "fixture-pinned-image"},
|
|
{"tool": "node", "args": [str(repo / "scripts/build/tui.mjs"),
|
|
"--source", str(repo), "--out", str(product)],
|
|
"cwd": str(repo), "image": "fixture-pinned-image"},
|
|
{"tool": "bash", "args": [str(repo / "scripts/termux/build_deb.sh"),
|
|
"--repo", str(repo), "--payload", str(payload), "--out", str(out),
|
|
"--tui-product", str(product), *identity],
|
|
"cwd": str(repo), "image": "fixture-pinned-image"},
|
|
]
|
|
assert (out / "assembly-received-product").read_text() == str(product)
|
|
|
|
|
|
@pytest.mark.parametrize("identity", [
|
|
(), ("--tag", "v1.2.3", "--commit", "a" * 40),
|
|
("--ta", "v1.2.3"), ("--commit", "short"),
|
|
("--commit", "A" * 40), ("--tag", ""), ("--tag", "latest"),
|
|
("--tag", "v1.2.3", "--skip-build"),
|
|
])
|
|
def test_invalid_cli_never_starts_a_build(build_fixture, identity):
|
|
_, payload, out, log, _ = build_fixture
|
|
result = invoke(build_fixture, *identity)
|
|
assert result.returncode == 2, result.stdout + result.stderr
|
|
assert "error:" in result.stderr
|
|
assert recorded_calls(log) == []
|
|
assert not (payload / ".work").exists()
|
|
assert not out.exists()
|
|
|
|
|
|
@pytest.mark.parametrize("missing", ["--repo", "--payload", "--out"])
|
|
def test_all_paths_are_explicit(build_fixture, missing):
|
|
repo, payload, out, log, env = build_fixture
|
|
paths = {"--repo": repo, "--payload": payload, "--out": out}
|
|
args = [value for option, path in paths.items() if option != missing
|
|
for value in (option, str(path))]
|
|
result = subprocess.run([sys.executable, str(DRIVER), *args, "--tag", "v1.2.3"],
|
|
cwd=repo.parent, env=env, capture_output=True, text=True, timeout=30)
|
|
assert result.returncode == 2
|
|
assert missing in result.stderr
|
|
assert recorded_calls(log) == []
|
|
|
|
|
|
def test_missing_prepared_payload_is_rejected_before_frontend_work(build_fixture):
|
|
_, payload, out, log, _ = build_fixture
|
|
payload.rmdir()
|
|
result = invoke(build_fixture, "--tag", "v1.2.3")
|
|
assert result.returncode == 2, result.stdout + result.stderr
|
|
assert "--payload" in result.stderr
|
|
assert recorded_calls(log) == []
|
|
assert not payload.exists()
|
|
assert not out.exists()
|
|
|
|
|
|
@pytest.mark.parametrize("stage, reached", [
|
|
("node-deps.mjs", ["node-deps.mjs"]),
|
|
("tui.mjs", ["node-deps.mjs", "tui.mjs"]),
|
|
("build_deb.sh", ["node-deps.mjs", "tui.mjs", "build_deb.sh"]),
|
|
])
|
|
def test_child_failure_stops_sequence_even_with_stale_product(build_fixture, stage, reached):
|
|
repo, payload, out, log, env = build_fixture
|
|
stale = repo / ".build/termux/tui/dist/entry.js"
|
|
stale.parent.mkdir(parents=True)
|
|
stale.write_text("stale product")
|
|
env["FAIL_STAGE"] = stage
|
|
result = invoke(build_fixture, "--tag", "v1.2.3")
|
|
assert result.returncode == 37, result.stdout + result.stderr
|
|
assert [Path(call["args"][0]).name for call in recorded_calls(log)] == reached
|
|
assert not out.exists()
|
|
|
|
|
|
@pytest.mark.parametrize("identity", [("--tag", "v1.2.3"), ("--commit", "a" * 40)])
|
|
def test_release_workflow_runs_the_shared_sequence(build_fixture, identity):
|
|
repo, payload, _, log, env = build_fixture
|
|
(repo / "scripts/termux/build.py").symlink_to(DRIVER)
|
|
(repo / "termux-build").mkdir()
|
|
(repo / "termux-build/payload").symlink_to(payload, target_is_directory=True)
|
|
tools = log.parent / "tools"
|
|
(tools / "python3").symlink_to(sys.executable)
|
|
env.update(HERMES_BUILD_COMMIT=identity[1] if identity[0] == "--commit" else "",
|
|
HERMES_PAYLOAD_TAG=identity[1] if identity[0] == "--tag" else "")
|
|
workflow = YAML(typ="safe").load((ROOT / ".github/workflows/desktop-bundled-release.yml").read_text())
|
|
step = next(step for step in workflow["jobs"]["termux-deb"]["steps"]
|
|
if step.get("name") == "Assemble the .deb")
|
|
bash = shutil.which("bash")
|
|
assert bash is not None
|
|
result = subprocess.run([bash, "-euo", "pipefail", "-c", step["run"]],
|
|
cwd=repo, env=env, capture_output=True, text=True, timeout=30)
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
calls = recorded_calls(log)
|
|
assert [Path(call["args"][0]).name for call in calls] == [
|
|
"node-deps.mjs", "tui.mjs", "build_deb.sh",
|
|
]
|
|
assert calls[-1]["args"][-2:] == list(identity)
|
|
assert (repo / "termux-build/deb/assembly-received-product").read_text() == str(
|
|
repo / ".build/termux/tui")
|
|
|
|
|
|
@pytest.mark.parametrize("changed_input", ["Dockerfile", "base"])
|
|
def test_builder_image_identity_covers_all_inputs(tmp_path, changed_input):
|
|
repo = tmp_path / "source with spaces"
|
|
scripts = repo / "scripts/termux"
|
|
scripts.mkdir(parents=True)
|
|
for name in ("build_builder_image.sh", "termux-builder.Dockerfile"):
|
|
shutil.copy2(ROOT / "scripts/termux" / name, scripts / name)
|
|
shutil.copytree(ROOT / "pm", repo / "pm", ignore=shutil.ignore_patterns("__pycache__"))
|
|
tools = tmp_path / "tools"
|
|
tools.mkdir()
|
|
(tools / "python3").symlink_to(sys.executable)
|
|
# Only inspect an already-published image; any build/push call is a failure.
|
|
command = 'docker() { [[ "$1 $2" == "manifest inspect" ]]; }; export -f docker; bash "$1"'
|
|
env = {**os.environ, "PATH": f"{tools}{os.pathsep}{os.environ['PATH']}",
|
|
"GITHUB_REPOSITORY_OWNER": "FiXtUrE"}
|
|
|
|
def image():
|
|
result = subprocess.run(["bash", "-c", command, "fixture", str(scripts / "build_builder_image.sh")],
|
|
env=env, capture_output=True, text=True, timeout=30)
|
|
assert result.returncode == 0, result.stderr
|
|
return result.stdout.splitlines()[-1]
|
|
|
|
original = image()
|
|
assert original.startswith("ghcr.io/fixture/hermes-termux-builder:")
|
|
assert image() == original
|
|
changed = scripts / "termux-builder.Dockerfile" if changed_input == "Dockerfile" else repo / "pm/lock.json"
|
|
before = changed.read_bytes()
|
|
if changed_input == "Dockerfile":
|
|
changed.write_bytes(before + b"\nRUN apt install -y git\n")
|
|
else:
|
|
pin = json.loads(before)
|
|
digest = pin["packages"]["termux-docker"]["version"]
|
|
pin["packages"]["termux-docker"]["version"] = digest[:-1] + ("a" if digest[-1] != "a" else "b")
|
|
changed.write_text(json.dumps(pin))
|
|
assert image() != original
|
|
changed.write_bytes(before)
|
|
assert image() == original
|