CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m2s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m33s
CI/CD / test_and_check_app (pull_request) Successful in 6m26s
CI/CD / Publish immutable image (pull_request) Skipped
510 lines
18 KiB
JavaScript
510 lines
18 KiB
JavaScript
import { Buffer } from 'node:buffer';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import {
|
|
copyFileSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
realpathSync,
|
|
renameSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { homedir } from 'node:os';
|
|
import { dirname, isAbsolute, join, relative } from 'node:path';
|
|
import process from 'node:process';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
import { parseArgs } from 'node:util';
|
|
|
|
export const appleTeamId = 'ZZX2KZGTHU';
|
|
export const bundleIdentifier = 'de.kanbangit.ios';
|
|
|
|
const plistReader = `import base64, datetime, json, plistlib, sys
|
|
def encode(value):
|
|
if isinstance(value, bytes): return base64.b64encode(value).decode()
|
|
if isinstance(value, datetime.datetime): return value.isoformat() + 'Z'
|
|
raise TypeError(type(value).__name__)
|
|
print(json.dumps(plistlib.loads(sys.stdin.buffer.read()), default=encode))`;
|
|
|
|
function execute(command, args, options) {
|
|
const result = spawnSync(command, args, {
|
|
cwd: options.cwd,
|
|
env: options.env,
|
|
input: options.input,
|
|
encoding: 'utf8',
|
|
maxBuffer: 32 * 1024 * 1024,
|
|
stdio: options.inherit ? 'inherit' : 'pipe',
|
|
});
|
|
if (result.error || result.status !== 0) {
|
|
throw new Error(
|
|
`${options.step} failed: ${result.error?.message || result.stderr?.trim() || `exit ${result.status ?? result.signal}`}`,
|
|
);
|
|
}
|
|
return result.stdout?.trim() || '';
|
|
}
|
|
|
|
export function validateProfile(profile, mode, now = new Date()) {
|
|
const entitlements = profile.Entitlements ?? {};
|
|
if (
|
|
profile.TeamIdentifier?.length !== 1 ||
|
|
profile.TeamIdentifier[0] !== appleTeamId ||
|
|
entitlements['com.apple.developer.team-identifier'] !== appleTeamId ||
|
|
entitlements['application-identifier'] !==
|
|
`${appleTeamId}.${bundleIdentifier}`
|
|
) {
|
|
throw new Error(
|
|
`The profile must explicitly match ${bundleIdentifier} in team ${appleTeamId}.`,
|
|
);
|
|
}
|
|
if (
|
|
!/^[a-f\d]{8}-(?:[a-f\d]{4}-){3}[a-f\d]{12}$/i.test(profile.UUID) ||
|
|
!(new Date(profile.ExpirationDate) > now) ||
|
|
!profile.DeveloperCertificates?.length ||
|
|
!profile.Platform?.includes('iOS')
|
|
) {
|
|
throw new Error(
|
|
'The iOS profile is invalid or expired. Download a current profile from your Apple account.',
|
|
);
|
|
}
|
|
if (mode === 'testflight') {
|
|
if (
|
|
entitlements['get-task-allow'] !== false ||
|
|
entitlements['beta-reports-active'] !== true ||
|
|
'ProvisionedDevices' in profile ||
|
|
profile.ProvisionsAllDevices
|
|
) {
|
|
throw new Error(
|
|
'TestFlight requires an App Store Connect profile, not a development, Ad Hoc or enterprise profile.',
|
|
);
|
|
}
|
|
} else if (
|
|
entitlements['get-task-allow'] !== true ||
|
|
!profile.ProvisionedDevices?.length ||
|
|
profile.ProvisionsAllDevices
|
|
) {
|
|
throw new Error(
|
|
'Device development requires an iOS App Development profile containing your registered device.',
|
|
);
|
|
}
|
|
return profile;
|
|
}
|
|
|
|
export function selectIdentity(profile, identities, mode) {
|
|
const fingerprints = profile.DeveloperCertificates.map((certificate) =>
|
|
createHash('sha1')
|
|
.update(Buffer.from(certificate, 'base64'))
|
|
.digest('hex')
|
|
.toUpperCase(),
|
|
);
|
|
const prefix =
|
|
mode === 'testflight' ? 'Apple Distribution: ' : 'Apple Development: ';
|
|
const matches = [
|
|
...identities.matchAll(/\) ([A-Fa-f\d]{40}) "([^"]+)"/g),
|
|
].filter(
|
|
([, fingerprint, name]) =>
|
|
fingerprints.includes(fingerprint.toUpperCase()) &&
|
|
name.startsWith(prefix),
|
|
);
|
|
if (matches.length !== 1) {
|
|
throw new Error(
|
|
`The profile must match exactly one usable ${prefix.replace(': ', '')} certificate with its private key in your local keychain. Select a profile containing the intended certificate.`,
|
|
);
|
|
}
|
|
return matches[0][1].toUpperCase();
|
|
}
|
|
|
|
export function buildIos({
|
|
mode = 'testflight',
|
|
env = process.env,
|
|
platform = process.platform,
|
|
directory = dirname(fileURLToPath(import.meta.url)),
|
|
home = homedir(),
|
|
run = execute,
|
|
log = console.log,
|
|
now = new Date(),
|
|
} = {}) {
|
|
if (!['testflight', 'development', 'simulator'].includes(mode)) {
|
|
throw new Error('Choose --mode testflight, development or simulator.');
|
|
}
|
|
if (platform !== 'darwin') {
|
|
throw new Error(
|
|
'iOS builds require a Mac with Xcode, XcodeGen and the matching Rust target installed permanently.',
|
|
);
|
|
}
|
|
if (
|
|
env.APPLE_DEVELOPMENT_TEAM &&
|
|
env.APPLE_DEVELOPMENT_TEAM !== appleTeamId
|
|
) {
|
|
throw new Error(`APPLE_DEVELOPMENT_TEAM must be ${appleTeamId}.`);
|
|
}
|
|
if (env.TAURI_CONFIG) {
|
|
throw new Error(
|
|
'Remove TAURI_CONFIG overrides before building the configured iOS app.',
|
|
);
|
|
}
|
|
const config = JSON.parse(
|
|
readFileSync(join(directory, 'src-tauri/tauri.conf.json'), 'utf8'),
|
|
);
|
|
const ios = JSON.parse(
|
|
readFileSync(join(directory, 'src-tauri/tauri.ios.conf.json'), 'utf8'),
|
|
);
|
|
const version = config.version;
|
|
const buildNumber = ios.bundle?.iOS?.bundleVersion;
|
|
if (
|
|
ios.identifier !== bundleIdentifier ||
|
|
ios.bundle?.iOS?.developmentTeam !== appleTeamId ||
|
|
config.identifier !== 'de.kanbangit.desktop' ||
|
|
config.productName !== 'KanbanGit' ||
|
|
!/^\d+\.\d+\.\d+$/.test(version) ||
|
|
!/^[1-9]\d*$/.test(buildNumber)
|
|
) {
|
|
throw new Error(
|
|
'Check the iOS team, bundle identifier, version and positive integer bundleVersion in the Tauri configuration.',
|
|
);
|
|
}
|
|
const simulator = mode === 'simulator';
|
|
const configuration = mode === 'testflight' ? 'release' : 'debug';
|
|
const target = simulator ? 'aarch64-apple-ios-sim' : 'aarch64-apple-ios';
|
|
const sdk = simulator ? 'iphonesimulator' : 'iphoneos';
|
|
const project = join(directory, 'src-tauri/gen/apple');
|
|
const targetDirectory = join(directory, 'src-tauri/target');
|
|
const outputRoot = join(targetDirectory, `ios-${mode}`);
|
|
const destination = join(outputRoot, `${version}-${buildNumber}`);
|
|
if (mode === 'testflight' && existsSync(destination)) {
|
|
throw new Error(
|
|
`Build output already exists: ${destination}. Move it aside, or increment bundle.iOS.bundleVersion for a new TestFlight upload.`,
|
|
);
|
|
}
|
|
const buildEnv = Object.fromEntries(
|
|
Object.entries(env).filter(
|
|
([key]) =>
|
|
!key.startsWith('APPLE_') &&
|
|
!key.startsWith('IOS_') &&
|
|
!key.startsWith('TAURI_') &&
|
|
![
|
|
'SDKROOT',
|
|
'CARGO_ENCODED_RUSTFLAGS',
|
|
'RUSTFLAGS',
|
|
'RUSTC_WRAPPER',
|
|
'RUSTC_WORKSPACE_WRAPPER',
|
|
'XCODE_XCCONFIG_FILE',
|
|
].includes(key),
|
|
),
|
|
);
|
|
function step(label, command, args, options = {}) {
|
|
log(label);
|
|
return run(command, args, {
|
|
cwd: directory,
|
|
env: buildEnv,
|
|
step: label,
|
|
...options,
|
|
});
|
|
}
|
|
function parsePlist(xml) {
|
|
return JSON.parse(
|
|
step(
|
|
'Reading local plist metadata',
|
|
'python3',
|
|
['-c', plistReader],
|
|
{ input: xml },
|
|
),
|
|
);
|
|
}
|
|
function readProfile(path) {
|
|
return validateProfile(
|
|
parsePlist(
|
|
step('Reading provisioning profile', 'security', [
|
|
'cms',
|
|
'-D',
|
|
'-i',
|
|
path,
|
|
]),
|
|
),
|
|
mode,
|
|
now,
|
|
);
|
|
}
|
|
step(
|
|
'Checking XcodeGen (install permanently with brew install xcodegen if missing)',
|
|
'xcodegen',
|
|
['--version'],
|
|
);
|
|
step('Checking Xcode', 'xcodebuild', ['-version']);
|
|
const installedTargets = step('Checking installed Rust targets', 'rustup', [
|
|
'target',
|
|
'list',
|
|
'--installed',
|
|
]);
|
|
if (!installedTargets.split(/\s+/).includes(target)) {
|
|
throw new Error(
|
|
`Install the missing target permanently with rustup target add ${target}, then retry.`,
|
|
);
|
|
}
|
|
const cargo = step('Selecting the rustup Cargo toolchain', 'rustup', [
|
|
'which',
|
|
'cargo',
|
|
]);
|
|
buildEnv.RUSTC = step('Selecting the matching Rust compiler', 'rustup', [
|
|
'which',
|
|
'rustc',
|
|
]);
|
|
buildEnv.PATH = `${dirname(cargo)}:${buildEnv.PATH ?? ''}`;
|
|
buildEnv.SDKROOT = step('Checking the iOS SDK', 'xcrun', [
|
|
'--sdk',
|
|
sdk,
|
|
'--show-sdk-path',
|
|
]);
|
|
buildEnv.IPHONEOS_DEPLOYMENT_TARGET = ios.bundle.iOS.minimumSystemVersion;
|
|
buildEnv.CARGO_TARGET_DIR = targetDirectory;
|
|
let profile;
|
|
let profilePath;
|
|
let identity;
|
|
if (!simulator) {
|
|
if (
|
|
!env.IOS_PROVISIONING_PROFILE ||
|
|
!existsSync(env.IOS_PROVISIONING_PROFILE)
|
|
) {
|
|
throw new Error(
|
|
'Set IOS_PROVISIONING_PROFILE to your downloaded .mobileprovision file outside the repository. Apple account setup is manual; this command never creates or downloads profiles.',
|
|
);
|
|
}
|
|
profilePath = realpathSync(env.IOS_PROVISIONING_PROFILE);
|
|
const withinProject = relative(
|
|
realpathSync(join(directory, '..')),
|
|
profilePath,
|
|
);
|
|
if (
|
|
!withinProject.startsWith(
|
|
`..${process.platform === 'win32' ? '\\' : '/'}`,
|
|
) &&
|
|
!isAbsolute(withinProject)
|
|
) {
|
|
throw new Error(
|
|
'Keep provisioning profiles outside the repository.',
|
|
);
|
|
}
|
|
profile = readProfile(profilePath);
|
|
identity = selectIdentity(
|
|
profile,
|
|
step('Checking local signing identities', 'security', [
|
|
'find-identity',
|
|
'-v',
|
|
'-p',
|
|
'codesigning',
|
|
]),
|
|
mode,
|
|
);
|
|
}
|
|
mkdirSync(outputRoot, { recursive: true });
|
|
const working = mkdtempSync(join(outputRoot, '.building-'));
|
|
let installedProfile;
|
|
try {
|
|
if (profile) {
|
|
const installedPath = join(
|
|
home,
|
|
'Library/Developer/Xcode/UserData/Provisioning Profiles',
|
|
`${profile.UUID}.mobileprovision`,
|
|
);
|
|
mkdirSync(dirname(installedPath), { recursive: true });
|
|
if (existsSync(installedPath)) {
|
|
if (
|
|
!readFileSync(installedPath).equals(
|
|
readFileSync(profilePath),
|
|
)
|
|
) {
|
|
throw new Error(
|
|
'A different provisioning profile already exists with the same UUID. Resolve it in Xcode before retrying.',
|
|
);
|
|
}
|
|
} else {
|
|
copyFileSync(profilePath, installedPath);
|
|
installedProfile = installedPath;
|
|
}
|
|
}
|
|
step('Generating the Xcode project', 'xcodegen', [
|
|
'generate',
|
|
'--spec',
|
|
join(project, 'project.yml'),
|
|
]);
|
|
step(
|
|
'Building the iOS Rust library',
|
|
cargo,
|
|
[
|
|
'build',
|
|
'--manifest-path',
|
|
join(directory, 'src-tauri/Cargo.toml'),
|
|
'--lib',
|
|
'--locked',
|
|
'--target',
|
|
target,
|
|
'--features',
|
|
'custom-protocol',
|
|
...(configuration === 'release' ? ['--release'] : []),
|
|
],
|
|
{ inherit: true },
|
|
);
|
|
const library = join(
|
|
targetDirectory,
|
|
target,
|
|
configuration,
|
|
'libkanbangit_native.a',
|
|
);
|
|
if (!existsSync(library)) {
|
|
throw new Error(
|
|
`The iOS Rust library was not produced: ${library}`,
|
|
);
|
|
}
|
|
buildEnv.KANBANGIT_IOS_LIBRARY = library;
|
|
const archive = join(working, 'KanbanGit.xcarchive');
|
|
step(
|
|
'Archiving locally without Apple provisioning updates',
|
|
'xcodebuild',
|
|
[
|
|
'-project',
|
|
join(project, 'kanbangit-native.xcodeproj'),
|
|
'-scheme',
|
|
'kanbangit-native_iOS',
|
|
'-configuration',
|
|
configuration,
|
|
'-sdk',
|
|
sdk,
|
|
'-destination',
|
|
`generic/platform=${simulator ? 'iOS Simulator' : 'iOS'}`,
|
|
'-archivePath',
|
|
archive,
|
|
'-derivedDataPath',
|
|
join(targetDirectory, `ios-derived-${mode}`),
|
|
'archive',
|
|
`MARKETING_VERSION=${version}`,
|
|
`CURRENT_PROJECT_VERSION=${buildNumber}`,
|
|
`DEVELOPMENT_TEAM=${appleTeamId}`,
|
|
`PRODUCT_BUNDLE_IDENTIFIER=${bundleIdentifier}`,
|
|
'CODE_SIGN_STYLE=Manual',
|
|
'ARCHS=arm64',
|
|
...(simulator
|
|
? ['CODE_SIGNING_ALLOWED=NO', 'CODE_SIGNING_REQUIRED=NO']
|
|
: [
|
|
`CODE_SIGN_IDENTITY=${identity}`,
|
|
`PROVISIONING_PROFILE_SPECIFIER=${profile.UUID}`,
|
|
]),
|
|
],
|
|
{ inherit: true },
|
|
);
|
|
function verifyApp(app) {
|
|
const info = parsePlist(readFileSync(join(app, 'Info.plist')));
|
|
if (
|
|
info.CFBundleIdentifier !== bundleIdentifier ||
|
|
info.CFBundleShortVersionString !== version ||
|
|
info.CFBundleVersion !== buildNumber
|
|
) {
|
|
throw new Error(
|
|
'The built app has an unexpected identifier, version or build number.',
|
|
);
|
|
}
|
|
if (!simulator) {
|
|
step('Verifying the Apple signature and team', 'codesign', [
|
|
'--verify',
|
|
'--deep',
|
|
'--strict',
|
|
'-R',
|
|
`=anchor apple generic and identifier "${bundleIdentifier}" and certificate leaf[subject.OU] = "${appleTeamId}" and certificate leaf = H"${identity}" and entitlement["application-identifier"] = "${appleTeamId}.${bundleIdentifier}" and entitlement["com.apple.developer.team-identifier"] = "${appleTeamId}" and ${mode === 'testflight' ? '! ' : ''}entitlement["get-task-allow"] exists${mode === 'testflight' ? ' and entitlement["beta-reports-active"] exists' : ''}`,
|
|
app,
|
|
]);
|
|
if (
|
|
readProfile(join(app, 'embedded.mobileprovision')).UUID !==
|
|
profile.UUID
|
|
) {
|
|
throw new Error(
|
|
'The app was signed with an unexpected provisioning profile.',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
verifyApp(join(archive, 'Products/Applications/KanbanGit.app'));
|
|
if (!simulator) {
|
|
const exportOptions = join(working, 'ExportOptions.plist');
|
|
writeFileSync(
|
|
exportOptions,
|
|
JSON.stringify({
|
|
method:
|
|
mode === 'testflight'
|
|
? 'app-store-connect'
|
|
: 'debugging',
|
|
destination: 'export',
|
|
signingStyle: 'manual',
|
|
teamID: appleTeamId,
|
|
signingCertificate: identity,
|
|
provisioningProfiles: { [bundleIdentifier]: profile.UUID },
|
|
manageAppVersionAndBuildNumber: false,
|
|
}),
|
|
);
|
|
step('Preparing local export options', 'plutil', [
|
|
'-convert',
|
|
'xml1',
|
|
exportOptions,
|
|
]);
|
|
step(
|
|
'Exporting the signed IPA locally (no upload)',
|
|
'xcodebuild',
|
|
[
|
|
'-exportArchive',
|
|
'-archivePath',
|
|
archive,
|
|
'-exportPath',
|
|
join(working, 'export'),
|
|
'-exportOptionsPlist',
|
|
exportOptions,
|
|
],
|
|
{ inherit: true },
|
|
);
|
|
const ipa = join(working, 'export/KanbanGit.ipa');
|
|
if (!existsSync(ipa)) {
|
|
throw new Error('Xcode did not produce the signed IPA.');
|
|
}
|
|
const extracted = join(working, 'verify');
|
|
step('Verifying the exported IPA contents', 'ditto', [
|
|
'-x',
|
|
'-k',
|
|
ipa,
|
|
extracted,
|
|
]);
|
|
verifyApp(join(extracted, 'Payload/KanbanGit.app'));
|
|
rmSync(extracted, { recursive: true, force: true });
|
|
writeFileSync(
|
|
`${ipa}.sha256`,
|
|
`${createHash('sha256').update(readFileSync(ipa)).digest('hex')} KanbanGit.ipa\n`,
|
|
);
|
|
}
|
|
if (mode !== 'testflight') {
|
|
rmSync(destination, { recursive: true, force: true });
|
|
}
|
|
renameSync(working, destination);
|
|
log(`Verified local ${mode} build: ${destination}`);
|
|
return destination;
|
|
} catch (error) {
|
|
log(`Build did not complete. Diagnostic output remains in ${working}`);
|
|
throw error;
|
|
} finally {
|
|
if (installedProfile) {
|
|
rmSync(installedProfile);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (
|
|
process.argv[1] &&
|
|
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
) {
|
|
try {
|
|
const { values } = parseArgs({
|
|
options: { mode: { type: 'string', default: 'testflight' } },
|
|
});
|
|
buildIos({ mode: values.mode });
|
|
} catch (error) {
|
|
console.error(error.message);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|