Files
ZuujisandClaude Opus 5.5 50a6bc4968 ci(release): cross-compile FreeBSD aarch64 in the x86_64 VM (#50)
* ci(release): cross-compile FreeBSD aarch64 in the x86_64 VM

The FreeBSD release job built aarch64 in an emulated aarch64 guest, which
ran into the 300-minute limit for most apps, so aarch64 was dropped from
releases (#39). #49 fixed the same problem in freebsd.yml by cross-compiling
in the KVM-accelerated x86_64 guest; this ports that setup to release.yml.

The release VM is now always x86_64. For aarch64 it pins the quarterly
package branch, installs lang/rust and runs the same "Set up the aarch64
cross toolchain" step as freebsd.yml; a test keeps the two copies identical.
The build step sets the version before loading the cross environment, so
xtask still builds for the guest. The built-in packager reads binaries from
target/<triple>/release, and the job fails if an archive does not match the
matrix architecture.

aarch64 stays opt-in through the new freebsd-architectures input (default
'["x86_64"]'): each app's package.sh still reads target/release and names
its archive after uname -m, so it must honor CARGO_BUILD_TARGET first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci(release): reject unsupported FreeBSD architectures before the VM starts

The guest is always x86_64 and only aarch64 is cross-compiled, so any other
freebsd-architectures value would build x86_64 binaries and the fallback
packager would label them with that value. Check matrix.arch on the host
first and fail for anything but x86_64 and aarch64.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:58:24 -04:00

132 lines
6.6 KiB
Python

"""Exercise release fallback packaging and explicit copyback with an isolated guest."""
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tarfile
import tempfile
import unittest
import yaml
ROOT = Path(__file__).resolve().parents[1]
DATA = yaml.load((ROOT / '.github/workflows/release.yml').read_text(), Loader=yaml.BaseLoader)
STEPS = DATA['jobs']['freebsd']['steps']
BUILD = next(step['run'] for step in STEPS if step.get('name') == 'Build and package')
COPY = next(step['run'] for step in STEPS if step.get('name') == 'Copy release archives from the VM')
@unittest.skipUnless(shutil.which('rsync'), 'rsync required')
class FreeBSDReleaseTests(unittest.TestCase):
def run_release(self, fail_build=False, with_cli=False, arch='x86_64', package_arch=None, input_version=''):
"""Return (exit code, complete archive copied to the host, stale partial file left, cargo log, output).
arch='aarch64' writes the cross environment the toolchain step leaves behind.
package_arch makes a packaging/freebsd/package.sh that names its archive after it.
"""
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
guest, host, tools = (root / name for name in ('guest', 'host', 'tools'))
for path in (guest, host, tools):
path.mkdir()
target = guest / 'craft-target'
(target / 'release').mkdir(parents=True)
(target / 'release/partial').write_text('failed restore')
if arch == 'aarch64':
(guest / 'craft-cross').mkdir()
(guest / 'craft-cross/env').write_text('export CARGO_BUILD_TARGET=aarch64-unknown-freebsd\n')
if package_arch:
(guest / 'packaging/freebsd').mkdir(parents=True)
(guest / 'packaging/freebsd/package.sh').write_text(
f'mkdir -p dist/release && echo archive > dist/release/fixture-$VERSION-freebsd-{package_arch}.tar.gz\n')
log = root / 'cargo.log'
cargo = tools / 'cargo'
cargo.write_text('''#!/bin/sh
set -eu
echo "$1|${CARGO_BUILD_TARGET:-}" >> "$CARGO_LOG"
if [ "$1" = metadata ]; then
if [ "$WITH_CLI" = true ]; then echo '{"packages":[{"name":"fixture-cli"}]}'; else echo '{"packages":[]}'; fi
exit 0
fi
if [ "$1" = xtask ]; then exit 0; fi
[ "$FAIL_BUILD" = false ] || exit 31
out="$CARGO_TARGET_DIR/${CARGO_BUILD_TARGET:+$CARGO_BUILD_TARGET/}release"
mkdir -p "$out"
echo binary > "$out/fixture"
if [ "$WITH_CLI" = true ]; then echo cli > "$out/fixture-cli"; fi
''')
cargo.chmod(0o755)
git = tools / 'git'
git.write_text('#!/bin/sh\nexit 0\n')
git.chmod(0o755)
rsync = tools / 'rsync'
rsync.write_text(f'#!{sys.executable}\n' + '''import os, subprocess, sys
args = []
items = iter(sys.argv[1:])
for arg in items:
if arg == '-e': next(items)
elif arg.startswith('--rsync-path='): continue
else: args.append(arg.removeprefix('freebsd:'))
sys.exit(subprocess.run([os.environ['REAL_RSYNC'], *args]).returncode)
''')
rsync.chmod(0o755)
env = dict(os.environ, PATH=str(tools) + os.pathsep + os.environ['PATH'],
CARGO_TARGET_DIR=str(target), GITHUB_WORKSPACE=str(guest),
REAL_RSYNC=shutil.which('rsync'), VERSION='1.0.0', INPUT_VERSION=input_version,
FAIL_BUILD=str(fail_build).lower(), WITH_CLI=str(with_cli).lower(), APP_NAME='fixture',
CARGO_LOG=str(log))
env.pop('CARGO_BUILD_TARGET', None)
script = BUILD.replace('${{ inputs.app-name }}', 'fixture').replace('${{ matrix.arch }}', arch)
script = script.replace('${{ steps.cargo-cache.outcome }}', 'failure')
script = script.replace('/var/tmp/', str(guest) + '/')
script = script.replace('. "$HOME/.cargo/env"', ': # isolated toolchain')
result = subprocess.run(['sh', '-c', script], cwd=guest, env=env, capture_output=True, text=True)
cargo_log = log.read_text().splitlines() if log.exists() else []
if result.returncode:
return result.returncode, False, (target / 'release/partial').exists(), cargo_log, result.stderr
copied = subprocess.run(['bash', '-eu', '-c', COPY], cwd=host, env=env, capture_output=True, text=True)
archive = host / f'dist/release/fixture-1.0.0-freebsd-{arch}.tar.gz'
complete = archive.is_file()
if complete and not package_arch:
# The built-in packager's archive must hold the binaries it was asked for.
with tarfile.open(archive) as packaged:
names = packaged.getnames()
prefix = f'fixture-1.0.0-freebsd-{arch}/'
complete = prefix + 'fixture' in names
if with_cli:
complete = complete and prefix + 'fixture-cli' in names
return copied.returncode, complete, (target / 'release/partial').exists(), cargo_log, result.stderr
def test_failed_restore_rebuilds_and_copies_real_archive(self):
self.assertEqual(self.run_release()[:3], (0, True, False))
def test_optional_cli_is_packaged_from_cached_target(self):
self.assertEqual(self.run_release(with_cli=True)[:3], (0, True, False))
def test_aarch64_fallback_packages_the_cross_built_cli(self):
self.assertEqual(self.run_release(with_cli=True, arch='aarch64')[:3], (0, True, False))
def test_failed_build_never_delivers_archive(self):
self.assertEqual(self.run_release(fail_build=True)[:3], (31, False, False))
def test_aarch64_fallback_packages_the_cross_target(self):
code, copied, _, log, output = self.run_release(arch='aarch64', input_version='1.0.0')
self.assertEqual((code, copied), (0, True), output)
# xtask runs on the guest; only the app is built for aarch64.
self.assertEqual(log[0], 'xtask|', log)
self.assertEqual(log[-1], 'build|aarch64-unknown-freebsd', log)
def test_package_script_archive_must_match_the_matrix_arch(self):
for arch, package_arch, code in (('x86_64', 'x86_64', 0), ('aarch64', 'aarch64', 0),
('aarch64', 'x86_64', 1), ('x86_64', 'aarch64', 1)):
with self.subTest(arch=arch, package_arch=package_arch):
result = self.run_release(arch=arch, package_arch=package_arch)
self.assertEqual(result[0], code, result)
if code:
self.assertIn('must honor CARGO_BUILD_TARGET', result[4])
if __name__ == '__main__':
unittest.main()