From f1c7084ace22b9d19ec944e3776b53839b45a48a Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sun, 20 Sep 2026 02:01:27 -0700 Subject: [PATCH] ci(release): overlap validation and reuse macOS package binaries Start release compilation once signing credentials and web assets are ready, while retaining every validation prerequisite on publication. Avoid making the longest native build wait behind the full test matrix. Package macOS tarballs from the native job's compiled daemon, CLI and app instead of compiling a second release graph. Preserve standalone signing, notarization verification, archive names and checksums. Co-Authored-By: Nova (GPT-6) --- .github/workflows/ci.yml | 80 +++++++++++++----------- scripts/tests/macos-ci-coverage.test.mjs | 21 +++++-- scripts/tests/macos-release.test.mjs | 71 ++++++++++++++++++++- 3 files changed, 128 insertions(+), 44 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 636304f2e..4038ffee1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1535,7 +1535,7 @@ jobs: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.release_artifacts == 'full') - needs: [release-credentials, rust-check-shared, rust-check-macos, rust-test, rust-test-servo, rust-windows, rust-deny, sdk, ui, e2e, web-assets, python, python-generated] + needs: [release-credentials, web-assets] strategy: fail-fast: false matrix: @@ -1763,6 +1763,45 @@ jobs: --app "$app" --dmg "$dmg" --provenance "${dmg}.notarization.json" \ --target '${{ matrix.rust-target }}' --team-id "$APPLE_TEAM_ID" + - name: Assemble signed macOS distribution + if: runner.os == 'macOS' + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }} + run: | + # The sidecars use the host profile; Tauri builds the app with an + # explicit target. Package those exact binaries without recompiling. + bin_dir="${RUNNER_TEMP}/macos-release-binaries" + mkdir -p "${bin_dir}" + install -m755 "${CARGO_TARGET_DIR}/release/hypercolor-daemon" "${bin_dir}/hypercolor-daemon" + install -m755 "${CARGO_TARGET_DIR}/release/hypercolor" "${bin_dir}/hypercolor" + install -m755 "${CARGO_TARGET_DIR}/${{ matrix.rust-target }}/release/hypercolor-app" "${bin_dir}/hypercolor-app" + bash scripts/with-macos-signing.sh scripts/dist.sh --ci --skip-docs \ + --web-assets web-assets --target '${{ matrix.rust-target }}' \ + --bin-dir "${bin_dir}" --version '${{ steps.version.outputs.version }}' + platform="macos-arm64" + if [[ '${{ matrix.cask_arch }}' == 'x86_64' ]]; then platform="macos-amd64"; fi + dist_name="hypercolor-${{ steps.version.outputs.version }}-${platform}" + ./scripts/sign-macos-artifacts.sh verify-standalone \ + --directory "dist/${dist_name}" \ + --target '${{ matrix.rust-target }}' --team-id "$APPLE_TEAM_ID" + (cd dist && shasum -a 256 "${dist_name}.tar.gz" > "${dist_name}.tar.gz.sha256") + + - name: Upload macOS release tarball + if: runner.os == 'macOS' + uses: actions/upload-artifact@v7 + with: + name: hypercolor-tarball-${{ steps.version.outputs.version }}-${{ matrix.target }} + path: | + dist/hypercolor-*.tar.gz + dist/hypercolor-*.tar.gz.sha256 + if-no-files-found: error + - name: Upload native release bundle uses: actions/upload-artifact@v7 with: @@ -1893,7 +1932,7 @@ jobs: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.release_artifacts == 'full') - needs: [release-credentials, rust-check-shared, rust-check-macos, rust-test, rust-test-servo, rust-windows, rust-deny, sdk, ui, e2e, web-assets, python, python-generated] + needs: [release-credentials, web-assets] strategy: fail-fast: false matrix: @@ -1904,12 +1943,6 @@ jobs: - target: linux-arm64 os: ubuntu-24.04-arm rust-target: aarch64-unknown-linux-gnu - - target: macos-arm64 - os: macos-26 - rust-target: aarch64-apple-darwin - - target: macos-amd64 - os: macos-26-intel - rust-target: x86_64-apple-darwin # A full workflow_dispatch on the default branch warms these release # shapes. Tags restore that trusted cache; they never publish cache state. timeout-minutes: 120 @@ -1917,16 +1950,6 @@ jobs: steps: - uses: actions/checkout@v7 - - name: Qualify macOS runner and SDK - if: runner.os == 'macOS' - run: | - sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app/Contents/Developer" - test "$(xcrun --show-sdk-version | cut -d. -f1)" = "26" - - - name: Install NASM for Intel macOS - if: matrix.target == 'macos-amd64' - run: brew install nasm - - name: Report runner capacity shell: bash run: | @@ -2030,24 +2053,6 @@ jobs: --version "${{ steps.version.outputs.version }}" test -f "dist/${{ steps.version.outputs.dist_name }}.tar.gz" - - name: Assemble signed macOS distribution - if: runner.os == 'macOS' - env: - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }} - run: | - bash scripts/with-macos-signing.sh scripts/dist.sh --ci --skip-docs \ - --web-assets web-assets --target '${{ matrix.target }}' \ - --version '${{ steps.version.outputs.version }}' - ./scripts/sign-macos-artifacts.sh verify-standalone \ - --directory 'dist/${{ steps.version.outputs.dist_name }}' \ - --target '${{ matrix.rust-target }}' --team-id "$APPLE_TEAM_ID" - # Memory exhaustion can take down the VM before this step runs. - name: Report runner capacity after failure if: failure() @@ -2124,11 +2129,12 @@ jobs: # Accepts workflow_dispatch as well as push: release.yml pushes tags # with GITHUB_TOKEN (which never fires `on: push`) and then dispatches # this workflow on the tag ref. + # Compilation runs alongside validation; only publication waits for both. create-release: if: >- (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && startsWith(github.ref, 'refs/tags/') - needs: [build-release, build-native-app, python, python-generated] + needs: [build-release, build-native-app, release-credentials, rust-check-shared, rust-check-macos, rust-test, rust-test-servo, rust-windows, rust-deny, sdk, ui, e2e, web-assets, python, python-generated] runs-on: ubuntu-latest permissions: contents: write diff --git a/scripts/tests/macos-ci-coverage.test.mjs b/scripts/tests/macos-ci-coverage.test.mjs index 2a28611a8..aea3736dc 100644 --- a/scripts/tests/macos-ci-coverage.test.mjs +++ b/scripts/tests/macos-ci-coverage.test.mjs @@ -54,12 +54,21 @@ test('cache ownership and restored target directories agree across lanes', () => assert.equal(condition('Save Rust build caches'), 'always()'); }); -test('both release builders require the macOS matrix and Windows tests', () => { +test('publication retains every validation gate while compilation overlaps it', () => { + const body = id => workflow.match(new RegExp(`^ ${id}:\\n([\\s\\S]*?)(?=^ [a-z][\\w-]*:)`, 'm'))?.[1]; + const needs = id => body(id)?.match(/^ needs: \[(.+)\]$/m)?.[1].split(', '); for (const id of ['build-release', 'build-native-app']) { - const body = workflow.match(new RegExp(`^ ${id}:\\n([\\s\\S]*?)(?=^ [a-z][\\w-]*:)`, 'm'))?.[1]; - assert.ok(body, `missing release builder: ${id}`); - const needs = body.match(/^ needs: \[(.+)\]$/m)?.[1].split(', ').map((value) => value.trim()); - assert.ok(needs?.includes('rust-check-macos'), `${id} must wait for both lanes on both architectures`); - assert.ok(needs?.includes('rust-windows'), `${id} must wait for Windows tests before producing release artifacts`); + assert.deepEqual(needs(id), ['release-credentials', 'web-assets']); } + const validation = [ + 'release-credentials', 'rust-check-shared', 'rust-check-macos', 'rust-test', + 'rust-test-servo', 'rust-windows', 'rust-deny', 'sdk', 'ui', 'e2e', + 'web-assets', 'python', 'python-generated', + ]; + assert.deepEqual(needs('create-release'), ['build-release', 'build-native-app', ...validation]); + // Preserve GitHub's implicit success() gate: failed or skipped validation + // must never become publishable through always() or !cancelled(). + const condition = body('create-release').split(' needs:')[0]; + assert.doesNotMatch(condition, /always\(|cancelled\(|failure\(/); + assert.match(condition, /startsWith\(github.ref, 'refs\/tags\/'\)/); }); diff --git a/scripts/tests/macos-release.test.mjs b/scripts/tests/macos-release.test.mjs index 4157e2914..748f89f3f 100644 --- a/scripts/tests/macos-release.test.mjs +++ b/scripts/tests/macos-release.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; -import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync } from 'node:fs'; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { createHash } from 'node:crypto'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -125,3 +125,72 @@ test('Homebrew checksum step supplies every value consumed by its renderer', () rmSync(dir, { recursive: true, force: true }); } }); + +test('macOS tarballs package the native job binaries for both architectures', () => { + const workflow = readFileSync(new URL('../../.github/workflows/ci.yml', import.meta.url), 'utf8'); + const native = workflow.match(/^ build-native-app:\n([\s\S]*?)(?=^ [a-z][\w-]*:)/m)[1]; + const tarballs = workflow.match(/^ build-release:\n([\s\S]*?)(?=^ [a-z][\w-]*:)/m)[1]; + assert.doesNotMatch(tarballs, /target: macos-/); + const step = native.match(/ - name: Assemble signed macOS distribution\n([\s\S]*?)(?= - name:)/)[1]; + assert.match(step, /if: runner.os == 'macOS'/); + const script = step.split(' run: |\n')[1].replace(/^ /gm, ''); + assert.doesNotMatch(script, /cargo build|cargo tauri/); + assert.match(native, /name: hypercolor-tarball-\$\{\{ steps.version.outputs.version \}\}-\$\{\{ matrix.target \}\}/); + assert.match(native, /dist\/hypercolor-\*\.tar.gz\n/); + for (const [arch, target, platform] of [ + ['arm64', 'aarch64-apple-darwin', 'macos-arm64'], + ['x86_64', 'x86_64-apple-darwin', 'macos-amd64'], + ]) { + const dir = mkdtempSync(path.join(tmpdir(), 'macos-prebuilt-release-')); + try { + mkdirSync(path.join(dir, 'scripts')); + mkdirSync(path.join(dir, 'target/release'), { recursive: true }); + mkdirSync(path.join(dir, `target/${target}/release`), { recursive: true }); + for (const binary of ['hypercolor-daemon', 'hypercolor']) { + writeFileSync(path.join(dir, 'target/release', binary), binary, { mode: 0o755 }); + } + writeFileSync(path.join(dir, `target/${target}/release/hypercolor-app`), 'hypercolor-app', { mode: 0o755 }); + writeFileSync(path.join(dir, 'scripts/with-macos-signing.sh'), '#!/bin/bash\nexec "$@"\n'); + // Replace the platform signing transport; execute the actual workflow's + // path selection, staging, package invocation and checksum generation. + writeFileSync(path.join(dir, 'scripts/dist.sh'), `#!/bin/bash +set -euo pipefail +bin_dir='' +while (( $# )); do + case "$1" in + --bin-dir) bin_dir="$2"; shift ;; + --target) test "$2" = '${target}'; shift ;; + --version) test "$2" = '0.5.2'; shift ;; + --web-assets) shift ;; + esac + shift +done +for binary in hypercolor-daemon hypercolor hypercolor-app; do + test -x "$bin_dir/$binary" + test "$(cat "$bin_dir/$binary")" = "$binary" +done +mkdir -p dist/hypercolor-0.5.2-${platform} +printf 'archive-fixture' > dist/hypercolor-0.5.2-${platform}.tar.gz +`, { mode: 0o755 }); + writeFileSync(path.join(dir, 'scripts/sign-macos-artifacts.sh'), `#!/bin/bash +set -euo pipefail +test "$1" = verify-standalone +test "$3" = dist/hypercolor-0.5.2-${platform} +test "$5" = '${target}' +test "$7" = fixture-team +`, { mode: 0o755 }); + const run = script.replaceAll('${{ matrix.rust-target }}', target) + .replaceAll('${{ matrix.cask_arch }}', arch) + .replaceAll('${{ steps.version.outputs.version }}', '0.5.2'); + const result = spawnSync('bash', ['-e', '-c', run], { + cwd: dir, encoding: 'utf8', + env: { ...environment, RUNNER_TEMP: dir, CARGO_TARGET_DIR: path.join(dir, 'target'), APPLE_TEAM_ID: 'fixture-team' }, + }); + assert.equal(result.status, 0, result.stderr); + const checksum = readFileSync(path.join(dir, `dist/hypercolor-0.5.2-${platform}.tar.gz.sha256`), 'utf8'); + assert.equal(checksum.trim(), `${createHash('sha256').update('archive-fixture').digest('hex')} hypercolor-0.5.2-${platform}.tar.gz`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } +});