mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 14:55:37 +00:00
Merge pull request #801 from webadderallorg/ci/macos-release-candidate-validation
ci(macos): add fail-closed release candidate gate
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
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"
|
||||
if ! openssl pkcs12 \
|
||||
-in "$cert_path" \
|
||||
-clcerts \
|
||||
-nokeys \
|
||||
-passin env:APPLE_SIGNING_CERTIFICATE_PASSWORD \
|
||||
-out "$cert_pem_path"; then
|
||||
rm -f "$cert_pem_path"
|
||||
pkcs12_help="$(openssl pkcs12 -help 2>&1 || true)"
|
||||
if [[ "$pkcs12_help" != *"-legacy"* ]]; then
|
||||
echo "PKCS#12 extraction failed and this OpenSSL has no legacy-provider fallback."
|
||||
exit 1
|
||||
fi
|
||||
echo "Standard PKCS#12 extraction failed; retrying legacy Keychain compatibility."
|
||||
openssl pkcs12 \
|
||||
-legacy \
|
||||
-in "$cert_path" \
|
||||
-clcerts \
|
||||
-nokeys \
|
||||
-passin env:APPLE_SIGNING_CERTIFICATE_PASSWORD \
|
||||
-out "$cert_pem_path"
|
||||
fi
|
||||
|
||||
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."
|
||||
@@ -0,0 +1,124 @@
|
||||
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",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects unreviewed root runtime exceptions", () => {
|
||||
expect(
|
||||
collectEntitlementErrors({
|
||||
...validEntitlements,
|
||||
"com.apple.security.cs.allow-dyld-environment-variables": true,
|
||||
}),
|
||||
).toEqual([
|
||||
"unexpected root application entitlement: com.apple.security.cs.allow-dyld-environment-variables",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects disabled but unreviewed entitlement keys", () => {
|
||||
expect(
|
||||
collectEntitlementErrors({
|
||||
...validEntitlements,
|
||||
"com.apple.security.get-task-allow": false,
|
||||
}),
|
||||
).toEqual(["unexpected root application entitlement: 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)"]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
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",
|
||||
]);
|
||||
|
||||
const ALLOWED_MACOS_ENTITLEMENTS = new Set(REQUIRED_MACOS_ENTITLEMENTS);
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const entitlement of Object.keys(entitlements).sort()) {
|
||||
if (ALLOWED_MACOS_ENTITLEMENTS.has(entitlement)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
entitlement === "com.apple.security.get-task-allow" &&
|
||||
entitlements[entitlement] === true
|
||||
) {
|
||||
errors.push("distribution build must not enable com.apple.security.get-task-allow");
|
||||
continue;
|
||||
}
|
||||
|
||||
errors.push(`unexpected root application entitlement: ${entitlement}`);
|
||||
}
|
||||
|
||||
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"})`,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
#!/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 fileClassificationBatchSize = 100;
|
||||
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("<?xml");
|
||||
const plistStart = output.indexOf("<plist");
|
||||
const start = xmlStart >= 0 ? xmlStart : plistStart;
|
||||
const end = output.lastIndexOf("</plist>");
|
||||
if (start < 0 || end < start) {
|
||||
throw new Error("codesign did not return an XML entitlement plist");
|
||||
}
|
||||
return output.slice(start, end + "</plist>".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 = [];
|
||||
const regularFiles = walkRegularFiles(appPath);
|
||||
for (let index = 0; index < regularFiles.length; index += fileClassificationBatchSize) {
|
||||
const batch = regularFiles.slice(index, index + fileClassificationBatchSize);
|
||||
const fileTypes = runProcess("file", ["-b", ...batch]).stdout.split(/\r?\n/);
|
||||
if (fileTypes.length !== batch.length) {
|
||||
throw new Error(
|
||||
`file classification returned ${fileTypes.length} rows for ${batch.length} paths`,
|
||||
);
|
||||
}
|
||||
|
||||
for (let batchIndex = 0; batchIndex < batch.length; batchIndex += 1) {
|
||||
if (fileTypes[batchIndex].includes("Mach-O")) {
|
||||
machOBinaries.push(batch[batchIndex]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 attaches read-only for inspection", () => {
|
||||
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) {
|
||||
try {
|
||||
writeReport(report, options.reportPath, options.summaryPath);
|
||||
} catch (reportError) {
|
||||
console.error(
|
||||
`[macos-distribution] failed to write report: ${
|
||||
reportError instanceof Error ? reportError.message : String(reportError)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user