diff --git a/CHANGELOG.md b/CHANGELOG.md index 581cab5..85ec019 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased +- `apple build` exports each build into `build/ios/export/-/` (cleaned first), runs xcodebuild with + Apple's tool folders first on PATH (a Homebrew rsync broke Xcode's IPA step: "exportArchive Copy failed"), shows the + IDEDistribution log's real error lines and exits non-zero on any export failure (pitfalls 53, 54). +- `apple upload` fails with exit 1 when the IPA's build number is already on App Store Connect (a stale IPA after a + failed build) instead of reporting it VALID; `--allow-existing` re-checks, `--build N` picks that build's IPA. The + release and beta flows use both. +- `storebound doctor` flags a non-Apple rsync first on PATH; desktop publish calls rsync by absolute path without + `--chmod`. +- Site: the download notice says per OS: macOS signed and notarized, Windows unsigned for now (SmartScreen: More info › + Run anyway), Linux unsigned. + ## 0.8.0 · 2026-10-13 One product: the CLI and the desktop app share one version, the store accounts and the app folder list, and diff --git a/cli/apple/build.ts b/cli/apple/build.ts index c8b096a..62c5db4 100644 --- a/cli/apple/build.ts +++ b/cli/apple/build.ts @@ -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 /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 /export/.ipa for `storebound apple upload`. +// (-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). // 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, 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): Promise { +export async function runLogged(argv: string[], cwd: string, logFile: string, show: string, env?: Record): Promise { 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: /export/- (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 { + 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 { @@ -117,7 +146,7 @@ export async function run(ctx: Ctx): Promise { const opts = exportOptions({ teamId: teamId ?? '', style, profiles: style === 'manual' ? (Object.keys(profiles).length ? profiles : { [bundleId]: '' }) : 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 { 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 { 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)`); diff --git a/cli/apple/upload.ts b/cli/apple/upload.ts index 873b788..7d1b10b 100644 --- a/cli/apple/upload.ts +++ b/cli/apple/upload.ts @@ -1,8 +1,9 @@ // Upload an IPA to App Store Connect and wait until Apple has processed the build. -// storebound apple upload [] [--via api|altool] [--no-wait] [--timeout 60m] [--dry] [--yes] [--json] -// Default IPA: the newest in /export. The bundle id, version and build number come from -// the IPA's Info.plist; the upload is refused when that build number is already on App Store Connect for the app -// (`apple next-build` / `apple build` pick a free one). Before any network call every PrivacyInfo.xcprivacy in the IPA +// storebound apple upload [] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--dry] [--yes] [--json] +// Default IPA: the newest in /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 // (app root, Frameworks/*.framework, bundles; XML or binary) is checked: NSPrivacyTracking and NSPrivacyTrackingDomains // must agree (ITMS-91064, Invalid Binary a minute after the upload), else nothing is uploaded. // --via api (default): the App Store Connect API build upload (Build Uploads, API 4.1+): POST /v1/buildUploads, @@ -196,20 +197,48 @@ async function altoolUpload(root: string, ipa: string, deadline: number, o: Hear } finally { k.cleanup(); } } +/** Every .ipa in the export folder and its per-build subfolders (-/), newest first. */ +export function exportedIpas(dir: string): string[] { + if (!existsSync(dir)) return []; + const out: string[] = []; + for (const f of readdirSync(dir)) { + const p = join(dir, f); + if (f.endsWith('.ipa')) out.push(p); + else if (statSync(p).isDirectory()) for (const g of readdirSync(p)) if (g.endsWith('.ipa')) out.push(join(p, g)); + } + return out.sort((x, y) => statSync(y).mtimeMs - statSync(x).mtimeMs); +} + +/** + * An IPA whose build number App Store Connect already has (a build, or a finished build upload): an Error (exit 1) + * unless --allow-existing, then 'recheck' (wait for it, send nothing). A stale IPA from a failed `apple build` must + * never pass as "uploaded" (pitfall 53). + */ +export function existingBuild(o: { ipa: string; build: string; onAsc: boolean; state?: string; uploadComplete: boolean; allowExisting: boolean; next: string }): 'upload' | 'recheck' | Error { + if (!o.onAsc && !o.uploadComplete) return 'upload'; + if (o.allowExisting) return 'recheck'; + return new Error(`${o.ipa} is build ${o.build}, which is already on App Store Connect${o.state ? ` (${o.state})` : ''}: nothing uploaded. ` + + `Did the last build fail? Run \`storebound apple build\` again (next free build number: ${o.next}) or pass the new ipa. ` + + 'To only re-check a build this command uploaded before, add --allow-existing.'); +} + export async function run(ctx: Ctx): Promise { const { config, root, resolve } = ctx.loaded; const a = ctx.args; const bundleId = need(config.apple?.bundleId, 'apple.bundleId'); const via = a.str('via', 'api'); if (via !== 'api' && via !== 'altool') throw new Error('--via api | altool'); + const want = a.get('build'); let ipa = a._.find(x => x.endsWith('.ipa')); if (!ipa) { const dir = join(resolve(config.apple?.build?.out ?? 'build/ios'), 'export'); - const found = existsSync(dir) ? readdirSync(dir).filter(f => f.endsWith('.ipa')).map(f => join(dir, f)).sort((x, y) => statSync(y).mtimeMs - statSync(x).mtimeMs) : []; - if (!found[0]) throw new Error(`usage: storebound apple upload (nothing in ${relative(root, dir)}; storebound apple build makes one)`); + let found = exportedIpas(dir); + if (want) found = found.filter(f => { try { return ipaInfo(f).build === want; } catch { return false; } }); + if (!found[0]) throw new Error(`usage: storebound apple upload (no ${want ? `build ${want} ` : ''}IPA in ${relative(root, dir)}; storebound apple build makes one)`); ipa = found[0]; } else ipa = resolve(ipa); const info = ipaInfo(ipa); + if (want && info.build !== want) throw new Error(`${relative(root, ipa)} is build ${info.build}, not ${want} (--build): did the last apple build fail?`); if (info.bundleId !== bundleId) throw new Error(`${relative(root, ipa)} is ${info.bundleId}, the config says ${bundleId}`); // every PrivacyInfo.xcprivacy in the app (root, Frameworks, bundles): Apple refuses the build minutes after the // upload when NSPrivacyTracking and NSPrivacyTrackingDomains disagree (ITMS-91064), so stop before any network call @@ -223,20 +252,22 @@ export async function run(ctx: Ctx): Promise { const c = await ascClient(root, bundleId, config.apple?.appId); const appId = await c.app(); - const builds = await c.all(`/v1/builds?filter[app]=${appId}&limit=200&fields[builds]=version`); + const builds = await c.all(`/v1/builds?filter[app]=${appId}&limit=200&fields[builds]=version,processingState`); const numbers = builds.map(b => String(b.attributes.version)); const next = nextBuildNumber(numbers); // the same IPA uploaded by an earlier run: nothing to send again, only the wait const already = via === 'api' && !dry ? (await uploadsOf(c, appId, info).catch(() => [])).find((u: J) => u.attributes?.state?.state === 'COMPLETE') : undefined; - if (already) { - log(TAG, `build ${info.version} (${info.build}) was uploaded already (build upload ${already.id}); not sending it again`); + const onAsc = builds.find(b => String(b.attributes.version) === info.build); + const decision = existingBuild({ ipa: relative(root, ipa), build: info.build, onAsc: !!onAsc, state: onAsc?.attributes?.processingState, uploadComplete: !!already, allowExisting: a.has('allow-existing'), next }); + if (decision instanceof Error) throw decision; + if (decision === 'recheck') { + log(TAG, `build ${info.version} (${info.build}) is on App Store Connect already${already ? ` (build upload ${already.id})` : ''}; --allow-existing: not sending it again`); if (!wait) { if (json) printJson({ ...{ ipa: relative(root, ipa), version: info.version, build: info.build }, uploaded: true, alreadyUploaded: true }); done(TAG, `build ${info.build} already uploaded`); return; } const r = await waitForBuild(c, appId, info.build, { marketing: info.version, timeoutMs: timeout, tag: TAG }); if (json) printJson({ ipa: relative(root, ipa), version: info.version, build: info.build, uploaded: true, alreadyUploaded: true, processingState: r.seen.processingState ?? null }); if (r.outcome === 'valid') { done(TAG, waitMessage(info.build, r)); return; } process.exit(r.outcome === 'failed' ? BUILD_WAIT_EXIT.failed : BUILD_WAIT_EXIT.timeout); } - if (numbers.includes(info.build)) throw new Error(`build ${info.build} is already on App Store Connect: rebuild with --build-number ${next} (storebound apple build)`); if (numbers.length && compareBuild(info.build, next) < 0) warn(TAG, `build ${info.build} is lower than the highest uploaded one (next free: ${next}); Apple accepts it only if this version train has no higher build`); const reqs = buildUploadReqs(appId, info, basename(ipa), size); const plan = via === 'api' diff --git a/cli/data/flags.json b/cli/data/flags.json index be65e8e..1377096 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -228,6 +228,7 @@ "version" ], "apple upload": [ + "allow-existing", "build", "csv", "group", @@ -784,6 +785,7 @@ "agent", "all", "all-pages", + "allow-existing", "allow-price-changes", "allow-stale-web", "alpha", @@ -1132,7 +1134,7 @@ "storebound apple testflight expire (--keep N | --older-than D) [--version x.y.z] [--dry] [--yes] [--json]" ], "apple upload": [ - "storebound apple upload [] [--via api|altool] [--no-wait] [--timeout 60m] [--dry] [--yes] [--json]" + "storebound apple upload [] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--dry] [--yes] [--json]" ], "apple win-back": [ "storebound apple win-back [--only sub1,sub2] [--dry] [--yes] [--json]" diff --git a/cli/doctor.ts b/cli/doctor.ts index 0039c7e..f7b846b 100644 --- a/cli/doctor.ts +++ b/cli/doctor.ts @@ -12,6 +12,7 @@ import { log } from './lib/log'; import { textTable } from './lib/table'; import { androidSdk, type AndroidSdkLookup, detectTools, findPlaywright, installCommands, type ToolStatus } from './lib/tools'; import * as rt from './lib/rt'; +import { foreignRsync } from './lib/xcode'; export interface DoctorRow { id: ToolStatus['id'] | 'playwright'; @@ -32,6 +33,8 @@ export interface DoctorResult { /** Store credentials of the project folder (env, .storebound/credentials.json, profile): set, missing or * placeholder (a template value, treated as missing). Never the values. */ credentials: { key: string; status: 'set' | 'missing' | 'placeholder' }[]; + /** macOS: a non-Apple rsync first on PATH (Homebrew 3.x breaks Xcode's exportArchive, pitfall 54), else null. */ + foreignRsync: string | null; } export function doctor(cwd: string, env: Record = process.env): DoctorResult { @@ -42,7 +45,8 @@ export function doctor(cwd: string, env: Record = pr usedFor: 'screenshot renders and web captures from a terminal (the desktop app brings its own)', install: pw ? [] : installCommands('playwright'), url: 'https://playwright.dev/docs/intro' }); const project = CONFIG_NAMES.some(n => existsSync(join(cwd, n))) ? cwd : null; const sdk = androidSdk([join(cwd, 'android'), cwd], env); - return { storebound: pkg.version, bun: rt.bunVersion, runtime: `${rt.runtime.name} ${rt.runtime.version}`, platform: `${process.platform}-${process.arch}`, project, tools, missing: tools.filter(t => !t.found).length, androidSdk: sdk, credentials: credentialStatus(cwd) }; + return { storebound: pkg.version, bun: rt.bunVersion, runtime: `${rt.runtime.name} ${rt.runtime.version}`, platform: `${process.platform}-${process.arch}`, project, tools, missing: tools.filter(t => !t.found).length, androidSdk: sdk, credentials: credentialStatus(cwd), + foreignRsync: process.platform === 'darwin' ? foreignRsync(env.PATH) : null }; } export async function run(args: Args): Promise { @@ -54,6 +58,7 @@ export async function run(args: Args): Promise { : `Android SDK: not found (play build needs it). Checked: ${r.androidSdk.checked.join('; ')}`); const set = r.credentials.filter(c => c.status === 'set').map(c => c.key), ph = r.credentials.filter(c => c.status === 'placeholder').map(c => c.key); log('doctor', `credentials: ${set.length ? set.join(', ') : 'none set'}${ph.length ? ` · placeholders (treated as missing): ${ph.join(', ')}` : ''}`); + if (r.foreignRsync) log('doctor', `rsync: ${r.foreignRsync} is first on PATH and breaks Xcode exportArchive ("Copy failed") outside storebound; storebound apple build puts /usr/bin first. Fix for everything else: brew unlink rsync, or put /usr/bin before it`); const missing = r.tools.filter(t => !t.found); if (!missing.length) { log('doctor', 'everything storebound can use is here'); return; } console.log('\nTo install what is missing (pick one per tool; storebound never runs these for you):'); diff --git a/cli/lib/embedded.ts b/cli/lib/embedded.ts index 7d05fdf..58a6ac7 100644 --- a/cli/lib/embedded.ts +++ b/cli/lib/embedded.ts @@ -18,8 +18,8 @@ export const TEMPLATE_TEXT: Record = { "profiles/app/privacy-policy.html": "\n\n\n\n\nPrivacy Policy · Example Studio\n\n\n\n\n
\n \n\n
\n

