diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b8e9aa14ee..17d8047358 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,8 +31,13 @@ concurrency: jobs: build: if: github.repository == 'block/buzz-app' && !inputs.candidates && !inputs.promote_version - name: Build, sign, and notarize + name: Build, sign, and notarize (${{ matrix.arch }}) runs-on: macos-latest + strategy: + matrix: + include: + - { target: aarch64-apple-darwin, arch: aarch64, lipo: arm64 } + - { target: x86_64-apple-darwin, arch: x64, lipo: x86_64 } timeout-minutes: 120 permissions: contents: read @@ -42,6 +47,7 @@ jobs: env: CI: "true" CARGO_TERM_COLOR: always + TARGET: ${{ matrix.target }} steps: - name: Require main and signing configuration env: @@ -59,9 +65,18 @@ jobs: with: persist-credentials: false - uses: ./.github/actions/setup + # Hermit's Rust ships only the runner's (Apple Silicon) standard library. + # Cross-compile with the same pinned release from rustup, as Windows does. + - name: Select pinned Rust with the Intel standard library + if: matrix.arch == 'x64' + run: | + version=$(rustc --version | cut -d' ' -f2) + rustup toolchain install "$version" --profile minimal --target "$TARGET" + echo "RUSTUP_TOOLCHAIN=$version" >> "$GITHUB_ENV" + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: - key: macos-prerelease + key: macos-prerelease-${{ matrix.arch }} cache-all-crates: true - name: Set preview version @@ -85,14 +100,16 @@ jobs: env: TAURI_BUNDLER_DMG_IGNORE_CI: "true" run: | - rm -rf target/release/bundle - pnpm tauri build --ci --no-sign --bundles dmg --config "$RUNNER_TEMP/release.json" -- --locked + # Fail fast if the selected Rust lacks this target's standard library. + test -d "$(rustc --print target-libdir --target "$TARGET")" || { echo "::error::Rust has no $TARGET standard library"; exit 1; } + rm -rf "target/$TARGET/release/bundle" + pnpm tauri build --ci --no-sign --bundles dmg --target "$TARGET" --config "$RUNNER_TEMP/release.json" -- --locked - name: Locate unsigned DMG id: unsigned run: | shopt -s nullglob - dmgs=(target/release/bundle/dmg/*.dmg) + dmgs=("target/$TARGET/release/bundle/dmg/"*.dmg) test "${#dmgs[@]}" -eq 1 || { echo '::error::Expected exactly one DMG'; exit 1; } echo "path=${dmgs[0]}" >> "$GITHUB_OUTPUT" @@ -104,11 +121,12 @@ jobs: codesign-s3-bucket: ${{ secrets.CODESIGN_S3_BUCKET }} unsigned-artifact-path: ${{ steps.unsigned.outputs.path }} entitlements-plist-path: src-tauri/entitlements.plist - artifact-name: buzz-app-${{ github.run_id }}-${{ github.run_attempt }}-arm64 + artifact-name: buzz-app-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.lipo }} - name: Verify release DMG env: SIGNED_DMG: ${{ steps.codesign.outputs.signed-dmg-path }} + LIPO_ARCH: ${{ matrix.lipo }} run: | app_dir=$(mktemp -d "$RUNNER_TEMP/verify-release.XXXXXX") hdiutil attach "$SIGNED_DMG" -readonly -nobrowse -mountpoint "$app_dir" >/dev/null @@ -118,17 +136,22 @@ jobs: -R '=anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = EYF346PHUG' "$app" spctl --assess --type execute --verbose=4 "$app" xcrun stapler validate "$app" + test "$(lipo -archs "$app/Contents/MacOS/Buzz")" = "$LIPO_ARCH" || { echo "::error::Buzz is not $LIPO_ARCH"; exit 1; } + # Promotion inspects only the Apple Silicon executable, so check each leg here. + for value in "$BUZZ_UPDATER_PUBLIC_KEY" "$BUZZ_UPDATER_ENDPOINT"; do + grep -aqF "$value" "$app/Contents/MacOS/Buzz" || { echo '::error::Buzz does not embed the updater configuration'; exit 1; } + done test -n "$(/usr/libexec/PlistBuddy -c 'Print :NSMicrophoneUsageDescription' "$app/Contents/Info.plist")" codesign --display --entitlements :- "$app" > "$RUNNER_TEMP/signed-entitlements.plist" test "$(/usr/libexec/PlistBuddy -c 'Print :com.apple.security.device.audio-input' "$RUNNER_TEMP/signed-entitlements.plist")" = true || { echo '::error::Signed app is missing microphone entitlement'; exit 1; } goose="$app/Contents/Resources/bin/goose-acp" test -x "$goose" || { echo '::error::Signed app is missing goose-acp'; exit 1; } - test "$(lipo -archs "$goose")" = arm64 || { echo '::error::goose-acp is not arm64'; exit 1; } + test "$(lipo -archs "$goose")" = "$LIPO_ARCH" || { echo "::error::goose-acp is not $LIPO_ARCH"; exit 1; } codesign --verify --strict --verbose=2 \ -R '=anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = EYF346PHUG' "$goose" command="$app/Contents/Resources/bin/buzz-agent-command" test -x "$command" || { echo '::error::Signed app is missing buzz-agent-command'; exit 1; } - test "$(lipo -archs "$command")" = arm64 || { echo '::error::buzz-agent-command is not arm64'; exit 1; } + test "$(lipo -archs "$command")" = "$LIPO_ARCH" || { echo "::error::buzz-agent-command is not $LIPO_ARCH"; exit 1; } codesign --verify --strict --verbose=2 \ -R '=anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[subject.OU] = EYF346PHUG' "$command" @@ -136,12 +159,13 @@ jobs: env: SIGNED_DMG: ${{ steps.codesign.outputs.signed-dmg-path }} VERSION: ${{ steps.version.outputs.version }} + ARCH: ${{ matrix.arch }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | set -euo pipefail mkdir release-assets - cp "$SIGNED_DMG" "release-assets/Buzz_${VERSION}_aarch64.dmg" + cp "$SIGNED_DMG" "release-assets/Buzz_${VERSION}_${ARCH}.dmg" # Package the verified, Apple-signed and stapled app from the release DMG. app_dir=$(mktemp -d "$RUNNER_TEMP/updater-app.XXXXXX") @@ -149,7 +173,7 @@ jobs: trap 'hdiutil detach "$app_dir" >/dev/null' EXIT app="$app_dir/Buzz.app" test -d "$app" || { echo '::error::Signed DMG contains no app'; exit 1; } - archive="$PWD/release-assets/Buzz_${VERSION}_aarch64.app.tar.gz" + archive="$PWD/release-assets/Buzz_${VERSION}_${ARCH}.app.tar.gz" # ditto copies the complete signed bundle (including symlinks and metadata). staged_app=$(mktemp -d "$RUNNER_TEMP/updater-staging.XXXXXX") ditto "$app" "$staged_app/Buzz.app" @@ -161,7 +185,7 @@ jobs: - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: signed-macos-release + name: signed-macos-${{ matrix.arch }} path: release-assets/ if-no-files-found: error retention-days: 7 @@ -176,8 +200,12 @@ jobs: steps: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: signed-macos-release - path: build-assets/macos + name: signed-macos-aarch64 + path: build-assets/macos-aarch64 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: signed-macos-x64 + path: build-assets/macos-x64 - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: windows-x64-candidate @@ -196,15 +224,17 @@ jobs: SOURCE_SHA: ${{ github.sha }} run: | set -euo pipefail - for platform in macos windows linux; do + for platform in macos-aarch64 macos-x64 windows linux; do (cd "build-assets/$platform" && sha256sum --check SHA256SUMS) done # Exact names also reject mixed versions after a partial-job rerun. # Rerun all jobs so every platform uses the same run attempt/version. mkdir release-assets - cp "build-assets/macos/Buzz_${VERSION}_aarch64.dmg" release-assets/ - cp "build-assets/macos/Buzz_${VERSION}_aarch64.app.tar.gz" release-assets/ - cp "build-assets/macos/Buzz_${VERSION}_aarch64.app.tar.gz.sig" release-assets/ + for arch in aarch64 x64; do + for asset in dmg app.tar.gz app.tar.gz.sig; do + cp "build-assets/macos-$arch/Buzz_${VERSION}_$arch.$asset" release-assets/ + done + done cp "build-assets/windows/Buzz_${VERSION}_x64_unsigned.exe" release-assets/ cp "build-assets/linux/Buzz_${VERSION}_amd64.deb" release-assets/ cp "build-assets/linux/Buzz_${VERSION}_x86_64.AppImage" release-assets/ @@ -223,7 +253,7 @@ jobs: gh release create "v$VERSION" release-assets/* \ --target "$SOURCE_SHA" --prerelease --latest=false \ --title "Buzz $VERSION (desktop preview)" \ - --notes "Desktop preview from commit $SOURCE_SHA, built by Actions run $GITHUB_RUN_ID. Includes Apple Silicon macOS (signed and notarized), Windows x64 (unsigned NSIS installer), and Linux x64 (Ubuntu 24.04 deb and AppImage). Windows/Linux still require desktop acceptance. Updater-enabled installs update from the preview feed." + --notes "Desktop preview from commit $SOURCE_SHA, built by Actions run $GITHUB_RUN_ID. Includes Apple Silicon and Intel macOS (signed and notarized), Windows x64 (unsigned NSIS installer), and Linux x64 (Ubuntu 24.04 deb and AppImage). Windows/Linux still require desktop acceptance. Updater-enabled installs update from the preview feed." promote_preview: name: Promote verified preview @@ -253,13 +283,11 @@ jobs: archive="Buzz_${VERSION}_aarch64.app.tar.gz" mkdir -p "$RUNNER_TEMP/candidate" "$RUNNER_TEMP/current" "$RUNNER_TEMP/served" gh release view "v$VERSION" --json isPrerelease --jq .isPrerelease | grep -qx true - for asset in "$archive" "Buzz_${VERSION}_x64_unsigned.exe" "Buzz_${VERSION}_amd64.deb" "Buzz_${VERSION}_x86_64.AppImage"; do + for asset in "$archive" "Buzz_${VERSION}_x64.app.tar.gz" "Buzz_${VERSION}_x64_unsigned.exe" "Buzz_${VERSION}_amd64.deb" "Buzz_${VERSION}_x86_64.AppImage"; do gh release download "v$VERSION" --pattern "$asset" --pattern "$asset.sig" --dir "$RUNNER_TEMP/candidate" [[ -s "$RUNNER_TEMP/candidate/$asset" && -s "$RUNNER_TEMP/candidate/$asset.sig" ]] done - # Previews built before the executable became `Buzz` stay re-promotable until pruned. - executable=$(tar -tzf "$RUNNER_TEMP/candidate/$archive" | grep -xE 'Buzz\.app/Contents/MacOS/(Buzz|buzz-foundation)') - tar -xOzf "$RUNNER_TEMP/candidate/$archive" "$executable" > "$RUNNER_TEMP/candidate/Buzz" + tar -xOzf "$RUNNER_TEMP/candidate/$archive" Buzz.app/Contents/MacOS/Buzz > "$RUNNER_TEMP/candidate/Buzz" test -s "$RUNNER_TEMP/candidate/Buzz" current='' if gh release view preview-feed >/dev/null 2>&1; then diff --git a/README.md b/README.md index ac26e4f18e..ce3ea2c92e 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ Get the newest build from the [releases page](https://github.com/block/buzz-app/ | Platform | Download | Notes | | --- | --- | --- | -| macOS (Apple Silicon) | `Buzz_…_aarch64.dmg` | Signed and notarized. Intel Macs aren't supported. | +| macOS (Apple Silicon) | `Buzz_…_aarch64.dmg` | Signed and notarized. | +| macOS (Intel) | `Buzz_…_x64.dmg` | Signed and notarized. | | Windows (x64) | `Buzz_…_x64_unsigned.exe` | Not code-signed, so Windows SmartScreen warns you before it runs. | | Linux (x64) | `Buzz_…_amd64.deb` or `Buzz_…_x86_64.AppImage` | Built on Ubuntu 24.04. | @@ -94,8 +95,8 @@ edit files there. ## Preview limitations - Builds come straight from `main`; there's no stable release channel yet. -- macOS builds are for Apple Silicon only. The Windows installer is unsigned, - and Windows and Linux get less testing than macOS. +- The Windows installer is unsigned, and Intel macOS, Windows and Linux get + less testing than Apple Silicon macOS. - Claude and Codex agents need macOS or Linux. Goose and Pi work everywhere. - Buzz doesn't back up your key, so keep your own copy (see step 2). - Bringing your identity over from Buzz 0.X doesn't bring your communities diff --git a/docs/releases.md b/docs/releases.md index 0c4605fd6c..b47f5779c4 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -4,13 +4,13 @@ The **Desktop previews** workflow builds all supported desktop platforms from `main` and publishes one GitHub prerelease, tagged `v-preview..` at the built commit: -- **Apple Silicon macOS:** signed/notarized DMG and a signed updater archive with - its `.sig`. +- **Apple Silicon and Intel macOS:** a signed/notarized DMG and a signed updater + archive with its `.sig` for each architecture. - **Windows x64:** unsigned NSIS `.exe` installer and its updater `.sig`. - **Linux x64:** Ubuntu 24.04 `.deb` and AppImage, each with its updater `.sig`. -- **Provenance:** `SOURCE_COMMIT` and one `SHA256SUMS` covering all nine asset files. +- **Provenance:** `SOURCE_COMMIT` and one `SHA256SUMS` covering all twelve asset files. -Publication waits for all three platform jobs, their existing payload checks and +Publication waits for every platform job, their existing payload checks and the Windows/Linux updater signatures. A failed platform blocks publication and automatic feed promotion; it does not publish a partial release. Downloads are checked against each job's checksums before assembly. If a job fails, use @@ -24,9 +24,11 @@ Runs are scheduled at **00:17, 06:17, 12:17, and 18:17 UTC**. To start one manua gh workflow run release.yml --repo block/buzz-app --ref main ``` -The macOS build uses `macos-latest` and the repository's pinned toolchain. The -workflow publishes an Apple-signed/notarized DMG and a separately Tauri-signed -updater `.app.tar.gz` and `.sig` for Apple Silicon. The updater archive is +The macOS builds run on Apple Silicon `macos-latest` with the repository's +pinned toolchain; the Intel build cross-compiles `x86_64-apple-darwin` with the +same Rust release from rustup, because Hermit's Rust ships only the host's +standard library. Each publishes an Apple-signed/notarized DMG and a separately +Tauri-signed updater `.app.tar.gz` and `.sig`. The updater archive is rebuilt from the verified app in the signed DMG; built-in Tauri artifact creation remains disabled because this DMG-only build does not emit an updater archive. @@ -97,7 +99,7 @@ enable the client flag alone: existing unbound signatures would fail. A signatur mismatch fails before the rolling manifest is replaced. The job builds the Tauri manifest with each artifact's signature from that -release and an immutable versioned asset URL: `darwin-aarch64`, `windows-x86_64`, +release and an immutable versioned asset URL: `darwin-aarch64`, `darwin-x86_64`, `windows-x86_64`, `linux-x86_64-deb` and `linux-x86_64-appimage`. Linux has no generic entry, so a deb install never receives an AppImage. The job refuses full-SemVer rollback and changed same-version metadata, and replaces the rolling manifest last. The diff --git a/scripts/preview-feed.mjs b/scripts/preview-feed.mjs index ce262fa88c..89806d2c84 100644 --- a/scripts/preview-feed.mjs +++ b/scripts/preview-feed.mjs @@ -21,6 +21,7 @@ const release = `v${version}`; // Linux entries are installer-specific: a deb install must never receive an AppImage. const assets = { "darwin-aarch64": `Buzz_${version}_aarch64.app.tar.gz`, + "darwin-x86_64": `Buzz_${version}_x64.app.tar.gz`, "windows-x86_64": `Buzz_${version}_x64_unsigned.exe`, "linux-x86_64-deb": `Buzz_${version}_amd64.deb`, "linux-x86_64-appimage": `Buzz_${version}_x86_64.AppImage`, diff --git a/scripts/prune-preview-releases.mjs b/scripts/prune-preview-releases.mjs index 9a8430ba65..b5e20c024f 100644 --- a/scripts/prune-preview-releases.mjs +++ b/scripts/prune-preview-releases.mjs @@ -36,6 +36,7 @@ assert.match(manifest.version, versionPattern); const protectedTag = `v${manifest.version}`; const assets = { "darwin-aarch64": "aarch64.app.tar.gz", + "darwin-x86_64": "x64.app.tar.gz", "windows-x86_64": "x64_unsigned.exe", "linux-x86_64-deb": "amd64.deb", "linux-x86_64-appimage": "x86_64.AppImage", diff --git a/tests/integration/desktop-release.test.mjs b/tests/integration/desktop-release.test.mjs index f989260e35..f9fac98923 100644 --- a/tests/integration/desktop-release.test.mjs +++ b/tests/integration/desktop-release.test.mjs @@ -27,11 +27,16 @@ const version = `${baseVersion}-preview.42.2`; const sha = "a".repeat(40); const hash = (bytes) => createHash("sha256").update(bytes).digest("hex"); const assets = { - macos: [ + "macos-aarch64": [ `Buzz_${version}_aarch64.dmg`, `Buzz_${version}_aarch64.app.tar.gz`, `Buzz_${version}_aarch64.app.tar.gz.sig`, ], + "macos-x64": [ + `Buzz_${version}_x64.dmg`, + `Buzz_${version}_x64.app.tar.gz`, + `Buzz_${version}_x64.app.tar.gz.sig`, + ], windows: [`Buzz_${version}_x64_unsigned.exe`], linux: [`Buzz_${version}_amd64.deb`, `Buzz_${version}_x86_64.AppImage`], signatures: [ @@ -143,7 +148,7 @@ function fixture(t) { }; } -test("actual assembly command collects all nine assets and emits one complete checksum file", (t) => { +test("actual assembly command collects all twelve assets and emits one complete checksum file", (t) => { const { dir, run } = fixture(t); const result = run(); assert.equal(result.status, 0, result.stderr); @@ -169,6 +174,7 @@ for (const failure of [ "corrupt", "mixed version", "missing platform", + "missing Intel macOS", "missing signature", ]) { test(`actual assembly refuses ${failure} before publication`, (t) => { @@ -182,6 +188,8 @@ for (const failure of [ writeFileSync(join(windows, "SHA256SUMS"), `${hash(file)} ./${older}\n`); } if (failure === "missing platform") rmSync(windows, { recursive: true }); + if (failure === "missing Intel macOS") + rmSync(join(dir, "build-assets/macos-x64"), { recursive: true }); if (failure === "missing signature") rmSync(join(dir, "build-assets/signatures", assets.signatures[2])); assert.notEqual(run().status, 0); diff --git a/tests/integration/packaging.test.mjs b/tests/integration/packaging.test.mjs index 8680fc6a31..d4bd3535c7 100644 --- a/tests/integration/packaging.test.mjs +++ b/tests/integration/packaging.test.mjs @@ -113,20 +113,34 @@ test("all-platform releases preserve candidate isolation and read-only installer assert.deepEqual( downloads.map((step) => step.with.path), [ - "build-assets/macos", + "build-assets/macos-aarch64", + "build-assets/macos-x64", "build-assets/windows", "build-assets/linux", "build-assets/signatures", ], ); - for (const [index, job] of [ - jobs.build, - jobs.windows, - jobs.linux, - jobs.updater_signatures, - ].entries()) { - assert.equal(downloads[index].with.name, job.steps.at(-1).with.name); - } + assert.deepEqual(jobs.build.strategy.matrix.include, [ + { target: "aarch64-apple-darwin", arch: "aarch64", lipo: "arm64" }, + { target: "x86_64-apple-darwin", arch: "x64", lipo: "x86_64" }, + ]); + const build = jobs.build.steps.find( + (step) => step.name === "Build unsigned app and DMG", + ).run; + assert.match(build, /target-libdir --target "\$TARGET"/); + assert.match(build, /pnpm tauri build .*--target "\$TARGET"/); + const macos = jobs.build.steps.at(-1).with.name; + assert.deepEqual( + downloads.map((step) => step.with.name), + [ + ...jobs.build.strategy.matrix.include.map(({ arch }) => + macos.replace(/\$\{\{ matrix\.arch \}\}/, arch), + ), + ...[jobs.windows, jobs.linux, jobs.updater_signatures].map( + (job) => job.steps.at(-1).with.name, + ), + ], + ); const publication = jobs.publish.steps.at(-1).run; assert.match( publication, diff --git a/tests/integration/preview-feed.test.mjs b/tests/integration/preview-feed.test.mjs index e3e9318228..f8d017f968 100644 --- a/tests/integration/preview-feed.test.mjs +++ b/tests/integration/preview-feed.test.mjs @@ -51,6 +51,7 @@ function fixture(dir, version = "0.0.0-preview.22.1") { mkdirSync(assetDir); const installers = [ `Buzz_${version}_aarch64.app.tar.gz`, + `Buzz_${version}_x64.app.tar.gz`, `Buzz_${version}_x64_unsigned.exe`, `Buzz_${version}_amd64.deb`, `Buzz_${version}_x86_64.AppImage`, @@ -173,6 +174,7 @@ test("preview feed signs every desktop installer into its updater target", () => const { platforms } = JSON.parse(readFileSync(manifest, "utf8")); const targets = [ "darwin-aarch64", + "darwin-x86_64", "windows-x86_64", "linux-x86_64-deb", "linux-x86_64-appimage", @@ -186,7 +188,7 @@ test("preview feed signs every desktop installer into its updater target", () => url: `https://github.com/block/buzz-app/releases/download/v0.0.0-preview.22.1/${name}`, }); }); - const [, windows, deb] = installers; + const [, , windows, deb] = installers; const data = JSON.parse(readFileSync(manifest, "utf8")); delete data.platforms["windows-x86_64"]; writeFileSync(manifest, JSON.stringify(data)); diff --git a/tests/integration/prune-preview-releases.test.mjs b/tests/integration/prune-preview-releases.test.mjs index 28a9084966..71ead22664 100644 --- a/tests/integration/prune-preview-releases.test.mjs +++ b/tests/integration/prune-preview-releases.test.mjs @@ -56,6 +56,7 @@ function fixture(t) { platforms: Object.fromEntries( Object.entries({ "darwin-aarch64": "aarch64.app.tar.gz", + "darwin-x86_64": "x64.app.tar.gz", "windows-x86_64": "x64_unsigned.exe", "linux-x86_64-deb": "amd64.deb", "linux-x86_64-appimage": "x86_64.AppImage",