feat(KAN-66): prepare local iOS TestFlight signing
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
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
This commit is contained in:
@@ -0,0 +1,534 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
appleTeamId,
|
||||
buildIos,
|
||||
bundleIdentifier,
|
||||
selectIdentity,
|
||||
validateProfile,
|
||||
} from '../build-ios.mjs';
|
||||
|
||||
const certificate = Buffer.from('synthetic test certificate');
|
||||
const fingerprint = createHash('sha1')
|
||||
.update(certificate)
|
||||
.digest('hex')
|
||||
.toUpperCase();
|
||||
|
||||
function profileFor(mode = 'testflight') {
|
||||
return {
|
||||
UUID: '00000000-1111-2222-3333-444444444444',
|
||||
TeamIdentifier: [appleTeamId],
|
||||
Platform: ['iOS'],
|
||||
ExpirationDate: '2099-01-01T00:00:00Z',
|
||||
DeveloperCertificates: [certificate.toString('base64')],
|
||||
...(mode === 'development'
|
||||
? { ProvisionedDevices: ['test-device'] }
|
||||
: {}),
|
||||
Entitlements: {
|
||||
'application-identifier': `${appleTeamId}.${bundleIdentifier}`,
|
||||
'com.apple.developer.team-identifier': appleTeamId,
|
||||
'get-task-allow': mode === 'development',
|
||||
'beta-reports-active': mode === 'testflight',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fixture(t, mode = 'testflight', overrides = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'kanbangit-ios-test-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const directory = join(root, 'repo/native');
|
||||
const profilePath = join(root, 'profile.mobileprovision');
|
||||
const config = {
|
||||
productName: 'KanbanGit',
|
||||
identifier: 'de.kanbangit.desktop',
|
||||
version: '1.2.3',
|
||||
};
|
||||
const ios = {
|
||||
identifier: bundleIdentifier,
|
||||
bundle: {
|
||||
iOS: {
|
||||
developmentTeam: appleTeamId,
|
||||
bundleVersion: '7',
|
||||
minimumSystemVersion: '17.0',
|
||||
},
|
||||
},
|
||||
};
|
||||
mkdirSync(join(directory, 'src-tauri'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(directory, 'src-tauri/tauri.conf.json'),
|
||||
JSON.stringify(config),
|
||||
);
|
||||
writeFileSync(
|
||||
join(directory, 'src-tauri/tauri.ios.conf.json'),
|
||||
JSON.stringify(ios),
|
||||
);
|
||||
writeFileSync(profilePath, 'external provisioning profile');
|
||||
const profile = profileFor(mode);
|
||||
const calls = [];
|
||||
const destination = join(directory, `src-tauri/target/ios-${mode}/1.2.3-7`);
|
||||
const home = join(root, 'home');
|
||||
const installedProfile = join(
|
||||
home,
|
||||
`Library/Developer/Xcode/UserData/Provisioning Profiles/${profile.UUID}.mobileprovision`,
|
||||
);
|
||||
const info = {
|
||||
CFBundleIdentifier: bundleIdentifier,
|
||||
CFBundleShortVersionString: '1.2.3',
|
||||
CFBundleVersion: '7',
|
||||
};
|
||||
function app(path) {
|
||||
mkdirSync(path, { recursive: true });
|
||||
writeFileSync(
|
||||
join(path, 'Info.plist'),
|
||||
JSON.stringify({ ...info, ...overrides.info }),
|
||||
);
|
||||
}
|
||||
const options = {
|
||||
directory,
|
||||
home,
|
||||
mode,
|
||||
platform: 'darwin',
|
||||
log() {},
|
||||
env: { PATH: '/test/bin', IOS_PROVISIONING_PROFILE: profilePath },
|
||||
run(command, args, settings) {
|
||||
calls.push({ command, args, settings });
|
||||
if (overrides.failStep === settings.step) {
|
||||
throw new Error('Simulated build failure');
|
||||
}
|
||||
if (command === 'python3') {
|
||||
return settings.input.toString();
|
||||
}
|
||||
if (command === 'security') {
|
||||
if (args[0] === 'cms') {
|
||||
const embedded = args
|
||||
.at(-1)
|
||||
.endsWith('embedded.mobileprovision');
|
||||
return JSON.stringify(
|
||||
embedded && overrides.embeddedProfile
|
||||
? overrides.embeddedProfile
|
||||
: profile,
|
||||
);
|
||||
}
|
||||
return `1) ${fingerprint} "Apple ${mode === 'development' ? 'Development' : 'Distribution'}: Example (${appleTeamId})"`;
|
||||
}
|
||||
if (command === 'rustup') {
|
||||
if (args[0] === 'target') {
|
||||
return (
|
||||
overrides.targets ??
|
||||
'aarch64-apple-ios\naarch64-apple-ios-sim'
|
||||
);
|
||||
}
|
||||
return `/permanent/toolchain/bin/${args[1]}`;
|
||||
}
|
||||
if (command === '/permanent/toolchain/bin/cargo') {
|
||||
const library = join(
|
||||
directory,
|
||||
'src-tauri/target',
|
||||
args[args.indexOf('--target') + 1],
|
||||
args.includes('--release') ? 'release' : 'debug',
|
||||
'libkanbangit_native.a',
|
||||
);
|
||||
mkdirSync(dirname(library), { recursive: true });
|
||||
writeFileSync(library, 'compiled library');
|
||||
}
|
||||
if (command === 'xcodebuild' && args.includes('archive')) {
|
||||
assert.ok(mode === 'simulator' || existsSync(installedProfile));
|
||||
app(
|
||||
join(
|
||||
args[args.indexOf('-archivePath') + 1],
|
||||
'Products/Applications/KanbanGit.app',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (command === 'xcodebuild' && args.includes('-exportArchive')) {
|
||||
const output = args[args.indexOf('-exportPath') + 1];
|
||||
mkdirSync(output, { recursive: true });
|
||||
if (!overrides.missingIpa) {
|
||||
writeFileSync(
|
||||
join(output, 'KanbanGit.ipa'),
|
||||
'signed ipa bytes',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (command === 'ditto') {
|
||||
app(join(args.at(-1), 'Payload/KanbanGit.app'));
|
||||
}
|
||||
if (command === 'xcrun') {
|
||||
return '/permanent/Xcode/SDK';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
};
|
||||
return {
|
||||
options,
|
||||
calls,
|
||||
profile,
|
||||
profilePath,
|
||||
destination,
|
||||
installedProfile,
|
||||
ios,
|
||||
};
|
||||
}
|
||||
|
||||
test('TestFlight produces a verified local IPA with manual signing and no Apple mutation or upload flags', (t) => {
|
||||
const f = fixture(t);
|
||||
f.options.env.APPLE_API_KEY = 'must-not-reach-build';
|
||||
f.options.env.IOS_CERTIFICATE = 'must-not-reach-build';
|
||||
assert.equal(buildIos(f.options), f.destination);
|
||||
const options = JSON.parse(
|
||||
readFileSync(join(f.destination, 'ExportOptions.plist'), 'utf8'),
|
||||
);
|
||||
assert.equal(options.method, 'app-store-connect');
|
||||
assert.equal(options.destination, 'export');
|
||||
assert.equal(options.signingStyle, 'manual');
|
||||
assert.equal(options.manageAppVersionAndBuildNumber, false);
|
||||
assert.equal(options.signingCertificate, fingerprint);
|
||||
assert.deepEqual(options.provisioningProfiles, {
|
||||
[bundleIdentifier]: f.profile.UUID,
|
||||
});
|
||||
assert.equal(
|
||||
readFileSync(
|
||||
join(f.destination, 'export/KanbanGit.ipa.sha256'),
|
||||
'utf8',
|
||||
),
|
||||
`${createHash('sha256').update('signed ipa bytes').digest('hex')} KanbanGit.ipa\n`,
|
||||
);
|
||||
assert.equal(
|
||||
f.calls.filter(
|
||||
(call) =>
|
||||
call.command === 'codesign' && call.args.includes('--verify'),
|
||||
).length,
|
||||
2,
|
||||
);
|
||||
assert.equal(existsSync(f.installedProfile), false);
|
||||
const verification = f.calls.find((call) => call.command === 'codesign');
|
||||
const requirement = verification.args[verification.args.indexOf('-R') + 1];
|
||||
assert.ok(requirement.includes('! entitlement["get-task-allow"] exists'));
|
||||
assert.ok(
|
||||
requirement.includes('entitlement["beta-reports-active"] exists'),
|
||||
);
|
||||
assert.ok(requirement.includes(`certificate leaf = H"${fingerprint}"`));
|
||||
for (const call of f.calls) {
|
||||
assert.equal(call.settings.env.APPLE_API_KEY, undefined);
|
||||
assert.equal(call.settings.env.IOS_CERTIFICATE, undefined);
|
||||
assert.ok(
|
||||
!call.args.some((arg) =>
|
||||
/allowProvisioning|authenticationKey|upload|downloadPlatform/.test(
|
||||
arg,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
const archive = f.calls.find((call) => call.args.includes('archive'));
|
||||
assert.ok(archive.args.includes('CODE_SIGN_STYLE=Manual'));
|
||||
assert.ok(
|
||||
archive.args.includes(
|
||||
`PROVISIONING_PROFILE_SPECIFIER=${f.profile.UUID}`,
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
archive.settings.env.KANBANGIT_IOS_LIBRARY.endsWith(
|
||||
'/aarch64-apple-ios/release/libkanbangit_native.a',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('the simulator needs no certificate or profile and disables signing explicitly', (t) => {
|
||||
const f = fixture(t, 'simulator');
|
||||
delete f.options.env.IOS_PROVISIONING_PROFILE;
|
||||
buildIos(f.options);
|
||||
assert.ok(
|
||||
f.calls.some((call) => call.args.includes('CODE_SIGNING_ALLOWED=NO')),
|
||||
);
|
||||
assert.ok(
|
||||
f.calls.some((call) => call.args.includes('aarch64-apple-ios-sim')),
|
||||
);
|
||||
assert.ok(
|
||||
!f.calls.some(
|
||||
(call) =>
|
||||
['security', 'codesign'].includes(call.command) ||
|
||||
call.args.includes('-exportArchive'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('local device development uses a registered-device development profile', (t) => {
|
||||
const f = fixture(t, 'development');
|
||||
buildIos(f.options);
|
||||
const options = JSON.parse(
|
||||
readFileSync(join(f.destination, 'ExportOptions.plist'), 'utf8'),
|
||||
);
|
||||
assert.equal(options.method, 'debugging');
|
||||
assert.equal(options.destination, 'export');
|
||||
});
|
||||
|
||||
for (const [name, change, message] of [
|
||||
[
|
||||
'different team',
|
||||
(p) => {
|
||||
p.TeamIdentifier = ['OLDTEAM'];
|
||||
},
|
||||
/explicitly match/,
|
||||
],
|
||||
[
|
||||
'wildcard identifier',
|
||||
(p) => {
|
||||
p.Entitlements['application-identifier'] = `${appleTeamId}.*`;
|
||||
},
|
||||
/explicitly match/,
|
||||
],
|
||||
[
|
||||
'macOS identifier',
|
||||
(p) => {
|
||||
p.Entitlements['application-identifier'] =
|
||||
`${appleTeamId}.de.kanbangit.desktop`;
|
||||
},
|
||||
/explicitly match/,
|
||||
],
|
||||
[
|
||||
'expired profile',
|
||||
(p) => {
|
||||
p.ExpirationDate = '2000-01-01';
|
||||
},
|
||||
/expired/,
|
||||
],
|
||||
[
|
||||
'invalid expiry',
|
||||
(p) => {
|
||||
p.ExpirationDate = 'invalid';
|
||||
},
|
||||
/expired/,
|
||||
],
|
||||
[
|
||||
'invalid UUID',
|
||||
(p) => {
|
||||
p.UUID = '../unexpected';
|
||||
},
|
||||
/invalid/,
|
||||
],
|
||||
[
|
||||
'missing certificate',
|
||||
(p) => {
|
||||
p.DeveloperCertificates = [];
|
||||
},
|
||||
/invalid/,
|
||||
],
|
||||
[
|
||||
'wrong platform',
|
||||
(p) => {
|
||||
p.Platform = ['OSX'];
|
||||
},
|
||||
/iOS profile/,
|
||||
],
|
||||
[
|
||||
'Ad Hoc profile',
|
||||
(p) => {
|
||||
p.ProvisionedDevices = ['device'];
|
||||
},
|
||||
/App Store Connect/,
|
||||
],
|
||||
[
|
||||
'enterprise profile',
|
||||
(p) => {
|
||||
p.ProvisionsAllDevices = true;
|
||||
},
|
||||
/App Store Connect/,
|
||||
],
|
||||
[
|
||||
'development entitlement',
|
||||
(p) => {
|
||||
p.Entitlements['get-task-allow'] = true;
|
||||
},
|
||||
/App Store Connect/,
|
||||
],
|
||||
[
|
||||
'missing beta entitlement',
|
||||
(p) => {
|
||||
delete p.Entitlements['beta-reports-active'];
|
||||
},
|
||||
/App Store Connect/,
|
||||
],
|
||||
]) {
|
||||
test(`rejects ${name} before signing`, () => {
|
||||
const profile = profileFor();
|
||||
change(profile);
|
||||
assert.throws(() => validateProfile(profile, 'testflight'), message);
|
||||
});
|
||||
}
|
||||
|
||||
test('a distribution profile cannot be used for device development', () => {
|
||||
assert.throws(
|
||||
() => validateProfile(profileFor(), 'development'),
|
||||
/App Development/,
|
||||
);
|
||||
});
|
||||
|
||||
test('identity selection requires the profile certificate and a usable private key of the correct kind', () => {
|
||||
const profile = profileFor();
|
||||
for (const identities of [
|
||||
'0 valid identities found',
|
||||
`1) ${'A'.repeat(40)} "Apple Distribution: Other"`,
|
||||
`1) ${fingerprint} "Developer ID Application: Example"`,
|
||||
`1) ${fingerprint} "Apple Development: Example"`,
|
||||
]) {
|
||||
assert.throws(
|
||||
() => selectIdentity(profile, identities, 'testflight'),
|
||||
/private key/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
for (const step of [
|
||||
'Generating the Xcode project',
|
||||
'Building the iOS Rust library',
|
||||
'Archiving locally without Apple provisioning updates',
|
||||
'Verifying the Apple signature and team',
|
||||
'Exporting the signed IPA locally (no upload)',
|
||||
]) {
|
||||
test(`failure at ${step} does not publish a successful build and cleans its profile`, (t) => {
|
||||
const f = fixture(t, 'testflight', { failStep: step });
|
||||
assert.throws(() => buildIos(f.options), /Simulated build failure/);
|
||||
assert.equal(existsSync(f.destination), false);
|
||||
assert.equal(existsSync(f.installedProfile), false);
|
||||
});
|
||||
}
|
||||
|
||||
test('an existing identical local profile is preserved after the build', (t) => {
|
||||
const f = fixture(t);
|
||||
mkdirSync(dirname(f.installedProfile), { recursive: true });
|
||||
writeFileSync(f.installedProfile, readFileSync(f.profilePath));
|
||||
buildIos(f.options);
|
||||
assert.equal(existsSync(f.installedProfile), true);
|
||||
});
|
||||
|
||||
test('a conflicting local profile is never overwritten or deleted', (t) => {
|
||||
const f = fixture(t);
|
||||
mkdirSync(dirname(f.installedProfile), { recursive: true });
|
||||
writeFileSync(f.installedProfile, 'user profile');
|
||||
assert.throws(() => buildIos(f.options), /same UUID/);
|
||||
assert.equal(readFileSync(f.installedProfile, 'utf8'), 'user profile');
|
||||
});
|
||||
|
||||
test('missing toolchain targets fail before compilation without installing anything', (t) => {
|
||||
const f = fixture(t, 'testflight', { targets: 'aarch64-apple-darwin' });
|
||||
assert.throws(
|
||||
() => buildIos(f.options),
|
||||
/rustup target add aarch64-apple-ios/,
|
||||
);
|
||||
assert.ok(
|
||||
!f.calls.some(
|
||||
(call) => call.args.includes('build') || call.args.includes('add'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('missing profile and ambient team overrides fail clearly', (t) => {
|
||||
const f = fixture(t);
|
||||
delete f.options.env.IOS_PROVISIONING_PROFILE;
|
||||
assert.throws(() => buildIos(f.options), /IOS_PROVISIONING_PROFILE/);
|
||||
f.options.env.APPLE_DEVELOPMENT_TEAM = 'OLDTEAM';
|
||||
assert.throws(() => buildIos(f.options), /must be ZZX2KZGTHU/);
|
||||
delete f.options.env.APPLE_DEVELOPMENT_TEAM;
|
||||
f.options.env.TAURI_CONFIG = '{}';
|
||||
assert.throws(() => buildIos(f.options), /Remove TAURI_CONFIG/);
|
||||
});
|
||||
|
||||
for (const [name, overrides, message] of [
|
||||
[
|
||||
'wrong app version',
|
||||
{ info: { CFBundleVersion: '8' } },
|
||||
/unexpected identifier, version/,
|
||||
],
|
||||
[
|
||||
'changed embedded profile',
|
||||
{
|
||||
embeddedProfile: {
|
||||
...profileFor(),
|
||||
UUID: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
},
|
||||
},
|
||||
/unexpected provisioning profile/,
|
||||
],
|
||||
['missing IPA', { missingIpa: true }, /did not produce/],
|
||||
]) {
|
||||
test(`rejects ${name} in the build output`, (t) => {
|
||||
const f = fixture(t, 'testflight', overrides);
|
||||
assert.throws(() => buildIos(f.options), message);
|
||||
assert.equal(existsSync(f.destination), false);
|
||||
});
|
||||
}
|
||||
|
||||
test('completed artifacts are not silently overwritten', (t) => {
|
||||
const f = fixture(t);
|
||||
buildIos(f.options);
|
||||
assert.throws(() => buildIos(f.options), /already exists/);
|
||||
});
|
||||
|
||||
for (const mode of ['simulator', 'development']) {
|
||||
test(`${mode} can rebuild the same version and preserves the previous build on failure`, (t) => {
|
||||
const overrides = {};
|
||||
const f = fixture(t, mode, overrides);
|
||||
buildIos(f.options);
|
||||
const previousBuild = join(f.destination, 'previous-build');
|
||||
writeFileSync(previousBuild, 'verified artifact');
|
||||
overrides.failStep = 'Building the iOS Rust library';
|
||||
assert.throws(() => buildIos(f.options), /Simulated build failure/);
|
||||
assert.equal(readFileSync(previousBuild, 'utf8'), 'verified artifact');
|
||||
delete overrides.failStep;
|
||||
buildIos(f.options);
|
||||
assert.equal(existsSync(previousBuild), false);
|
||||
assert.ok(existsSync(join(f.destination, 'KanbanGit.xcarchive')));
|
||||
assert.equal(existsSync(f.installedProfile), false);
|
||||
});
|
||||
}
|
||||
|
||||
test('every iOS icon has its declared dimensions and contains no alpha channel or transparency chunk', () => {
|
||||
const assets = fileURLToPath(
|
||||
new URL(
|
||||
'../src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/',
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const catalog = JSON.parse(
|
||||
readFileSync(join(assets, 'Contents.json'), 'utf8'),
|
||||
);
|
||||
assert.ok(
|
||||
catalog.images.some(
|
||||
(icon) =>
|
||||
icon.idiom === 'ios-marketing' && icon.size === '1024x1024',
|
||||
),
|
||||
);
|
||||
for (const icon of catalog.images) {
|
||||
const png = readFileSync(join(assets, icon.filename));
|
||||
assert.equal(png.subarray(0, 8).toString('hex'), '89504e470d0a1a0a');
|
||||
const [width, height] = icon.size.split('x').map(Number);
|
||||
const scale = Number.parseInt(icon.scale, 10);
|
||||
assert.equal(png.readUInt32BE(16), width * scale, icon.filename);
|
||||
assert.equal(png.readUInt32BE(20), height * scale, icon.filename);
|
||||
assert.equal(png[25], 2, `${icon.filename} must use RGB without alpha`);
|
||||
for (
|
||||
let offset = 8;
|
||||
offset < png.length;
|
||||
offset += png.readUInt32BE(offset) + 12
|
||||
) {
|
||||
assert.notEqual(
|
||||
png.subarray(offset + 4, offset + 8).toString(),
|
||||
'tRNS',
|
||||
icon.filename,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user