From e2df84f5b98bd05b5433fb20ac6a1df3a09cb51e Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Sun, 9 Aug 2026 01:50:38 +0700 Subject: [PATCH] ci(macos): add fail-closed release candidate gate --- .github/workflows/macos-release-candidate.yml | 259 ++++++++++ electron/macosDistributionPolicy.test.mjs | 104 ++++ package.json | 1 + scripts/macos-distribution-policy.mjs | 111 ++++ scripts/verify-macos-distribution.mjs | 474 ++++++++++++++++++ 5 files changed, 949 insertions(+) create mode 100644 .github/workflows/macos-release-candidate.yml create mode 100644 electron/macosDistributionPolicy.test.mjs create mode 100644 scripts/macos-distribution-policy.mjs create mode 100644 scripts/verify-macos-distribution.mjs diff --git a/.github/workflows/macos-release-candidate.yml b/.github/workflows/macos-release-candidate.yml new file mode 100644 index 00000000..d7b7843c --- /dev/null +++ b/.github/workflows/macos-release-candidate.yml @@ -0,0 +1,259 @@ +name: macOS Release Candidate + +run-name: macOS RC validation for ${{ inputs.source_sha }} + +on: + workflow_dispatch: + inputs: + source_sha: + description: Exact 40-character SHA at the current head of main + required: true + type: string + +permissions: + contents: read + +concurrency: + group: macos-release-candidate-${{ inputs.source_sha }} + cancel-in-progress: false + +jobs: + authorize-source: + name: Authorize source commit + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + source_sha: ${{ steps.authorize.outputs.source_sha }} + steps: + - name: Checkout current main + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: main + + - name: Require the canonical repository and current main + id: authorize + shell: bash + env: + REQUESTED_SHA: ${{ inputs.source_sha }} + run: | + set -euo pipefail + + if [[ "${GITHUB_REPOSITORY,,}" != "webadderallorg/recordly" ]]; then + echo "This workflow may run only in webadderallorg/Recordly." + exit 1 + fi + + if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then + echo "This workflow must itself be dispatched from main." + exit 1 + fi + + requested_sha="${REQUESTED_SHA,,}" + if [[ ! "$requested_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "source_sha must be an exact 40-character commit SHA." + exit 1 + fi + + main_sha="$(git rev-parse HEAD)" + if [[ "$requested_sha" != "$main_sha" ]]; then + echo "Refusing to sign a stale or non-main commit." + echo "Requested: $requested_sha" + echo "Current main: $main_sha" + exit 1 + fi + + echo "source_sha=$main_sha" >> "$GITHUB_OUTPUT" + echo "Authorized current main commit: $main_sha" + + build-and-verify: + name: Sign, notarize, and verify macOS ${{ matrix.arch }} + needs: authorize-source + runs-on: ${{ matrix.runner }} + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + include: + - arch: x64 + arch_tag: darwin-x64 + runner: macos-15-intel + - arch: arm64 + arch_tag: darwin-arm64 + runner: macos-14 + env: + CI: true + steps: + - name: Checkout authorized source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ needs.authorize-source.outputs.source_sha }} + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + cache: npm + cache-dependency-path: package-lock.json + node-version: '22' + + - name: Setup Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.11' + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Install and verify bundled FFmpeg + shell: bash + run: | + set -euo pipefail + node scripts/install-ffmpeg-static.mjs + node -e "const fs=require('node:fs'); const ffmpeg=require('ffmpeg-static'); if (typeof ffmpeg !== 'string' || !fs.existsSync(ffmpeg)) throw new Error('Bundled FFmpeg is missing'); console.log(ffmpeg);" + + - name: Install app dependencies + run: npx electron-builder install-app-deps + + - name: Validate source entitlement plists + run: | + plutil -lint build/entitlements.mac.plist + plutil -lint build/entitlements.mac.inherit.plist + + - name: Build macOS application inputs + shell: bash + run: | + set -euo pipefail + npm run build:platform-native-helpers + npx tsc + npx vite build --config vite.config.ts + npm run normalize:electron-main-cjs + npm run smoke:electron-main-cjs + + - name: Validate Apple credentials and signing certificate + shell: bash + env: + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_SIGNING_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_SIGNING_CERTIFICATE_P12_BASE64 }} + APPLE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_SIGNING_CERTIFICATE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + set -euo pipefail + umask 077 + + for name in APPLE_SIGNING_CERTIFICATE_P12_BASE64 APPLE_SIGNING_CERTIFICATE_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do + if [[ -z "${!name:-}" ]]; then + echo "Missing required macOS candidate secret: $name" + exit 1 + fi + done + + if [[ ! "$APPLE_TEAM_ID" =~ ^[A-Z0-9]{10}$ ]]; then + echo "APPLE_TEAM_ID must be exactly 10 uppercase letters or digits." + exit 1 + fi + + cert_path="$RUNNER_TEMP/recordly-developer-id.p12" + cert_pem_path="$RUNNER_TEMP/recordly-developer-id.pem" + printf '%s' "$APPLE_SIGNING_CERTIFICATE_P12_BASE64" | base64 --decode > "$cert_path" + openssl pkcs12 \ + -in "$cert_path" \ + -clcerts \ + -nokeys \ + -passin env:APPLE_SIGNING_CERTIFICATE_PASSWORD \ + -out "$cert_pem_path" + + openssl x509 -in "$cert_pem_path" -noout -checkend 86400 + cert_subject="$(openssl x509 -in "$cert_pem_path" -noout -subject -nameopt RFC2253 | sed 's/^subject=//')" + if [[ "$cert_subject" != *"CN=Developer ID Application:"* ]]; then + echo "The P12 leaf certificate is not a Developer ID Application certificate." + exit 1 + fi + if [[ ! "$cert_subject" =~ (^|,)OU=${APPLE_TEAM_ID}(,|$) ]]; then + echo "The P12 certificate team does not match APPLE_TEAM_ID." + exit 1 + fi + + echo "Developer ID certificate preflight passed and remains valid for at least 24 hours." + + - name: Package, sign, and notarize macOS ${{ matrix.arch }} + shell: bash + env: + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + CSC_KEY_PASSWORD: ${{ secrets.APPLE_SIGNING_CERTIFICATE_PASSWORD }} + CSC_LINK: ${{ runner.temp }}/recordly-developer-id.p12 + run: | + set -euo pipefail + npx electron-builder \ + --mac dir dmg zip \ + --${{ matrix.arch }} \ + --publish never \ + -c.mac.forceCodeSigning=true \ + -c.mac.notarize=true + + - name: Remove local signing material + if: always() + shell: bash + run: | + rm -f \ + "$RUNNER_TEMP/recordly-developer-id.p12" \ + "$RUNNER_TEMP/recordly-developer-id.pem" + + - name: Smoke test packaged binary paths + env: + PACKAGED_SMOKE_ARCH_TAGS: ${{ matrix.arch_tag }} + run: npm run smoke:packaged-binaries + + - name: Verify signed and notarized distribution artifacts + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: >- + npm run verify:macos-distribution -- + --release-dir release + --arch ${{ matrix.arch }} + --team-id "$APPLE_TEAM_ID" + --report "release/macos-distribution-report-${{ matrix.arch }}.json" + --summary "$GITHUB_STEP_SUMMARY" + + - name: Generate candidate checksums + run: npm run checksums:release -- SHA256SUMS-macos-${{ matrix.arch }}.txt + + - name: Upload temporary candidate evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: macos-${{ matrix.arch }}-release-candidate + path: | + release/*.dmg + release/*.zip + release/*.blockmap + release/latest-mac.yml + release/SHA256SUMS-macos-${{ matrix.arch }}.txt + release/macos-distribution-report-${{ matrix.arch }}.json + if-no-files-found: error + retention-days: 3 + + candidate-verdict: + name: macOS candidate verdict + if: always() + needs: + - authorize-source + - build-and-verify + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Require both architectures to pass + shell: bash + env: + AUTHORIZE_RESULT: ${{ needs.authorize-source.result }} + BUILD_RESULT: ${{ needs.build-and-verify.result }} + run: | + set -euo pipefail + if [[ "$AUTHORIZE_RESULT" != "success" || "$BUILD_RESULT" != "success" ]]; then + echo "macOS candidate rejected: authorize=$AUTHORIZE_RESULT build=$BUILD_RESULT" + exit 1 + fi + echo "Both signed and notarized macOS architectures passed the distribution gate." diff --git a/electron/macosDistributionPolicy.test.mjs b/electron/macosDistributionPolicy.test.mjs new file mode 100644 index 00000000..d6f56b9c --- /dev/null +++ b/electron/macosDistributionPolicy.test.mjs @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { + assertValidAppleTeamId, + collectArchitectureErrors, + collectCodeSigningMetadataErrors, + collectEntitlementErrors, + expectedMachOArchitecture, + parseLipoArchitectures, +} from "../scripts/macos-distribution-policy.mjs"; + +const validCodeSigningDetails = ` +Identifier=dev.recordly.app +CodeDirectory v=20500 size=123 flags=0x10000(runtime) hashes=3+7 location=embedded +Authority=Developer ID Application: Recordly Developer (A1B2C3D4E5) +Authority=Developer ID Certification Authority +Authority=Apple Root CA +Timestamp=Aug 9, 2026 at 10:00:00 +TeamIdentifier=A1B2C3D4E5 +`; + +describe("macOS distribution signing policy", () => { + it("accepts a valid Developer ID signature", () => { + expect(collectCodeSigningMetadataErrors(validCodeSigningDetails, "A1B2C3D4E5")).toEqual([]); + }); + + it("rejects a wrong identity, team, timestamp, runtime, and bundle identifier", () => { + const details = ` +Identifier=dev.example.app +CodeDirectory v=20400 size=123 flags=0x0(none) +Authority=Apple Development: Example (Z9Y8X7W6V5) +Timestamp=none +TeamIdentifier=Z9Y8X7W6V5 +`; + + expect(collectCodeSigningMetadataErrors(details, "A1B2C3D4E5")).toEqual([ + "unexpected bundle identifier: dev.example.app", + "the leaf signing authority is not Developer ID Application", + "unexpected TeamIdentifier: Z9Y8X7W6V5", + "secure signing timestamp is missing", + "hardened runtime flag is missing", + ]); + }); + + it("validates the expected Apple team ID shape", () => { + expect(() => assertValidAppleTeamId("A1B2C3D4E5")).not.toThrow(); + expect(() => assertValidAppleTeamId("short")).toThrow(/exactly 10/); + }); +}); + +describe("macOS distribution entitlement policy", () => { + const validEntitlements = { + "com.apple.security.cs.allow-jit": true, + "com.apple.security.cs.allow-unsigned-executable-memory": true, + "com.apple.security.cs.disable-library-validation": true, + "com.apple.security.device.audio-input": true, + "com.apple.security.device.camera": true, + }; + + it("accepts the intended production entitlements", () => { + expect(collectEntitlementErrors(validEntitlements)).toEqual([]); + }); + + it("rejects missing capabilities and debug attachment", () => { + expect( + collectEntitlementErrors({ + ...validEntitlements, + "com.apple.security.cs.allow-jit": false, + "com.apple.security.get-task-allow": true, + }), + ).toEqual([ + "required entitlement is missing or disabled: com.apple.security.cs.allow-jit", + "distribution build must not enable com.apple.security.get-task-allow", + ]); + }); +}); + +describe("macOS distribution architecture policy", () => { + it("parses thin and fat lipo output", () => { + expect(parseLipoArchitectures("Non-fat file: App is architecture: arm64")).toEqual([ + "arm64", + ]); + expect( + parseLipoArchitectures("Architectures in the fat file: App are: x86_64 arm64"), + ).toEqual(["x86_64", "arm64"]); + }); + + it("uses path-specific helper architecture before the build architecture", () => { + expect( + expectedMachOArchitecture("app/electron/native/bin/darwin-arm64/helper", "x64"), + ).toBe("arm64"); + expect( + expectedMachOArchitecture("app/electron/native/bin/darwin-x64/helper", "arm64"), + ).toBe("x86_64"); + expect(expectedMachOArchitecture("Recordly.app/Contents/MacOS/Recordly", "x64")).toBe( + "x86_64", + ); + }); + + it("reports a binary that lacks the required architecture", () => { + expect( + collectArchitectureErrors("Recordly.app/Contents/MacOS/Recordly", "arm64", "x64"), + ).toEqual(["Recordly.app/Contents/MacOS/Recordly does not contain x86_64 (found: arm64)"]); + }); +}); diff --git a/package.json b/package.json index 6fd53cd3..bf17e9eb 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "normalize:electron-main-cjs": "node scripts/normalize-electron-main-cjs.mjs", "smoke:electron-main-cjs": "node scripts/smoke-electron-main-cjs.mjs", "smoke:packaged-binaries": "node scripts/smoke-packaged-binaries.mjs", + "verify:macos-distribution": "node scripts/verify-macos-distribution.mjs", "checksums:release": "node scripts/write-release-checksums.mjs", "release:create": "node scripts/create-release.mjs", "test": "vitest --run", diff --git a/scripts/macos-distribution-policy.mjs b/scripts/macos-distribution-policy.mjs new file mode 100644 index 00000000..c35eda35 --- /dev/null +++ b/scripts/macos-distribution-policy.mjs @@ -0,0 +1,111 @@ +const TEAM_ID_PATTERN = /^[A-Z0-9]{10}$/; + +export const REQUIRED_MACOS_ENTITLEMENTS = Object.freeze([ + "com.apple.security.cs.allow-jit", + "com.apple.security.cs.allow-unsigned-executable-memory", + "com.apple.security.cs.disable-library-validation", + "com.apple.security.device.audio-input", + "com.apple.security.device.camera", +]); + +function readCodeSignValue(details, key) { + const prefix = `${key}=`; + return details + .split(/\r?\n/) + .find((line) => line.startsWith(prefix)) + ?.slice(prefix.length) + .trim(); +} + +export function assertValidAppleTeamId(teamId) { + if (!TEAM_ID_PATTERN.test(teamId)) { + throw new Error("APPLE_TEAM_ID must be exactly 10 uppercase letters or digits"); + } +} + +export function collectCodeSigningMetadataErrors(details, expectedTeamId) { + const errors = []; + const authorities = details + .split(/\r?\n/) + .filter((line) => line.startsWith("Authority=")) + .map((line) => line.slice("Authority=".length).trim()); + const identifier = readCodeSignValue(details, "Identifier"); + const teamIdentifier = readCodeSignValue(details, "TeamIdentifier"); + const timestamp = readCodeSignValue(details, "Timestamp"); + const codeDirectory = details.split(/\r?\n/).find((line) => line.startsWith("CodeDirectory ")); + + if (identifier !== "dev.recordly.app") { + errors.push(`unexpected bundle identifier: ${identifier ?? "missing"}`); + } + + if (!authorities[0]?.startsWith("Developer ID Application:")) { + errors.push("the leaf signing authority is not Developer ID Application"); + } + + if (teamIdentifier !== expectedTeamId) { + errors.push(`unexpected TeamIdentifier: ${teamIdentifier ?? "missing"}`); + } + + if (!timestamp || timestamp.toLowerCase() === "none") { + errors.push("secure signing timestamp is missing"); + } + + if (!codeDirectory?.includes("runtime")) { + errors.push("hardened runtime flag is missing"); + } + + return errors; +} + +export function collectEntitlementErrors(entitlements) { + const errors = []; + + for (const entitlement of REQUIRED_MACOS_ENTITLEMENTS) { + if (entitlements[entitlement] !== true) { + errors.push(`required entitlement is missing or disabled: ${entitlement}`); + } + } + + if (entitlements["com.apple.security.get-task-allow"] === true) { + errors.push("distribution build must not enable com.apple.security.get-task-allow"); + } + + return errors; +} + +export function expectedMachOArchitecture(filePath, buildArch) { + const normalizedPath = filePath.replaceAll("\\", "/"); + if (normalizedPath.includes("/darwin-arm64/")) { + return "arm64"; + } + + if (normalizedPath.includes("/darwin-x64/")) { + return "x86_64"; + } + + return buildArch === "arm64" ? "arm64" : "x86_64"; +} + +export function parseLipoArchitectures(output) { + const trimmed = output.trim(); + const architectureList = trimmed.match(/are:\s+(.+)$/i)?.[1]; + if (architectureList) { + return architectureList.trim().split(/\s+/); + } + + const singleArchitecture = trimmed.match(/architecture:\s+([^\s]+)$/i)?.[1]; + return singleArchitecture ? [singleArchitecture] : trimmed.split(/\s+/).filter(Boolean); +} + +export function collectArchitectureErrors(filePath, lipoOutput, buildArch) { + const expectedArchitecture = expectedMachOArchitecture(filePath, buildArch); + const architectures = parseLipoArchitectures(lipoOutput); + + if (architectures.includes(expectedArchitecture)) { + return []; + } + + return [ + `${filePath} does not contain ${expectedArchitecture} (found: ${architectures.join(", ") || "none"})`, + ]; +} diff --git a/scripts/verify-macos-distribution.mjs b/scripts/verify-macos-distribution.mjs new file mode 100644 index 00000000..72cf03d1 --- /dev/null +++ b/scripts/verify-macos-distribution.mjs @@ -0,0 +1,474 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { + existsSync, + lstatSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + assertValidAppleTeamId, + collectArchitectureErrors, + collectCodeSigningMetadataErrors, + collectEntitlementErrors, +} from "./macos-distribution-policy.mjs"; + +const projectRoot = process.cwd(); +const packageJson = JSON.parse(readFileSync(path.join(projectRoot, "package.json"), "utf8")); +const productName = packageJson.productName ?? packageJson.name ?? "Recordly"; +const expectedBundleId = "dev.recordly.app"; +const commandTimeoutMs = 5 * 60 * 1000; +const maxReportDetailLength = 4_000; + +function parseArguments(argv) { + const options = {}; + for (let index = 0; index < argv.length; index += 2) { + const key = argv[index]; + const value = argv[index + 1]; + if (!key?.startsWith("--") || value === undefined) { + throw new Error(`Invalid argument near ${key ?? "end of input"}`); + } + options[key.slice(2)] = value; + } + + const arch = options.arch; + if (arch !== "x64" && arch !== "arm64") { + throw new Error("--arch must be x64 or arm64"); + } + + if (!options["team-id"]) { + throw new Error("--team-id is required"); + } + assertValidAppleTeamId(options["team-id"]); + + return { + arch, + releaseDir: path.resolve(options["release-dir"] ?? "release"), + reportPath: path.resolve( + options.report ?? `release/macos-distribution-report-${arch}.json`, + ), + summaryPath: options.summary ? path.resolve(options.summary) : null, + teamId: options["team-id"], + }; +} + +function formatCommand(command, args) { + return [command, ...args] + .map((part) => (/^[\w./:=@+-]+$/.test(part) ? part : JSON.stringify(part))) + .join(" "); +} + +function runProcess(command, args, { timeout = commandTimeoutMs } = {}) { + const result = spawnSync(command, args, { + cwd: projectRoot, + encoding: "utf8", + timeout, + }); + const stdout = result.stdout?.trim() ?? ""; + const stderr = result.stderr?.trim() ?? ""; + const output = [stdout, stderr].filter(Boolean).join("\n"); + + if (result.error) { + throw new Error(`${formatCommand(command, args)} failed: ${result.error.message}`); + } + + if (result.status !== 0) { + throw new Error( + `${formatCommand(command, args)} exited with ${result.status}${output ? `\n${output}` : ""}`, + ); + } + + return { output, stderr, stdout }; +} + +function assertPolicy(errors, label) { + if (errors.length > 0) { + throw new Error(`${label}:\n- ${errors.join("\n- ")}`); + } +} + +function assertFile(filePath, label) { + if (!existsSync(filePath) || !statSync(filePath).isFile()) { + throw new Error(`${label} is missing: ${filePath}`); + } + + if (statSync(filePath).size === 0) { + throw new Error(`${label} is empty: ${filePath}`); + } +} + +function findAppBundles(rootPath) { + if (!existsSync(rootPath)) { + return []; + } + + const matches = []; + const queue = [rootPath]; + while (queue.length > 0) { + const currentPath = queue.shift(); + if (!currentPath) { + continue; + } + + for (const entry of readdirSync(currentPath, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.isSymbolicLink()) { + continue; + } + + const childPath = path.join(currentPath, entry.name); + if (entry.name === `${productName}.app`) { + matches.push(childPath); + continue; + } + + queue.push(childPath); + } + } + + return matches; +} + +function findSingleAppBundle(rootPath, label) { + const appBundles = findAppBundles(rootPath); + if (appBundles.length !== 1) { + throw new Error( + `${label} must contain exactly one ${productName}.app; found ${appBundles.length}`, + ); + } + return appBundles[0]; +} + +function walkRegularFiles(rootPath) { + const files = []; + const queue = [rootPath]; + while (queue.length > 0) { + const currentPath = queue.shift(); + if (!currentPath) { + continue; + } + + for (const entry of readdirSync(currentPath, { withFileTypes: true })) { + const childPath = path.join(currentPath, entry.name); + const childStats = lstatSync(childPath); + if (childStats.isSymbolicLink()) { + continue; + } + + if (childStats.isDirectory()) { + queue.push(childPath); + } else if (childStats.isFile()) { + files.push(childPath); + } + } + } + + return files; +} + +function extractPlist(commandResult) { + const output = [commandResult.stdout, commandResult.stderr].filter(Boolean).join("\n"); + const xmlStart = output.indexOf("= 0 ? xmlStart : plistStart; + const end = output.lastIndexOf(""); + if (start < 0 || end < start) { + throw new Error("codesign did not return an XML entitlement plist"); + } + return output.slice(start, end + "".length); +} + +function shortenDetail(value) { + const detail = String(value ?? "").trim(); + if (detail.length <= maxReportDetailLength) { + return detail; + } + return `${detail.slice(0, maxReportDetailLength)}\n[truncated]`; +} + +function createRecorder(report) { + return function check(name, operation) { + const startedAt = Date.now(); + try { + const detail = operation(); + report.checks.push({ + durationMs: Date.now() - startedAt, + name, + status: "passed", + ...(detail ? { detail: shortenDetail(detail) } : {}), + }); + console.log(`[macos-distribution] PASS ${name}`); + return detail; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + report.checks.push({ + detail: shortenDetail(detail), + durationMs: Date.now() - startedAt, + name, + status: "failed", + }); + console.error(`[macos-distribution] FAIL ${name}: ${detail}`); + throw error; + } + }; +} + +function verifyInfoPlist(appPath, label, check) { + const infoPlistPath = path.join(appPath, "Contents", "Info.plist"); + check(`${label}: Info.plist policy`, () => { + assertFile(infoPlistPath, "Info.plist"); + const plistJson = runProcess("plutil", [ + "-convert", + "json", + "-o", + "-", + infoPlistPath, + ]).stdout; + const info = JSON.parse(plistJson); + const errors = []; + if (info.CFBundleIdentifier !== expectedBundleId) { + errors.push(`unexpected CFBundleIdentifier: ${info.CFBundleIdentifier ?? "missing"}`); + } + if (info.CFBundleShortVersionString !== packageJson.version) { + errors.push( + `unexpected CFBundleShortVersionString: ${info.CFBundleShortVersionString ?? "missing"}`, + ); + } + for (const usageKey of [ + "NSAudioCaptureUsageDescription", + "NSCameraUsageDescription", + "NSMicrophoneUsageDescription", + ]) { + if (typeof info[usageKey] !== "string" || info[usageKey].trim().length === 0) { + errors.push(`${usageKey} is missing or empty`); + } + } + assertPolicy(errors, `${label} Info.plist policy failed`); + return `${info.CFBundleIdentifier} ${info.CFBundleShortVersionString}`; + }); +} + +function verifyEntitlements(appPath, label, tempRoot, check) { + check(`${label}: signed entitlements`, () => { + const result = runProcess("codesign", [ + "--display", + "--entitlements", + "-", + "--xml", + appPath, + ]); + const entitlementsPath = path.join( + tempRoot, + `${label.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-")}-entitlements.plist`, + ); + writeFileSync(entitlementsPath, extractPlist(result)); + runProcess("plutil", ["-lint", entitlementsPath]); + const entitlements = JSON.parse( + runProcess("plutil", ["-convert", "json", "-o", "-", entitlementsPath]).stdout, + ); + assertPolicy(collectEntitlementErrors(entitlements), `${label} entitlement policy failed`); + return "required runtime, camera, and audio entitlements are present; get-task-allow is absent"; + }); +} + +function verifyMachOBinaries(appPath, arch, check) { + check("packaged app: nested Mach-O signatures and architectures", () => { + const machOBinaries = []; + for (const filePath of walkRegularFiles(appPath)) { + const fileType = runProcess("file", ["-b", filePath]).stdout; + if (fileType.includes("Mach-O")) { + machOBinaries.push(filePath); + } + } + + if (machOBinaries.length === 0) { + throw new Error("no Mach-O binaries were found in the app bundle"); + } + + for (const binaryPath of machOBinaries) { + runProcess("codesign", ["--verify", "--strict", "--verbose=2", binaryPath]); + const lipoOutput = runProcess("lipo", ["-info", binaryPath]).output; + assertPolicy( + collectArchitectureErrors(path.relative(appPath, binaryPath), lipoOutput, arch), + "Mach-O architecture policy failed", + ); + } + + return `${machOBinaries.length} Mach-O binaries verified`; + }); +} + +function verifyAppBundle(appPath, label, { arch, check, full, teamId, tempRoot }) { + check(`${label}: strict deep code signature`, () => { + runProcess("codesign", ["--verify", "--deep", "--strict", "--verbose=2", appPath]); + }); + + check(`${label}: Developer ID metadata`, () => { + const details = runProcess("codesign", ["--display", "--verbose=4", appPath]).output; + assertPolicy( + collectCodeSigningMetadataErrors(details, teamId), + `${label} code-signing policy failed`, + ); + return `Developer ID Application; TeamIdentifier=${teamId}; hardened runtime; secure timestamp`; + }); + + verifyInfoPlist(appPath, label, check); + verifyEntitlements(appPath, label, tempRoot, check); + + if (full) { + verifyMachOBinaries(appPath, arch, check); + } + + check(`${label}: stapled notarization ticket`, () => { + runProcess("xcrun", ["stapler", "validate", appPath]); + }); + + check(`${label}: Gatekeeper execution assessment`, () => { + return runProcess("spctl", ["--assess", "--type", "execute", "--verbose=4", appPath]) + .output; + }); + + check(`${label}: distribution policy assessment`, () => { + return runProcess("syspolicy_check", ["distribution", appPath], { + timeout: 10 * 60 * 1000, + }).output; + }); +} + +function writeReport(report, reportPath, summaryPath) { + report.completedAt = new Date().toISOString(); + report.result = report.checks.some((item) => item.status === "failed") ? "failed" : "passed"; + writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); + + if (!summaryPath) { + return; + } + + const lines = [ + `## macOS ${report.arch} distribution verification`, + "", + `Result: **${report.result.toUpperCase()}**`, + "", + "| Check | Result |", + "| --- | --- |", + ...report.checks.map( + (item) => + `| ${item.name.replaceAll("|", "\\|")} | ${item.status === "passed" ? "PASS" : "FAIL"} |`, + ), + "", + ]; + writeFileSync(summaryPath, `${lines.join("\n")}\n`, { flag: "a" }); +} + +export function verifyMacOSDistribution(argv = process.argv.slice(2)) { + if (process.platform !== "darwin") { + throw new Error("macOS distribution verification must run on macOS"); + } + + const options = parseArguments(argv); + const report = { + arch: options.arch, + checks: [], + packageVersion: packageJson.version, + schemaVersion: 1, + sourceCommit: process.env.GITHUB_SHA ?? null, + startedAt: new Date().toISOString(), + }; + const check = createRecorder(report); + const tempRoot = mkdtempSync(path.join(os.tmpdir(), "recordly-macos-distribution-")); + let mountedDmgPath = null; + + try { + const artifactSuffix = options.arch === "arm64" ? "arm64" : "x64"; + const dmgPath = path.join(options.releaseDir, `${productName}-${artifactSuffix}.dmg`); + const zipPath = path.join(options.releaseDir, `${productName}-${artifactSuffix}.zip`); + + check("release artifacts exist", () => { + assertFile(dmgPath, "DMG artifact"); + assertFile(zipPath, "ZIP artifact"); + return `${path.basename(dmgPath)}, ${path.basename(zipPath)}`; + }); + + const packagedAppPath = check("packaged app bundle exists", () => + findSingleAppBundle(options.releaseDir, "release directory"), + ); + verifyAppBundle(packagedAppPath, "packaged app", { + ...options, + check, + full: true, + tempRoot, + }); + + check("DMG filesystem integrity", () => runProcess("hdiutil", ["verify", dmgPath]).output); + const dmgMountPath = path.join(tempRoot, "dmg"); + check("DMG mounts read-only", () => { + runProcess("mkdir", ["-p", dmgMountPath]); + runProcess("hdiutil", [ + "attach", + "-nobrowse", + "-readonly", + "-mountpoint", + dmgMountPath, + dmgPath, + ]); + mountedDmgPath = dmgMountPath; + }); + const dmgAppPath = check("DMG contains one app bundle", () => + findSingleAppBundle(dmgMountPath, "DMG"), + ); + verifyAppBundle(dmgAppPath, "DMG app", { + ...options, + check, + full: false, + tempRoot, + }); + check("DMG detaches cleanly", () => { + runProcess("hdiutil", ["detach", dmgMountPath]); + mountedDmgPath = null; + }); + + const zipExtractPath = path.join(tempRoot, "zip"); + check("ZIP extracts cleanly", () => { + runProcess("mkdir", ["-p", zipExtractPath]); + runProcess("ditto", ["-x", "-k", "--sequesterRsrc", zipPath, zipExtractPath]); + }); + const zipAppPath = check("ZIP contains one app bundle", () => + findSingleAppBundle(zipExtractPath, "ZIP"), + ); + verifyAppBundle(zipAppPath, "ZIP app", { + ...options, + check, + full: false, + tempRoot, + }); + + writeReport(report, options.reportPath, options.summaryPath); + console.log(`[macos-distribution] verification report: ${options.reportPath}`); + return report; + } catch (error) { + writeReport(report, options.reportPath, options.summaryPath); + throw error; + } finally { + if (mountedDmgPath) { + spawnSync("hdiutil", ["detach", "-force", mountedDmgPath], { encoding: "utf8" }); + } + rmSync(tempRoot, { force: true, recursive: true }); + } +} + +const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : null; +if (invokedPath === fileURLToPath(import.meta.url)) { + try { + verifyMacOSDistribution(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } +}