apple build exports each build into build/ios/export/<version>-<build>/ (cleaned first), runs xcodebuild with Apple's tool folders first on PATH (Homebrew rsync 3.x broke Xcode's IPA step: exportArchive Copy failed), shows the IDEDistribution log's real error lines and exits non-zero on any export failure; apple upload fails with exit 1 when the IPA's build number is already on App Store Connect (stale IPA after a failed build), --allow-existing re-checks, --build N picks that build's IPA, release and beta flows use both; doctor flags a non-Apple rsync first on PATH; desktop publish calls rsync by absolute path without --chmod (publishSteps); site download notice per OS (macOS signed and notarized, Windows unsigned for now, Linux unsigned); game-report credential tests hermetic against a machine default profile (pitfalls 53, 54)
This commit is contained in:
parent
8fd4ac741e
commit
0e4f579899
20 changed files with 338 additions and 48 deletions
124
tests/apple-build-export.test.ts
Normal file
124
tests/apple-build-export.test.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
// Pitfalls 53 + 54: a second `apple build` failed in exportArchive ("Copy failed": Homebrew rsync first on PATH, and
|
||||
// the old App.ipa still in build/ios/export), then `apple upload` took the stale IPA (build 1, already VALID) and
|
||||
// exited 0. Per-build export folders, the IDEDistribution log's real error, Apple's PATH first for xcodebuild, a loud
|
||||
// upload failure for a build number App Store Connect already has, and rsync by absolute path in desktop publish.
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { exportDir, exportIpa } from '../cli/apple/build';
|
||||
import { existingBuild, exportedIpas } from '../cli/apple/upload';
|
||||
import { distributionErrors, foreignRsync, xcodePath } from '../cli/lib/xcode';
|
||||
import { publishSteps } from '../tools/desktop-release';
|
||||
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'sb-export-'));
|
||||
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
/** A fake xcodebuild -exportArchive: fails like Xcode ("Copy failed", exit 70) when the target IPA already exists. */
|
||||
function fakeXcodebuild(dir: string, logs: string): string {
|
||||
const f = join(dir, 'xcodebuild');
|
||||
writeFileSync(f, `#!/bin/sh
|
||||
out=""; while [ $# -gt 0 ]; do [ "$1" = "-exportPath" ] && out="$2"; shift; done
|
||||
if [ -e "$out/App.ipa" ]; then
|
||||
echo "error: exportArchive Copy failed" >&2
|
||||
echo "IDEDistribution: -[IDEDistributionLogging _createLoggingBundleAtPath:]: Created bundle at path \\"${logs}\\"." >&2
|
||||
exit 70
|
||||
fi
|
||||
mkdir -p "$out" && echo ipa > "$out/App.ipa" && echo "** EXPORT SUCCEEDED **"
|
||||
`);
|
||||
chmodSync(f, 0o755);
|
||||
return f;
|
||||
}
|
||||
|
||||
describe('apple build: export', () => {
|
||||
test('each build exports into its own folder, cleaned first; a stale target makes the fake fail', async () => {
|
||||
const out = join(tmp, 'build-ios'), logs = join(tmp, 'App.xcdistributionlogs');
|
||||
mkdirSync(logs, { recursive: true });
|
||||
writeFileSync(join(logs, 'IDEDistribution.standard.log'), 'step\nrsync error: syntax or usage error (code 1) at main.c(1795) [server=3.5.1]\n');
|
||||
const xb = fakeXcodebuild(tmp, logs);
|
||||
const argv = (p: string): string[] => [xb, '-exportArchive', '-exportPath', p];
|
||||
expect(exportDir(out, '1.0.0', '2')).toBe(join(out, 'export', '1.0.0-2'));
|
||||
expect(exportDir(out, undefined, '2')).toBe(join(out, 'export', 'build-2'));
|
||||
const p1 = exportDir(out, '1.0.0', '1');
|
||||
expect(await exportIpa({ root: tmp, out, archivePath: 'a', exportPath: p1, auth: null, argv: argv(p1) })).toBe(join(p1, 'App.ipa'));
|
||||
// the same build again: the folder is cleaned first, so the fake (fails on an existing target) passes
|
||||
expect(await exportIpa({ root: tmp, out, archivePath: 'a', exportPath: p1, auth: null, argv: argv(p1) })).toBe(join(p1, 'App.ipa'));
|
||||
const p2 = exportDir(out, '1.0.0', '2');
|
||||
expect(await exportIpa({ root: tmp, out, archivePath: 'a', exportPath: p2, auth: null, argv: argv(p2) })).toBe(join(p2, 'App.ipa'));
|
||||
expect(existsSync(join(p1, 'App.ipa'))).toBe(true);
|
||||
// a stale target that the export does not clean (argv aimed at another folder): non-zero exit, real reason shown
|
||||
const err = await exportIpa({ root: tmp, out, archivePath: 'a', exportPath: join(out, 'export', 'x'), auth: null, argv: argv(p1), tmp: join(tmp, 'none') }).then(() => new Error("no error"), (e: unknown) => e as Error);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.message).toContain('exit 70');
|
||||
expect(err.message).toContain('rsync error: syntax or usage error');
|
||||
expect(err.message).toContain('storebound doctor');
|
||||
});
|
||||
|
||||
test('an export that exits 0 without an IPA fails', async () => {
|
||||
const f = join(tmp, 'noipa');
|
||||
writeFileSync(f, '#!/bin/sh\nexit 0\n');
|
||||
chmodSync(f, 0o755);
|
||||
const p = join(tmp, 'empty-export');
|
||||
const err = await exportIpa({ root: tmp, out: tmp, archivePath: 'a', exportPath: p, auth: null, argv: [f] }).then(() => new Error("no error"), (e: unknown) => e as Error);
|
||||
expect(err.message).toContain('left no .ipa');
|
||||
});
|
||||
|
||||
test('distributionErrors: the folder named in the output, else the newest in the temp folder', () => {
|
||||
const t = join(tmp, 'tmpdir'), d = join(t, 'X.xcdistributionlogs');
|
||||
mkdirSync(d, { recursive: true });
|
||||
writeFileSync(join(d, 'IDEDistribution.verbose.log'), 'ok\nerror: something broke\n');
|
||||
expect(distributionErrors('', t)?.lines).toEqual(['error: something broke']);
|
||||
expect(distributionErrors('', join(tmp, 'nothing'))).toBeNull();
|
||||
});
|
||||
|
||||
test('xcodebuild PATH: Apple folders first; a Homebrew rsync is reported', () => {
|
||||
expect(xcodePath('/opt/homebrew/bin:/usr/bin:/bin')).toBe('/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin');
|
||||
expect(xcodePath('/x', '/Applications/Xcode.app/Contents/Developer').split(':')[0]).toBe('/Applications/Xcode.app/Contents/Developer/usr/bin');
|
||||
const has = (set: string[]) => (p: string) => set.includes(p);
|
||||
expect(foreignRsync('/opt/homebrew/bin:/usr/bin', has(['/opt/homebrew/bin/rsync', '/usr/bin/rsync']))).toBe('/opt/homebrew/bin/rsync');
|
||||
expect(foreignRsync('/opt/homebrew/bin:/usr/bin', has(['/usr/bin/rsync']))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('apple upload: a build number App Store Connect already has', () => {
|
||||
const base = { ipa: 'build/ios/export/App.ipa', build: '1', next: '3', allowExisting: false, uploadComplete: false, onAsc: false };
|
||||
test('a stale IPA fails loudly (exit 1 via the thrown error)', () => {
|
||||
const r = existingBuild({ ...base, onAsc: true, state: 'VALID' });
|
||||
expect(r).toBeInstanceOf(Error);
|
||||
expect((r as Error).message).toContain('is build 1, which is already on App Store Connect (VALID)');
|
||||
expect((r as Error).message).toContain('Did the last build fail?');
|
||||
expect(existingBuild({ ...base, uploadComplete: true })).toBeInstanceOf(Error);
|
||||
});
|
||||
test('a new build uploads; --allow-existing only re-checks', () => {
|
||||
expect(existingBuild(base)).toBe('upload');
|
||||
expect(existingBuild({ ...base, onAsc: true, allowExisting: true })).toBe('recheck');
|
||||
expect(existingBuild({ ...base, uploadComplete: true, allowExisting: true })).toBe('recheck');
|
||||
});
|
||||
test('the default IPA search covers the per-build folders, newest first', () => {
|
||||
const e = join(tmp, 'ups', 'export');
|
||||
mkdirSync(join(e, '1.0.0-2'), { recursive: true });
|
||||
writeFileSync(join(e, 'App.ipa'), 'old');
|
||||
writeFileSync(join(e, '1.0.0-2', 'App.ipa'), 'new');
|
||||
utimesSync(join(e, 'App.ipa'), new Date(2020, 0, 1), new Date(2020, 0, 1));
|
||||
expect(exportedIpas(e)).toEqual([join(e, '1.0.0-2', 'App.ipa'), join(e, 'App.ipa')]);
|
||||
expect(exportedIpas(join(tmp, 'missing'))).toEqual([]);
|
||||
});
|
||||
test('the release and beta flows pin the build and resume with --allow-existing', () => {
|
||||
for (const f of ['templates/workflows/release.yml', 'templates/workflows/beta.yml', 'templates/profiles/app/workflows/release.yml', 'templates/profiles/app/workflows/beta.yml'])
|
||||
expect(readFileSync(join(import.meta.dir, '..', f), 'utf8')).toContain('run: apple upload --no-wait --build ${BUILD} --allow-existing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('desktop publish: rsync by absolute path', () => {
|
||||
const o = { host: 'deploy@example.com', v: '0.8.1', channel: 'stable', out: '/o', files: ['latest.json'] };
|
||||
test('Apple rsync without --chmod; modes set on the server', () => {
|
||||
const s = publishSteps(o, p => p === '/usr/bin/rsync');
|
||||
expect(s[1]!.slice(0, 3)).toEqual(['/usr/bin/rsync', '-a', '--delete']);
|
||||
expect(s.flat().join(' ')).not.toContain('--chmod');
|
||||
expect(s[2]![2]).toContain('chmod 644');
|
||||
});
|
||||
test('the Homebrew keg when there is no /usr/bin/rsync; none is an error', () => {
|
||||
expect(publishSteps(o, p => p === '/opt/homebrew/opt/rsync/bin/rsync')[1]![0]).toBe('/opt/homebrew/opt/rsync/bin/rsync');
|
||||
expect(() => publishSteps(o, () => false)).toThrow('publish needs rsync');
|
||||
});
|
||||
});
|
||||
|
|
@ -152,7 +152,7 @@ describe('flow command', () => {
|
|||
const r = JSON.parse(p.stdout.toString());
|
||||
expect(r.mode).toBe('plan');
|
||||
expect(r.results.find((x: { id: string }) => x.id === 'submit').status).toBe('owner');
|
||||
expect(r.results.find((x: { id: string }) => x.id === 'upload').command).toBe('storebound apple upload --no-wait --dry');
|
||||
expect(r.results.find((x: { id: string }) => x.id === 'upload').command).toBe('storebound apple upload --no-wait --build ${BUILD} --allow-existing --dry');
|
||||
expect(r.results.find((x: { id: string }) => x.id === 'build').command).toContain('--version 2.0.0');
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -285,19 +285,22 @@ describe('credentials: template placeholders are missing, never credentials', ()
|
|||
PLAY_SERVICE_ACCOUNT: '/absolute/path/to/play-service-account.json', ASC_KEY_ID: 'YOUR_KEY_ID', ASC_P8: '/absolute/path/to/AuthKey_<KEY_ID>.p8',
|
||||
REVENUECAT_SECRET_KEY: 'sk_your_revenuecat_v2_secret_key', ANDROID_KEYSTORE: '/absolute/path/to/upload.p12', ANDROID_KEY_ALIAS: 'upload', ASC_VENDOR_NUMBER: '',
|
||||
}));
|
||||
const keep = Object.fromEntries(['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_P8', 'REVENUECAT_SECRET_KEY', 'ANDROID_KEYSTORE', 'ANDROID_KEY_ALIAS', 'STOREBOUND_PROFILE'].map(k => [k, process.env[k]]));
|
||||
const keep = Object.fromEntries(['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_P8', 'REVENUECAT_SECRET_KEY', 'ANDROID_KEYSTORE', 'ANDROID_KEY_ALIAS', 'STOREBOUND_PROFILE', 'STOREBOUND_CREDENTIALS_DIR'].map(k => [k, process.env[k]]));
|
||||
for (const k of Object.keys(keep)) delete process.env[k];
|
||||
// hermetic: a machine's default credential profile (~/.storebound/credentials/default-profile) must not fill these
|
||||
process.env.STOREBOUND_CREDENTIALS_DIR = join(tmp, 'no-profiles');
|
||||
try {
|
||||
for (const k of ['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_P8', 'REVENUECAT_SECRET_KEY', 'ANDROID_KEYSTORE']) expect({ k, v: credentialValue(d, k) }).toEqual({ k, v: undefined });
|
||||
expect(credentialValue(d, 'ANDROID_KEY_ALIAS')).toBe('upload');
|
||||
const st = Object.fromEntries(credentialStatus(d).map(s => [s.key, s.status]));
|
||||
expect(st).toMatchObject({ PLAY_SERVICE_ACCOUNT: 'placeholder', ASC_KEY_ID: 'placeholder', REVENUECAT_SECRET_KEY: 'placeholder', ANDROID_KEY_ALIAS: 'set', ASC_VENDOR_NUMBER: 'missing', ASC_ISSUER_ID: 'missing' });
|
||||
} finally { for (const [k, v] of Object.entries(keep)) if (v !== undefined) process.env[k] = v; }
|
||||
} finally { delete process.env.STOREBOUND_CREDENTIALS_DIR; for (const [k, v] of Object.entries(keep)) if (v !== undefined) process.env[k] = v; }
|
||||
});
|
||||
test('check warns about placeholders, the empty manifest list and an unused scan.ignore entry', () => {
|
||||
const d = variant('check', { '.storebound/credentials.json': JSON.stringify({ ASC_KEY_ID: 'YOUR_KEY_ID', REVENUECAT_SECRET_KEY: 'sk_your_revenuecat_v2_secret_key' }) });
|
||||
const env = { ...process.env };
|
||||
for (const k of ['ASC_KEY_ID', 'REVENUECAT_SECRET_KEY', 'STOREBOUND_PROFILE']) delete env[k];
|
||||
env.STOREBOUND_CREDENTIALS_DIR = join(tmp, 'no-profiles');
|
||||
const r = Bun.spawnSync(['bun', join(ROOT, 'cli', 'index.ts'), 'check'], { cwd: d, env });
|
||||
const err = r.stderr.toString() + r.stdout.toString();
|
||||
expect(err).toContain('credentials: ASC_KEY_ID, REVENUECAT_SECRET_KEY still hold template placeholders');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue