Sync latest TrueGrowth updates
This commit is contained in:
417
scripts/desktop-signing.mjs
Normal file
417
scripts/desktop-signing.mjs
Normal file
@@ -0,0 +1,417 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user