Files
craft-common-actions/tests/test_freebsd.py
T
Brandon ThomasandClaude Opus 5.5 39962f26c3 Release: drop FreeBSD aarch64 until it can be built in time (#40)
The emulated aarch64 VM takes over 5 hours for most apps, so the job is
cancelled at the limit. GitHub's ARM64 runners have no KVM, so they do not
help. Releases ship FreeBSD x86_64 only until #39 is fixed.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:41:36 -04:00

283 lines
14 KiB
Python

"""Exercise the VM's actual run template with failing test/package commands.
Linux /bin/sh exercises the POSIX failure paths without building an app or booting
a VM. Native FreeBSD and Windows acceptance still belongs to the caller PR runs.
Requires PyYAML (python3-yaml on the hosted Ubuntu runner).
"""
import os
import json
from types import SimpleNamespace
from pathlib import Path
import re
import subprocess
import tempfile
import unittest
import yaml
ROOT = Path(__file__).resolve().parents[1]
WORKFLOW = Path(os.environ.get("FREEBSD_WORKFLOW", ROOT / ".github/workflows/freebsd.yml"))
DATA = yaml.load(WORKFLOW.read_text(), Loader=yaml.BaseLoader)
STEPS = DATA["jobs"]["freebsd"]["steps"]
BUILD_TEMPLATE = next(step["run"] for step in STEPS if step.get("name") == "Build and test")
PACKAGE_TEMPLATE = next(step["run"] for step in STEPS if step.get("name") == "Build and validate the FreeBSD package")
class FreeBSDRunTests(unittest.TestCase):
def run_vm(self, *, command="", fail_test=0, package="valid", package_check=True,
skip_tests=False, fonts=False, require_fonts=False, fail_build=False, arch="x86_64", restore_failed=False, checkpoint_failed=False):
inputs = {key: value.get("default", "") for key, value in DATA["on"]["workflow_call"]["inputs"].items()}
inputs.update({
"custom-test-command": command,
"skip-tests": str(skip_tests).lower(),
"run-package-check": str(package_check).lower(),
"require-craft-fonts": str(require_fonts).lower(),
})
def replace(match):
key = match.group(1).strip()
if key == "custom-test-command != ''":
return str(bool(command)).lower()
return inputs[key]
def render(template):
script = re.sub(r"\$\{\{ inputs\.(.*?) \}\}", replace, template)
script = script.replace("${{ matrix.arch }}", arch)
script = script.replace("${{ steps.cargo-cache.outcome }}", "failure" if restore_failed else "success")
script = script.replace("${{ steps.package-cache.outcome }}", "failure" if checkpoint_failed else "success")
# Redirect destructive cleanup to the fixture; never touch real guest paths.
script = script.replace("/var/tmp/", str(work / "guest") + "/")
return script.replace('. "$HOME/.cargo/env"', ': # isolated cargo/rustc shims')
with tempfile.TemporaryDirectory(prefix="shared-freebsd-test-") as directory:
work = Path(directory)
tools = work / "tools"
tools.mkdir()
if fonts:
(work / "craft-fonts").mkdir()
cargo = tools / "cargo"
cargo.write_text("""#!/bin/sh
set -eu
printf '%s|%s|%s|%s\\n' "$1" "${CRAFT_FONTS_DIR-}" "${CRAFT_FONTS_REQUIRED-}" "$*" >> "$PWD/cargo.log"
if [ "$1" = build ]; then
[ "$FAIL_BUILD" = false ] || exit 31
else
count=0
if [ -f test-count ]; then count=$(cat test-count); fi
count=$((count + 1))
echo "$count" > test-count
[ "$count" -ne "$FAIL_TEST" ] || exit 42
fi
""")
cargo.chmod(0o755)
for tool in ("git", "rustc"):
path = tools / tool
path.write_text("#!/bin/sh\nexit 0\n")
path.chmod(0o755)
if package != "missing-script":
path = work / "packaging/freebsd/package.sh"
path.parent.mkdir(parents=True)
path.write_text("""#!/bin/bash
set -euo pipefail
touch package-ran
printf 'package|%s|%s|%s\\n' "${CRAFT_FONTS_DIR-}" "${CRAFT_FONTS_REQUIRED-}" package >> "$PWD/package.log"
case "$PACKAGE_MODE" in
failed) exit 43 ;;
absent) exit 0 ;;
esac
mkdir -p dist/release stage/bin
echo fixture > stage/bin/app
target_arch="${TARGET_ARCH:-x86_64}"
tar -czf "dist/release/app-freebsd-$target_arch.tar.gz" stage
if [ "$PACKAGE_MODE" = corrupt ]; then
echo corrupt > "dist/release/app-freebsd-$target_arch.tar.gz"
elif [ "$PACKAGE_MODE" = second-corrupt ]; then
echo corrupt > "dist/release/other-freebsd-$target_arch.tar.gz"
elif [ "$PACKAGE_MODE" = multiple ]; then
cp "dist/release/app-freebsd-$target_arch.tar.gz" "dist/release/other-freebsd-$target_arch.tar.gz"
fi
""")
env = os.environ.copy()
for key in ("CRAFT_FONTS_DIR", "CRAFT_FONTS_REQUIRED"):
env.pop(key, None)
env.update(PATH=str(tools) + os.pathsep + env["PATH"], FAIL_TEST=str(fail_test),
PACKAGE_MODE=package, FAIL_BUILD=str(fail_build).lower(),
TARGET_ARCH=arch)
for profile in ("debug", "release"):
path = work / "guest/craft-target" / profile
path.mkdir(parents=True)
(path / "partial").write_text("incomplete cache transfer")
result = subprocess.run(["/bin/sh", "-c", render(BUILD_TEMPLATE)], cwd=work, env=env,
text=True, capture_output=True, timeout=15)
if result.returncode == 0 and package_check:
# Separate processes share files, never shell exports.
result = subprocess.run(["/bin/sh", "-c", render(PACKAGE_TEMPLATE)], cwd=work, env=env.copy(),
text=True, capture_output=True, timeout=15)
log = (work / "cargo.log").read_text().splitlines() if (work / "cargo.log").exists() else []
package_log = (work / "package.log").read_text().splitlines() if (work / "package.log").exists() else []
remaining = [p.parent.name for p in (work / "guest/craft-target").glob("*/partial")]
outcome = dict(package_log=package_log, remaining=remaining, code=result.returncode, packaged=(work / "package-ran").exists(),
log=log, output=result.stdout + result.stderr)
return outcome
def test_each_failure_in_custom_chain_is_fatal_before_packaging(self):
command = " && ".join("cargo test --group " + str(n) for n in range(1, 5))
for group in range(1, 5):
with self.subTest(group=group):
result = self.run_vm(command=command, fail_test=group)
self.assertEqual(result["code"], 42, result)
self.assertFalse(result["packaged"], result)
self.assertEqual(len(result["log"]), 1 + group, result)
def test_each_failure_in_multiline_commands_stops_later_commands(self):
command = "\n".join("cargo test --group " + str(n) for n in range(1, 5))
for group in range(1, 5):
with self.subTest(group=group):
result = self.run_vm(command=command, fail_test=group)
self.assertEqual(result["code"], 42, result)
self.assertFalse(result["packaged"], result)
self.assertEqual(len(result["log"]), 1 + group, result)
def test_default_tests_must_pass_before_packaging(self):
result = self.run_vm(fail_test=1)
self.assertEqual(result["code"], 42, result)
self.assertFalse(result["packaged"], result)
def test_build_failure_is_fatal_before_tests(self):
result = self.run_vm(fail_build=True)
self.assertEqual(result["code"], 31, result)
self.assertEqual(len(result["log"]), 1, result)
self.assertFalse(result["packaged"], result)
def test_successful_custom_tests_reach_packaging(self):
result = self.run_vm(command="cargo test --lib\ncargo test --doc")
self.assertEqual(result["code"], 0, result)
self.assertEqual(len(result["log"]), 3, result)
self.assertTrue(result["packaged"], result)
def test_skip_tests_still_checks_requested_package(self):
result = self.run_vm(skip_tests=True, fail_test=1)
self.assertEqual(result["code"], 0, result)
self.assertEqual(len(result["log"]), 1, result)
self.assertTrue(result["packaged"], result)
def test_packaging_is_optional(self):
result = self.run_vm(package_check=False, package="missing-script")
self.assertEqual(result["code"], 0, result)
self.assertFalse(result["packaged"], result)
def test_requested_packaging_must_produce_readable_archives(self):
for mode in ("missing-script", "failed", "absent", "corrupt", "second-corrupt"):
with self.subTest(mode=mode):
result = self.run_vm(package=mode)
self.assertNotEqual(result["code"], 0, result)
def test_all_valid_archives_are_readable(self):
result = self.run_vm(package="multiple")
self.assertEqual(result["code"], 0, result)
self.assertEqual(result["output"].count("stage/bin/app"), 2, result)
def test_required_fonts_reach_build_and_both_test_paths(self):
for command in ("", "cargo test --lib\ncargo test --doc"):
with self.subTest(command=command):
result = self.run_vm(command=command, fonts=True, require_fonts=True)
self.assertEqual(result["code"], 0, result)
for line in result["log"] + result["package_log"]:
_, directory, required, _ = line.split("|", 3)
self.assertTrue(directory.endswith("/craft-fonts"), result)
self.assertEqual(required, "1", result)
def test_required_missing_fonts_fail_before_build(self):
result = self.run_vm(require_fonts=True)
self.assertNotEqual(result["code"], 0, result)
self.assertEqual(result["log"], [], result)
def test_optional_fonts_remain_optional(self):
for fonts in (True, False):
with self.subTest(fonts=fonts):
result = self.run_vm(fonts=fonts)
self.assertEqual(result["code"], 0, result)
for line in result["log"] + result["package_log"]:
_, directory, required, _ = line.split("|", 3)
self.assertEqual(bool(directory), fonts, result)
self.assertEqual(required, "", result)
def test_failed_caches_fall_back_to_clean_build_and_package(self):
result = self.run_vm(restore_failed=True, checkpoint_failed=True)
self.assertEqual(result["code"], 0, result)
self.assertTrue(result["packaged"], result)
self.assertEqual(result["remaining"], [], result)
def test_packaging_always_reclaims_debug_space(self):
result = self.run_vm()
self.assertEqual(result["code"], 0, result)
self.assertNotIn("debug", result["remaining"], result)
def test_matrix_defined_in_workflow(self):
matrix = DATA["jobs"]["freebsd"].get("strategy", {}).get("matrix", {})
self.assertIn("arch", matrix)
expression = matrix["arch"].removeprefix("${{").removesuffix("}}").strip()
expression = expression.replace("&&", "and").replace("||", "or")
for event in ("pull_request", "pull_request_target", "push", "schedule", "workflow_dispatch"):
for override in ("", '["aarch64"]', '["x86_64","aarch64"]'):
with self.subTest(event=event, override=override):
result = eval(expression, {"__builtins__": {}}, {
"fromJSON": json.loads, "inputs": SimpleNamespace(architectures=override),
"github": SimpleNamespace(event_name=event),
})
expected = json.loads(override) if override else (["x86_64"] if event.startswith("pull_request") else ["x86_64", "aarch64"])
self.assertEqual(result, expected)
def test_packaging_works_for_all_matrix_architectures(self):
for arch in ("x86_64", "aarch64"):
with self.subTest(arch=arch):
result = self.run_vm(package="multiple", arch=arch)
self.assertEqual(result["code"], 0, result)
self.assertEqual(result["output"].count("stage/bin/app"), 2, result)
def test_release_workflow_freebsd_matrix(self):
release_path = ROOT / ".github/workflows/release.yml"
release_data = yaml.load(release_path.read_text(), Loader=yaml.BaseLoader)
matrix = release_data["jobs"]["freebsd"].get("strategy", {}).get("matrix", {})
self.assertIn("arch", matrix)
# aarch64 is paused in releases until it fits the job limit (#39).
self.assertEqual(matrix["arch"], ["x86_64"])
def vm_steps(self):
release = yaml.load((ROOT / ".github/workflows/release.yml").read_text(), Loader=yaml.BaseLoader)
for name, job in (("freebsd.yml", DATA["jobs"]["freebsd"]), ("release.yml", release["jobs"]["freebsd"])):
vm = dict(next(step["with"] for step in job["steps"] if "with" in step and "prepare" in step["with"]))
vm["run"] = next(step["run"] for step in job["steps"] if step.get("shell") == "freebsd {0}")
yield name, vm
def test_aarch64_installs_rust_from_packages_and_x86_64_uses_rustup(self):
# rustup has no aarch64-unknown-freebsd toolchain: the VM must not ask it for one.
for name, vm in self.vm_steps():
for arch, chosen, other in (("aarch64", "pkg-install -y rust", "sh.rustup.rs"),
("x86_64", "sh.rustup.rs", "pkg-install -y rust")):
with self.subTest(workflow=name, arch=arch):
# Print the commands instead of installing anything.
script = (vm["prepare"].replace("${{ matrix.arch }}", arch)
.replace("pkg install", "echo pkg-install").replace("curl -", "echo curl -")
.replace("| sh -s --", "#"))
result = subprocess.run(["/bin/sh", "-eu", "-c", script], text=True, capture_output=True, timeout=15)
self.assertEqual(result.returncode, 0, result)
self.assertIn(chosen, result.stdout, result)
self.assertNotIn(other, result.stdout, result)
def test_run_step_works_without_a_rustup_environment(self):
# The lang/rust package writes no $HOME/.cargo/env, so sourcing it must be optional.
for name, vm in self.vm_steps():
line = next(line.strip() for line in vm["run"].splitlines()
if ".cargo/env" in line and not line.strip().startswith("#"))
with self.subTest(workflow=name), tempfile.TemporaryDirectory() as home:
result = subprocess.run(["/bin/sh", "-eu", "-c", line], env={**os.environ, "HOME": home},
text=True, capture_output=True, timeout=15)
self.assertEqual(result.returncode, 0, result)
if __name__ == "__main__":
unittest.main()