// 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'); }); });