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
|
|
@ -7,10 +7,11 @@
|
|||
// Unity Unity-iPhone), takes the next safe build number from App Store Connect (apple next-build) unless
|
||||
// --build-number is given, writes <out>/ExportOptions.plist (method app-store-connect, team, signing style and the
|
||||
// profiles from apple.signing), runs xcodebuild archive and -exportArchive with the API key
|
||||
// (-allowProvisioningUpdates -authenticationKey*), and leaves <out>/export/<App>.ipa for `storebound apple upload`.
|
||||
// (-allowProvisioningUpdates -authenticationKey*), and leaves <out>/export/<version>-<build>/<App>.ipa for `storebound apple upload`. Each build exports into its own
|
||||
// folder, cleaned first: xcodebuild refuses to overwrite an earlier App.ipa ("exportArchive Copy failed", pitfall 53).
|
||||
// Full xcodebuild output goes to <out>/xcodebuild-*.log; the terminal gets the result lines.
|
||||
// Local only: nothing is sent to App Store Connect except reads (build numbers, team id, profiles).
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { ascKeyFile } from '../lib/credentials';
|
||||
|
|
@ -22,7 +23,7 @@ import { parseMobileprovision, toXmlPlist } from '../lib/plist';
|
|||
import { nextBuildNumber } from '../lib/release';
|
||||
import { exportOptions, profileDirs } from '../lib/signing';
|
||||
import { capacitorProject, webStep } from '../lib/web-assets';
|
||||
import { archiveArgs, type AscAuth, exportArgs, flutterConfigArgs, ipaInfo, pickXcode, scanXcode, showCmd } from '../lib/xcode';
|
||||
import { archiveArgs, type AscAuth, distributionErrors, exportArgs, foreignRsync, xcodePath, flutterConfigArgs, ipaInfo, pickXcode, scanXcode, showCmd } from '../lib/xcode';
|
||||
import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files';
|
||||
import { ascClient } from './asc';
|
||||
import { findBundleId } from './signing';
|
||||
|
|
@ -52,17 +53,45 @@ export function installedProfiles(bundleId: string, teamId: string | null, home
|
|||
return out.sort((a, b) => Date.parse(b.expires) - Date.parse(a.expires));
|
||||
}
|
||||
|
||||
async function runLogged(argv: string[], cwd: string, logFile: string, show: string, env?: Record<string, string>): Promise<void> {
|
||||
export async function runLogged(argv: string[], cwd: string, logFile: string, show: string, env?: Record<string, string>): Promise<void> {
|
||||
log(TAG, `$ ${show}`);
|
||||
log(TAG, `full output: ${logFile}`);
|
||||
const p = rt.spawn(argv, { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8', ...env } });
|
||||
// Apple's tool folders first: Xcode's IPA step runs `rsync` from PATH, and Homebrew's rsync 3.x breaks it (pitfall 54)
|
||||
const p = rt.spawn(argv, { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, PATH: xcodePath(process.env.PATH, process.env.DEVELOPER_DIR), LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8', ...env } });
|
||||
const chunks: string[] = [];
|
||||
const keep = /(\berror:|\*\* (ARCHIVE|EXPORT|BUILD) (SUCCEEDED|FAILED) \*\*|requires a provisioning profile|No signing certificate|Signing for .* requires|Exported .* to)/;
|
||||
const each = (line: string): void => { chunks.push(line); if (keep.test(line)) log(TAG, line.trim().slice(0, 300)); };
|
||||
await Promise.all([pump(p.stdout, 'stdout', each), pump(p.stderr, 'stderr', each)]);
|
||||
const code = await p.exited;
|
||||
writeFileSync(logFile, chunks.join('\n'));
|
||||
if (code !== 0) throw new Error(`${argv[0]} ${argv[1] ?? ''} failed (exit ${code}); last lines:\n${chunks.slice(-15).join('\n')}\nfull log: ${logFile}`);
|
||||
if (code !== 0) throw Object.assign(new Error(`${argv[0]} ${argv[1] ?? ''} failed (exit ${code}); last lines:\n${chunks.slice(-15).join('\n')}\nfull log: ${logFile}`), { output: chunks.join('\n') });
|
||||
}
|
||||
|
||||
/** The export folder of one build: <out>/export/<version>-<build> (build-<build> when the project sets the version). */
|
||||
export function exportDir(out: string, version: string | undefined, build: string): string {
|
||||
return join(out, 'export', version ? `${version}-${build}` : `build-${build}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* xcodebuild -exportArchive into a fresh folder (removed first, so an IPA left by an earlier export never makes it
|
||||
* fail with "Copy failed" or pass as this build). Throws on a non-zero exit or when no .ipa came out, so
|
||||
* `apple build && apple upload` and `flow run` stop there.
|
||||
*/
|
||||
export async function exportIpa(o: { root: string; out: string; archivePath: string; exportPath: string; auth: AscAuth | null; argv?: string[]; tmp?: string }): Promise<string> {
|
||||
rmSync(o.exportPath, { recursive: true, force: true });
|
||||
const exp = o.argv ?? exportArgs(o.archivePath, join(o.out, 'ExportOptions.plist'), o.exportPath, o.auth);
|
||||
try {
|
||||
await runLogged(exp, o.root, join(o.out, 'xcodebuild-export.log'), showCmd(exp, o.auth));
|
||||
} catch (e) {
|
||||
// "Copy failed" says nothing: the IDEDistribution log has the real line (e.g. "rsync error: syntax or usage error")
|
||||
const d = distributionErrors((e as { output?: string }).output ?? '', o.tmp);
|
||||
if (d?.lines.length) (e as Error).message += `\nwhy (${d.log}):\n${d.lines.map(l => ` ${l}`).join('\n')}`
|
||||
+ (d.lines.some(l => /rsync/i.test(l)) ? '\nA non-Apple rsync (Homebrew) breaks Xcode\'s IPA step: storebound doctor shows which one is first on PATH.' : '');
|
||||
throw e;
|
||||
}
|
||||
const ipa = existsSync(o.exportPath) ? readdirSync(o.exportPath).find(f => f.endsWith('.ipa')) : undefined;
|
||||
if (!ipa) throw new Error(`xcodebuild -exportArchive exited 0 but left no .ipa in ${o.exportPath}: see ${join(o.out, 'xcodebuild-export.log')}`);
|
||||
return join(o.exportPath, ipa);
|
||||
}
|
||||
|
||||
export async function run(ctx: Ctx): Promise<void> {
|
||||
|
|
@ -117,7 +146,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const opts = exportOptions({ teamId: teamId ?? '<TEAM_ID>', style, profiles: style === 'manual' ? (Object.keys(profiles).length ? profiles : { [bundleId]: '<profile uuid>' }) : undefined });
|
||||
const plist = toXmlPlist(opts);
|
||||
const archivePath = join(out, `${target.scheme}.xcarchive`);
|
||||
const exportPath = join(out, 'export');
|
||||
const exportPath = exportDir(out, version, buildNo);
|
||||
const authPreview: AscAuth = { keyPath: '$ASC_P8', keyId: '$ASC_KEY_ID', issuerId: '$ASC_ISSUER_ID' };
|
||||
const auth = style === 'automatic' ? authPreview : null;
|
||||
const steps = [
|
||||
|
|
@ -141,6 +170,8 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (process.platform !== 'darwin' || !rt.which('xcodebuild')) throw new Error('apple build needs macOS with Xcode (xcode-select --install is not enough: the full Xcode app)');
|
||||
const web = await webStep({ ...webOpts, dry: false });
|
||||
for (const n of target.notes) log(TAG, n);
|
||||
const rsync = foreignRsync(process.env.PATH);
|
||||
if (rsync) log(TAG, `note: ${rsync} is first on your PATH; xcodebuild runs with /usr/bin first (a non-Apple rsync breaks Xcode's IPA export)`);
|
||||
mkdirSync(out, { recursive: true });
|
||||
writeFileSync(join(out, 'ExportOptions.plist'), plist);
|
||||
const key = style === 'automatic' ? ascKeyFile(root) : null;
|
||||
|
|
@ -149,11 +180,9 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (target.framework === 'flutter') await runLogged(flutterConfigArgs(build!, version), root, join(out, 'flutter-config.log'), showCmd(flutterConfigArgs(build!, version), null));
|
||||
const arch = archiveArgs({ target, root, configuration, archivePath, version, build: build!, teamId: teamId ?? undefined, auth: realAuth });
|
||||
await runLogged(arch, root, join(out, 'xcodebuild-archive.log'), showCmd(arch, realAuth));
|
||||
const exp = exportArgs(archivePath, join(out, 'ExportOptions.plist'), exportPath, realAuth);
|
||||
await runLogged(exp, root, join(out, 'xcodebuild-export.log'), showCmd(exp, realAuth));
|
||||
await exportIpa({ root, out, archivePath, exportPath, auth: realAuth });
|
||||
} finally { key?.cleanup(); }
|
||||
const ipa = existsSync(exportPath) ? readdirSync(exportPath).find(f => f.endsWith('.ipa')) : undefined;
|
||||
if (!ipa) throw new Error(`export finished but no .ipa in ${exportPath}`);
|
||||
const ipa = readdirSync(exportPath).find(f => f.endsWith('.ipa'))!;
|
||||
const info = ipaInfo(join(exportPath, ipa));
|
||||
if (info.bundleId !== bundleId) warn(TAG, `the IPA's bundle id is ${info.bundleId}, the config says ${bundleId}`);
|
||||
if (info.build !== build) warn(TAG, `the IPA has build ${info.build}, not ${build}: the project sets CFBundleVersion itself (use $(CURRENT_PROJECT_VERSION) in Info.plist)`);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue