CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m0s
CI/CD / test_and_check_app (pull_request) Successful in 7m2s
CI/CD / Publish immutable image (pull_request) Skipped
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m35s
268 lines
8.6 KiB
JavaScript
268 lines
8.6 KiB
JavaScript
import { spawnSync } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import {
|
|
appendFileSync,
|
|
copyFileSync,
|
|
readlinkSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
renameSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
import process from 'node:process';
|
|
import { updateSource } from './update-manifest.mjs';
|
|
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 nativePackage = JSON.parse(
|
|
readFileSync(join(directory, 'package.json'), 'utf8'),
|
|
);
|
|
const packageVersion = nativePackage.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.',
|
|
);
|
|
}
|
|
if (config.plugins?.updater?.pubkey !== updateSource.publicKey) {
|
|
throw new Error(
|
|
'The app updater key must match the permanent release key.',
|
|
);
|
|
}
|
|
if (
|
|
!/^\d+(?:\.\d+){0,2}$/.test(config.bundle?.macOS?.bundleVersion || '')
|
|
) {
|
|
throw new Error(
|
|
'Set a numeric macOS bundleVersion for the About build number.',
|
|
);
|
|
}
|
|
if (!nativePackage.releaseNotes?.trim()) {
|
|
throw new Error(
|
|
'Add releaseNotes to native/package.json before publishing.',
|
|
);
|
|
}
|
|
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,
|
|
notes: nativePackage.releaseNotes,
|
|
target,
|
|
arch,
|
|
filename: `KanbanGit-${config.version}-${target}.dmg`,
|
|
updaterFilename: `KanbanGit-${config.version}-${target}.app.tar.gz`,
|
|
};
|
|
}
|
|
|
|
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);
|
|
const bundle = join(root, metadata.target, 'release/bundle');
|
|
copyFileSync(
|
|
join(
|
|
bundle,
|
|
'dmg',
|
|
`KanbanGit_${metadata.version}_${metadata.arch === 'arm64' ? 'aarch64' : 'x64'}.dmg`,
|
|
),
|
|
archive,
|
|
);
|
|
const mounted = join(staging, 'mounted');
|
|
mkdirSync(mounted);
|
|
run('hdiutil', ['verify', archive]);
|
|
run('hdiutil', [
|
|
'attach',
|
|
archive,
|
|
'-readonly',
|
|
'-nobrowse',
|
|
'-mountpoint',
|
|
mounted,
|
|
]);
|
|
try {
|
|
if (
|
|
readlinkSync(join(mounted, 'Applications')) !== '/Applications'
|
|
) {
|
|
throw new Error(
|
|
'DMG must contain the Applications installation link.',
|
|
);
|
|
}
|
|
verifyNotarizedApp(join(mounted, 'KanbanGit.app'), metadata, run);
|
|
} finally {
|
|
run('hdiutil', ['detach', mounted]);
|
|
}
|
|
rmSync(mounted, { recursive: true, force: true });
|
|
const updateArchive = join(staging, metadata.updaterFilename);
|
|
copyFileSync(join(bundle, 'macos/KanbanGit.app.tar.gz'), updateArchive);
|
|
copyFileSync(
|
|
join(bundle, 'macos/KanbanGit.app.tar.gz.sig'),
|
|
`${updateArchive}.sig`,
|
|
);
|
|
if (!readFileSync(`${updateArchive}.sig`, 'utf8').trim()) {
|
|
throw new Error('The updater signature is missing.');
|
|
}
|
|
const extracted = join(staging, 'extracted');
|
|
mkdirSync(extracted);
|
|
run('tar', ['-xzf', updateArchive, '-C', extracted]);
|
|
verifyNotarizedApp(join(extracted, 'KanbanGit.app'), metadata, run);
|
|
rmSync(extracted, { recursive: true, force: true });
|
|
const checksum = createHash('sha256')
|
|
.update(readFileSync(archive))
|
|
.digest('hex');
|
|
for (const name of [metadata.filename, metadata.updaterFilename]) {
|
|
const hash = createHash('sha256')
|
|
.update(readFileSync(join(staging, name)))
|
|
.digest('hex');
|
|
writeFileSync(
|
|
join(staging, `${name}.sha256`),
|
|
`${hash} ${name}\n`,
|
|
);
|
|
}
|
|
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)
|
|
.filter(([key]) => key !== 'notes')
|
|
.map(([key, value]) => `${key}=${value}\n`)
|
|
.join(''),
|
|
);
|
|
}
|
|
console.log(JSON.stringify(output));
|
|
} catch (error) {
|
|
console.error(error.message);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|