apple build exports each build into build/ios/export/<version>-<build>/ (cleaned first), runs xcodebuild with Apple's tool folders first on PATH (Homebrew rsync 3.x broke Xcode's IPA step: exportArchive Copy failed), shows the IDEDistribution log's real error lines and exits non-zero on any export failure; apple upload fails with exit 1 when the IPA's build number is already on App Store Connect (stale IPA after a failed build), --allow-existing re-checks, --build N picks that build's IPA, release and beta flows use both; doctor flags a non-Apple rsync first on PATH; desktop publish calls rsync by absolute path without --chmod (publishSteps); site download notice per OS (macOS signed and notarized, Windows unsigned for now, Linux unsigned); game-report credential tests hermetic against a machine default profile (pitfalls 53, 54)
This commit is contained in:
parent
8fd4ac741e
commit
0e4f579899
20 changed files with 338 additions and 48 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -2,6 +2,17 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- `apple build` exports each build into `build/ios/export/<version>-<build>/` (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
|
||||
|
|
|
|||
|
|
@ -7,10 +7,11 @@
|
|||
// Unity Unity-iPhone), takes the next safe build number from App Store Connect (apple next-build) unless
|
||||
// --build-number is given, writes <out>/ExportOptions.plist (method app-store-connect, team, signing style and the
|
||||
// profiles from apple.signing), runs xcodebuild archive and -exportArchive with the API key
|
||||
// (-allowProvisioningUpdates -authenticationKey*), and leaves <out>/export/<App>.ipa for `storebound apple upload`.
|
||||
// (-allowProvisioningUpdates -authenticationKey*), and leaves <out>/export/<version>-<build>/<App>.ipa for `storebound apple upload`. Each build exports into its own
|
||||
// folder, cleaned first: xcodebuild refuses to overwrite an earlier App.ipa ("exportArchive Copy failed", pitfall 53).
|
||||
// Full xcodebuild output goes to <out>/xcodebuild-*.log; the terminal gets the result lines.
|
||||
// Local only: nothing is sent to App Store Connect except reads (build numbers, team id, profiles).
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { ascKeyFile } from '../lib/credentials';
|
||||
|
|
@ -22,7 +23,7 @@ import { parseMobileprovision, toXmlPlist } from '../lib/plist';
|
|||
import { nextBuildNumber } from '../lib/release';
|
||||
import { exportOptions, profileDirs } from '../lib/signing';
|
||||
import { capacitorProject, webStep } from '../lib/web-assets';
|
||||
import { archiveArgs, type AscAuth, exportArgs, flutterConfigArgs, ipaInfo, pickXcode, scanXcode, showCmd } from '../lib/xcode';
|
||||
import { archiveArgs, type AscAuth, distributionErrors, exportArgs, foreignRsync, xcodePath, flutterConfigArgs, ipaInfo, pickXcode, scanXcode, showCmd } from '../lib/xcode';
|
||||
import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files';
|
||||
import { ascClient } from './asc';
|
||||
import { findBundleId } from './signing';
|
||||
|
|
@ -52,17 +53,45 @@ export function installedProfiles(bundleId: string, teamId: string | null, home
|
|||
return out.sort((a, b) => Date.parse(b.expires) - Date.parse(a.expires));
|
||||
}
|
||||
|
||||
async function runLogged(argv: string[], cwd: string, logFile: string, show: string, env?: Record<string, string>): Promise<void> {
|
||||
export async function runLogged(argv: string[], cwd: string, logFile: string, show: string, env?: Record<string, string>): Promise<void> {
|
||||
log(TAG, `$ ${show}`);
|
||||
log(TAG, `full output: ${logFile}`);
|
||||
const p = rt.spawn(argv, { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8', ...env } });
|
||||
// Apple's tool folders first: Xcode's IPA step runs `rsync` from PATH, and Homebrew's rsync 3.x breaks it (pitfall 54)
|
||||
const p = rt.spawn(argv, { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, PATH: xcodePath(process.env.PATH, process.env.DEVELOPER_DIR), LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8', ...env } });
|
||||
const chunks: string[] = [];
|
||||
const keep = /(\berror:|\*\* (ARCHIVE|EXPORT|BUILD) (SUCCEEDED|FAILED) \*\*|requires a provisioning profile|No signing certificate|Signing for .* requires|Exported .* to)/;
|
||||
const each = (line: string): void => { chunks.push(line); if (keep.test(line)) log(TAG, line.trim().slice(0, 300)); };
|
||||
await Promise.all([pump(p.stdout, 'stdout', each), pump(p.stderr, 'stderr', each)]);
|
||||
const code = await p.exited;
|
||||
writeFileSync(logFile, chunks.join('\n'));
|
||||
if (code !== 0) throw new Error(`${argv[0]} ${argv[1] ?? ''} failed (exit ${code}); last lines:\n${chunks.slice(-15).join('\n')}\nfull log: ${logFile}`);
|
||||
if (code !== 0) throw Object.assign(new Error(`${argv[0]} ${argv[1] ?? ''} failed (exit ${code}); last lines:\n${chunks.slice(-15).join('\n')}\nfull log: ${logFile}`), { output: chunks.join('\n') });
|
||||
}
|
||||
|
||||
/** The export folder of one build: <out>/export/<version>-<build> (build-<build> when the project sets the version). */
|
||||
export function exportDir(out: string, version: string | undefined, build: string): string {
|
||||
return join(out, 'export', version ? `${version}-${build}` : `build-${build}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* xcodebuild -exportArchive into a fresh folder (removed first, so an IPA left by an earlier export never makes it
|
||||
* fail with "Copy failed" or pass as this build). Throws on a non-zero exit or when no .ipa came out, so
|
||||
* `apple build && apple upload` and `flow run` stop there.
|
||||
*/
|
||||
export async function exportIpa(o: { root: string; out: string; archivePath: string; exportPath: string; auth: AscAuth | null; argv?: string[]; tmp?: string }): Promise<string> {
|
||||
rmSync(o.exportPath, { recursive: true, force: true });
|
||||
const exp = o.argv ?? exportArgs(o.archivePath, join(o.out, 'ExportOptions.plist'), o.exportPath, o.auth);
|
||||
try {
|
||||
await runLogged(exp, o.root, join(o.out, 'xcodebuild-export.log'), showCmd(exp, o.auth));
|
||||
} catch (e) {
|
||||
// "Copy failed" says nothing: the IDEDistribution log has the real line (e.g. "rsync error: syntax or usage error")
|
||||
const d = distributionErrors((e as { output?: string }).output ?? '', o.tmp);
|
||||
if (d?.lines.length) (e as Error).message += `\nwhy (${d.log}):\n${d.lines.map(l => ` ${l}`).join('\n')}`
|
||||
+ (d.lines.some(l => /rsync/i.test(l)) ? '\nA non-Apple rsync (Homebrew) breaks Xcode\'s IPA step: storebound doctor shows which one is first on PATH.' : '');
|
||||
throw e;
|
||||
}
|
||||
const ipa = existsSync(o.exportPath) ? readdirSync(o.exportPath).find(f => f.endsWith('.ipa')) : undefined;
|
||||
if (!ipa) throw new Error(`xcodebuild -exportArchive exited 0 but left no .ipa in ${o.exportPath}: see ${join(o.out, 'xcodebuild-export.log')}`);
|
||||
return join(o.exportPath, ipa);
|
||||
}
|
||||
|
||||
export async function run(ctx: Ctx): Promise<void> {
|
||||
|
|
@ -117,7 +146,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const opts = exportOptions({ teamId: teamId ?? '<TEAM_ID>', style, profiles: style === 'manual' ? (Object.keys(profiles).length ? profiles : { [bundleId]: '<profile uuid>' }) : undefined });
|
||||
const plist = toXmlPlist(opts);
|
||||
const archivePath = join(out, `${target.scheme}.xcarchive`);
|
||||
const exportPath = join(out, 'export');
|
||||
const exportPath = exportDir(out, version, buildNo);
|
||||
const authPreview: AscAuth = { keyPath: '$ASC_P8', keyId: '$ASC_KEY_ID', issuerId: '$ASC_ISSUER_ID' };
|
||||
const auth = style === 'automatic' ? authPreview : null;
|
||||
const steps = [
|
||||
|
|
@ -141,6 +170,8 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (process.platform !== 'darwin' || !rt.which('xcodebuild')) throw new Error('apple build needs macOS with Xcode (xcode-select --install is not enough: the full Xcode app)');
|
||||
const web = await webStep({ ...webOpts, dry: false });
|
||||
for (const n of target.notes) log(TAG, n);
|
||||
const rsync = foreignRsync(process.env.PATH);
|
||||
if (rsync) log(TAG, `note: ${rsync} is first on your PATH; xcodebuild runs with /usr/bin first (a non-Apple rsync breaks Xcode's IPA export)`);
|
||||
mkdirSync(out, { recursive: true });
|
||||
writeFileSync(join(out, 'ExportOptions.plist'), plist);
|
||||
const key = style === 'automatic' ? ascKeyFile(root) : null;
|
||||
|
|
@ -149,11 +180,9 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (target.framework === 'flutter') await runLogged(flutterConfigArgs(build!, version), root, join(out, 'flutter-config.log'), showCmd(flutterConfigArgs(build!, version), null));
|
||||
const arch = archiveArgs({ target, root, configuration, archivePath, version, build: build!, teamId: teamId ?? undefined, auth: realAuth });
|
||||
await runLogged(arch, root, join(out, 'xcodebuild-archive.log'), showCmd(arch, realAuth));
|
||||
const exp = exportArgs(archivePath, join(out, 'ExportOptions.plist'), exportPath, realAuth);
|
||||
await runLogged(exp, root, join(out, 'xcodebuild-export.log'), showCmd(exp, realAuth));
|
||||
await exportIpa({ root, out, archivePath, exportPath, auth: realAuth });
|
||||
} finally { key?.cleanup(); }
|
||||
const ipa = existsSync(exportPath) ? readdirSync(exportPath).find(f => f.endsWith('.ipa')) : undefined;
|
||||
if (!ipa) throw new Error(`export finished but no .ipa in ${exportPath}`);
|
||||
const ipa = readdirSync(exportPath).find(f => f.endsWith('.ipa'))!;
|
||||
const info = ipaInfo(join(exportPath, ipa));
|
||||
if (info.bundleId !== bundleId) warn(TAG, `the IPA's bundle id is ${info.bundleId}, the config says ${bundleId}`);
|
||||
if (info.build !== build) warn(TAG, `the IPA has build ${info.build}, not ${build}: the project sets CFBundleVersion itself (use $(CURRENT_PROJECT_VERSION) in Info.plist)`);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
// Upload an IPA to App Store Connect and wait until Apple has processed the build.
|
||||
// storebound apple upload [<app.ipa>] [--via api|altool] [--no-wait] [--timeout 60m] [--dry] [--yes] [--json]
|
||||
// Default IPA: the newest in <apple.build.out or build/ios>/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 [<app.ipa>] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--dry] [--yes] [--json]
|
||||
// Default IPA: the newest in <apple.build.out or build/ios>/export (and its <version>-<build>/ 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 (<version>-<build>/), 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<void> {
|
||||
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 <app.ipa> (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 <app.ipa> (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<void> {
|
|||
|
||||
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'
|
||||
|
|
|
|||
|
|
@ -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 [<app.ipa>] [--via api|altool] [--no-wait] [--timeout 60m] [--dry] [--yes] [--json]"
|
||||
"storebound apple upload [<app.ipa>] [--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]"
|
||||
|
|
|
|||
|
|
@ -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<string, string | undefined> = process.env): DoctorResult {
|
||||
|
|
@ -42,7 +45,8 @@ export function doctor(cwd: string, env: Record<string, string | undefined> = 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<void> {
|
||||
|
|
@ -54,6 +58,7 @@ export async function run(args: Args): Promise<void> {
|
|||
: `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):');
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/<version>-<build>/` (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 <id> --account <profile>` writes a store-only entry (`storeApps`, no path; `apps folders` shows "no local project", `apps remove apple:<id>`); 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 <profile>` (and `--clear`) writes the pointer file `<credentials>/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`). |
|
||||
|
|
|
|||
17
site/main.js
17
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/<version>-<build>/*.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
|
||||
|
|
|
|||
|
|
@ -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/<version>-<build>/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
|
||||
|
|
|
|||
|
|
@ -626,3 +626,18 @@ that fails without the fix (see CONTRIBUTING.md).
|
|||
- **Do:** `storebound apps add --store apple --app-id <id> --account <profile>`; the desktop shows it read-only
|
||||
("Store only": status, reviews, analytics). `apps remove apple:<id>` 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/<version>-<build>/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).
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
124
tests/apple-build-export.test.ts
Normal file
124
tests/apple-build-export.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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_<KEY_ID>.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');
|
||||
|
|
|
|||
|
|
@ -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 <version folder> 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.`);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue