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 }); + } + } +});