diff --git a/CHANGELOG.md b/CHANGELOG.md index 7067715..b471f55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## Unreleased +- `apple build` is hermetic and can be path-free. `--derived-data ` (`apple.build.derivedData`) is xcodebuild `-derivedDataPath`; with `--out` (or `apple.build.out`) it defaults to `/DerivedData` instead of `~/Library/Developer/Xcode/DerivedData` (without `--out` Xcode's folder stays). `--path-free` (`apple.build.pathFree`) remaps the home folder, checkout, out and DerivedData out of the app binary and the archive's dSYMs / Symbols (Swift `-debug-prefix-map` / `-file-prefix-map`, clang `-ffile-prefix-map`; dwarf-with-dsym stays, symbol upload keeps working), scans the archive and stops before the export with the hit count and the first file + offsets (never the paths). `--scan-paths` only scans and warns. A `--out` outside the home folder prints a hint. `--json` / `latest.json` carry `derivedData`, `pathFree` and `leaks`. Store-pitfalls 124. + ## 0.8.53 ยท 2026-10-26 - Updates are agent steps (the owner's standing rule), first releases stay the owner's. `play release --production completed|inProgress --yes`, `play upload --track production --yes`, `apple submit --build --yes` and `apple release --manual-release --yes` run without an owner gate, in a terminal and in a pipeline, when the store says the app is already live (Play: the production track holds a completed / inProgress / halted release; App Store: a version of the platform is or was `READY_FOR_SALE` / `READY_FOR_DISTRIBUTION` / `REPLACED_WITH_NEW_VERSION`; `cli/lib/release-kind.ts`). The result line carries `releaseKind`; a re-run is exit 7. The first-ever release of an app with `--yes` is exit 3 `OWNER_STEP` ("first release of this app: the owner approves it once"), approved by `--first-release` (after the owner's yes) or bypass mode; a person answering the prompt (no `--yes`) is the approval too. `apple release` is now a ship step (`--json`, `--result-file`, exit 7 when the version is released already). Policy in docs/bypass.md ("Updates are agent steps"), docs/ship-step.md, the release-management and play-first-release skills, AGENTS.md; pitfall 123 / 96. diff --git a/cli/apple/build.ts b/cli/apple/build.ts index 855451b..87fa428 100644 --- a/cli/apple/build.ts +++ b/cli/apple/build.ts @@ -3,6 +3,7 @@ // (Mac App Store build of a Tauri app: cli/apple/build-mas.ts) // storebound apple build [--version 1.2.0] [--build-number N] [--scheme S] [--workspace W | --project P] // [--configuration Release] [--out build/ios] [--no-prebuild] [--allow-stale-web] [--dry] [--json] +// [--derived-data ] [--path-free] [--scan-paths] // [--keychain ] [--keychain-unlock] [--allow-ssh] [--allow-app-group-gap] // Over SSH (no GUI session) codesign needs an unlocked keychain: --keychain-unlock unlocks it with the password in // STOREBOUND_KEYCHAIN_PASSWORD (never printed) and sets its key partition list; --keychain signs with a build @@ -16,15 +17,21 @@ // profiles from apple.signing), runs xcodebuild archive and -exportArchive with the API key // (-allowProvisioningUpdates -authenticationKey*), and leaves /export/-/.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). +// DerivedData: --derived-data (apple.build.derivedData) is xcodebuild -derivedDataPath; with --out (or apple.build.out) +// it defaults to /DerivedData, so a build is hermetic per output folder (without --out Xcode keeps ~/Library/...). +// --path-free (apple.build.pathFree) remaps the home folder, checkout, out and DerivedData out of the binary and the dSYM / +// Symbols (Swift -debug-prefix-map / -file-prefix-map, clang -ffile-prefix-map), then scans the archive and stops before the +// export when a home path is left; --scan-paths only scans and warns. A --out outside the home folder prints a hint. // Full xcodebuild output goes to /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, rmSync, writeFileSync } from 'node:fs'; -import { homedir } from 'node:os'; +import { homedir, userInfo } from 'node:os'; import { dirname, join, relative } from 'node:path'; import { ascKeyFile } from '../lib/credentials'; import type { Ctx } from '../lib/ctx'; import { need } from '../lib/ctx'; import { done, log, printJson, warn } from '../lib/log'; +import { derivedDataFor, archiveScanDirs, leakNeedles, leakProblem, outsideHome, pathFreeSettings, scanForPaths } from '../lib/path-free'; import { pump } from '../lib/runner'; import { parseMobileprovision, toXmlPlist } from '../lib/plist'; import { nextBuildNumber } from '../lib/release'; @@ -148,6 +155,14 @@ export async function run(ctx: Ctx): Promise { if (sub) target.notes.unshift(`Capacitor project: ${sub} (${sub}/${cap!.config}; the only one found; set apple.build.workspace to use another)`); const configuration = a.str('configuration', cfg.configuration ?? 'Release'); const out = resolve(a.get('out') ?? cfg.out ?? 'build/ios'); + const outChosen = !!(a.get('out') ?? cfg.out); + const derivedData = derivedDataFor({ flag: a.get('derived-data'), config: cfg.derivedData, out, outChosen, resolve }); + const pathFree = a.has('path-free') || cfg.pathFree === true; + const scanPaths = pathFree || a.has('scan-paths'); + const home = homedir(); + const pfSettings = pathFree ? pathFreeSettings({ home, root, out, derivedData }) : []; + if (!pathFree && outsideHome(out, home)) warn(TAG, `--out is outside the home folder, but the build embeds the home folder and checkout paths (binary, dSYM, Symbols): add --path-free (or apple.build.pathFree) to remap them and scan the archive`); + if (pathFree && (!derivedData || !outsideHome(derivedData, home))) warn(TAG, `--path-free: DerivedData ${derivedData ? 'is' : 'defaults to'} inside the home folder, and dsymutil / Xcode record that path in the dSYM, so the scan below will find it. Build with --out (or --derived-data) outside the home folder, e.g. a shared folder.`); const version = a.get('version'); if (version && !/^\d+(\.\d+){0,2}$/.test(version)) throw new Error('--version: x.y.z'); @@ -212,12 +227,12 @@ export async function run(ctx: Ctx): Promise { const auth = style === 'automatic' ? authPreview : null; const steps = [ ...(target.framework === 'flutter' ? [showCmd(flutterConfigArgs(buildNo, version), null)] : []), - showCmd(archiveArgs({ target, root, configuration, archivePath, version, build: buildNo, teamId: teamId ?? undefined, auth, settings: keychain ? keychainSigningArgs(keychain) : undefined }), auth), + showCmd(archiveArgs({ target, root, configuration, archivePath, version, build: buildNo, teamId: teamId ?? undefined, auth, settings: [...(keychain ? keychainSigningArgs(keychain) : []), ...pfSettings], derivedData }), auth), showCmd(exportArgs(archivePath, join(out, 'ExportOptions.plist'), exportPath, auth), auth), ]; const unlockPreview = unlock ? unlockSteps(keychain ?? '', []).map(showSecurity) : []; const plan = { framework: target.framework, workspace: target.workspace ?? null, project: target.project ?? null, scheme: target.scheme, configuration, - version: version ?? '(project value)', build: buildNo, teamId, signingStyle: style, exportOptions: opts, out: relative(root, out) || '.', steps: [...unlockPreview, ...steps], notes: target.notes }; + version: version ?? '(project value)', build: buildNo, derivedData: derivedData ?? '(Xcode default)', pathFree, teamId, signingStyle: style, exportOptions: opts, out: relative(root, out) || '.', steps: [...unlockPreview, ...steps], notes: target.notes }; const webOpts = { config, root, cap, store: 'apple' as const, tag: TAG, noPrebuild: a.has('no-prebuild'), allowStale: a.has('allow-stale-web') }; if (dry) { const web = await webStep({ ...webOpts, dry: true, quiet: json }); @@ -245,13 +260,21 @@ export async function run(ctx: Ctx): Promise { 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); + let leaks = 0; const key = style === 'automatic' ? ascKeyFile(root) : null; const realAuth: AscAuth | null = key ? { keyPath: key.keyPath, keyId: key.keyId, issuerId: key.issuerId } : null; try { if (target.framework === 'flutter') await runLogged(flutterConfigArgs(build!, version), root, join(out, 'flutter-config.log'), showCmd(flutterConfigArgs(build!, version), null)); shipPhase('build', `xcodebuild archive, build ${build}`); - const arch = archiveArgs({ target, root, configuration, archivePath, version, build: build!, teamId: teamId ?? undefined, auth: realAuth, settings: keychain ? keychainSigningArgs(keychain) : undefined }); + const arch = archiveArgs({ target, root, configuration, archivePath, version, build: build!, teamId: teamId ?? undefined, auth: realAuth, settings: [...(keychain ? keychainSigningArgs(keychain) : []), ...pfSettings], derivedData }); await runLogged(arch, root, join(out, 'xcodebuild-archive.log'), showCmd(arch, realAuth)); + if (scanPaths) { + shipPhase('verify', 'scanning the archive for local paths'); + const found = scanForPaths(archiveScanDirs(archivePath), leakNeedles({ home, user: userInfo().username, root })); + leaks = found.total; + const why = leakProblem(found, relative(root, archivePath)); + if (why) { if (pathFree) throw new Error(why); warn(TAG, why.split(String.fromCharCode(10))[0]!); } else log(TAG, 'no local path in the archive (app, dSYMs, Symbols)'); + } shipPhase('sign', 'xcodebuild -exportArchive (signing)'); await exportIpa({ root, out, archivePath, exportPath, auth: realAuth }); } finally { key?.cleanup(); } @@ -265,7 +288,7 @@ export async function run(ctx: Ctx): Promise { // ITMS-91064 (Invalid Binary after the upload): NSPrivacyTracking / NSPrivacyTrackingDomains of every manifest in the IPA const privacy = bundleTrackingIssues(ipaManifests(join(exportPath, ipa))); if (privacy.length) throw new Error(`${relative(root, join(exportPath, ipa))} was built, but Apple would refuse it at upload:\n${privacy.map(x => ` ${x}`).join('\n')}\nFix the manifest and build again.`); - const result = { ipa: join(exportPath, ipa), bundleId: info.bundleId, version: info.version, build: info.build, web: web.bundle }; + 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)} `); if (json) printJson(result); diff --git a/cli/data/flags.json b/cli/data/flags.json index 798cab7..9493636 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -73,6 +73,7 @@ "allow-version-mismatch", "build-number", "configuration", + "derived-data", "env-file", "keychain", "keychain-unlock", @@ -1201,6 +1202,7 @@ "debug-dump", "declarations", "delay", + "derived-data", "detail", "device", "dir", diff --git a/cli/lib/path-free.ts b/cli/lib/path-free.ts new file mode 100644 index 0000000..4587520 --- /dev/null +++ b/cli/lib/path-free.ts @@ -0,0 +1,99 @@ +// Path-free iOS builds: no home folder, user name or checkout path inside the app binary or the archive's debug +// symbols (a privacy gate over a shared release folder found hundreds of hits). Used by `apple build --path-free`. +// - derivedDataFor: DerivedData per output folder (hermetic), not ~/Library/Developer/Xcode/DerivedData +// - pathFreeSettings: xcodebuild build settings with Swift / clang prefix maps (dSYM and Symbols stay, paths are remapped) +// - scanArchiveLeaks / leakProblem: scan the archive's .app, dSYMs and Symbols for the home folder and the user name +// Pure except scanArchiveLeaks (bounded file reads). +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; +import { posix } from 'node:path'; + +const trim = (p: string): string => (p.length > 1 ? p.replace(/\/+$/, '') : p); + +/** DerivedData folder of a build: --derived-data, else apple.build.derivedData, else /DerivedData when the output + * folder was chosen (--out / apple.build.out), else null (Xcode's default under ~/Library). */ +export function derivedDataFor(o: { flag?: string; config?: string; out: string; outChosen: boolean; resolve: (p: string) => string }): string | null { + const given = o.flag ?? o.config; + if (given) return o.resolve(given); + return o.outChosen ? posix.join(o.out, 'DerivedData') : null; +} + +/** True when `dir` is not inside `home`. */ +export const outsideHome = (dir: string, home: string): boolean => { const h = trim(home); return !!h && dir !== h && !dir.startsWith(`${h}/`); }; + +export interface PathFreeInput { home: string; root: string; out: string; derivedData?: string | null } + +/** [from, to] prefix maps: the home folder, the checkout, the output folder and DerivedData, longest (most specific) first. + * swiftc applies the FIRST matching map, clang the LAST: pathFreeSettings reverses the list for clang. */ +export function prefixMaps(i: PathFreeInput): [string, string][] { + const all: [string, string][] = [[trim(i.home), '/home'], [trim(i.root), '.'], [trim(i.out), '/out']]; + if (i.derivedData) all.push([trim(i.derivedData), '/derived']); + const seen = new Set(); + return all.filter(([from]) => from.length > 1 && !seen.has(from) && seen.add(from)).sort((a, b) => b[0].length - a[0].length); +} + +const q = (s: string): string => (/[\s"'$]/.test(s) ? `"${s.replace(/(["\$])/g, '\$1')}"` : s); + +/** xcodebuild build settings that remap every local path in Swift, C, C++ and Objective-C output and in DWARF. The + * debug format is left alone (dwarf-with-dsym), so symbol upload keeps working. */ +export function pathFreeSettings(i: PathFreeInput): string[] { + const maps = prefixMaps(i); + const swift = maps.flatMap(([f, t]) => ['-debug-prefix-map', q(`${f}=${t}`), '-file-prefix-map', q(`${f}=${t}`)]); + const clang = [...maps].reverse().flatMap(([f, t]) => [q(`-ffile-prefix-map=${f}=${t}`)]); + // SWIFT_SERIALIZE_DEBUGGING_OPTIONS=NO: Xcode embeds the compiler's include paths, module cache and -ffile-compilation-dir + // (the checkout) in the binary's __swift_ast section; no prefix map reaches it. Only lldb's Swift expression evaluation + // uses it; crash symbolication (the dSYM's functions and lines) does not. + return ['SWIFT_SERIALIZE_DEBUGGING_OPTIONS=NO', `OTHER_SWIFT_FLAGS=$(inherited) ${swift.join(' ')}`, `OTHER_CFLAGS=$(inherited) ${clang.join(' ')}`, `OTHER_CPLUSPLUSFLAGS=$(inherited) ${clang.join(' ')}`]; +} + +export interface PathHit { file: string; kind: string; offset: number } + +/** The needles of a leak scan, named by kind so a report never prints the value: the home folder, /Users// and the + * checkout. DerivedData is no needle: outside the home folder it is not the user's (dsymutil and Xcode record it in the + * dSYM and the __swift_ast section and no prefix map reaches it); inside the home folder the home needle finds it. */ +export function leakNeedles(i: { home: string; user: string; root: string }): { kind: string; value: string }[] { + const list = [{ kind: 'home folder', value: trim(i.home) + '/' }, { kind: 'user folder', value: `/Users/${i.user}/` }, { kind: 'checkout', value: trim(i.root) + '/' }]; + const seen = new Set(); + return list.filter(n => n.value.length >= 6 && !seen.has(n.value) && seen.add(n.value)); +} + +const MAX_FILE = 600 * 1048576; + +/** Every needle occurrence in the files under `dirs` (all of them are counted; the first `perFile` offsets per file kept). */ +export function scanForPaths(dirs: string[], needles: { kind: string; value: string }[], perFile = 3): { hits: PathHit[]; total: number; files: number } { + const ns = needles.map(n => ({ kind: n.kind, b: Buffer.from(n.value) })); + const hits: PathHit[] = []; + const bad = new Set(); + let total = 0; + const walk = (base: string, d: string): void => { + let names: string[]; try { names = readdirSync(d); } catch { return; } + for (const name of names) { + const p = join(d, name); + let st; try { st = statSync(p); } catch { continue; } + if (st.isDirectory()) { walk(base, p); continue; } + if (!st.isFile() || st.size > MAX_FILE) continue; + const buf = readFileSync(p); + let kept = 0; + for (const { kind, b } of ns) { + for (let at = buf.indexOf(b); at >= 0; at = buf.indexOf(b, at + b.length)) { + total++; bad.add(p); + if (kept++ < perFile) hits.push({ file: relative(base, p).split(sep).join('/'), kind, offset: at }); + } + } + } + }; + for (const d of dirs) if (existsSync(d)) walk(d, d); + return { hits, total, files: bad.size }; +} + +/** What the archive holds that ships or symbolicates: Products (the .app, extensions, frameworks), dSYMs, Symbols. */ +export const archiveScanDirs = (archive: string): string[] => ['Products', 'dSYMs', 'Symbols'].map(x => join(archive, x)); + +/** The refusal text of a path-free build that still has local paths, else null. Counts, kinds, file and offset only. */ +export function leakProblem(r: { hits: PathHit[]; total: number; files: number }, archive: string): string | null { + if (!r.total) return null; + const lines = r.hits.slice(0, 8).map(h => ` ${h.file} @${h.offset}: ${h.kind}`); + return `${r.total} local path${r.total === 1 ? '' : 's'} left in ${r.files} file${r.files === 1 ? '' : 's'} of ${archive} (home folder, user name or checkout):\n${lines.join('\n')}${r.hits.length > 8 ? '\n ...' : ''}\n` + + 'Stopped before the export. Causes: #file / #filePath in Swift sources (no prefix map reaches those literals: use #fileID), a prebuilt framework or Pod that carries its own paths ' + + '(rebuild it path-free), or DerivedData / --out inside the home folder (dsymutil and Xcode record that path: use --out or --derived-data outside it, e.g. a shared folder).'; +} diff --git a/cli/lib/xcode.ts b/cli/lib/xcode.ts index a961fd6..cccad53 100644 --- a/cli/lib/xcode.ts +++ b/cli/lib/xcode.ts @@ -18,6 +18,10 @@ export interface AppleBuildConfig { configuration?: string; /** Output folder for the archive, ExportOptions.plist, the IPA and logs. Default build/ios. */ out?: string; + /** xcodebuild -derivedDataPath. Default /DerivedData when the output folder is chosen (--out / out), else Xcode's own folder under ~/Library. */ + derivedData?: string; + /** Remap the home folder, checkout, output folder and DerivedData out of the binary and the debug symbols, then scan the archive (same as --path-free). */ + pathFree?: boolean; } export function validateAppleBuild(b: AppleBuildConfig | undefined): string[] { @@ -26,6 +30,8 @@ export function validateAppleBuild(b: AppleBuildConfig | undefined): string[] { if (b.workspace && !b.workspace.endsWith('.xcworkspace')) e.push('apple.build.workspace: a path ending in .xcworkspace'); if (b.project && !b.project.endsWith('.xcodeproj')) e.push('apple.build.project: a path ending in .xcodeproj'); if (b.scheme !== undefined && !b.scheme.trim()) e.push('apple.build.scheme: empty'); + if (b.derivedData !== undefined && (typeof b.derivedData !== 'string' || !b.derivedData.trim())) e.push('apple.build.derivedData: a folder path'); + if (b.pathFree !== undefined && typeof b.pathFree !== 'boolean') e.push('apple.build.pathFree: true or false'); return e; } @@ -125,13 +131,13 @@ export interface AscAuth { keyPath: string; keyId: string; issuerId: string } const authArgs = (a: AscAuth | null): string[] => (a ? ['-allowProvisioningUpdates', '-authenticationKeyPath', a.keyPath, '-authenticationKeyID', a.keyId, '-authenticationKeyIssuerID', a.issuerId] : []); -export interface ArchiveInput { target: XcodeTarget; root: string; configuration: string; archivePath: string; version?: string; build: string; teamId?: string; auth: AscAuth | null; /** extra build settings, e.g. OTHER_CODE_SIGN_FLAGS=--keychain */ settings?: string[] } +export interface ArchiveInput { target: XcodeTarget; root: string; configuration: string; archivePath: string; version?: string; build: string; teamId?: string; auth: AscAuth | null; /** extra build settings, e.g. OTHER_CODE_SIGN_FLAGS=--keychain */ settings?: string[]; /** xcodebuild -derivedDataPath */ derivedData?: string | null } /** xcodebuild archive. Version / build go in as build settings (they beat the project's values). */ export function archiveArgs(i: ArchiveInput): string[] { return ['xcodebuild', ...(i.target.workspace ? ['-workspace', join(i.root, i.target.workspace)] : ['-project', join(i.root, i.target.project!)]), '-scheme', i.target.scheme, '-configuration', i.configuration, '-destination', 'generic/platform=iOS', '-archivePath', i.archivePath, - ...authArgs(i.auth), 'archive', `CURRENT_PROJECT_VERSION=${i.build}`, ...(i.version ? [`MARKETING_VERSION=${i.version}`] : []), + ...(i.derivedData ? ['-derivedDataPath', i.derivedData] : []), ...authArgs(i.auth), 'archive', `CURRENT_PROJECT_VERSION=${i.build}`, ...(i.version ? [`MARKETING_VERSION=${i.version}`] : []), ...(i.teamId ? [`DEVELOPMENT_TEAM=${i.teamId}`] : []), ...(i.settings ?? [])]; } diff --git a/docs/pitfalls.md b/docs/pitfalls.md index f3f01e7..ca3710c 100644 --- a/docs/pitfalls.md +++ b/docs/pitfalls.md @@ -63,6 +63,7 @@ fails without the fix. | 27 | 2026-10 | A real Play first release (an agent with a browser tool) found the steps no API does: the App category stayed "Not selected" (`play listing` cannot set it: edits.details has no category field); production had 0 countries (edits.countryAvailability is read-only); App content had ten cards with their own pages; the IARC questionnaire is one long page whose Yes answers reveal follow-ups, with an IARC Terms of Use agreement on page 1 and a final "Save" (not "Submit") on the Summary; Play's material checkboxes, like its radios, ignore native input; long evaluate calls timed out at 45 s; "Send changes for review" ignored a JS click; managed publishing is off by default, so approved means live. | Plans `play-store-settings`, `play-app-content` (+ one per card), `play-production-countries`, `play-production-release`; config `play.category` (Play's lists), `play.countries`, `play.managedPublishing`, `console.contentRating.details`, `console.appContent`, `social`, `products[].randomRewards`; every ask-owner answer in one batch; `check` warns without a category and counts the open App content answers; `validate` reports the category reminder and "production has 0 countries"; `play status` and `play release` say what is console-only; the IARC Terms box is never-press; material checkboxes get the inner [role=checkbox] click (plan and `console fill`); every plan ships `inPageLoop`. `tests/console-play-plans.test.ts`. | | 26 | 2026-10 | An agent using storebound spent hours handing the owner one click, command or answer at a time ("press Next", "download the CSV template", "now run the upload", a compliance question in the middle of a run): skills said "let the owner do it" / "by hand" for navigation, exports and uploads. | The first rule "Do it, don't ask" heads `docs/agents-core.md` (every generated adapter), README and the store-pitfalls skill: the agent does API calls, uploads, builds, exports, console navigation and forms itself; the owner presses only the final public or irreversible buttons and answers the facts no file proves in one batch at the start. `tests/do-it-dont-ask.test.ts` checks the rule is there and fails on skill text that hands navigation, draft saves, exports, uploads or form filling to the owner. | | 25 | 2026-10 | In a non-interactive shell `play build` stopped in Gradle with "SDK location not found" (no `ANDROID_HOME` there, `local.properties` gitignored) although the SDK was in `~/Library/Android/sdk`; `apple upload --no-wait --yes` sent the IPA for over 10 minutes without a line (progress every 10th part only, no per-part or per-call timeout). | `resolveAndroidSdk` (ANDROID_HOME, ANDROID_SDK_ROOT, `sdk.dir` in local.properties, the Android Studio default folder; only folders with platforms/ or platform-tools/) hands the SDK to Gradle in the environment, never writes local.properties, stops before Gradle with the places checked; `doctor` shows it (`cli/lib/tools.ts`, `cli/play/build.ts`, `cli/doctor.ts`). `apple upload` prints the size, a line per part and a 20 s heartbeat (api, altool, processing wait), per-part and per-call timeouts, `--timeout` exits 7 with the resume hint (`sendParts` in `cli/apple/upload.ts`, `cli/lib/progress.ts`; `tests/tools-path.test.ts`, `tests/build-sign.test.ts`, `tests/upload-progress.test.ts`). | +| 25 | 2026-10 | `apple build --out ` still used `~/Library/Developer/Xcode/DerivedData`, and the app binary plus the archive's dSYMs / `Symbols/*.symbols` held the home folder, user name and checkout path (543 privacy-gate hits). | `--derived-data` / default `/DerivedData`, `--path-free` (Swift and clang prefix maps) with an archive scan that stops before the export: `cli/lib/path-free.ts`, `tests/path-free.test.ts`; store-pitfalls 124. | | 24 | 2026-10 | `flow run release --apply --yes` ran `apple upload --no-wait`, which printed "not a terminal: re-run with --yes to confirm" and declined, yet the flow logged "4/13 upload: ok" and went on to `apple testflight wait` for a build that was never uploaded: a declined confirmation exited 0, and the flow's `--yes` never reached the steps. | `confirm()` (`cli/lib/ctx.ts`): every decline (answered no, not a terminal without `--yes`, under serve) prints `declined: ` and exits 3 (`DECLINED_EXIT`). `flow run` reports "step N declined: " (status `declined`) and stops; `--apply --yes` adds `--yes` to write steps only (`planStep`, `declinedReason`; `tests/flow.test.ts`). | | 23 | 2026-10 | Capacitor: native build ships old web assets. `flow run release --apply` went straight into `apple build` (xcodebuild archive) with `ios/App/App/public` and `android/app/src/main/assets/public` 3.5 hours older than the latest source commit: the native projects hold the copy `npx cap sync` made last, and nothing ran the web build + cap sync first, so the old game would have shipped. | New `build.prebuild` (per store `apple.prebuild` / `play.prebuild`): `apple build` / `play build` run it first (printed, streamed, nonzero exit stops; `--dry` only prints; `--no-prebuild`; once per `flow run` through `STOREBOUND_PREBUILD_DONE`). After it, a Capacitor project's copied `public/index.html` must be newer than the last commit outside ios/ android/ node_modules/ store/ .storebound/ and `/index.html`, else the build stops ("web assets are older than the source ..."; `--allow-stale-web` overrides). `check` warns when stale without a prebuild; build output and dry plan show the bundle's sha256 + mtime. Monorepo (`apps/native` with its own capacitor.config.ts, android/, ios/; `play build` failed with "no Gradle project ... in android or ."): the one Capacitor project one or two levels down is found and named, several ask for `play.build.dir` / `apple.build.*` (`cli/lib/web-assets.ts`, `tests/web-assets.test.ts`). | | 22 | 2026-10 | `play build` built a good AAB (Capacitor 8 / AGP), then failed its own check with "AAB manifest without package / versionCode". The aapt2 proto manifest was read with element field numbers one too high (name 4, attribute 5, child 6 instead of Resources.proto's 3, 4, 5). The test fixture came from the same wrong encoder, so the tests passed. | `aabInfo` / `encodeProtoManifest` in `cli/lib/gradle.ts` follow Resources.proto. `tests/build-sign.test.ts` builds the manifest bytes by tag, without the encoder, and the parser was checked against real AGP bundles. Lesson: a parser's fixture must not come from the same code's encoder. | diff --git a/skills/build-and-sign/SKILL.md b/skills/build-and-sign/SKILL.md index 77c0e52..844e24a 100644 --- a/skills/build-and-sign/SKILL.md +++ b/skills/build-and-sign/SKILL.md @@ -64,6 +64,11 @@ when the owner said so for this run). The MCP server offers only `apple_signing_ unlocks that one; `signing ensure --keychain ` imports the key + certificate into it). It also stops before the archive when a target's App Group is missing (manual: its installed profile; automatic: the profile the API lists; store-pitfalls 121; `--allow-app-group-gap` overrides). + `--out ` also puts DerivedData in `/DerivedData` (`--derived-data ` / `apple.build.derivedData` + overrides), so a build is hermetic per output folder. Building under a shared folder, or anything a privacy gate scans: + add `--path-free` (`apple.build.pathFree`): Swift and clang prefix maps keep the home folder, user name and checkout + out of the binary and the archive's dSYMs / Symbols, and the build stops before the export on a leftover hit + (store-pitfalls 124). `--scan-paths` scans without remapping. - Capacitor: the archive ships the web assets `npx cap sync` copied last. Set `build.prebuild` (e.g. `"npm run build && npx cap sync"`; per store `apple.prebuild` / `play.prebuild`) and `apple build` / `play build` run it first (once per `flow run`; `--no-prebuild` skips it). Then the copied `public/index.html` must be newer diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 48595dd..aa40e01 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1533,3 +1533,17 @@ that fails without the fix (see CONTRIBUTING.md). update-or-first from the config instead of the store. - **Guard:** `cli/lib/release-kind.ts` (the kind from the store, the policy), `tests/update-gate.test.ts` (fake stores: update goes through, first release is exit 3, already there is exit 7), docs/bypass.md "Updates are agent steps". + +## 124. iOS builds leak the home folder: DerivedData in ~/Library and local paths in the binary and the symbols + +- **Symptom:** a release pipeline building under a shared folder outside the user's home (`apple build --out `) + still wrote DerivedData to `~/Library/Developer/Xcode/DerivedData`, and the app binary and the `.xcarchive` + `Symbols/*.symbols` / dSYMs held the home folder, the user name and the checkout path (a privacy gate counted 543 hits). +- **Do:** `storebound apple build --out --path-free` (or `apple.build.pathFree: true`). With `--out` DerivedData is + `/DerivedData` (`--derived-data ` / `apple.build.derivedData` to choose); `--path-free` passes Swift + `-debug-prefix-map` + `-file-prefix-map` and clang `-ffile-prefix-map` for the home folder, checkout, out and DerivedData, + keeps dwarf-with-dsym (symbol upload still works) and scans the archive's Products, dSYMs and Symbols before the export. + A hit stops the build with the count and the first file + offsets. Xcode's own toolchain paths (`/Applications/Xcode.app`) + are not the user's and are not hits. A prebuilt vendored framework carries its own paths: rebuild it path-free. +- **Never:** point a shared build folder at the default DerivedData, or run the privacy scan only after the upload. +- **Guard:** `cli/lib/path-free.ts`, `tests/path-free.test.ts`, the hint when `--out` is outside the home folder. diff --git a/tests/build-sign.test.ts b/tests/build-sign.test.ts index 8a491dc..1b91c9f 100644 --- a/tests/build-sign.test.ts +++ b/tests/build-sign.test.ts @@ -408,6 +408,23 @@ describe('CLI dry runs (no store access)', () => { expect(j.exportOptions.method).toBe('app-store-connect'); expect(j.steps[0]).toContain('CURRENT_PROJECT_VERSION=42'); expect(j.steps[0]).toContain('-authenticationKeyPath $ASC_P8'); + // no --out: Xcode's DerivedData stays, nothing remapped + expect(j).toMatchObject({ derivedData: '(Xcode default)', pathFree: false }); + expect(j.steps[0]).not.toContain('-derivedDataPath'); + expect(j.steps[0]).not.toContain('prefix-map'); + }); + test('apple build --dry --json: --out makes DerivedData hermetic, --path-free adds the prefix maps', () => { + const d = project('cli-apple-dd'); + const r = cli(d, ['apple', 'build', '--dry', '--json', '--build-number', '42', '--out', 'out/ios', '--path-free']); + expect(r.exitCode).toBe(0); + const j = JSON.parse(r.stdout.toString()); + expect(j.pathFree).toBe(true); + expect(j.derivedData.split(String.fromCharCode(92)).join('/')).toMatch(/out\/ios\/DerivedData$/); + expect(j.steps[0]).toContain('-derivedDataPath'); + expect(j.steps[0]).toContain('OTHER_SWIFT_FLAGS='); + expect(j.steps[0]).toContain('-ffile-prefix-map='); + const e = cli(d, ['apple', 'build', '--dry', '--json', '--build-number', '42', '--out', 'out/ios', '--derived-data', 'dd']); + expect(JSON.parse(e.stdout.toString()).derivedData.split(String.fromCharCode(92)).join('/')).toMatch(/\/dd$/); }); test('play build --dry --json: task, versionCode, signing source', () => { const r = cli(project('cli-play'), ['play', 'build', '--dry', '--json', '--version-code', '12']); diff --git a/tests/path-free.test.ts b/tests/path-free.test.ts new file mode 100644 index 0000000..8278817 --- /dev/null +++ b/tests/path-free.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from 'bun:test'; +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'; + +const resolve = (p: string): string => (p.startsWith('/') ? p : `/proj/${p}`); +const t = { framework: 'native' as const, project: 'App.xcodeproj', scheme: 'App', notes: [] }; + +describe('derived data', () => { + test('flag beats config beats /DerivedData; without a chosen out Xcode keeps its default', () => { + expect(derivedDataFor({ flag: '/dd', config: 'cfg', out: '/o', outChosen: true, resolve })).toBe('/dd'); + expect(derivedDataFor({ config: 'cfg/dd', out: '/o', outChosen: true, resolve })).toBe('/proj/cfg/dd'); + expect(derivedDataFor({ out: '/shared/ci/out', outChosen: true, resolve })).toBe('/shared/ci/out/DerivedData'); + expect(derivedDataFor({ out: '/proj/build/ios', outChosen: false, resolve })).toBeNull(); + }); + test('archive gets -derivedDataPath only when set', () => { + const base = { target: t, root: '/p', configuration: 'Release', archivePath: '/o/App.xcarchive', build: '1', auth: null }; + const withDd = archiveArgs({ ...base, derivedData: '/o/DerivedData' }); + expect(withDd.slice(withDd.indexOf('-archivePath'), withDd.indexOf('-archivePath') + 4)).toEqual(['-archivePath', '/o/App.xcarchive', '-derivedDataPath', '/o/DerivedData']); + expect(archiveArgs(base)).not.toContain('-derivedDataPath'); + expect(archiveArgs({ ...base, derivedData: null })).not.toContain('-derivedDataPath'); + }); + test('config keys are validated', () => { + expect(validateAppleBuild({ derivedData: 'dd', pathFree: true })).toEqual([]); + expect(validateAppleBuild({ derivedData: ' ' })).toHaveLength(1); + expect(validateAppleBuild({ pathFree: 'yes' as unknown as boolean })).toHaveLength(1); + }); + test('outsideHome', () => { + expect(outsideHome('/srv/shared/ci/out', '/Users/you')).toBe(true); + expect(outsideHome('/Users/you/proj/out', '/Users/you')).toBe(false); + expect(outsideHome('/Users/you-too/out', '/Users/you')).toBe(true); + }); +}); + +describe('prefix maps', () => { + const i = { home: '/Users/you', root: '/Users/you/work/app', out: '/Users/you/work/app/build/ios', derivedData: '/Users/you/work/app/build/ios/DerivedData' }; + test('longest first (swiftc takes the first match; clang gets the list reversed)', () => { + expect(prefixMaps(i).map(m => m[0])).toEqual(['/Users/you/work/app/build/ios/DerivedData', '/Users/you/work/app/build/ios', '/Users/you/work/app', '/Users/you']); + }); + test('duplicates and a root slash are dropped', () => { + expect(prefixMaps({ home: '/h/', root: '/h', out: '/h', derivedData: null }).map(m => m[0])).toEqual(['/h']); + }); + test('settings carry swift and clang flags and keep $(inherited)', () => { + const s = pathFreeSettings(i); + expect(s.map(x => x.split('=')[0])).toEqual(['SWIFT_SERIALIZE_DEBUGGING_OPTIONS', 'OTHER_SWIFT_FLAGS', 'OTHER_CFLAGS', 'OTHER_CPLUSPLUSFLAGS']); + expect(s[0]!).toBe('SWIFT_SERIALIZE_DEBUGGING_OPTIONS=NO'); + expect(s[1]!).toContain('$(inherited) -debug-prefix-map /Users/you/work/app/build/ios/DerivedData=/derived -file-prefix-map /Users/you/work/app/build/ios/DerivedData=/derived'); + expect(s[1]!.endsWith('-file-prefix-map /Users/you=/home')).toBe(true); + expect(s[1]!.indexOf('app=.')).toBeLessThan(s[1]!.indexOf('/Users/you=/home')); + // clang: the last match wins, so the home folder comes first + expect(s[2]!.indexOf('-ffile-prefix-map=/Users/you=/home')).toBeLessThan(s[2]!.indexOf('-ffile-prefix-map=/Users/you/work/app/build/ios/DerivedData=/derived')); + expect(s.join()).not.toContain('DEBUG_INFORMATION_FORMAT'); + }); + test('a path with spaces is quoted', () => { + const s = pathFreeSettings({ home: '/Users/you', root: '/Users/you/Visual Studio Workspaces/app', out: '/Users/you/o' }); + expect(s[1]!).toContain('"/Users/you/Visual Studio Workspaces/app=."'); + expect(s[2]!).toContain('"-ffile-prefix-map=/Users/you/Visual Studio Workspaces/app=."'); + }); +}); + +describe('leak scan', () => { + const dir = mkdtempSync(join(tmpdir(), 'pf-')); + const arch = join(dir, 'App.xcarchive'); + mkdirSync(join(arch, 'Products/Applications/App.app'), { recursive: true }); + mkdirSync(join(arch, 'dSYMs/App.app.dSYM'), { recursive: true }); + mkdirSync(join(arch, 'Symbols'), { recursive: true }); + writeFileSync(join(arch, 'Products/Applications/App.app/App'), Buffer.concat([Buffer.from([0, 1, 2]), Buffer.from('/Users/you/work/app/Sources/A.swift'), Buffer.from([0]), Buffer.from('/Users/you/Library/Developer/Xcode/DerivedData/x')])); + writeFileSync(join(arch, 'dSYMs/App.app.dSYM/clean'), 'DW_AT_comp_dir ./Sources /Applications/Xcode.app/Contents'); + writeFileSync(join(arch, 'Symbols/0.symbols'), 'path /Users/you/work/app/B.swift'); + const needles = leakNeedles({ home: '/Users/you', user: 'you', root: '/Users/you/work/app' }); + + test('needles are named by kind; /Users// is dropped when it equals the home folder', () => { + expect(needles.map(n => n.kind)).toEqual(['home folder', 'checkout']); + }); + test('counts hits, keeps first offsets per file, scans app, dSYMs and Symbols', () => { + const r = scanForPaths(archiveScanDirs(arch), needles); + expect(r.files).toBe(2); + expect(r.total).toBeGreaterThanOrEqual(5); + const app = r.hits.filter(h => h.file === 'Applications/App.app/App'); + expect(app.length).toBe(3); + expect(app[0]!.offset).toBe(3); + expect(r.hits.some(h => h.file === '0.symbols')).toBe(true); + expect(r.hits.some(h => h.file.includes('clean'))).toBe(false); + }); + test('the report has counts, kinds, files and offsets but never the paths', () => { + const msg = leakProblem(scanForPaths(archiveScanDirs(arch), needles), 'build/App.xcarchive')!; + expect(msg).toContain('local paths left in 2 files'); + expect(msg).toContain('Applications/App.app/App @3: home folder'); + expect(msg).not.toContain('/Users/you'); + }); + test('a clean archive has no problem', () => { + const clean = join(dir, 'Clean.xcarchive'); + mkdirSync(join(clean, 'Products'), { recursive: true }); + writeFileSync(join(clean, 'Products/a'), 'x /Applications/Xcode.app/y'); + expect(leakProblem(scanForPaths(archiveScanDirs(clean), needles), 'c')).toBeNull(); + rmSync(dir, { recursive: true, force: true }); + }); +});