Privacy Policy

\n

Example Studio · applies to all our apps · last updated 2026-01-01

\n\n

We collect what an app needs to work for you, never sell personal data and show no ads. This page explains what\n each app collects, why, and what you can do about it.

\n\n

What our apps use

\n
\n \n \n \n \n \n
AppServiceWhat and whyYour choice
Example AppAccount (our servers)Email address, account id and the lists and notes you create: to sign you in and sync your content between your devices.The account is optional; export or delete it in Settings > Account
Example AppAnalytics (e.g. a privacy-friendly analytics SDK)Random install id, screens used, device model, OS version: to improve the app.Settings > Analytics off
Example AppCrash reportingCrash logs and device model: to fix crashes.-
Example AppApp storesPurchases and subscriptions are handled by Google Play / the App Store; we never see payment details.-
\n\n

Deleting your account

\n

In the app: Settings > Account > Delete account. Without the app: example.com/delete-account.\n We delete your account and its content within 30 days; backups roll over within 90 days. A store subscription is\n not cancelled by deleting the account: cancel it in your Google Play or App Store subscriptions.

\n\n

Children

\n

Our apps are not directed at children under 13. We do not knowingly collect personal data from children. If you\n believe a child sent us data, contact us and we will delete it.

\n\n

Retention and your rights

\n

We keep account data while the account exists and analytics data for up to 24 months. You can ask for a copy or\n the deletion of your data by writing to privacy@example.com; we answer\n within 30 days.

\n\n

Security

\n

All network traffic of our apps uses HTTPS; passwords are stored only as salted hashes.

\n\n

Changes

\n

When an app starts collecting something new we update this page and its date first.

\n\n

Contact

\n

Example Studio · privacy@example.com

\n
\n\n
\n

Gizlilik Politikası

\n

Example Studio · tüm uygulamalarımız için geçerlidir · son güncelleme 2026-01-01

\n

Yalnızca uygulamanın sizin için çalışması gereken verileri topluyoruz, kişisel verileri asla satmıyoruz ve reklam\n göstermiyoruz. Yukarıdaki tablo her uygulamanın neyi neden topladığını gösterir.

\n

Hesabınızı silmek

\n

Uygulamada: Ayarlar > Hesap > Hesabı sil. Uygulama olmadan: example.com/delete-account.\n Mağaza aboneliği hesap silinince iptal olmaz; Google Play veya App Store aboneliklerinizden iptal edin.

\n

Haklarınız

\n

Verilerinizin bir kopyasını veya silinmesini privacy@example.com adresine\n yazarak isteyebilirsiniz; 30 gün içinde yanıtlıyoruz.

