diff --git a/CHANGELOG.md b/CHANGELOG.md index ba0057f..fa80824 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## Unreleased +- `apple upload` finds the IPA of a build made with `--out`: `apple build` notes its IPA and out folder in `.storebound/last-apple-build.json`, and upload searches `--out` (new flag), then that last build's out, then `apple.build.out` / `build/ios`. When nothing is found, the error names every folder it searched and the last build's IPA. + ## 0.8.56 ยท 2026-10-27 - Release type. `apple.releaseType: afterApproval | manual | scheduled` (+ `apple.earliestReleaseDate`): `apple listing` creates versions with it (MANUAL when unset, existing versions untouched) and `apple submit` / `resubmit` set it before submitting when it differs, the dry run showing old -> new. New `apple release --after-approval | --manual | --scheduled [--platform] [--version] [--first-release] --yes` changes it on a version App Review has not approved yet (clear refusal otherwise, with `--manual-release` as the way out); exit 7 when it already has that type; an update is an agent step, after-approval on the first version of an app is exit 3 OWNER_STEP. Pitfall 125. diff --git a/cli/apple/build.ts b/cli/apple/build.ts index 13e8a89..d877447 100644 --- a/cli/apple/build.ts +++ b/cli/apple/build.ts @@ -41,7 +41,7 @@ import { liveDeviceFamilies } from './device-families'; import { keychainPassword, keychainSigningArgs, parseKeychainList, showSecurity, sshBuildRefusal, unlockSteps } from '../lib/keychain'; import { appGroupSteps, appGroupVerdicts, type GroupEvidence } from '../lib/targets'; import { capacitorProject, webStep } from '../lib/web-assets'; -import { archiveArgs, type AscAuth, distributionErrors, exportArgs, foreignRsync, remoteBuildSession, xcodePath, flutterConfigArgs, ipaInfo, ipaVersionProblem, pickXcode, scanXcode, showCmd } from '../lib/xcode'; +import { archiveArgs, type AscAuth, distributionErrors, exportArgs, foreignRsync, remoteBuildSession, xcodePath, flutterConfigArgs, ipaInfo, ipaVersionProblem, pickXcode, recordLastBuild, scanXcode, showCmd } from '../lib/xcode'; import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files'; import { ascClient } from './asc'; import { parsePlatform } from './platform'; @@ -305,6 +305,7 @@ export async function run(ctx: Ctx): Promise { const result = { ipa: join(exportPath, ipa), bundleId: info.bundleId, version: info.version, build: info.build, web: web.bundle, derivedData: derivedData ?? null, pathFree, ...(scanPaths ? { leaks } : {}) }; writeFileSync(join(out, 'latest.json'), `${JSON.stringify(result, null, 2)} `); + recordLastBuild(root, { ipa: result.ipa, out, version: info.version, build: info.build }); if (json) printJson(result); done(TAG, `${relative(root, result.ipa)}: ${info.bundleId} ${info.version} (${info.build}). Next: storebound apple upload ${relative(root, result.ipa)} --dry`); } diff --git a/cli/apple/upload.ts b/cli/apple/upload.ts index 114d989..28ccd4a 100644 --- a/cli/apple/upload.ts +++ b/cli/apple/upload.ts @@ -1,7 +1,8 @@ // Upload an IPA to App Store Connect and wait until Apple has processed the build. -// storebound apple upload [] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--pre-check ""] [--evidence ] [--dry] [--yes] [--json] +// storebound apple upload [] [--out D] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--pre-check ""] [--evidence ] [--dry] [--yes] [--json] // storebound apple upload --platform macos [...] (Mac App Store .pkg from apple build --platform macos --tauri; platform MAC_OS, uti com.apple.pkg; altool gets --type macos; no SDK / privacy-manifest check) -// Default IPA: the newest in /export (and its -/ folders), or with +// Default IPA: the newest in <--out>/export, else the last apple build's /export (.storebound/last-apple-build.json, +// so a build with --out is found), then /export (and its -/ folders), or with // --build N the one with that build number. The bundle id, version and build number come from the IPA's Info.plist. // An IPA whose build number is already on App Store Connect fails with exit 1 (a stale IPA left by a failed // `apple build`, pitfall 53); only --allow-existing (flows that resume after a finished upload) re-checks it instead. Before any network call every PrivacyInfo.xcprivacy in the IPA @@ -27,7 +28,7 @@ import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; import { done, log, printJson, warn } from '../lib/log'; import { compareBuild, nextBuildNumber, nextPollDelay, parseDuration, WAIT_EXIT } from '../lib/release'; -import { altoolArgs, buildUploadReqs, exportedIpas, ipaInfo, ipaVersionProblem, showCmd, UPLOAD_DONE, uploadProblems } from '../lib/xcode'; +import { altoolArgs, buildUploadReqs, exportedIpas, ipaInfo, ipaVersionProblem, readLastBuild, showCmd, UPLOAD_DONE, uploadProblems, uploadSearchDirs } from '../lib/xcode'; import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files'; import { BUILD_WAIT_EXIT } from '../lib/build-ops'; import { type Asc, ascClient, type J } from './asc'; @@ -238,11 +239,20 @@ export async function run(ctx: Ctx): Promise { const macos = parsePlatform(a.get('platform')) === 'MAC_OS'; let ipa = a._.find(x => x.endsWith('.ipa') || x.endsWith('.pkg')); if (!ipa) { - const dir = join(resolve(config.apple?.build?.out ?? 'build/ios'), 'export'); - let found = exportedIpas(dir); - if (want) found = found.filter(f => { try { return ipaInfo(f).build === want; } catch { return false; } }); + const last = readLastBuild(root); + const flagOut = a.get('out'); + const dirs = uploadSearchDirs({ flagOut: flagOut ? resolve(flagOut) : undefined, lastOut: last?.out, configOut: resolve(config.apple?.build?.out ?? 'build/ios') }); + let found: string[] = []; + for (const dir of dirs) { + found = exportedIpas(dir); + if (want) found = found.filter(f => { try { return ipaInfo(f).build === want; } catch { return false; } }); + if (found[0]) break; + } if (!found[0] && macos) throw new Error('usage: storebound apple upload --platform macos (storebound apple build --platform macos --tauri makes one)'); - if (!found[0]) throw new Error(`usage: storebound apple upload (no ${want ? `build ${want} ` : ''}IPA in ${relative(root, dir)}; storebound apple build makes one)`); + if (!found[0]) { + const lastHint = last && !existsSync(last.ipa) ? `; the last apple build (${last.build}) wrote ${last.ipa}, which is gone` : last ? `; the last apple build (${last.build}) is ${last.ipa}` : ''; + throw new Error(`usage: storebound apple upload (no ${want ? `build ${want} ` : ''}IPA in ${dirs.map(d => relative(root, d) || '.').join(', ')}${lastHint}; pass the IPA, --out , or run storebound apple build)`); + } ipa = found[0]; } else ipa = resolve(ipa); shipPhase('verify', `checking ${relative(root, ipa)}`); diff --git a/cli/data/flags.json b/cli/data/flags.json index aa55fc7..3a24466 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -366,6 +366,7 @@ "locale", "no-wait", "older-than", + "out", "platform", "pre-check", "progress", @@ -1574,7 +1575,7 @@ "storebound apple testflight expire (--keep N | --older-than D) [--version x.y.z] [--dry] [--yes] [--json]" ], "apple upload": [ - "storebound apple upload [] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--pre-check \"\"] [--evidence ] [--dry] [--yes] [--json]", + "storebound apple upload [] [--out D] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--pre-check \"\"] [--evidence ] [--dry] [--yes] [--json]", "storebound apple upload --platform macos [...] (Mac App Store .pkg from apple build --platform macos --tauri; platform MAC_OS, uti com.apple.pkg; altool gets --type macos; no SDK / privacy-manifest check)" ], "apple win-back": [ diff --git a/cli/lib/xcode.ts b/cli/lib/xcode.ts index cccad53..ed5f4a7 100644 --- a/cli/lib/xcode.ts +++ b/cli/lib/xcode.ts @@ -1,7 +1,7 @@ // Xcode builds without Xcode's UI: find the workspace / project and scheme (native, Capacitor, React Native, Flutter, // Unity-exported projects), build the xcodebuild archive and export command lines, read an IPA's Info.plist. // Pure except scanXcode (a bounded directory walk) and ipaInfo (reads the IPA). -import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { basename, dirname, join, relative } from 'node:path'; import { parsePlist, type PlistValue } from './plist'; import { zipEntries, zipRead } from './zip'; @@ -266,3 +266,21 @@ export function remoteBuildSession(env: Record, mana if (managerName && managerName !== 'Aqua') return `this is not the logged-in GUI session (launchctl manager: ${managerName})`; return null; } + +/** Where `apple build` notes its last IPA for `apple upload`: .storebound/last-apple-build.json (gitignored). */ +export const lastBuildFile = (root: string): string => join(root, '.storebound', 'last-apple-build.json'); + +export function recordLastBuild(root: string, r: { ipa: string; out: string; version: string; build: string }): void { + try { mkdirSync(join(root, '.storebound'), { recursive: true }); writeFileSync(lastBuildFile(root), `${JSON.stringify(r, null, 2)}\n`); } catch { /* best effort */ } +} + +export function readLastBuild(root: string): { ipa: string; out: string; version?: string; build?: string } | null { + try { const j = JSON.parse(readFileSync(lastBuildFile(root), 'utf8')); return typeof j?.ipa === 'string' && typeof j?.out === 'string' ? j : null; } catch { return null; } +} + +/** The export folders `apple upload` searches, in order: --out, else the last build's out (a --out that build used), + * then apple.build.out / build/ios. Duplicates dropped. */ +export function uploadSearchDirs(o: { flagOut?: string; lastOut?: string; configOut: string }): string[] { + const dirs = o.flagOut ? [o.flagOut] : [...(o.lastOut ? [o.lastOut] : []), o.configOut]; + return [...new Set(dirs.map(d => join(d, 'export')))]; +} diff --git a/tests/path-free.test.ts b/tests/path-free.test.ts index 0a2e628..a6b599d 100644 --- a/tests/path-free.test.ts +++ b/tests/path-free.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { archiveScanDirs, derivedDataFor, leakNeedles, leakProblem, outsideHome, pathFreeSettings, prefixMaps, scanForPaths } from '../cli/lib/path-free'; -import { archiveArgs, validateAppleBuild } from '../cli/lib/xcode'; +import { archiveArgs, readLastBuild, recordLastBuild, uploadSearchDirs, validateAppleBuild } from '../cli/lib/xcode'; const resolve = (p: string): string => (p.startsWith('/') ? p : `/proj/${p}`); const t = { framework: 'native' as const, project: 'App.xcodeproj', scheme: 'App', notes: [] }; @@ -102,3 +102,18 @@ describe('leak scan', () => { rmSync(dir, { recursive: true, force: true }); }); }); + +describe('apple upload finds the last build', () => { + test('--out wins; else the last build out, then the config out', () => { + expect(uploadSearchDirs({ flagOut: '/o', lastOut: '/l', configOut: '/c' })).toEqual([join('/o', 'export')]); + expect(uploadSearchDirs({ lastOut: '/l', configOut: '/c' })).toEqual([join('/l', 'export'), join('/c', 'export')]); + expect(uploadSearchDirs({ lastOut: '/c', configOut: '/c' })).toEqual([join('/c', 'export')]); + }); + test('build records the IPA and out in .storebound, upload reads it back', () => { + const root = mkdtempSync(join(tmpdir(), 'lb-')); + expect(readLastBuild(root)).toBeNull(); + recordLastBuild(root, { ipa: '/shared/out/export/build-31/App.ipa', out: '/shared/out', version: '1.0', build: '31' }); + expect(readLastBuild(root)).toEqual({ ipa: '/shared/out/export/build-31/App.ipa', out: '/shared/out', version: '1.0', build: '31' }); + rmSync(root, { recursive: true, force: true }); + }); +});