CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 57s
CI/CD / test_and_check_app (pull_request) Successful in 6m33s
CI/CD / Publish immutable image (pull_request) Skipped
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
204 lines
6.1 KiB
JavaScript
204 lines
6.1 KiB
JavaScript
import { spawnSync } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import {
|
|
appendFileSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
renameSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
import process from 'node:process';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
import {
|
|
appleTeamId,
|
|
bundleIdentifier,
|
|
verifySignedMacosApp,
|
|
} from './release-macos.mjs';
|
|
|
|
const nativeDirectory = dirname(fileURLToPath(import.meta.url));
|
|
|
|
export function releaseMetadata(
|
|
env = process.env,
|
|
directory = nativeDirectory,
|
|
) {
|
|
const config = JSON.parse(
|
|
readFileSync(join(directory, 'src-tauri/tauri.conf.json'), 'utf8'),
|
|
);
|
|
const packageVersion = JSON.parse(
|
|
readFileSync(join(directory, 'package.json'), 'utf8'),
|
|
).version;
|
|
const cargoVersion = readFileSync(
|
|
join(directory, 'src-tauri/Cargo.toml'),
|
|
'utf8',
|
|
)
|
|
.match(/\[package\]([\s\S]*?)(?:\n\[|$)/)?.[1]
|
|
.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
|
|
if (
|
|
!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(config.version) ||
|
|
config.version !== packageVersion ||
|
|
config.version !== cargoVersion
|
|
) {
|
|
throw new Error(
|
|
'Tauri, package.json and Cargo.toml must have the same stable release version.',
|
|
);
|
|
}
|
|
if (env.RELEASE_TAG !== `macos-v${config.version}`) {
|
|
throw new Error(
|
|
'Release tag must be macos-vMAJOR.MINOR.PATCH and match the app version.',
|
|
);
|
|
}
|
|
if (
|
|
config.identifier !== bundleIdentifier ||
|
|
config.productName !== 'KanbanGit'
|
|
) {
|
|
throw new Error(
|
|
'The macOS product name and bundle identifier must stay unchanged.',
|
|
);
|
|
}
|
|
const target = env.MACOS_RELEASE_TARGET || 'aarch64-apple-darwin';
|
|
const arch = {
|
|
'aarch64-apple-darwin': 'arm64',
|
|
'x86_64-apple-darwin': 'x86_64',
|
|
}[target];
|
|
if (!arch) {
|
|
throw new Error(
|
|
'MACOS_RELEASE_TARGET must select Apple Silicon or Intel macOS.',
|
|
);
|
|
}
|
|
const identity = env.APPLE_SIGNING_IDENTITY;
|
|
if (
|
|
!identity?.startsWith('Developer ID Application: ') ||
|
|
!identity.endsWith(` (${appleTeamId})`) ||
|
|
/[\r\n"]/.test(identity)
|
|
) {
|
|
throw new Error(
|
|
`A Developer ID Application identity for ${appleTeamId} is required.`,
|
|
);
|
|
}
|
|
return {
|
|
version: config.version,
|
|
target,
|
|
arch,
|
|
filename: `KanbanGit-${config.version}-${target}.zip`,
|
|
};
|
|
}
|
|
|
|
export function execute(command, args) {
|
|
const result = spawnSync(command, args, {
|
|
encoding: 'utf8',
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
});
|
|
if (result.error || result.status !== 0) {
|
|
throw new Error(
|
|
`${command} failed (exit ${result.status ?? 'unknown'}). ${result.stderr || ''}`,
|
|
);
|
|
}
|
|
return result.stdout?.trim() || result.stderr?.trim() || '';
|
|
}
|
|
|
|
export function verifyNotarizedApp(app, metadata, run = execute) {
|
|
verifySignedMacosApp({
|
|
app,
|
|
version: metadata.version,
|
|
step: (_label, command, args) => run(command, args),
|
|
});
|
|
const arch = run('lipo', [
|
|
'-archs',
|
|
join(app, 'Contents/MacOS/kanbangit-native'),
|
|
]).trim();
|
|
if (arch !== metadata.arch) {
|
|
throw new Error(
|
|
'The app architecture does not match its download path.',
|
|
);
|
|
}
|
|
run('xcrun', ['stapler', 'validate', app]);
|
|
run('spctl', ['--assess', '--type', 'execute', '--verbose=2', app]);
|
|
}
|
|
|
|
export function packageNotarizedApp({
|
|
env = process.env,
|
|
directory = nativeDirectory,
|
|
run = execute,
|
|
} = {}) {
|
|
const metadata = releaseMetadata(env, directory);
|
|
const root = join(directory, 'src-tauri/target');
|
|
const app = join(
|
|
root,
|
|
metadata.target,
|
|
'release/bundle/macos/KanbanGit.app',
|
|
);
|
|
verifyNotarizedApp(app, metadata, run);
|
|
const releaseRoot = join(root, 'macos-release');
|
|
const destination = join(
|
|
releaseRoot,
|
|
`${metadata.version}-${metadata.target}`,
|
|
);
|
|
if (existsSync(destination)) {
|
|
throw new Error(
|
|
'Release output already exists; start from a clean checkout.',
|
|
);
|
|
}
|
|
mkdirSync(releaseRoot, { recursive: true });
|
|
const staging = mkdtempSync(join(releaseRoot, '.pending-'));
|
|
try {
|
|
const archive = join(staging, metadata.filename);
|
|
run('ditto', [
|
|
'-c',
|
|
'-k',
|
|
'--sequesterRsrc',
|
|
'--keepParent',
|
|
app,
|
|
archive,
|
|
]);
|
|
const checksum = createHash('sha256')
|
|
.update(readFileSync(archive))
|
|
.digest('hex');
|
|
writeFileSync(
|
|
`${archive}.sha256`,
|
|
`${checksum} ${metadata.filename}\n`,
|
|
);
|
|
const extracted = join(staging, 'extracted');
|
|
run('ditto', ['-x', '-k', archive, extracted]);
|
|
verifyNotarizedApp(join(extracted, 'KanbanGit.app'), metadata, run);
|
|
rmSync(extracted, { recursive: true, force: true });
|
|
renameSync(staging, destination);
|
|
return { ...metadata, directory: destination, checksum };
|
|
} finally {
|
|
rmSync(staging, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
if (
|
|
process.argv[1] &&
|
|
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
|
) {
|
|
try {
|
|
let output;
|
|
if (process.argv[2] === 'metadata') {
|
|
output = releaseMetadata();
|
|
} else if (process.argv[2] === 'package') {
|
|
output = packageNotarizedApp();
|
|
} else {
|
|
throw new Error(
|
|
'Use metadata or package. Building and notarizing are handled by tauri-action.',
|
|
);
|
|
}
|
|
if (process.env.GITHUB_OUTPUT) {
|
|
appendFileSync(
|
|
process.env.GITHUB_OUTPUT,
|
|
Object.entries(output)
|
|
.map(([key, value]) => `${key}=${value}\n`)
|
|
.join(''),
|
|
);
|
|
}
|
|
console.log(JSON.stringify(output));
|
|
} catch (error) {
|
|
console.error(error.message);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|