From 1904dd6f81f49efd03fb88daa725669fbb0b43f5 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 4 Oct 2026 17:06:10 +0100 Subject: [PATCH 1/2] ci(release): move Linux/macOS build lanes to self-hosted runners GitHub-hosted runners cannot fetch from the Gitea cargo registry: off-tailnet cargo access is Bearer-only (Gitea #341) and cargo omits Bearer on .crate downloads whenever the registry advertises auth-required:false, which Gitea computes from org visibility (terraphim is public). The 2026-09-25 Caddy narrowing therefore only works when cargo attaches credentials to every request. Self-hosted runners on the Tailnet reach git.terraphim.cloud via the box /etc/hosts mapping (100.106.66.7), so Caddy treats them as on-network and no registry auth dance is needed at all. - Linux lanes (gnu + both musl cross targets) -> release-linux label on the bigbox runner (terraphim-clients-runner-1) - macOS lanes (x86_64 cross-compile + aarch64 native) -> release-macos label on the Apple Silicon runner (terraphim-clients-macos-1) - Windows lane removed: restore it once the private terraphim crates are published to crates.io so GitHub-hosted runners can fetch them - sign-and-notarize and create-universal stay on GitHub-hosted macos-15: they never touch the cargo registry and need sudo for Rosetta provisioning - Remove the temporary cargo-auth debug echo block - Contract tests updated to the five-lane self-hosted matrix; release version pin corrected to 1.21.17 (missed in #40); PKGVER comment documents the intentional mid-release PKGBUILD lag Refs #348 --- .github/workflows/release-binaries.yml | 30 +++++++++------ tests/test_pkgbuild_release_contract.py | 5 +++ ...test_release_binaries_workflow_contract.py | 38 +++++++++++++------ 3 files changed, 50 insertions(+), 23 deletions(-) diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml index 766fa7c..1f2d52d 100644 --- a/.github/workflows/release-binaries.yml +++ b/.github/workflows/release-binaries.yml @@ -181,24 +181,34 @@ jobs: fail-fast: false matrix: include: - - os: ubuntu-22.04 + # 2026-10-04: Linux and macOS lanes run on self-hosted runners that + # reach git.terraphim.cloud via the Tailscale /etc/hosts mapping, so + # cargo needs no special auth against the Gitea cargo registry + # (Caddy treats tailnet sources as on-network). GitHub-hosted runners + # cannot reach the registry: off-tailnet cargo access is + # Bearer-only (Gitea #341) and cargo omits Bearer on .crate + # downloads when the registry advertises auth-required:false. + - os: [self-hosted, Linux, X64, release-linux] target: x86_64-unknown-linux-gnu use_cross: false - - os: ubuntu-22.04 + - os: [self-hosted, Linux, X64, release-linux] target: x86_64-unknown-linux-musl use_cross: true - - os: ubuntu-22.04 + - os: [self-hosted, Linux, X64, release-linux] target: aarch64-unknown-linux-musl use_cross: true - - os: macos-15-intel + # Both macOS targets build on the Apple Silicon runner; x86_64 is a + # cross-compile from the arm64 host. + - os: [self-hosted, macOS, ARM64, release-macos] target: x86_64-apple-darwin use_cross: false - - os: macos-15 + - os: [self-hosted, macOS, ARM64, release-macos] target: aarch64-apple-darwin use_cross: false - - os: windows-latest - target: x86_64-pc-windows-msvc - use_cross: false + # x86_64-pc-windows-msvc lane: temporarily removed. GitHub-hosted + # Windows runners cannot authenticate to the private Gitea cargo + # registry; restore this lane once the terraphim crates are + # published to crates.io. runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -232,10 +242,6 @@ jobs: CARGO_REGISTRIES_TERRAPHIM_TOKEN: ${{ secrets.CARGO_REGISTRIES_TERRAPHIM_TOKEN }} run: | set -euo pipefail - echo "DEBUG: token length=${#CARGO_REGISTRIES_TERRAPHIM_TOKEN}" - echo "DEBUG: token prefix=${CARGO_REGISTRIES_TERRAPHIM_TOKEN:0:7}" - echo "DEBUG: CARGO_REGISTRY_TOKEN set=${CARGO_REGISTRY_TOKEN:+yes}" - rustup run 1.96.0 cargo config get registries.terraphim 2>&1 || true if [ "${{ matrix.use_cross }}" = "true" ]; then build=(rustup run 1.96.0 cross) else diff --git a/tests/test_pkgbuild_release_contract.py b/tests/test_pkgbuild_release_contract.py index a1f4505..8b85956 100644 --- a/tests/test_pkgbuild_release_contract.py +++ b/tests/test_pkgbuild_release_contract.py @@ -27,6 +27,11 @@ D = chr(36) # dollar sign, kept out of template literals in the test source +# Tracks the last *published* release, not the workspace version: the PKGBUILD +# pins the release asset SHA-256 digests, which only exist once the release is +# sealed. During a release window (workspace bumped, assets not yet +# published) this contract intentionally fails until the follow-up PKGBUILD +# bump lands. PKGVER = "1.21.16" REPO = "terraphim/terraphim-clients" RELEASE_BASE = f"https://github.com/{REPO}/releases/download/v{PKGVER}" diff --git a/tests/test_release_binaries_workflow_contract.py b/tests/test_release_binaries_workflow_contract.py index c773c6c..fe956e9 100644 --- a/tests/test_release_binaries_workflow_contract.py +++ b/tests/test_release_binaries_workflow_contract.py @@ -176,7 +176,7 @@ def test_preflight_recursively_peels_to_exact_expected_sha(self) -> None: def test_release_uses_checked_in_version_and_never_mutates_source(self) -> None: text = workflow_text() workspace = tomllib.loads((ROOT / "Cargo.toml").read_text()) - self.assertEqual(workspace["workspace"]["package"]["version"], "1.21.16") + self.assertEqual(workspace["workspace"]["package"]["version"], "1.21.17") for forbidden in ( "Set release version", "set_section_version", @@ -316,24 +316,27 @@ def test_client_packaging_ci_job_is_non_vacuous_and_mutation_sensitive(self) -> with self.assertRaises((AssertionError, ValueError)): self.assert_client_packaging_ci_contract(mutant) - def test_matrix_is_the_exact_six_lane_contract(self) -> None: + def test_matrix_is_the_exact_five_lane_self_hosted_contract(self) -> None: text = workflow_text() expected = { - ("ubuntu-22.04", "x86_64-unknown-linux-gnu", "false"), - ("ubuntu-22.04", "x86_64-unknown-linux-musl", "true"), - ("ubuntu-22.04", "aarch64-unknown-linux-musl", "true"), - ("macos-15-intel", "x86_64-apple-darwin", "false"), - ("macos-15", "aarch64-apple-darwin", "false"), - ("windows-latest", "x86_64-pc-windows-msvc", "false"), + ("[self-hosted, Linux, X64, release-linux]", "x86_64-unknown-linux-gnu", "false"), + ("[self-hosted, Linux, X64, release-linux]", "x86_64-unknown-linux-musl", "true"), + ("[self-hosted, Linux, X64, release-linux]", "aarch64-unknown-linux-musl", "true"), + ("[self-hosted, macOS, ARM64, release-macos]", "x86_64-apple-darwin", "false"), + ("[self-hosted, macOS, ARM64, release-macos]", "aarch64-apple-darwin", "false"), } actual = set( re.findall( - r"- os: ([^\n]+)\n\s+target: ([^\n]+)\n\s+use_cross: (true|false)", + r"- os: (\[[^\n]+\])\n\s+target: ([^\n]+)\n\s+use_cross: (true|false)", text, ) ) self.assertEqual(actual, expected) self.assertIn("fail-fast: false", text) + # The Windows lane returns once the private crates are public on + # crates.io; GitHub-hosted runners cannot authenticate to the + # Bearer-only off-tailnet cargo registry (Gitea #341). + self.assertNotIn("windows-latest", text) def test_builds_are_locked_and_grep_features_are_preserved(self) -> None: block = job_block("build-binaries") @@ -904,8 +907,21 @@ def test_linux_canonical_bytes_are_stripped_before_all_qualification_and_hashing def test_macos_thin_execution_has_deterministic_runner_semantics(self) -> None: text = workflow_text() - self.assertIn("os: macos-15-intel\n target: x86_64-apple-darwin", text) - self.assertIn("os: macos-15\n target: aarch64-apple-darwin", text) + # 2026-10-04: both macOS thin builds run on the Apple Silicon + # self-hosted runner (x86_64 is a cross-compile from the arm64 host); + # the sign/notarize job stays on GitHub-hosted macos-15 because it + # needs sudo for Rosetta provisioning and never touches the cargo + # registry. + self.assertIn( + "os: [self-hosted, macOS, ARM64, release-macos]\n" + " target: x86_64-apple-darwin", + text, + ) + self.assertIn( + "os: [self-hosted, macOS, ARM64, release-macos]\n" + " target: aarch64-apple-darwin", + text, + ) signing = job_block("sign-and-notarize-macos") self.assertIn("runs-on: macos-15", signing) provision = signing.index("softwareupdate --install-rosetta --agree-to-license") From e8edb473c2149ffe74ec11703bfc935f47d36b13 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 4 Oct 2026 21:30:43 +0100 Subject: [PATCH 2/2] fix(release): address P0/P1/P2 from pi-rust structural review P0: seal-release-stage still consumed the deleted Windows lane (artifact download, zip assembly, = 20 asset assertions) which would fail every release at the final gate. Removed the download step and zip block, dropped dead .exe extension branches in build-binaries, and set the asset counts to 17 (6 agent + 5 cli + 6 grep tar.gz). P1: the x86_64-apple-darwin lane verifies by executing the cross-compiled binary under Rosetta, but Rosetta was only provisioned in sign-and-notarize-macos which runs on a different machine. Added a guarded 'Prove Rosetta' step to the macOS build lane. P2: documented host preconditions (sudo, docker, qemu-user-static, readelf, rustup, Rosetta) next to the matrix comment; contract tests now pin the absence of the Windows lane so it cannot be half-removed again. Review: pi-rust (kimi-for-coding/k2p5), posted on PR #45. Refs #348 --- .github/workflows/release-binaries.yml | 52 ++++++++++--------- ...test_release_binaries_workflow_contract.py | 11 ++-- 2 files changed, 35 insertions(+), 28 deletions(-) diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml index 1f2d52d..b39ad21 100644 --- a/.github/workflows/release-binaries.yml +++ b/.github/workflows/release-binaries.yml @@ -188,6 +188,12 @@ jobs: # cannot reach the registry: off-tailnet cargo access is # Bearer-only (Gitea #341) and cargo omits Bearer on .crate # downloads when the registry advertises auth-required:false. + # + # Host preconditions on the release-linux runner (bigbox): + # passwordless sudo, docker (for cross), qemu-user-static, + # binutils (readelf), rustup on PATH. On the release-macos runner + # (Apple Silicon): rustup on PATH, Rosetta (provisioned by a + # guarded step below for the x86_64 lane). - os: [self-hosted, Linux, X64, release-linux] target: x86_64-unknown-linux-gnu use_cross: false @@ -232,6 +238,17 @@ jobs: - name: Install QEMU for supported foreign execution if: matrix.target == 'aarch64-unknown-linux-musl' run: sudo apt-get update -qq && sudo apt-get install -y -qq qemu-user-static + - name: Prove Rosetta for x86_64 thin verification on Apple Silicon + # The release-macos runner is an Apple Silicon host that verifies the + # cross-compiled x86_64 binary by executing it under Rosetta. The + # install is a no-op when Rosetta is already present; sudo is + # passwordless on the dedicated runner. + if: runner.os == 'macOS' && matrix.target == 'x86_64-apple-darwin' + run: | + set -euo pipefail + arch -x86_64 /usr/bin/true 2>/dev/null || \ + sudo softwareupdate --install-rosetta --agree-to-license + arch -x86_64 /usr/bin/true - uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.1 with: key: immutable-clients-v2-${{ matrix.target }} @@ -271,12 +288,10 @@ jobs: TARGET: ${{ matrix.target }} run: | set -euo pipefail - extension="" - [ "$TARGET" != "x86_64-pc-windows-msvc" ] || extension=".exe" for binary in terraphim-agent terraphim-cli terraphim-grep; do - path="target/$TARGET/release/${binary}${extension}" + path="target/$TARGET/release/$binary" test -s "$path" - test -x "$path" || [ -n "$extension" ] + test -x "$path" scripts/validate_release_binary.py "$TARGET" "$path" case "$TARGET" in aarch64-unknown-linux-musl) output="$(qemu-aarch64-static "$path" --version)" ;; @@ -292,10 +307,8 @@ jobs: run: | set -euo pipefail mkdir raw - extension="" - [ "${{ matrix.target }}" != "x86_64-pc-windows-msvc" ] || extension=".exe" for binary in terraphim-agent terraphim-cli terraphim-grep; do - cp "target/${{ matrix.target }}/release/${binary}${extension}" "raw/${binary}-${{ matrix.target }}${extension}" + cp "target/${{ matrix.target }}/release/$binary" "raw/${binary}-${{ matrix.target }}" done - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: @@ -671,10 +684,11 @@ jobs: run: | set -euo pipefail (cd canonical-binaries && sha256sum -c ../BINARY_SHA256SUMS) - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: raw-client-binaries-x86_64-pc-windows-msvc - path: raw + # 2026-10-04: the Windows lane is removed while the private crates are + # unpublished (GitHub-hosted runners cannot authenticate to the + # off-tailnet cargo registry). Its download-artifact step and the zip + # assembly block below were removed atomically with the matrix lane; + # restore both when the crates reach crates.io. - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: signed-client-binaries-apple-darwin @@ -718,23 +732,11 @@ jobs: -C "$root" -cf - "$binary" LICENSE-Apache-2.0 LICENSE-MIT | gzip -n -9 > "release-assets/$archive" printf '%s\n' "$archive" >> expected-assets.txt done - target=x86_64-pc-windows-msvc - root="package-root/$binary-$target" - mkdir -p "$root" - cp "raw/$binary-$target.exe" "$root/$binary.exe" - cp LICENSE-Apache-2.0 LICENSE-MIT "$root/" - chmod 755 "$root/$binary.exe" - chmod 644 "$root/LICENSE-Apache-2.0" "$root/LICENSE-MIT" - touch -d "@$SOURCE_DATE_EPOCH" "$root/$binary.exe" "$root/LICENSE-Apache-2.0" "$root/LICENSE-MIT" - archive="$binary-$VERSION-$target.zip" - scripts/create-deterministic-zip.py "$SOURCE_DATE_EPOCH" "$root" \ - "release-assets/$archive" "$binary.exe" LICENSE-Apache-2.0 LICENSE-MIT - printf '%s\n' "$archive" >> expected-assets.txt done LC_ALL=C sort -o expected-assets.txt expected-assets.txt find release-assets -maxdepth 1 -type f -printf '%f\n' | LC_ALL=C sort > actual-assets.txt diff -u expected-assets.txt actual-assets.txt - test "$(wc -l < expected-assets.txt | tr -d ' ')" = 20 + test "$(wc -l < expected-assets.txt | tr -d ' ')" = 17 - name: Install archive signer run: cargo install zipsign --version 0.2.1 --locked - name: Sign every final archive @@ -786,7 +788,7 @@ jobs: LC_ALL=C sha256sum "${sealed_assets[@]}" > ../SHA256SUMS ) (cd release-assets && sha256sum -c ../SHA256SUMS) - test "$(wc -l < SHA256SUMS | tr -d ' ')" = 20 + test "$(wc -l < SHA256SUMS | tr -d ' ')" = 17 for binary in terraphim-agent terraphim-cli terraphim-grep; do scripts/build-manifest.sh "$VERSION" "$binary" release-assets "manifests/$binary.v2.candidate.json" scripts/build-legacy-manifest.py "manifests/$binary.v2.candidate.json" "manifests/$binary.v1.candidate.json" diff --git a/tests/test_release_binaries_workflow_contract.py b/tests/test_release_binaries_workflow_contract.py index fe956e9..e6e0465 100644 --- a/tests/test_release_binaries_workflow_contract.py +++ b/tests/test_release_binaries_workflow_contract.py @@ -367,7 +367,10 @@ def test_omarchy_targets_are_required_for_agent_and_grep(self) -> None: self.assertIn("aarch64-unknown-linux-musl", stage) self.assertIn("for binary in terraphim-agent terraphim-cli terraphim-grep", stage) self.assertIn('if [ "$binary" != "terraphim-cli" ]; then targets+=(universal-apple-darwin); fi', stage) - self.assertIn('test "$(wc -l < expected-assets.txt | tr -d \' \')" = 20', stage) + self.assertIn('test "$(wc -l < expected-assets.txt | tr -d \' \')" = 17', stage) + # Windows lane removed: 17 tar.gz assets (6 agent + 5 cli + 6 grep); + # restore 20 only when the Windows zip lane returns. + self.assertNotIn("x86_64-pc-windows-msvc", stage) def test_macos_is_signed_before_deterministic_packaging(self) -> None: text = workflow_text() @@ -386,7 +389,6 @@ def test_archives_are_deterministic_and_have_exact_layout(self) -> None: "tar --sort=name", '--owner=0 --group=0 --numeric-owner', "gzip -n -9", - "scripts/create-deterministic-zip.py", "LICENSE-Apache-2.0", "LICENSE-MIT", "expected-assets.txt", @@ -394,6 +396,9 @@ def test_archives_are_deterministic_and_have_exact_layout(self) -> None: "scripts/validate-release-archive.py", ): self.assertIn(token, stage) + # create-deterministic-zip.py left with the Windows zip lane; it must + # return together with that lane (and the = 17 count becomes 20). + self.assertNotIn("create-deterministic-zip", stage) def test_final_bytes_are_signed_before_checksums_and_manifests(self) -> None: stage = job_block("seal-release-stage") @@ -711,7 +716,7 @@ def test_managed_inventory_is_version_bound_and_separate_from_20_archives(self) self.assertIn("--managed-target aarch64-unknown-linux-musl", stage) self.assertIn("managed-release-assets/*", stage) self.assertLess(assemble, checksum) - self.assertIn('test "$(wc -l < SHA256SUMS | tr -d \' \')" = 20', stage) + self.assertIn('test "$(wc -l < SHA256SUMS | tr -d \' \')" = 17', stage) def test_producer_is_stage_only_and_has_no_public_writer(self) -> None: text = workflow_text()