\n
\n
\n\n\n", "profiles/app/shots-copy.json": "{\n \"en\": {\n \"home\": { \"eyebrow\": \"Example App\", \"title\": [\"Everything in order,\", \"in one calm place\"], \"sub\": \"Lists and notes, synced on all your devices.\" },\n \"detail\": { \"eyebrow\": \"Get it done\", \"title\": [\"See what is left\", \"at a glance\"], \"sub\": \"Tap to tick, swipe to reorder.\" },\n \"share\": { \"eyebrow\": \"Together\", \"title\": [\"Share a list,\", \"plan together\"], \"sub\": \"Invite people by link and edit live.\" },\n \"privacy\": { \"eyebrow\": \"Your data\", \"title\": [\"Private by default,\", \"yours to export\"], \"sub\": \"Export your data or delete your account in Settings.\" }\n },\n \"tr\": {\n \"home\": { \"eyebrow\": \"Example App\", \"title\": [\"Her şey düzenli,\", \"tek sakin bir yerde\"], \"sub\": \"Listeler ve notlar, tüm cihazlarında eşitlenir.\" },\n \"detail\": { \"eyebrow\": \"Bitir\", \"title\": [\"Kalanları\", \"bir bakışta gör\"], \"sub\": \"Dokun işaretle, sürükle sırala.\" },\n \"share\": { \"eyebrow\": \"Birlikte\", \"title\": [\"Listeyi paylaş,\", \"birlikte planla\"], \"sub\": \"Bağlantıyla davet et, canlı düzenleyin.\" },\n \"privacy\": { \"eyebrow\": \"Verilerin\", \"title\": [\"Varsayılan olarak gizli,\", \"dışa aktarması senin\"], \"sub\": \"Verilerini dışa aktar ya da hesabını Ayarlar'dan sil.\" }\n }\n}\n", "profiles/app/storebound.config.ts": "// storebound config for one app (profile \"app\": a utility / productivity app with an optional account and an\n// analytics SDK, no ads). Every answer below is a starting point: edit it to match your app. Credentials NEVER go\n// here (env vars or .storebound/credentials.json, gitignored). Paths are relative to this file.\n// The type import is optional: it only works when storebound is installed in the project (bun add -d storebound).\nimport type { ShipConfig } from 'storebound';\n\nconst config: ShipConfig = {\n app: {\n name: 'Example App',\n profile: 'app',\n copyright: '2026 Example Studio',\n privacyPolicyUrl: 'https://example.com/privacy',\n // termsUrl: 'https://example.com/terms', // Terms of Use; required once you sell subscriptions\n supportUrl: 'https://example.com/support',\n marketingUrl: 'https://example.com',\n website: 'https://example.com',\n contactEmail: 'support@example.com',\n },\n\n // Accounts (account-deletion skill). Remove this block if the app has no sign-up / sign-in at all.\n // Apple 5.1.1(v): deletion must start inside the app. Google Play: in the app AND on a public web page.\n accounts: {\n enabled: true,\n deletionUrl: 'https://example.com/delete-account', // public page, no login needed to request deletion\n inApp: 'Settings > Account > Delete account', // goes into the App Review notes\n // How people sign up: 'username' (a name picked in the app, no password, no email), 'username-password',\n // 'email-password', 'email-link', 'phone', 'oauth', 'other'. `storebound scan` proposes the account data and\n // dataSafety.accountCreation from it; only the 'email-*' methods mean an email address.\n signUp: ['email-password'],\n },\n\n // Sign-in for store reviewers (review-access skill). The demo account itself lives ONLY in\n // .storebound/credentials.json (\"reviewDemoAccount\") or REVIEW_DEMO_USER / REVIEW_DEMO_PASSWORD.\n reviewAccess: {\n signInRequired: true, // false if every feature works without signing in\n instructions: 'Sign in with the demo account. Sample data is already there; nothing needs a second device.',\n },\n\n // id = your folder / copy key; play = Play language code(s); apple = App Store Connect locale\n languages: [\n { id: 'en', play: 'en-US', apple: 'en-US' },\n { id: 'tr', play: 'tr-TR', apple: 'tr' },\n ],\n\n play: {\n packageName: 'com.example.app',\n defaultLanguage: 'en-US',\n listing: 'store/listing.md',\n icon: 'store/icons/play-512.png',\n },\n\n apple: {\n bundleId: 'com.example.app',\n // name: 'Example: Short Tagline', // App Store name (max 30) when app.name is taken there (\"already being used\"); default app.name\n listing: 'store/appstore.md',\n // Export compliance (export-compliance skill): decide it, then uncomment. false ONLY if the app uses no encryption\n // beyond the OS (HTTPS through the system, standard sign-in).\n // usesNonExemptEncryption: false,\n // ipad: true,\n info: {\n categories: { primary: 'PRODUCTIVITY', secondary: 'UTILITIES' },\n ageRating: {}, // every content question NONE / false; change what applies (user-generated content, web access ...)\n contentRights: 'DOES_NOT_USE_THIRD_PARTY_CONTENT',\n free: true,\n reviewNotes: [\n 'Example App keeps lists and notes in sync across devices. An account is optional on the first screen.',\n 'Privacy policy and Terms: Settings > About.',\n ].join('\\n\\n'),\n },\n },\n\n // In-app purchases (optional): a one-time \"Pro\" unlock, for example. Subscriptions: see the subscription-app profile\n // (storebound init --profile subscription-app) or the store-subscriptions skill.\n // products: [{ id: 'com.example.app.pro', type: 'non_consumable', usd: 4.99,\n // listings: { en: { title: 'Pro', description: 'Unlimited lists and themes.' } }, reviewScreenshot: 'store/iap-review/pro.png' }],\n\n // Google Play Data safety. Types not listed = not collected. Typical app with accounts + an analytics SDK acting as\n // your processor (collected, not shared). Edit to match what your app and its SDKs really do.\n dataSafety: {\n collectsData: true,\n encryptedInTransit: true,\n // Sign-up methods. Google does not publish these ids: compare with Play Console > App content > Data safety >\n // Export to CSV before the first import. The delete-account URL (PSL_ACCOUNT_DELETION_URL) comes from\n // accounts.deletionUrl; `storebound check` names any other row Play requires.\n accountCreation: ['PSL_ACM_USER_ID_PASSWORD'],\n // Can people delete their data without deleting the account (a data-only page)? true needs partialDeletionUrl.\n // Play's \"can users request deletion?\" is answered yes by accounts.deletionUrl. Ask the owner.\n partialDeletion: false,\n data: {\n PSL_EMAIL: { shared: false, collect: ['functionality', 'account'] },\n PSL_USER_ACCOUNT: { shared: false, collect: ['functionality', 'account'] },\n PSL_USER_GENERATED_CONTENT: { shared: false, collect: ['functionality'] },\n PSL_USER_INTERACTION: { shared: false, collect: ['analytics'] },\n PSL_CRASH_LOGS: { shared: false, collect: ['analytics'] },\n PSL_PERFORMANCE_DIAGNOSTICS: { shared: false, collect: ['analytics'] },\n PSL_DEVICE_ID: { shared: false, collect: ['analytics'] },\n },\n },\n\n renders: 'store/renders',\n\n shots: {\n style: 'app', // calm gradient, device frame, headline + subline (game: sparkles and badges)\n copy: 'store/shots/copy.json',\n frames: 'store/shots/frames', // /phone/.png, /tablet/.png\n out: 'store/renders',\n // fonts: { title: 'Inter', body: 'Inter' }, // the app style default\n scenes: [\n { id: 'home', palette: 'mist', frames: [{ src: 'home.png' }] },\n { id: 'detail', palette: 'mint', frames: [{ src: 'detail.png' }] },\n { id: 'share', palette: 'sky', frames: [{ src: 'share.png' }] },\n { id: 'privacy', palette: 'slate', frames: [{ src: 'settings.png' }] },\n ],\n feature: { frame: 'home.png', palette: 'mist' },\n // Raw frames from a web build, the iOS Simulator (ios-sim) or an Android emulator (android-emu): sign in to a demo\n // account with realistic sample data, never real user data (store-screenshots skill).\n capture: {\n baseUrl: 'http://127.0.0.1:5173',\n query: 'lang={lang}&demo=1',\n shots: {\n 'home': { path: '/', delayMs: 1000, device: 'both' },\n 'detail': { path: '/lists/1', delayMs: 1000, device: 'both' },\n 'share': { path: '/lists/1/share', delayMs: 1000, device: 'both' },\n 'settings': { path: '/settings', delayMs: 1000, device: 'both' },\n },\n },\n },\n\n icons: {\n composite: 'store/icon/icon.png',\n foreground: 'store/icon/foreground.png',\n background: 'store/icon/background.png',\n out: 'store/icons',\n },\n};\n\nexport default config;\n", - "profiles/app/workflows/beta.yml": "# storebound flow \"beta\": a TestFlight / internal testing build for your testers.\n# storebound flow run beta VERSION=1.2.0 [--apply]\ndescription: Next build to TestFlight and Play internal testing\nparams:\n VERSION: \"1.0.0\"\n GROUP: \"Internal\"\nsteps:\n - id: check\n run: check\n - id: next-build\n if: apple\n run: apple next-build\n capture: BUILD\n - id: build\n if: apple\n run: apple build --version ${VERSION} --build-number ${BUILD}\n - id: upload\n if: apple\n run: apple upload --no-wait\n - id: wait\n if: apple\n run: apple testflight wait --build ${BUILD} --version ${VERSION}\n - id: what-to-test\n if: apple\n run: apple testflight what-to-test --build ${BUILD}\n - id: distribute\n if: apple\n run: apple testflight distribute --build ${BUILD} --group ${GROUP}\n - id: play-build\n if: play\n run: play build --version-name ${VERSION}\n - id: play-upload\n if: play\n run: play upload --track internal\n - id: external\n owner: \"External testers: storebound apple testflight distribute --build ${BUILD} --group --submit-review (Beta App Review is your call)\"\n", - "profiles/app/workflows/release.yml": "# storebound flow \"release\": one version of the app on both stores, up to your Submit click.\n# storebound flow run release VERSION=1.2.0 reads run, writes run as dry runs (nothing changes)\n# storebound flow run release VERSION=1.2.0 --apply writes run for real; each command still asks you first\n# storebound flow run release --resume --apply continue after fixing a failed step\n# Steps are storebound commands (no \"storebound\" prefix). `if: apple` / `if: play` skip a store the config does not\n# have; `owner:` steps are never run, they list what you click. Edit freely: this file is yours.\ndescription: Build, upload and check one version on both stores, then your Submit click\nparams:\n VERSION: \"1.0.0\"\nsteps:\n - id: next-build\n if: apple\n run: apple next-build\n capture: BUILD\n - id: build\n if: apple\n run: apple build --version ${VERSION} --build-number ${BUILD}\n - id: upload\n if: apple\n run: apple upload --no-wait\n - id: wait\n if: apple\n run: apple testflight wait --build ${BUILD} --version ${VERSION}\n - id: play-build\n if: play\n run: play build --version-name ${VERSION}\n - id: play-upload\n if: play\n run: play upload --track internal\n - id: whats-new\n if: exists store/whats-new\n run: whats-new apply\n - id: listing\n if: apple\n run: apple listing --version ${VERSION} --build ${BUILD}\n - id: validate\n run: validate\n - id: scan\n run: scan\n - id: plan\n run: release plan ${VERSION}\n - id: submit\n owner: \"App Store Connect: Submit for Review (version ${VERSION}, build ${BUILD}). Play Console: promote the internal release and Send changes for review\"\n", + "profiles/app/workflows/beta.yml": "# storebound flow \"beta\": a TestFlight / internal testing build for your testers.\n# storebound flow run beta VERSION=1.2.0 [--apply]\ndescription: Next build to TestFlight and Play internal testing\nparams:\n VERSION: \"1.0.0\"\n GROUP: \"Internal\"\nsteps:\n - id: check\n run: check\n - id: next-build\n if: apple\n run: apple next-build\n capture: BUILD\n - id: build\n if: apple\n run: apple build --version ${VERSION} --build-number ${BUILD}\n - id: upload\n if: apple\n run: apple upload --no-wait --build ${BUILD} --allow-existing\n - id: wait\n if: apple\n run: apple testflight wait --build ${BUILD} --version ${VERSION}\n - id: what-to-test\n if: apple\n run: apple testflight what-to-test --build ${BUILD}\n - id: distribute\n if: apple\n run: apple testflight distribute --build ${BUILD} --group ${GROUP}\n - id: play-build\n if: play\n run: play build --version-name ${VERSION}\n - id: play-upload\n if: play\n run: play upload --track internal\n - id: external\n owner: \"External testers: storebound apple testflight distribute --build ${BUILD} --group --submit-review (Beta App Review is your call)\"\n", + "profiles/app/workflows/release.yml": "# storebound flow \"release\": one version of the app on both stores, up to your Submit click.\n# storebound flow run release VERSION=1.2.0 reads run, writes run as dry runs (nothing changes)\n# storebound flow run release VERSION=1.2.0 --apply writes run for real; each command still asks you first\n# storebound flow run release --resume --apply continue after fixing a failed step\n# Steps are storebound commands (no \"storebound\" prefix). `if: apple` / `if: play` skip a store the config does not\n# have; `owner:` steps are never run, they list what you click. Edit freely: this file is yours.\ndescription: Build, upload and check one version on both stores, then your Submit click\nparams:\n VERSION: \"1.0.0\"\nsteps:\n - id: next-build\n if: apple\n run: apple next-build\n capture: BUILD\n - id: build\n if: apple\n run: apple build --version ${VERSION} --build-number ${BUILD}\n - id: upload\n if: apple\n run: apple upload --no-wait --build ${BUILD} --allow-existing\n - id: wait\n if: apple\n run: apple testflight wait --build ${BUILD} --version ${VERSION}\n - id: play-build\n if: play\n run: play build --version-name ${VERSION}\n - id: play-upload\n if: play\n run: play upload --track internal\n - id: whats-new\n if: exists store/whats-new\n run: whats-new apply\n - id: listing\n if: apple\n run: apple listing --version ${VERSION} --build ${BUILD}\n - id: validate\n run: validate\n - id: scan\n run: scan\n - id: plan\n run: release plan ${VERSION}\n - id: submit\n owner: \"App Store Connect: Submit for Review (version ${VERSION}, build ${BUILD}). Play Console: promote the internal release and Send changes for review\"\n", "profiles/subscription-app/app-privacy.md": "# Example App · App Store \"App Privacy\" answers\n\nApp Store Connect > your app > App Privacy. **Web UI only, there is no API.** Fill it from this table, then press\n**Publish** (saving alone does not publish it). Keep it consistent with the privacy policy and the Play Data safety\nanswers (`dataSafety` in `storebound.config.ts`). **Edit to match your app.**\n\nTypical subscription app: account (email), content synced to your backend, auto-renewable subscriptions checked by\nyour server, an analytics SDK acting as your processor, no ads, no tracking.\n\n**Do you or your third-party partners collect data from this app?** Yes\n\n**Tracking**: No (no ad SDK, no data broker, nothing combined with other companies' data for advertising). If that\nchanges, add the App Tracking Transparency prompt first.\n\n| data type | collected | used to track | linked to the user | purposes |\n|---|---|---|---|---|\n| Contact Info > Email Address | yes (account) | no | yes | App Functionality |\n| Identifiers > User ID | yes (account id) | no | yes | App Functionality, Analytics |\n| User Content > Other User Content (lists, notes) | yes (synced) | no | yes | App Functionality |\n| Usage Data > Product Interaction | yes (analytics) | no | yes if tied to the account, else no | Analytics |\n| Purchases > Purchase History (subscription status) | yes | no | yes | App Functionality |\n| Diagnostics > Crash Data | yes | no | no | App Functionality, Analytics |\n| Diagnostics > Performance Data | yes | no | no | Analytics |\n\n\"Linked to you\" is **Yes** for what you store under the account. Not collected (typical): Location, Health and\nFitness, Financial Info, Contacts, Photos or Videos (unless people attach them), Browsing and Search History,\nSensitive Info.\n\nPer data type the UI asks, in order: purposes > \"Is this data linked to the user's identity?\" > \"Do you or your\nthird-party partners use this data for tracking purposes?\".\n", "profiles/subscription-app/appstore.md": "# Example App · App Store listing copy\n\nSource for `storebound apple listing`. One `## ` section per locale (the first word is the locale code:\n`en-US`, `tr`, `de-DE`, `ja`, `zh-Hans`, `ar-SA`, ...), one fenced block per `### `, field names exactly:\n`name` (optional, defaults to `apple.name`, else `app.name`), `subtitle` (30), `promotionalText` (170), `keywords` (100 characters, comma\nseparated, no spaces after commas, do not repeat the app name), `description` (4000), `whatsNew` (optional; not allowed\non the first version). Shared values (privacy policy, support and marketing URL) come from `storebound.config.ts`.\n\n---\n\n## en-US · English (U.S.)\n\n### subtitle\n```\nLists and notes, in sync\n```\n\n### promotionalText\n```\nOne or two sentences you can change any time without a new version.\n```\n\n### keywords\n```\ntodo,checklist,groceries,planner,notes,shared,family,organizer,tasks,sync\n```\n\n### description\n```\nFirst paragraph: the job the app does and why it is easier with it.\n\nFEATURES\n• Lists and notes that sync across your devices\n• Share a list with family or friends\n• Export your data or delete your account in Settings, any time\n\nFree to use with up to 3 lists. Example Pro unlocks unlimited lists, sharing and themes: yearly with a 1-week free trial, or monthly. Payment is charged to your store account; the subscription renews automatically unless cancelled at least 24 hours before the end of the period, and you can manage or cancel it in your account settings.\n\nTerms of Use: https://example.com/terms\nPrivacy Policy: https://example.com/privacy\n```\n\n## tr · Türkçe\n\n### subtitle\n```\nListeler ve notlar, eşitli\n```\n\n### promotionalText\n```\nYeni sürüm göndermeden istediğin zaman değiştirebileceğin bir iki cümle.\n```\n\n### keywords\n```\nyapılacaklar,kontrol,market,planlayıcı,not,paylaşım,aile,düzen,görev\n```\n\n### description\n```\nİlk paragraf: uygulamanın gördüğü iş ve onunla neden daha kolay olduğu.\n\nÖZELLİKLER\n• Tüm cihazlarında eşitlenen listeler ve notlar\n• Bir listeyi ailen ya da arkadaşlarınla paylaş\n• Verilerini dışa aktar ya da hesabını Ayarlar'dan istediğin an sil\n\n3 listeye kadar ücretsiz. Example Pro sınırsız liste, paylaşım ve tema açar: 1 hafta ücretsiz denemeli yıllık ya da aylık. Ödeme mağaza hesabınızdan alınır; abonelik, dönem bitmeden en az 24 saat önce iptal edilmezse otomatik yenilenir; hesap ayarlarınızdan yönetebilir ya da iptal edebilirsiniz.\n\nKullanım Koşulları: https://example.com/terms\nGizlilik Politikası: https://example.com/privacy\n```\n", "profiles/subscription-app/declarations.md": "# Example App · store declarations (answers to copy into the consoles)\n\nStarting answers for a typical **subscription app**: auto-renewable subscriptions (free trial on the yearly plan), an account (email + password, or sign in\nwith a provider), an analytics SDK acting as your processor, data synced to your own backend, no ads, no chat with\nstrangers, general audience. **Edit every line to match your app**: these are your statements to the stores. Everything\nmarked **UI** has no API: Play Console > your app > Policy and programs > **App content**, and App Store Connect for\nApple. Data safety (Play) has an API: `storebound play data-safety`.\n\n## Google Play · App content (UI)\n\n| declaration | answer |\n|---|---|\n| Privacy policy | your policy URL (same one in the listing and inside the app) |\n| App access | \"All or some functionality is restricted\": add the demo account (username, password) and the steps from `reviewAccess.instructions`; credentials must work at all times, no 2FA / OTP (review-access skill) |\n| Ads | No, my app does not contain ads |\n| Content rating | IARC questionnaire, below |\n| Target audience and content | 18 and over (or 13+ if teens use it); \"Could your app unintentionally appeal to children?\" No (only if true) |\n| News app | No |\n| Data safety | `storebound play data-safety`, then check the summary; with accounts: the delete-account URL (`accounts.deletionUrl`) |\n| Government apps | No |\n| Financial features | My app doesn't provide any financial features |\n| Health apps | No health features |\n| Advertising ID | No (unless an SDK in your app reads it: check the SDK docs and your merged manifest) |\n| Store settings | App, category (Productivity, Tools, ...); contact email + website |\n\n## Account deletion (account-deletion skill)\n\n- In the app: Settings > Account > Delete account deletes the account and its data (not only \"deactivate\").\n- On the web: `accounts.deletionUrl` lets people request deletion without the app; Play asks for it in Data safety.\n- With subscriptions: say that deleting the account does not cancel the store subscription, and how to cancel.\n\n## IARC content rating (Play UI; Apple has its own form, `storebound apple appinfo`)\n\nCategory: not a game (e.g. Utility, Productivity, Communication, or Social Networking if people share content with\nothers). Typical answers for a lists / notes app:\n\n| topic | answer |\n|---|---|\n| Violence, sexuality, language, controlled substances | No |\n| Gambling (simulated / real money) | No |\n| Users interact or exchange content | Yes only if people can share with others outside their own invitations; then also moderation and reporting |\n| Shares location with other users | No |\n| Digital purchases | Yes (subscriptions) |\n| Unrestricted internet / browser | No |\n\nExpected: PEGI 3, ESRB Everyone, IARC 3+. Apple age rating 4+ (all content questions None) unless users share\ncontent publicly.\n\n## App Store (UI-only parts)\n\n- App record: App Store Connect > Apps > + > New App. No API.\n- App Privacy: `app-privacy.md`, then **Publish**.\n- App Review information: `storebound apple review-details` sends the contact, the demo account (from\n `.storebound/credentials.json`, masked in the log) and the notes.\n- Export compliance (export-compliance skill): `ITSAppUsesNonExemptEncryption = NO` only if true.\n- Guideline 5.1.1: the privacy policy link must also be reachable **inside the app**; 5.1.1(v): account deletion\n inside the app.\n\n## Subscriptions (store-subscriptions skill)\n\n- Purchase screen (both stores): plan name, length, the full renewal price per period (most prominent), what is\n included, free trial length and what is charged after it, how to cancel, Restore Purchases, links to the Terms of Use\n and the Privacy Policy. Apple 3.1.2; Google Play subscriptions policy.\n- Metadata: the App Store description (or a custom EULA in App Information > License Agreement) links the Terms of\n Use; the Privacy Policy URL field is set by `storebound apple listing`.\n- First subscriptions are submitted together with an app version (App Store Connect: add them to the version on its\n page before Submit for Review), like first in-app purchases.\n- App Review notes say where the paywall is and that sandbox purchases work with the demo account.\n- Play: base plans and offers must be active before people can buy; new subscribers see the converted regional prices.\n", @@ -31,8 +31,8 @@ export const TEMPLATE_TEXT: Record = { "web/appstore.svg": "App Store", "web/googleplay.svg": "Google Play", "web/landing.html": "\n\n\n\n\n{{APP_NAME}}\n\n\n\n\n\n
\n

{{APP_NAME}}

\n \n
\n\n\n\n", - "workflows/beta.yml": "# storebound flow \"beta\" for a game: a playtest build on TestFlight and Play internal testing.\n# storebound flow run beta VERSION=1.2.0 [--apply]\ndescription: Next game build to TestFlight and Play internal testing\nparams:\n VERSION: \"1.0.0\"\n GROUP: \"Playtesters\"\nsteps:\n - id: check\n run: check\n - id: ads\n if: exists ios\n run: ads scan --ios ios\n continueOnError: true\n - id: next-build\n if: apple\n run: apple next-build\n capture: BUILD\n - id: build\n if: apple\n run: apple build --version ${VERSION} --build-number ${BUILD}\n - id: upload\n if: apple\n run: apple upload --no-wait\n - id: wait\n if: apple\n run: apple testflight wait --build ${BUILD} --version ${VERSION}\n - id: distribute\n if: apple\n run: apple testflight distribute --build ${BUILD} --group ${GROUP}\n - id: play-build\n if: play\n run: play build --version-name ${VERSION}\n - id: play-upload\n if: play\n run: play upload --track internal\n - id: external\n owner: \"External playtesters: storebound apple testflight distribute --build ${BUILD} --group --submit-review (Beta App Review is your call)\"\n", - "workflows/release.yml": "# storebound flow \"release\" for a game: one version on both stores, up to your Submit click.\n# storebound flow run release VERSION=1.2.0 reads run, writes run as dry runs (nothing changes)\n# storebound flow run release VERSION=1.2.0 --apply writes run for real; each command still asks you first\n# storebound flow run release --resume --apply continue after fixing a failed step\n# Steps are storebound commands (no \"storebound\" prefix). `if: apple` / `if: play` skip a store the config does not\n# have; `owner:` steps are never run, they list what you click. `sh:` runs your own build command (only with --apply).\ndescription: Build, upload and check one game version on both stores, then your Submit click\nparams:\n VERSION: \"1.0.0\"\nsteps:\n - id: shots\n if: exists store/renders\n run: shots check\n - id: next-build\n if: apple\n run: apple next-build\n capture: BUILD\n - id: build\n if: apple\n run: apple build --version ${VERSION} --build-number ${BUILD}\n - id: upload\n if: apple\n run: apple upload --no-wait\n - id: wait\n if: apple\n run: apple testflight wait --build ${BUILD} --version ${VERSION}\n - id: play-build\n if: play\n run: play build --version-name ${VERSION}\n - id: play-upload\n if: play\n run: play upload --track internal\n - id: whats-new\n if: exists store/whats-new\n run: whats-new apply\n - id: listing\n if: apple\n run: apple listing --version ${VERSION} --build ${BUILD}\n - id: validate\n run: validate\n - id: scan\n run: scan\n - id: plan\n run: release plan ${VERSION}\n - id: submit\n owner: \"App Store Connect: Submit for Review (version ${VERSION}, build ${BUILD}). Play Console: promote the internal release and Send changes for review\"\n", + "workflows/beta.yml": "# storebound flow \"beta\" for a game: a playtest build on TestFlight and Play internal testing.\n# storebound flow run beta VERSION=1.2.0 [--apply]\ndescription: Next game build to TestFlight and Play internal testing\nparams:\n VERSION: \"1.0.0\"\n GROUP: \"Playtesters\"\nsteps:\n - id: check\n run: check\n - id: ads\n if: exists ios\n run: ads scan --ios ios\n continueOnError: true\n - id: next-build\n if: apple\n run: apple next-build\n capture: BUILD\n - id: build\n if: apple\n run: apple build --version ${VERSION} --build-number ${BUILD}\n - id: upload\n if: apple\n run: apple upload --no-wait --build ${BUILD} --allow-existing\n - id: wait\n if: apple\n run: apple testflight wait --build ${BUILD} --version ${VERSION}\n - id: distribute\n if: apple\n run: apple testflight distribute --build ${BUILD} --group ${GROUP}\n - id: play-build\n if: play\n run: play build --version-name ${VERSION}\n - id: play-upload\n if: play\n run: play upload --track internal\n - id: external\n owner: \"External playtesters: storebound apple testflight distribute --build ${BUILD} --group --submit-review (Beta App Review is your call)\"\n", + "workflows/release.yml": "# storebound flow \"release\" for a game: one version on both stores, up to your Submit click.\n# storebound flow run release VERSION=1.2.0 reads run, writes run as dry runs (nothing changes)\n# storebound flow run release VERSION=1.2.0 --apply writes run for real; each command still asks you first\n# storebound flow run release --resume --apply continue after fixing a failed step\n# Steps are storebound commands (no \"storebound\" prefix). `if: apple` / `if: play` skip a store the config does not\n# have; `owner:` steps are never run, they list what you click. `sh:` runs your own build command (only with --apply).\ndescription: Build, upload and check one game version on both stores, then your Submit click\nparams:\n VERSION: \"1.0.0\"\nsteps:\n - id: shots\n if: exists store/renders\n run: shots check\n - id: next-build\n if: apple\n run: apple next-build\n capture: BUILD\n - id: build\n if: apple\n run: apple build --version ${VERSION} --build-number ${BUILD}\n - id: upload\n if: apple\n run: apple upload --no-wait --build ${BUILD} --allow-existing\n - id: wait\n if: apple\n run: apple testflight wait --build ${BUILD} --version ${VERSION}\n - id: play-build\n if: play\n run: play build --version-name ${VERSION}\n - id: play-upload\n if: play\n run: play upload --track internal\n - id: whats-new\n if: exists store/whats-new\n run: whats-new apply\n - id: listing\n if: apple\n run: apple listing --version ${VERSION} --build ${BUILD}\n - id: validate\n run: validate\n - id: scan\n run: scan\n - id: plan\n run: release plan ${VERSION}\n - id: submit\n owner: \"App Store Connect: Submit for Review (version ${VERSION}, build ${BUILD}). Play Console: promote the internal release and Send changes for review\"\n", }; /** The pages shots render / video load (cli/shots/*.html), by file name. */ diff --git a/cli/lib/xcode.ts b/cli/lib/xcode.ts index 0c5faf2..6fcde14 100644 --- a/cli/lib/xcode.ts +++ b/cli/lib/xcode.ts @@ -186,3 +186,46 @@ export function uploadProblems(state: { errors?: { code?: string; message?: stri const f = (x: { code?: string; message?: string; description?: string }) => [x.code, x.message ?? x.description].filter(Boolean).join(': '); return { errors: (state?.errors ?? []).map(f), warnings: (state?.warnings ?? []).map(f) }; } + +/** Apple's own tool folders, first on PATH for xcodebuild. */ +export const APPLE_PATH = ['/usr/bin', '/bin', '/usr/sbin', '/sbin']; + +/** + * PATH for xcodebuild: Xcode's developer usr/bin (when known) and Apple's folders first. Xcode's IPA packaging + * (IDEDistributionCreateIPAStep) runs `rsync` from PATH and fails with "rsync error: syntax or usage error (code 1)", + * shown as "exportArchive Copy failed", when Homebrew's rsync 3.x comes first (pitfall 54). + */ +export function xcodePath(path: string | undefined, developerDir?: string): string { + const first = [...(developerDir ? [join(developerDir, 'usr', 'bin')] : []), ...APPLE_PATH]; + const rest = (path ?? '').split(':').filter(p => p && !first.includes(p)); + return [...first, ...rest].join(':'); +} + +/** The first rsync on PATH when it is not Apple's (/usr/bin, /bin): Homebrew, MacPorts, ...; else null. */ +export function foreignRsync(path: string | undefined, exists: (p: string) => boolean = existsSync): string | null { + for (const d of (path ?? '').split(':').filter(Boolean)) { + const p = join(d, 'rsync'); + if (exists(p)) return d === '/usr/bin' || d === '/bin' ? null : p; + } + return null; +} + +/** + * The real reason behind "exportArchive Copy failed": the error lines of the newest IDEDistribution*.log in the + * .xcdistributionlogs folder xcodebuild names in its output, else the newest such folder in the temp folder. + */ +export function distributionErrors(output: string, tmp: string = process.env.TMPDIR ?? '/tmp'): { log: string; lines: string[] } | null { + let dir = (output.match(/\/[^\s"']+\.xcdistributionlogs/g) ?? []).filter(p => existsSync(p)).at(-1); + if (!dir && existsSync(tmp)) { + dir = readdirSync(tmp).filter(f => f.endsWith('.xcdistributionlogs')).map(f => join(tmp, f)) + .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0]; + } + if (!dir) return null; + const d = dir; + const logs = readdirSync(d).filter(f => /^IDEDistribution.*\.log$/.test(f)).map(f => join(d, f)).sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs); + for (const l of logs) { + const lines = readFileSync(l, 'utf8').split('\n').filter(x => /rsync error|\berror:/i.test(x)).map(x => x.trim().slice(0, 300)); + if (lines.length) return { log: l, lines: [...new Set(lines)].slice(-10) }; + } + return logs[0] ? { log: logs[0], lines: [] } : null; +} diff --git a/docs/pitfalls.md b/docs/pitfalls.md index 6d47b28..9d3e4a8 100644 --- a/docs/pitfalls.md +++ b/docs/pitfalls.md @@ -9,6 +9,8 @@ fails without the fix. | # | date | lesson | guard | |---|---|---|---| +| 54 | 2026-10 | The second `apple build` of a Capacitor game archived fine, then `xcodebuild -exportArchive` failed with "exportArchive Copy failed" (exit 70). The IDEDistribution log had the real cause: `rsync error: syntax or usage error (code 1)` in IDEDistributionCreateIPAStep, because a Homebrew rsync 3.x, installed and linked that day by another session's release tool, came first on PATH. A tool changed a global binary on a shared machine as a side effect. | `apple build` runs xcodebuild with Xcode's and Apple's tool folders first on PATH (`xcodePath`) and prints a note when a non-Apple rsync is first (`foreignRsync`); an export failure shows the error lines of the newest IDEDistribution*.log (`distributionErrors`, `cli/lib/xcode.ts`, `cli/apple/build.ts`). `storebound doctor` flags a non-Apple rsync first on PATH. `tools/desktop-release.ts publish` calls rsync by absolute path (`/usr/bin/rsync` without `--chmod`, modes set on the server; else the Homebrew keg), never from PATH (`publishSteps`). Rule: tools never install or relink global binaries as a side effect. `tests/apple-build-export.test.ts`. | +| 53 | 2026-10 | After that failed export, `apple upload build/ios/export/App.ipa --yes` uploaded nothing: it read the stale IPA of build 1, found build 1 already VALID on App Store Connect and exited 0 ("build 1 is VALID"), so `build && upload` looked green. The export also wrote into the same folder every time. | `apple build` exports each build into its own folder `build/ios/export/-/` (removed first) and exits non-zero on any export failure or a missing IPA (`exportIpa`). `apple upload` fails with exit 1 when the IPA's build number is already on App Store Connect ("this ipa is build 1, which is already on App Store Connect (VALID): did the last build fail?"); only `--allow-existing` re-checks it. `--build N` picks / requires that build's IPA; the release and beta flows use `apple upload --no-wait --build ${BUILD} --allow-existing`, so a resumed flow still passes and a stale IPA never does (`existingBuild`, `exportedIpas`, `cli/apple/upload.ts`; `tests/apple-build-export.test.ts`). | | 52 | 2026-10 | An iOS app that is live on the App Store but whose source is not on this machine could not appear in the desktop: the machine's app list (`~/.storebound/projects.json`) took only folders. | `storebound apps add --store apple\|play --app-id --account ` writes a store-only entry (`storeApps`, no path; `apps folders` shows "no local project", `apps remove apple:`); the desktop adds it to the app list of the account with that profile (`registry::with_store_only`, `store_apps_cached` / `store_apps_refresh`), shown read-only as "Store only"; writing the folder list keeps the entries. `tests/onboard.test.ts`, `src-tauri/tests/machine.rs`, the boot check's `shared-profiles` case (zero uncaught errors). | | 51 | 2026-10 | With several credential profiles the only way to have a machine default was naming one `default`. | `storebound accounts default ` (and `--clear`) writes the pointer file `/default-profile`; `accounts list` shows the default; order: env > a profile the project names > the project's own keys > the pointer > the only profile (or `default`) (`defaultProfile`, `setDefaultPointer` in `cli/lib/credentials.ts`; `tests/machine.test.ts`). | | 50 | 2026-10 | Onboarding an app with sign-in left "account deletion URL" as an owner question although the app's own website had the page. | `storebound onboard` looks on the app's site before asking: /delete-account, /account/delete, /delete, /account-deletion on the website and the privacy policy's host, then links in the privacy policy that mention delete / deletion, then a deletion section of the privacy page; a page counts only when it answers 200 and speaks of deleting. Not found stays a finding ("a real gap, Play requires it"); never invented (`findDeletionUrl` in `cli/lib/onboard.ts`; `tests/onboard.test.ts`). | diff --git a/site/main.js b/site/main.js index 9e04fe6..4e155c8 100644 --- a/site/main.js +++ b/site/main.js @@ -319,13 +319,20 @@ if (k === here) { b.classList.add('is-here', 'btn-primary'); b.classList.remove('btn-ghost'); } } const unsigned = keys.filter(k => !d[k].codeSigned && (k === 'macos-universal-dmg' || k === 'windows-x86_64-nsis')); - if (unsigned.length) { + { + // signing per OS, from latest.json's codeSigned (macOS is Developer ID signed and notarized) const n = el('div', { class: 'dl-notice', role: 'note' }); - n.appendChild(el('b', null, 'Unsigned preview. ')); - n.appendChild(document.createTextNode('This early build is not yet signed by Apple or Microsoft, so your system asks once before opening it. Check the SHA-256 under "Checksums and signatures" first.')); + n.appendChild(el('b', null, 'Signing. ')); + n.appendChild(document.createTextNode(unsigned.length ? 'Check the SHA-256 under "Checksums and signatures" before opening an unsigned build.' : 'The SHA-256 of every file is under "Checksums and signatures".')); const ol = el('ol'); - if (unsigned.includes('macos-universal-dmg')) ol.appendChild(el('li', null, 'macOS: drag storebound to Applications and open it; when macOS refuses, open System Settings > Privacy & Security and choose Open Anyway (once).')); - if (unsigned.includes('windows-x86_64-nsis')) ol.appendChild(el('li', null, 'Windows: when SmartScreen shows "Windows protected your PC", choose More info > Run anyway.')); + const has = k => keys.includes(k); + if (has('macos-universal-dmg')) ol.appendChild(el('li', null, unsigned.includes('macos-universal-dmg') + ? 'macOS: this build is not signed; when macOS refuses to open it, open System Settings > Privacy & Security and choose Open Anyway (once).' + : 'macOS: signed with an Apple Developer ID and notarized by Apple; it opens without a warning.')); + if (has('windows-x86_64-nsis')) ol.appendChild(el('li', null, unsigned.includes('windows-x86_64-nsis') + ? 'Windows: not signed yet, so SmartScreen may warn ("Windows protected your PC"): choose More info › Run anyway.' + : 'Windows: signed; it installs without a SmartScreen warning.')); + if (keys.some(k => k.startsWith('linux'))) ol.appendChild(el('li', null, 'Linux: AppImage and .deb are not signed (Linux has no OS signing check); compare the SHA-256.')); n.appendChild(ol); notice.appendChild(n); } diff --git a/skills/appstore-first-release/SKILL.md b/skills/appstore-first-release/SKILL.md index 6df8ea7..edb266a 100644 --- a/skills/appstore-first-release/SKILL.md +++ b/skills/appstore-first-release/SKILL.md @@ -36,7 +36,7 @@ answers the compliance facts no file proves, all in one batch at the start. ```sh storebound apple signing status # capabilities, certificate with a key on this Mac, profiles storebound apple build --version 1.0.0 --dry # project + scheme found, next build number, ExportOptions.plist - storebound apple build --version 1.0.0 # xcodebuild archive + exportArchive -> build/ios/export/*.ipa + storebound apple build --version 1.0.0 # xcodebuild archive + exportArchive -> build/ios/export/-/*.ipa storebound apple upload --dry # then without --dry (asks): API build upload, waits for VALID ``` `apple build` takes the build number from `storebound apple next-build` unless `--build-number N` is given, and diff --git a/skills/build-and-sign/SKILL.md b/skills/build-and-sign/SKILL.md index 4a2e9b3..b30be52 100644 --- a/skills/build-and-sign/SKILL.md +++ b/skills/build-and-sign/SKILL.md @@ -44,7 +44,11 @@ when the owner said so for this run). The MCP server offers only `apple_signing_ `project` / `scheme`), the build number (next free one from App Store Connect, or `--build-number N`), the ExportOptions.plist (`method` app-store-connect, `destination` export, team, style, profiles) and both xcodebuild commands. Then run it without `--dry` (minutes; full logs in `build/ios/xcodebuild-*.log`). It ends with the IPA - path, version and build. **Note the build number**: later steps use exactly it. + path (`build/ios/export/-/App.ipa`, a fresh folder per build), version and build. **Note the build + number**: later steps use exactly it. Any archive or export failure exits non-zero, so `apple build && apple upload` + stops there; an export failure shows the IDEDistribution log's real error lines. xcodebuild runs with Apple's tool + folders first on PATH (a Homebrew rsync breaks Xcode's IPA step: "exportArchive Copy failed"; `storebound doctor` + flags it). - 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 @@ -56,8 +60,10 @@ when the owner said so for this run). The MCP server offers only `apple_signing_ run warns when Pods/ is missing). Flutter: storebound runs `flutter build ios --config-only` with the build number. Unity: build the Xcode project from Unity first. - Capacitor needs a UTF-8 locale; storebound sets `LANG` / `LC_ALL` for xcodebuild. -5. **Upload.** `storebound apple upload --dry` (default: the newest IPA in `build/ios/export`): bundle id, version, - build, and a refusal when that build number is already on App Store Connect. Then without `--dry` after a yes. Default +5. **Upload.** `storebound apple upload --build N --dry` (the IPA of build N; without `--build`, the newest IPA under + `build/ios/export`): bundle id, version, build, and exit 1 when that build number is already on App Store Connect + (a stale IPA from a failed build: build again). `--allow-existing` only re-checks a build this command uploaded + before (flows resuming). Then without `--dry` after a yes. Default `--via api` is the App Store Connect API build upload (Build Uploads: reserve, upload the parts, commit with the MD5, poll the upload state; any OS). `--via altool` uses `xcrun altool --upload-package` with the API key. Apple lists both (and Xcode / Transporter); neither is deprecated. storebound then waits until the build is VALID diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index b04d6fe..c6fa186 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -626,3 +626,18 @@ that fails without the fix (see CONTRIBUTING.md). - **Do:** `storebound apps add --store apple --app-id --account `; the desktop shows it read-only ("Store only": status, reviews, analytics). `apps remove apple:` takes it out. - **Guard:** `storeApps` in `~/.storebound/projects.json` (`cli/lib/registry.ts`, `registry.rs`), the boot check. + +## 53. `apple upload` passed a stale IPA as "already VALID" +- **Do:** upload the IPA `apple build` just printed (`build/ios/export/-/App.ipa`), or `apple upload + --build N`. An IPA whose build number is already on App Store Connect now fails with exit 1: the last build failed; + run `apple build` again. `--allow-existing` only for re-checking a build this command uploaded before (flows use it + with `--build ${BUILD}`). +- **Guard:** per-build export folders, `existingBuild` / `exportedIpas` (`cli/apple/upload.ts`), `exportIpa` + (`cli/apple/build.ts`), `tests/apple-build-export.test.ts`. + +## 54. "exportArchive Copy failed": a Homebrew rsync on PATH +- **Do:** read the IDEDistribution log lines `apple build` prints. "rsync error: syntax or usage error" means a + non-Apple rsync (Homebrew 3.x) is first on PATH; storebound runs xcodebuild with /usr/bin first, `storebound doctor` + names the culprit. Never install or `brew link` a global tool as a side effect on a shared machine. +- **Guard:** `xcodePath`, `foreignRsync`, `distributionErrors` (`cli/lib/xcode.ts`), doctor, `publishSteps` + (`tools/desktop-release.ts`, rsync by absolute path). diff --git a/templates/profiles/app/workflows/beta.yml b/templates/profiles/app/workflows/beta.yml index 2a5676d..0d78b49 100644 --- a/templates/profiles/app/workflows/beta.yml +++ b/templates/profiles/app/workflows/beta.yml @@ -16,7 +16,7 @@ steps: run: apple build --version ${VERSION} --build-number ${BUILD} - id: upload if: apple - run: apple upload --no-wait + run: apple upload --no-wait --build ${BUILD} --allow-existing - id: wait if: apple run: apple testflight wait --build ${BUILD} --version ${VERSION} diff --git a/templates/profiles/app/workflows/release.yml b/templates/profiles/app/workflows/release.yml index 81f31bf..d9efd8e 100644 --- a/templates/profiles/app/workflows/release.yml +++ b/templates/profiles/app/workflows/release.yml @@ -17,7 +17,7 @@ steps: run: apple build --version ${VERSION} --build-number ${BUILD} - id: upload if: apple - run: apple upload --no-wait + run: apple upload --no-wait --build ${BUILD} --allow-existing - id: wait if: apple run: apple testflight wait --build ${BUILD} --version ${VERSION} diff --git a/templates/workflows/beta.yml b/templates/workflows/beta.yml index d236a7c..6049b2e 100644 --- a/templates/workflows/beta.yml +++ b/templates/workflows/beta.yml @@ -20,7 +20,7 @@ steps: run: apple build --version ${VERSION} --build-number ${BUILD} - id: upload if: apple - run: apple upload --no-wait + run: apple upload --no-wait --build ${BUILD} --allow-existing - id: wait if: apple run: apple testflight wait --build ${BUILD} --version ${VERSION} diff --git a/templates/workflows/release.yml b/templates/workflows/release.yml index 3ac672a..f73ecca 100644 --- a/templates/workflows/release.yml +++ b/templates/workflows/release.yml @@ -20,7 +20,7 @@ steps: run: apple build --version ${VERSION} --build-number ${BUILD} - id: upload if: apple - run: apple upload --no-wait + run: apple upload --no-wait --build ${BUILD} --allow-existing - id: wait if: apple run: apple testflight wait --build ${BUILD} --version ${VERSION} diff --git a/tests/apple-build-export.test.ts b/tests/apple-build-export.test.ts new file mode 100644 index 0000000..6ae7d8d --- /dev/null +++ b/tests/apple-build-export.test.ts @@ -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'); + }); +}); diff --git a/tests/flow.test.ts b/tests/flow.test.ts index a76662c..a9158b1 100644 --- a/tests/flow.test.ts +++ b/tests/flow.test.ts @@ -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'); } }); diff --git a/tests/game-report.test.ts b/tests/game-report.test.ts index fd181f4..200ca6d 100644 --- a/tests/game-report.test.ts +++ b/tests/game-report.test.ts @@ -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_.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'); diff --git a/tools/desktop-release.ts b/tools/desktop-release.ts index 627ae26..68519ac 100644 --- a/tools/desktop-release.ts +++ b/tools/desktop-release.ts @@ -648,6 +648,22 @@ export function releaseTogetherProblems(folderVersion: string, pkgVersion: strin return folderVersion === pkgVersion ? [] : [`the folder is desktop ${folderVersion} but package.json (the npm CLI) is ${pkgVersion}: the CLI and the app ship with one version (bun tools/desktop-version.ts --set ${folderVersion}, or build the folder from the release commit)`]; } +/** + * The publish commands. rsync by absolute path, never from PATH: Apple's /usr/bin/rsync (openrsync) with only the + * options it supports (no --chmod; the modes are set on the server after the upload), else Homebrew's keg. A tool + * never needs a global rsync linked or installed (pitfall 54: a linked Homebrew rsync broke Xcode's exportArchive). + */ +export function publishSteps(o: { host: string; v: string; channel: string; out: string; files: string[] }, exists: (p: string) => boolean = existsSync): string[][] { + const rsync = ['/usr/bin/rsync', '/opt/homebrew/opt/rsync/bin/rsync', '/usr/local/opt/rsync/bin/rsync'].find(p => exists(p)); + if (!rsync) throw new Error('publish needs rsync: /usr/bin/rsync (macOS, most Linux) or the Homebrew keg (brew install rsync; no link needed)'); + const dir = `${REMOTE_INCOMING}/${o.v}`; + return [ + ['ssh', o.host, `mkdir -p ${dir} && chmod 700 ${REMOTE_INCOMING}`], + [rsync, '-a', '--delete', ...o.files.map(f => join(o.out, f)), `${o.host}:${dir}/`], + ['ssh', o.host, `find ${dir} -type d -exec chmod 755 {} + && find ${dir} -type f -exec chmod 644 {} + && ${REMOTE_PUBLISH} ${o.v} ${o.channel}`], + ]; +} + function publish(argv: string[]): void { const a = args(argv); const out = resolve(a.get('out') ?? fail('--out is required')); @@ -660,11 +676,7 @@ function publish(argv: string[]): void { const together = releaseTogetherProblems(v, versions().version); if (together.length) fail(together.join('; ')); const unsigned = Object.values(manifest.downloads ?? {}).filter(d => !d!.codeSigned && /\.(dmg|exe)$/.test(d!.file)).map(d => d!.file); - const steps = [ - ['ssh', host, `mkdir -p ${REMOTE_INCOMING}/${v} && chmod 700 ${REMOTE_INCOMING}`], - ['rsync', '-a', '--chmod=F644,D755', '--delete', ...files.map(f => join(out, f)), `${host}:${REMOTE_INCOMING}/${v}/`], - ['ssh', host, `${REMOTE_PUBLISH} ${v} ${channel}`], - ]; + const steps = publishSteps({ host, v, channel, out, files }); console.log(`\npublish storebound desktop ${v} to the ${channel} channel (${channel === 'stable' ? `${BASE_URL}/latest.json` : `${BASE_URL}/beta/latest.json`}):`); for (const f of files) console.log(` ${f}`); if (unsigned.length) console.log(` note: unsigned preview builds (${unsigned.join(', ')}); the downloads page says so.`);