Files
TrueGrowth/scripts/desktop-signing.mjs
jiam 5119ac0ef8
Some checks failed
CI / main (push) Has been cancelled
CI / release-e2e (push) Has been cancelled
Sync latest TrueGrowth updates
2026-07-08 02:03:18 +08:00

418 lines
12 KiB
JavaScript

import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const workspaceRoot = path.resolve(__dirname, '..');
const defaultEnvPath = path.join(workspaceRoot, '.env.desktop-signing.local');
const secretKeys = new Set([
'APPLE_APP_SPECIFIC_PASSWORD',
'CSC_KEY_PASSWORD',
'WIN_CSC_KEY_PASSWORD',
]);
function expandHome(filePath) {
if (!filePath) return filePath;
const cleanPath = filePath.startsWith('file://')
? filePath.slice('file://'.length)
: filePath;
if (cleanPath === '~') {
return process.env.HOME || cleanPath;
}
if (cleanPath.startsWith('~/')) {
return path.join(process.env.HOME || '', cleanPath.slice(2));
}
return cleanPath;
}
function parseEnvValue(rawValue) {
let value = rawValue.trim();
const quote = value[0];
if (
value.length >= 2 &&
(quote === '"' || quote === "'") &&
value[value.length - 1] === quote
) {
value = value.slice(1, -1);
if (quote === '"') {
value = value.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
}
}
return value;
}
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) {
return { loaded: false, values: {} };
}
const values = {};
const content = fs.readFileSync(filePath, 'utf8');
for (const [index, line] of content.split(/\r?\n/).entries()) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) {
throw new Error(
`Invalid env line in ${path.relative(workspaceRoot, filePath)}:${index + 1}`
);
}
values[match[1]] = parseEnvValue(match[2]);
}
return { loaded: true, values };
}
function mergeEnv(envFilePath) {
const { loaded, values } = loadEnvFile(envFilePath);
for (const [key, value] of Object.entries(values)) {
if (process.env[key] == null || process.env[key] === '') {
process.env[key] = value;
}
}
return loaded;
}
function readArgValue(args, name, fallback) {
const prefix = `${name}=`;
const match = args.find((arg) => arg.startsWith(prefix));
return match ? match.slice(prefix.length) : fallback;
}
function flag(args, name) {
return args.includes(name);
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: workspaceRoot,
encoding: 'utf8',
env: process.env,
stdio: options.stdio || 'pipe',
timeout: options.timeout || 0,
});
if (result.error) {
throw result.error;
}
return result;
}
function runRequired(command, args) {
const result = spawnSync(command, args, {
cwd: workspaceRoot,
env: process.env,
stdio: 'inherit',
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(`Command failed: ${command} ${args.join(' ')}`);
}
}
function maskEnvValue(key, value) {
if (!value) return '<empty>';
return secretKeys.has(key) ? '<set>' : value;
}
function certificateSubjectsFromP12(p12Path) {
const result = run('openssl', [
'pkcs12',
'-in',
p12Path,
'-nokeys',
'-clcerts',
'-passin',
'env:CSC_KEY_PASSWORD',
'-info',
]);
return {
ok: result.status === 0,
output: [result.stdout, result.stderr].filter(Boolean).join('\n'),
};
}
function codesigningIdentities() {
const result = run('security', ['find-identity', '-v', '-p', 'codesigning']);
return {
ok: result.status === 0,
output: [result.stdout, result.stderr].filter(Boolean).join('\n'),
};
}
function checkTool(command, args) {
try {
const result = run(command, args, { timeout: 5000 });
return result.status === 0;
} catch {
return false;
}
}
function findXcodeDeveloperDir() {
try {
const result = run('xcode-select', ['-p'], { timeout: 5000 });
if (result.status === 0) {
const developerDir = result.stdout.trim();
return developerDir || '';
}
} catch {
return '';
}
return '';
}
function hasXcodeTool(toolName) {
const developerDir = findXcodeDeveloperDir();
if (developerDir) {
const directTool = path.join(developerDir, 'usr/bin', toolName);
if (fs.existsSync(directTool)) {
return true;
}
}
return checkTool('xcrun', ['--find', toolName]);
}
function hasCompleteApiKeyLogin() {
return Boolean(
process.env.APPLE_API_KEY &&
process.env.APPLE_API_KEY_ID &&
process.env.APPLE_API_ISSUER
);
}
function hasPartialApiKeyLogin() {
return Boolean(
process.env.APPLE_API_KEY ||
process.env.APPLE_API_KEY_ID ||
process.env.APPLE_API_ISSUER
);
}
function hasCompleteAppleIdLogin() {
return Boolean(
process.env.APPLE_ID &&
process.env.APPLE_APP_SPECIFIC_PASSWORD &&
process.env.APPLE_TEAM_ID
);
}
function hasPartialAppleIdLogin() {
return Boolean(
process.env.APPLE_ID ||
process.env.APPLE_APP_SPECIFIC_PASSWORD ||
process.env.APPLE_TEAM_ID
);
}
function checkSigning({ requireNotarization = true, quiet = false } = {}) {
const errors = [];
const warnings = [];
const details = [];
if (process.platform !== 'darwin') {
errors.push('macOS signed builds must run on macOS.');
}
const cscLink = process.env.CSC_LINK ? expandHome(process.env.CSC_LINK) : '';
const cscName = process.env.CSC_NAME || '';
if (cscLink) {
process.env.CSC_LINK = cscLink;
if (!fs.existsSync(cscLink)) {
errors.push(`CSC_LINK does not exist: ${cscLink}`);
} else {
details.push(`CSC_LINK=${cscLink}`);
if (!process.env.CSC_KEY_PASSWORD) {
errors.push('CSC_KEY_PASSWORD is required when CSC_LINK points to a .p12 file.');
} else {
const certInfo = certificateSubjectsFromP12(cscLink);
if (!certInfo.ok) {
errors.push('Unable to read CSC_LINK .p12. Check the certificate password.');
} else if (!/Developer ID Application:/i.test(certInfo.output)) {
errors.push(
'CSC_LINK is not a "Developer ID Application" certificate. It cannot be used for direct customer distribution outside the Mac App Store.'
);
} else {
const subject =
certInfo.output.match(/subject=.*Developer ID Application:[^\n]+/i)?.[0] ||
'Developer ID Application certificate found in CSC_LINK';
details.push(subject);
}
}
}
} else {
const identities = codesigningIdentities();
if (!identities.ok) {
errors.push('Unable to inspect macOS code signing identities with security.');
} else if (!/Developer ID Application:/i.test(identities.output)) {
errors.push(
'No valid "Developer ID Application" identity is available. Set CSC_LINK to a Developer ID .p12 or import one into Keychain.'
);
} else {
details.push('Developer ID Application identity found in Keychain.');
}
if (cscName) {
details.push(`CSC_NAME=${cscName}`);
}
}
const appleApiKey = process.env.APPLE_API_KEY
? expandHome(process.env.APPLE_API_KEY)
: '';
if (appleApiKey) {
process.env.APPLE_API_KEY = appleApiKey;
}
if (appleApiKey && !fs.existsSync(appleApiKey)) {
errors.push(`APPLE_API_KEY does not exist: ${appleApiKey}`);
}
if (requireNotarization) {
if (hasCompleteApiKeyLogin()) {
details.push(`APPLE_API_KEY=${appleApiKey}`);
details.push(`APPLE_API_KEY_ID=${process.env.APPLE_API_KEY_ID}`);
if (process.env.APPLE_TEAM_ID) {
details.push(`APPLE_TEAM_ID=${process.env.APPLE_TEAM_ID}`);
}
} else if (hasCompleteAppleIdLogin()) {
warnings.push(
'Apple ID notarization is configured. The App Store Connect API key method is recommended for repeatable production builds.'
);
details.push(`APPLE_ID=${process.env.APPLE_ID}`);
details.push(`APPLE_TEAM_ID=${process.env.APPLE_TEAM_ID}`);
} else {
if (hasPartialApiKeyLogin()) {
errors.push(
'Notarization API key config is incomplete. Set APPLE_API_KEY, APPLE_API_KEY_ID, and APPLE_API_ISSUER.'
);
} else if (hasPartialAppleIdLogin()) {
errors.push(
'Apple ID notarization config is incomplete. Set APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, and APPLE_TEAM_ID.'
);
} else {
errors.push(
'Notarization credentials are missing. Use APPLE_API_KEY + APPLE_API_KEY_ID + APPLE_API_ISSUER for production macOS builds.'
);
}
}
if (!hasXcodeTool('notarytool')) {
errors.push('xcrun notarytool is unavailable. Install or select Xcode command line tools.');
}
if (!hasXcodeTool('stapler')) {
warnings.push('xcrun stapler is unavailable. Notarization may succeed, but ticket validation/stapling checks will be limited.');
}
} else {
warnings.push('TRUEGROWTH_NOTARIZE=false: this build can be signed, but it is not a full commercial macOS release.');
}
if (!quiet) {
console.log('[desktop:signing] macOS signing inputs');
for (const key of [
'CSC_LINK',
'CSC_NAME',
'APPLE_API_KEY',
'APPLE_API_KEY_ID',
'APPLE_API_ISSUER',
'APPLE_ID',
'APPLE_TEAM_ID',
'TRUEGROWTH_MAC_TARGETS',
'TRUEGROWTH_NOTARIZE',
]) {
if (process.env[key]) {
console.log(` ${key}=${maskEnvValue(key, process.env[key])}`);
}
}
for (const detail of details) {
console.log(` ok: ${detail}`);
}
for (const warning of warnings) {
console.warn(` warn: ${warning}`);
}
for (const error of errors) {
console.error(` error: ${error}`);
}
}
return { ok: errors.length === 0, errors, warnings, details };
}
function resolveMacTargets(args) {
const fromArg = readArgValue(args, '--targets', '');
const fromEnv = process.env.TRUEGROWTH_MAC_TARGETS || '';
const raw = fromArg || fromEnv || 'zip';
return raw
.split(/[,\s]+/)
.map((target) => target.trim())
.filter(Boolean);
}
function buildMac(args) {
const envFile = readArgValue(args, '--env', defaultEnvPath);
const envLoaded = mergeEnv(envFile);
const requireNotarization = process.env.TRUEGROWTH_NOTARIZE !== 'false';
const check = checkSigning({ requireNotarization });
if (!check.ok) {
throw new Error('macOS signing preflight failed; fix the errors above before building a customer release.');
}
const targets = resolveMacTargets(args);
const builderArgs = [
'exec',
'electron-builder',
'--config',
'electron-builder.yml',
'--mac',
...targets,
'--arm64',
'-c.mac.forceCodeSigning=true',
];
if (process.env.APPLE_TEAM_ID) {
builderArgs.push(`-c.mac.notarize.teamId=${process.env.APPLE_TEAM_ID}`);
}
console.log(
`[desktop:signing] Loaded ${envLoaded ? envFile : 'process environment'}`
);
console.log(`[desktop:signing] Building macOS targets: ${targets.join(', ')}`);
runRequired('pnpm', ['build:web']);
runRequired('pnpm', ['desktop:prepare']);
runRequired('pnpm', builderArgs);
runRequired('pnpm', ['desktop:release-manifest', '--', '--platform=mac']);
}
function printHelp() {
console.log(`Usage:
node scripts/desktop-signing.mjs check [--env=.env.desktop-signing.local] [--no-notarize]
node scripts/desktop-signing.mjs build-mac [--env=.env.desktop-signing.local] [--targets=zip|dmg,zip]
`);
}
function main() {
const [command = 'help', ...args] = process.argv.slice(2);
const envFile = readArgValue(args, '--env', defaultEnvPath);
if (command === 'help' || flag(args, '--help')) {
printHelp();
return;
}
if (command === 'check') {
const loaded = mergeEnv(envFile);
const requireNotarization =
!flag(args, '--no-notarize') && process.env.TRUEGROWTH_NOTARIZE !== 'false';
if (!loaded && !process.env.CSC_LINK && !process.env.CSC_NAME) {
console.warn(
`[desktop:signing] ${path.relative(workspaceRoot, envFile)} was not found; checking process environment only.`
);
}
const result = checkSigning({ requireNotarization });
if (!result.ok) {
process.exitCode = 1;
}
return;
}
if (command === 'build-mac') {
buildMac(args);
return;
}
throw new Error(`Unknown command: ${command}`);
}
main();