diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml index 85c8f89..ac5fbbe 100644 --- a/.github/workflows/release-binaries.yml +++ b/.github/workflows/release-binaries.yml @@ -539,10 +539,18 @@ jobs: "aarch64/${binary}-aarch64-apple-darwin" \ -output "universal/${binary}-universal-apple-darwin" chmod 755 "universal/${binary}-universal-apple-darwin" - # Xcode 16.4 lipo requires the input file before the - # -verify_arch command/arch flags; the legacy flags-first order - # parses the file path as an architecture and fails. - lipo "universal/${binary}-universal-apple-darwin" -verify_arch x86_64 arm64 + # Newer lipo (Xcode 27) rejects several architectures in one + # -verify_arch ("requires exactly one input file"), and the + # input file must precede the flags: verify each architecture + # separately, then require exactly the two slices. + for arch in x86_64 arm64; do + lipo "universal/${binary}-universal-apple-darwin" -verify_arch "$arch" + done + archs="$(lipo -archs "universal/${binary}-universal-apple-darwin")" + [ "$(printf '%s' "$archs" | tr ' ' '\n' | LC_ALL=C sort | tr '\n' ' ')" = "arm64 x86_64 " ] || { + echo "unexpected architectures: $archs" >&2 + exit 1 + } done - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: diff --git a/.github/workflows/release-mcp.yml b/.github/workflows/release-mcp.yml index d825ba5..1139e78 100644 --- a/.github/workflows/release-mcp.yml +++ b/.github/workflows/release-mcp.yml @@ -269,8 +269,17 @@ jobs: aarch64/terraphim_mcp_server-aarch64-apple-darwin \ -output "$out" chmod 755 "$out" - # The input file must precede -verify_arch with Xcode 16.4 lipo. - lipo "$out" -verify_arch x86_64 arm64 + # Newer lipo (Xcode 27) rejects several architectures in one + # -verify_arch ("requires exactly one input file"): verify each + # separately, then require exactly the two slices. + for arch in x86_64 arm64; do + lipo "$out" -verify_arch "$arch" + done + archs="$(lipo -archs "$out")" + [ "$(printf '%s' "$archs" | tr ' ' '\n' | LC_ALL=C sort | tr '\n' ' ')" = "arm64 x86_64 " ] || { + echo "unexpected architectures: $archs" >&2 + exit 1 + } scripts/validate_release_binary.py universal-apple-darwin "$out" reported="$("$out" --version | tail -n1 | awk '{print $NF}')" [ "$reported" = "$CRATE_VERSION" ] || { diff --git a/tests/test_release_binaries_workflow_contract.py b/tests/test_release_binaries_workflow_contract.py index fb6a08f..ace1ec1 100644 --- a/tests/test_release_binaries_workflow_contract.py +++ b/tests/test_release_binaries_workflow_contract.py @@ -484,28 +484,31 @@ def test_managed_package_job_and_seal_gates_fail_closed(self) -> None: self.assert_seal_package_result_contract(mutant) def assert_lipo_verify_arch_order(self, block: str) -> None: - # Xcode 16.4 lipo requires the input file before the -verify_arch - # command and its architecture flags: the legacy order - # `lipo -verify_arch x86_64 arm64 FILE` parses FILE as an - # architecture and fails the universal macOS step. The only safe - # order is `lipo FILE -verify_arch x86_64 arm64`. + # Newer lipo (Xcode 27) rejects several architectures in a single + # -verify_arch ("requires exactly one input file"), and the input file + # must precede the flags. Verify each architecture on its own, then + # require exactly the two slices from `lipo -archs`. + self.assertIn("for arch in x86_64 arm64; do", block) self.assertIn( - 'lipo "universal/${binary}-universal-apple-darwin" -verify_arch x86_64 arm64', - block, + 'lipo "universal/${binary}-universal-apple-darwin" -verify_arch "$arch"', block ) - self.assertNotIn("lipo -verify_arch x86_64 arm64", block) + self.assertIn('lipo -archs "universal/${binary}-universal-apple-darwin"', block) + self.assertIn('= "arm64 x86_64 "', block) + self.assertNotIn("-verify_arch x86_64 arm64", block) + self.assertNotIn("lipo -verify_arch", block) def test_universal_lipo_verify_arch_uses_safe_argument_order(self) -> None: block = job_block("create-universal-macos") self.assert_lipo_verify_arch_order(block) + safe = 'lipo "universal/${binary}-universal-apple-darwin" -verify_arch "$arch"' mutations = { "flags-first": block.replace( - 'lipo "universal/${binary}-universal-apple-darwin" -verify_arch x86_64 arm64', - 'lipo -verify_arch x86_64 arm64 "universal/${binary}-universal-apple-darwin"', - 1, + safe, 'lipo -verify_arch "$arch" "universal/${binary}-universal-apple-darwin"', 1 ), - "verify-dropped": block.replace(" -verify_arch x86_64 arm64", "", 1), + "multi-arch": block.replace(safe, safe.replace('"$arch"', "x86_64 arm64"), 1), + "verify-dropped": block.replace(safe, "true", 1), + "archs-check-dropped": block.replace('= "arm64 x86_64 "', '= ""', 1), } for name, mutant in mutations.items(): with self.subTest(mutation=name): diff --git a/tests/test_release_mcp_workflow_contract.py b/tests/test_release_mcp_workflow_contract.py index a2b5ea6..ca900bc 100644 --- a/tests/test_release_mcp_workflow_contract.py +++ b/tests/test_release_mcp_workflow_contract.py @@ -159,6 +159,34 @@ def test_assembled_artefact_preserves_the_assets_directory(self) -> None: self.assertIn("test -d release/assets", publish) self.assertIn("test -s release/checksums.txt", publish) + @staticmethod + def assert_lipo_verification(block: str) -> None: + # Newer lipo rejects several architectures in one -verify_arch and + # wants the file first: verify each separately, then require exactly + # the two slices. + assert "for arch in x86_64 arm64; do" in block + assert 'lipo "$out" -verify_arch "$arch"' in block + assert 'lipo -archs "$out"' in block + assert '= "arm64 x86_64 "' in block + assert "-verify_arch x86_64 arm64" not in block + assert "lipo -verify_arch" not in block + + def test_universal_binary_verifies_each_architecture_separately(self) -> None: + block = job_block("universal-macos") + self.assert_lipo_verification(block) + safe = 'lipo "$out" -verify_arch "$arch"' + mutations = { + "flags-first": block.replace(safe, 'lipo -verify_arch "$arch" "$out"', 1), + "multi-arch": block.replace(safe, 'lipo "$out" -verify_arch x86_64 arm64', 1), + "verify-dropped": block.replace(safe, "true", 1), + "archs-check-dropped": block.replace('= "arm64 x86_64 "', '= ""', 1), + } + for name, mutant in mutations.items(): + with self.subTest(mutation=name): + self.assertNotEqual(mutant, block) + with self.assertRaises(AssertionError): + self.assert_lipo_verification(mutant) + def test_source_is_trusted_before_any_self_hosted_job(self) -> None: preflight = job_block("preflight") self.assertIn('git merge-base --is-ancestor "$SOURCE_SHA" origin/main', preflight)