validate: macOS versions need Mac screenshots, Play reads survive an ended edit; macOS build numbers across platforms; Mac build prebuild + apple.macAppCheck; signing wording off the Mac; dry flows plan uploads
This commit is contained in:
parent
b1f1b206d3
commit
f5da7c9f7a
21 changed files with 519 additions and 57 deletions
|
|
@ -133,7 +133,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
|||
'builds_betaBuildLocalizations_getToManyRelated': 'apple/testflight',
|
||||
'builds_buildBetaDetail_getToOneRelated': 'apple/testflight',
|
||||
'builds_diagnosticSignatures_getToManyRelated': 'health',
|
||||
'builds_getCollection': 'apple/build apple/build-mas apple/encryption apple/listing apple/next-build apple/resubmit apple/status apple/testflight apple/upload art health lib/store-apps triage',
|
||||
'builds_getCollection': 'apple/encryption apple/listing apple/next-build apple/resubmit apple/status apple/testflight apple/upload art health lib/store-apps triage',
|
||||
'builds_getInstance': 'apple/device-families',
|
||||
'builds_updateInstance': 'apple/encryption lib/build-ops',
|
||||
'bundleIdCapabilities_createInstance': 'lib/signing',
|
||||
|
|
@ -245,13 +245,13 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
|||
'androidpublisher.edits.bundles.upload': 'play/release play/upload',
|
||||
'androidpublisher.edits.commit': 'play/client',
|
||||
'androidpublisher.edits.countryavailability.get': 'validate',
|
||||
'androidpublisher.edits.delete': 'apps onboard play/client validate',
|
||||
'androidpublisher.edits.delete': 'apps onboard play/client',
|
||||
'androidpublisher.edits.details.get': 'apps onboard play/listing play/status validate',
|
||||
'androidpublisher.edits.details.update': 'play/listing',
|
||||
'androidpublisher.edits.images.deleteall': 'play/images',
|
||||
'androidpublisher.edits.images.list': 'apps art play/images play/listing validate',
|
||||
'androidpublisher.edits.images.upload': 'play/images play/listing',
|
||||
'androidpublisher.edits.insert': 'apps onboard play/client validate',
|
||||
'androidpublisher.edits.insert': 'apps onboard play/client',
|
||||
'androidpublisher.edits.listings.get': 'onboard',
|
||||
'androidpublisher.edits.listings.list': 'art listing-store play/listing play/status validate',
|
||||
'androidpublisher.edits.listings.patch': 'listing-store',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Mac App Store build of a Tauri app: `tauri build --bundles app`, signed for the Mac App Store, then productbuild to a .pkg.
|
||||
// storebound apple build --platform macos --tauri <src-tauri> [--path-free] [--target universal-apple-darwin]
|
||||
// [--version x.y.z] [--build-number N] [--profile <file.provisionprofile>] [--out build/macos] [--unsigned] [--dry] [--json]
|
||||
// Steps: Tauri project -> version / build number (next free one on App Store Connect, platform MAC_OS) -> signing
|
||||
// Steps: Tauri project -> version / build number (next free one on App Store Connect, every platform) -> signing
|
||||
// identities from the keychain ("Apple Distribution" or "3rd Party Mac Developer Application"; "Mac Installer
|
||||
// Distribution" or "3rd Party Mac Developer Installer") -> Mac App Store provisioning profile (--profile, installed, or
|
||||
// from the API) -> entitlements (app sandbox + application / team identifier, the project's own keys kept) -> a
|
||||
|
|
@ -19,10 +19,13 @@ import type { Ctx } from '../lib/ctx';
|
|||
import { need } from '../lib/ctx';
|
||||
import { done, log, printJson, warn } from '../lib/log';
|
||||
import { parseMobileprovision, parsePlist, type PlistValue } from '../lib/plist';
|
||||
import { nextBuildNumber } from '../lib/release';
|
||||
import { appNextBuild } from './next-build';
|
||||
import { parseIdentities, profileDirs, readProfiles, type ApiProfile } from '../lib/signing';
|
||||
import { sshBuildRefusal } from '../lib/keychain';
|
||||
import { remoteBuildSession, showCmd } from '../lib/xcode';
|
||||
import { prebuildFor, prebuildsDone, runPrebuild } from '../lib/web-assets';
|
||||
import { preCheckCommand } from '../lib/pre-check';
|
||||
import { pump } from '../lib/runner';
|
||||
import {
|
||||
appProblems, entitlementKeysOf, entitlementsXml, findBuiltApp, findTauri, leakNeedles, masEntitlements, type MasProfile, masSteps, pathFreeEnv, pickMasIdentities,
|
||||
pickMasProfiles, pkgProblems, parsePkgSignature, readAppInfo, scanPathLeaks, tauriOverlay, categoryId,
|
||||
|
|
@ -41,6 +44,16 @@ const run1 = (argv: string[], timeout = 120_000): { ok: boolean; out: string } =
|
|||
};
|
||||
|
||||
/** The Tauri CLI: the project's node_modules/.bin/tauri (walking up), `cargo tauri`, else bunx. */
|
||||
/** One sh -c command in root, its output streamed; the exit code and the last lines. */
|
||||
async function runCheck(cmd: string, root: string): Promise<{ ok: boolean; code: number; tail: string }> {
|
||||
const p = rt.spawn(['sh', '-c', cmd], { cwd: root, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', env: process.env });
|
||||
const tail: string[] = [];
|
||||
const each = (line: string, kind: 'stdout' | 'stderr'): void => { tail.push(line); if (tail.length > 10) tail.shift(); (kind === 'stderr' ? console.error : console.log)(` ${line}`); };
|
||||
await Promise.all([pump(p.stdout, 'stdout', each), pump(p.stderr, 'stderr', each)]);
|
||||
const code = await p.exited;
|
||||
return { ok: code === 0, code, tail: tail.join('\n') };
|
||||
}
|
||||
|
||||
export function tauriCommand(cwd: string, exists: (p: string) => boolean = existsSync, which: (c: string) => string | null = rt.which): string[] {
|
||||
for (let d = cwd, prev = ''; d !== prev; prev = d, d = dirname(d)) { const p = join(d, 'node_modules', '.bin', 'tauri'); if (exists(p)) return [p]; }
|
||||
if (which('cargo-tauri')) return ['cargo', 'tauri'];
|
||||
|
|
@ -72,7 +85,13 @@ async function fetchMasProfile(c: Asc, bundleId: string, dest: string): Promise<
|
|||
return dest;
|
||||
}
|
||||
|
||||
export async function run(ctx: Ctx): Promise<void> {
|
||||
/** The dry-run line about signing identities: on a machine that is not a Mac, say so instead of "none found". */
|
||||
export function identityLine(o: { platform: string; appIdentity: string | null; installerIdentity: string | null; profile: string | null }): string {
|
||||
if (o.platform !== 'darwin') return `this is not a build machine: Mac App Store builds run on a Mac (the signing identities live in its keychain), so none were looked up here; profile: ${o.profile ?? '(none)'}`;
|
||||
return `app identity: ${o.appIdentity ?? '(none found)'}; installer identity: ${o.installerIdentity ?? '(none found)'}; profile: ${o.profile ?? '(none)'}`;
|
||||
}
|
||||
|
||||
export async function run(ctx: Ctx, platform: string = process.platform): Promise<void> {
|
||||
const { config, root, resolve } = ctx.loaded;
|
||||
const a = ctx.args;
|
||||
const bundleId = need(config.apple?.bundleId, 'apple.bundleId');
|
||||
|
|
@ -92,9 +111,10 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
try {
|
||||
client = await ascClient(root, bundleId, config.apple?.appId);
|
||||
if (!build) {
|
||||
const builds = await client.all(`/v1/builds?filter[app]=${await client.app()}&filter[preReleaseVersion.platform]=MAC_OS&limit=200&fields[builds]=version`);
|
||||
build = nextBuildNumber(builds.map(b => String(b.attributes.version)));
|
||||
log(TAG, `${builds.length} macOS builds on App Store Connect: next build number ${build} (CFBundleVersion must increase)`);
|
||||
// every platform of the app record, not only macOS: CFBundleVersion must be unique across all its builds
|
||||
const found = await appNextBuild(client);
|
||||
build = found.next;
|
||||
log(TAG, `${found.builds} builds (all platforms) on App Store Connect: next build number ${build} (CFBundleVersion must increase)`);
|
||||
}
|
||||
if (!teamId) teamId = (await findBundleId(client, bundleId))?.attributes?.seedId ?? null;
|
||||
} catch (e) {
|
||||
|
|
@ -107,7 +127,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const team = teamId ?? '<TEAM_ID>';
|
||||
|
||||
// identities and profile (a real signed run needs them; a dry run shows placeholders)
|
||||
const mac = process.platform === 'darwin';
|
||||
const mac = platform === 'darwin';
|
||||
let appIdentity: string | null = null, installerIdentity: string | null = null;
|
||||
if (!unsigned && mac) {
|
||||
const picked = pickMasIdentities(parseIdentities(run1(['security', 'find-identity', '-v']).out), teamId);
|
||||
|
|
@ -142,18 +162,26 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const overlay = tauriOverlay({ appIdentity, entitlementsFile: entFile, profile, version, build: buildNo, unsigned, minOs: tauri.minOs, category });
|
||||
const input = { tauri, tauriCmd, target, out, configFile, entitlementsFile: entFile, profile, appIdentity, installerIdentity: installerIdentity ?? '<Mac Installer Distribution identity>', version, build: buildNo, unsigned, pathFree, pkg };
|
||||
const steps = masSteps(input);
|
||||
// the same hooks as apple build: build.prebuild / apple.prebuild first (a project computes its build-time env there),
|
||||
// apple.macAppCheck on the built .app before productbuild (e.g. a project's own check that a value was stamped in)
|
||||
const prebuild = prebuildFor(config, 'apple') ?? null;
|
||||
const prebuildSkip = !prebuild ? undefined : a.has('no-prebuild') ? '--no-prebuild' : prebuildsDone().has(prebuild) ? 'already ran in this flow run' : undefined;
|
||||
const appCheck = config.apple?.macAppCheck?.trim() || null;
|
||||
const existingEnt = tauri.entitlements && existsSync(tauri.entitlements) ? (parsePlist(readFileSync(tauri.entitlements)) as Record<string, PlistValue>) : undefined;
|
||||
const ent = masEntitlements({ teamId: team, bundleId, existing: existingEnt });
|
||||
const plan = { platform: 'MAC_OS', tauri: relative(root, tauri.confPath), target, version, build: buildNo, teamId: teamId, unsigned, pathFree, appIdentity, installerIdentity,
|
||||
profile: profile ? basenameOf(profile) : null, entitlements: Object.keys(ent), tauriOverlay: overlay, env: Object.fromEntries(Object.entries(env).map(([k, v]) => [k, k === 'RUSTFLAGS' ? v.replaceAll(home, '~') : v])),
|
||||
prebuild, ...(prebuildSkip ? { prebuildSkip } : {}), appCheck,
|
||||
steps: [showCmd(steps.tauri, null), showCmd(steps.codesignVerify, null), showCmd(steps.productbuild, null), showCmd(steps.pkgCheck, null)], out: relative(root, out) || '.', pkg: relative(root, pkg) };
|
||||
if (dry) {
|
||||
if (json) { printJson(plan); return; }
|
||||
log(TAG, `Tauri ${tauri.productName} (${bundleId}) ${version} (${buildNo}) for ${target}, ${unsigned ? 'UNSIGNED' : 'Mac App Store signing'}${pathFree ? ', path-free' : ''}`);
|
||||
log(TAG, `app identity: ${appIdentity ?? '(none found)'}; installer identity: ${installerIdentity ?? '(none found)'}; profile: ${plan.profile ?? '(none)'}`);
|
||||
log(TAG, identityLine({ platform, appIdentity, installerIdentity, profile: plan.profile }));
|
||||
log(TAG, `entitlements: ${plan.entitlements.join(', ')}`);
|
||||
if (pathFree) log(TAG, `RUSTFLAGS: ${plan.env.RUSTFLAGS}`);
|
||||
if (prebuild) log(TAG, prebuildSkip ? `prebuild skipped (${prebuildSkip}): ${prebuild}` : `would run the prebuild: ${prebuild}`);
|
||||
for (const s of plan.steps) log(TAG, `would run: ${s}`);
|
||||
if (appCheck) log(TAG, `would check the built .app: ${appCheck}`);
|
||||
done(TAG, 'macOS build planned (dry run)');
|
||||
return;
|
||||
}
|
||||
|
|
@ -164,6 +192,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
mkdirSync(out, { recursive: true });
|
||||
writeFileSync(entFile, entitlementsXml(ent));
|
||||
writeFileSync(configFile, `${JSON.stringify(overlay, null, 2)}\n`);
|
||||
if (prebuild && !prebuildSkip) await runPrebuild(prebuild, root, TAG);
|
||||
shipPhase('build', `tauri build ${target}, build ${buildNo}`);
|
||||
await runLogged(steps.tauri, tauri.cwd, join(out, 'tauri-build.log'), showCmd(steps.tauri, null), env);
|
||||
const appDir = findBuiltApp(tauri.dir, root, target, tauri.productName);
|
||||
|
|
@ -179,6 +208,13 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
{ bundleId, version, build: buildNo, signed: !unsigned, pathFree });
|
||||
for (const w of checks.warnings) warn(TAG, w);
|
||||
if (checks.errors.length) throw new Error(`${basenameOf(appDir)} was built, but the Mac App Store would refuse it:\n${checks.errors.map(x => ` ${x}`).join('\n')}`);
|
||||
if (appCheck) {
|
||||
const cmd = preCheckCommand(appCheck, appDir);
|
||||
log(TAG, `apple.macAppCheck: $ ${cmd}`);
|
||||
const r = await runCheck(cmd, root);
|
||||
if (!r.ok) throw new Error(`apple.macAppCheck failed (exit ${r.code}) on ${basenameOf(appDir)}; nothing packaged:\n${r.tail}`);
|
||||
log(TAG, 'apple.macAppCheck: ok');
|
||||
}
|
||||
shipPhase('sign', 'productbuild');
|
||||
rmSync(exportPath, { recursive: true, force: true });
|
||||
mkdirSync(exportPath, { recursive: true });
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import { done, log, printJson, warn } from '../lib/log';
|
|||
import { derivedDataFor, archiveScanDirs, leakNeedles, leakProblem, outsideHome, pathFreeSettings, scanForPaths } from '../lib/path-free';
|
||||
import { pump } from '../lib/runner';
|
||||
import { parseMobileprovision, toXmlPlist } from '../lib/plist';
|
||||
import { nextBuildNumber } from '../lib/release';
|
||||
import { appNextBuild } from './next-build';
|
||||
import { exportOptions, profileDirs } from '../lib/signing';
|
||||
import { type FamilyVerdict, familyVerdicts, projectFamilies } from '../lib/device-families';
|
||||
import { liveDeviceFamilies } from './device-families';
|
||||
|
|
@ -177,9 +177,9 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
try {
|
||||
const c = await ascClient(root, bundleId, config.apple?.appId);
|
||||
if (!build) {
|
||||
const builds = await c.all(`/v1/builds?filter[app]=${await c.app()}&limit=200&fields[builds]=version`);
|
||||
build = nextBuildNumber(builds.map(b => String(b.attributes.version)));
|
||||
log(TAG, `${builds.length} builds on App Store Connect: next build number ${build}`);
|
||||
const found = await appNextBuild(c);
|
||||
build = found.next;
|
||||
log(TAG, `${found.builds} builds on App Store Connect: next build number ${build}`);
|
||||
}
|
||||
if (!teamId) teamId = (await findBundleId(c, bundleId))?.attributes?.seedId ?? null;
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -7,15 +7,25 @@ import type { Ctx } from '../lib/ctx';
|
|||
import { need } from '../lib/ctx';
|
||||
import { printJson } from '../lib/log';
|
||||
import { nextBuildNumber } from '../lib/release';
|
||||
import { ascClient } from './asc';
|
||||
import { ascClient, type Asc } from './asc';
|
||||
|
||||
/**
|
||||
* The one place that computes the next CFBundleVersion: the highest build of the app over EVERY platform (iOS, macOS,
|
||||
* tvOS, visionOS), every processing state and every page, plus one. CFBundleVersion must be unique across the app
|
||||
* record's builds, so a platform filter (a macOS build once got a number an uploaded macOS build already had) or a
|
||||
* missed page gives a number the store refuses. `apple next-build`, `apple build` and `apple build --platform macos`
|
||||
* (also their dry runs) all call this, so they agree.
|
||||
*/
|
||||
export async function appNextBuild(c: Pick<Asc, 'all' | 'app'>): Promise<{ next: string; builds: number }> {
|
||||
const builds = await c.all(`/v1/builds?filter[app]=${await c.app()}&limit=200&fields[builds]=version`);
|
||||
return { next: nextBuildNumber(builds.map(b => String(b.attributes.version))), builds: builds.length };
|
||||
}
|
||||
|
||||
export async function run(ctx: Ctx): Promise<void> {
|
||||
const { config, root } = ctx.loaded;
|
||||
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.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 next = nextBuildNumber(builds.map(b => String(b.attributes.version)));
|
||||
if (ctx.args.has('json')) { printJson({ next, builds: builds.length }); return; }
|
||||
console.error(`[apple] ${builds.length} builds on App Store Connect; next build number:`);
|
||||
const { next, builds } = await appNextBuild(c);
|
||||
if (ctx.args.has('json')) { printJson({ next, builds }); return; }
|
||||
console.error(`[apple] ${builds} builds on App Store Connect; next build number:`);
|
||||
console.log(next);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,8 +52,10 @@ export function skippedDuoSets(apple: { iphoneDuo?: boolean; appId?: string } |
|
|||
/** Display types `validate` requires on every locale: apple.screenshotTypes, else the classes App Store Connect's
|
||||
* "App Previews and Screenshots" tab lists (October 2026): iPhone with Dynamic Island (medium display), filled by
|
||||
* scaling a 6.9" or 6.5" set down (ACCEPTED_INSTEAD), plus iPad 13" display with ipad. iPhone Duo is optional. */
|
||||
export const requiredDisplayTypes = (apple: { ipad?: boolean; screenshotTypes?: string[] } | undefined): string[] =>
|
||||
apple?.screenshotTypes ?? ['APP_IPHONE_61', ...(apple?.ipad ? ['APP_IPAD_PRO_3GEN_129'] : [])];
|
||||
export const requiredDisplayTypes = (apple: { ipad?: boolean; mac?: boolean; screenshotTypes?: string[] } | undefined, platform: string = 'IOS'): string[] =>
|
||||
platform === 'MAC_OS' ? (apple?.mac ? Object.values(MAC_DISPLAY) : []) // a macOS version takes the Mac set only (apple.screenshotTypes is the iOS list)
|
||||
: platform !== 'IOS' ? []
|
||||
: apple?.screenshotTypes ?? ['APP_IPHONE_61', ...(apple?.ipad ? ['APP_IPAD_PRO_3GEN_129'] : [])];
|
||||
|
||||
/** Makes one screenshot set (of a version localization, a custom product page localization or a Product Page
|
||||
* Optimization treatment localization) hold exactly `files`, in order: left alone when the MD5s match, else the
|
||||
|
|
|
|||
|
|
@ -284,7 +284,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const editable = await c.editableVersion(platform).catch(() => null);
|
||||
const trainWhy = ipaVersionProblem(info.version, editable?.attributes?.versionString as string | undefined, 'the editable App Store version');
|
||||
if (trainWhy) { if (a.has('allow-version-mismatch')) warn(TAG, trainWhy); else throw new Error(`${relative(root, ipa)}: not uploaded, ${trainWhy}`); }
|
||||
const builds = await c.all(`/v1/builds?filter[app]=${appId}${isPkg ? '&filter[preReleaseVersion.platform]=MAC_OS' : ''}&limit=200&fields[builds]=version,processingState`);
|
||||
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
|
||||
|
|
|
|||
16
cli/flow.ts
16
cli/flow.ts
|
|
@ -20,7 +20,7 @@ import { dirname, join, relative } from 'node:path';
|
|||
import type { Args } from './lib/args';
|
||||
import { CONFIG_NAMES, loadConfig, type ShipConfig } from './lib/config';
|
||||
import {
|
||||
type CondCtx, expand, type Flow, flowsOfFile, kindOf, type Mode, parseParams, planStep, type PlannedStep, resumePoint, type RunState, startIndex,
|
||||
type CondCtx, type DryPending, expand, type Flow, flowsOfFile, kindOf, missingParams, type Mode, noteDry, parseParams, planStep, plannedForDryInput, type PlannedStep, resumePoint, type RunState, startIndex,
|
||||
type StepResult, validateFlows,
|
||||
} from './lib/flow';
|
||||
import { DECLINED_MARK } from './lib/ctx';
|
||||
|
|
@ -170,6 +170,12 @@ async function runFlow(args: Args, root: string): Promise<void> {
|
|||
const subParams = expand(flow, all, 'steps').params;
|
||||
const vars: Record<string, string> = { ...subParams, ...flow.params, ...(from ? last?.vars ?? {} : {}), ...params };
|
||||
const c = await condCtx(root);
|
||||
vars['config.testflight.internal'] = (c.config?.apple?.testflight?.groups ?? []).filter(g => g.internal).map(g => g.name).join(',');
|
||||
if (!args.has('resume')) {
|
||||
const missing = missingParams(vars);
|
||||
if (missing.length) throw new Error(`${name} needs ${missing.map(k => `${k}=<value>`).join(' ')} (storebound flow run ${name} ${missing.map(k => `${k}=...`).join(' ')})`);
|
||||
}
|
||||
const pending: DryPending = { built: new Set(), uploaded: new Set() };
|
||||
const prebuilt = prebuildsDone();
|
||||
const state: RunState = { flow: name, mode, startedAt: new Date().toISOString(), vars, results: [], ok: true };
|
||||
const env = process.env as Record<string, string | undefined>;
|
||||
|
|
@ -196,12 +202,20 @@ async function runFlow(args: Args, root: string): Promise<void> {
|
|||
state.results.push(r); save();
|
||||
continue;
|
||||
}
|
||||
const planned = plannedForDryInput(p, mode, pending);
|
||||
if (planned) {
|
||||
r.status = 'planned'; r.detail = planned;
|
||||
if (!json) log(TAG, `${label}: ${describe(p)} (${planned})`);
|
||||
state.results.push(r); save();
|
||||
continue;
|
||||
}
|
||||
if (!json) log(TAG, `${label}: ${describe(p)}${p.dryAdded ? ' (dry run)' : ''}`);
|
||||
const t = Date.now();
|
||||
const x = await exec(p, root, json, prebuilt.size ? { [PREBUILD_DONE_ENV]: [...prebuilt].join('\n') } : {});
|
||||
r.exitCode = x.code; r.ms = Date.now() - t;
|
||||
if (x.code === 0) {
|
||||
r.status = 'ok';
|
||||
noteDry(p, pending);
|
||||
const pre = p.argv ? prebuildRan(p.argv, c.config) : null;
|
||||
if (pre) prebuilt.add(pre);
|
||||
if (p.capture) {
|
||||
|
|
|
|||
|
|
@ -716,6 +716,9 @@ export interface ShipConfig {
|
|||
build?: AppleBuildConfig;
|
||||
/** Shell command `apple build` runs first in the project root; overrides build.prebuild for the App Store. */
|
||||
prebuild?: string;
|
||||
/** `apple build --platform macos`: shell command run on the built .app before productbuild ({file} = the .app path);
|
||||
* a nonzero exit stops the build, nothing is packaged. E.g. a project's own check that a build-time value is in the binary. */
|
||||
macAppCheck?: string;
|
||||
/** Promoted in-app purchases on the product page, in order (`storebound apple promoted`; at most 20). */
|
||||
promoted?: PromotedConfig;
|
||||
/** Custom product pages (`storebound apple cpp`; at most 70). */
|
||||
|
|
@ -1019,6 +1022,7 @@ export function validateExtras(cfg: Partial<ShipConfig>): string[] {
|
|||
if (cfg.build !== undefined && (typeof cfg.build !== 'object' || cfg.build === null)) e.push('build: { prebuild?: "<shell command>" }');
|
||||
if (badCmd(cfg.build?.prebuild)) e.push('build.prebuild: a shell command run in the project root before apple build / play build, e.g. "npm run build && npx cap sync"');
|
||||
if (badCmd(cfg.apple?.prebuild)) e.push('apple.prebuild: a shell command (overrides build.prebuild for apple build)');
|
||||
if (badCmd(cfg.apple?.macAppCheck)) e.push('apple.macAppCheck: a shell command run on the built Mac .app ({file} = its path), e.g. "node tools/check-app.mjs {file}"');
|
||||
if (badCmd(cfg.play?.prebuild)) e.push('play.prebuild: a shell command (overrides build.prebuild for play build)');
|
||||
if (cfg.apple?.teamId !== undefined &&!/^[A-Z0-9]{10}$/.test(cfg.apple.teamId)) e.push('apple.teamId: the 10-character Team ID (Membership details)');
|
||||
if (cfg.play?.developerId !== undefined && !/^\d{1,25}$/.test(cfg.play.developerId)) e.push('play.developerId: the digits of play.google.com/console/u/0/developers/<id>');
|
||||
|
|
|
|||
|
|
@ -31,8 +31,8 @@ export const TEMPLATE_TEXT: Record<string, string> = {
|
|||
"web/appstore.svg": "<svg role=\"img\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\"><title>App Store</title><path d=\"M8.8086 14.9194l6.1107-11.0368c.0837-.1513.1682-.302.2437-.4584.0685-.142.1267-.2854.1646-.4403.0803-.3259.0588-.6656-.066-.9767-.1238-.3095-.3417-.5678-.6201-.7355a1.4175 1.4175 0 0 0-.921-.1924c-.3207.043-.6135.1935-.8443.4288-.1094.1118-.1996.2361-.2832.369-.092.1463-.175.2979-.259.4492l-.3864.6979-.3865-.6979c-.0837-.1515-.1667-.303-.2587-.4492-.0837-.1329-.1739-.2572-.2835-.369-.2305-.2353-.5233-.3857-.844-.429a1.4181 1.4181 0 0 0-.921.1926c-.2784.1677-.4964.426-.6203.7355-.1246.311-.1461.6508-.066.9767.038.155.0962.2984.1648.4403.0753.1564.1598.307.2437.4584l1.248 2.2543-4.8625 8.7825H2.0295c-.1676 0-.3351-.0007-.5026.0092-.1522.009-.3004.0284-.448.0714-.3108.0906-.5822.2798-.7783.548-.195.2665-.3006.5929-.3006.9279 0 .3352.1057.6612.3006.9277.196.2683.4675.4575.7782.548.1477.043.296.0623.4481.0715.1675.01.335.009.5026.009h13.0974c.0171-.0357.059-.1294.1-.2697.415-1.4151-.6156-2.843-2.0347-2.843zM3.113 18.5418l-.7922 1.5008c-.0818.1553-.1644.31-.2384.4705-.067.1458-.124.293-.1611.452-.0785.3346-.0576.6834.0645 1.0029.1212.3175.3346.583.607.7549.2727.172.5891.2416.9013.1975.3139-.044.6005-.1986.8263-.4402.1072-.1148.1954-.2424.2772-.3787.0902-.1503.1714-.3059.2535-.4612L6 19.4636c-.0896-.149-.9473-1.4704-2.887-.9218m20.5861-3.0056a1.4707 1.4707 0 0 0-.779-.5407c-.1476-.0425-.2961-.0616-.4483-.0705-.1678-.0099-.3352-.0091-.503-.0091H18.648l-4.3891-7.817c-.6655.7005-.9632 1.485-1.0773 2.1976-.1655 1.0333.0367 2.0934.546 3.0004l5.2741 9.3933c.084.1494.167.299.2591.4435.0837.131.1739.2537.2836.364.231.2323.5238.3809.8449.4232.3192.0424.643-.0244.9217-.1899.2784-.1653.4968-.4204.621-.7257.1246-.3072.146-.6425.0658-.9641-.0381-.1529-.0962-.2945-.165-.4346-.0753-.1543-.1598-.303-.2438-.4524l-1.216-2.1662h1.596c.1677 0 .3351.0009.5029-.009.1522-.009.3007-.028.4483-.0705a1.4707 1.4707 0 0 0 .779-.5407A1.5386 1.5386 0 0 0 24 16.452a1.539 1.539 0 0 0-.3009-.9158Z\"/></svg>",
|
||||
"web/googleplay.svg": "<svg role=\"img\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\"><title>Google Play</title><path d=\"M22.018 13.298l-3.919 2.218-3.515-3.493 3.543-3.521 3.891 2.202a1.49 1.49 0 0 1 0 2.594zM1.337.924a1.486 1.486 0 0 0-.112.568v21.017c0 .217.045.419.124.6l11.155-11.087L1.337.924zm12.207 10.065l3.258-3.238L3.45.195a1.466 1.466 0 0 0-.946-.179l11.04 10.973zm0 2.067l-11 10.933c.298.036.612-.016.906-.183l13.324-7.54-3.23-3.21z\"/></svg>",
|
||||
"web/landing.html": "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>{{APP_NAME}}</title>\n<!--{{SMART_BANNER}}-->\n<!--\n Minimal landing page written by `storebound web init --landing` from templates/web/landing.html. Deploy the whole\n output folder to the domain root: index.html, appstore.svg, googleplay.svg and .well-known/. No external requests.\n The store marks are the simple-icons files (CC0 data), shown as a mask in the text colour; they only name the store.\n-->\n<style>\n :root { color-scheme: light dark; --fg: #1b1c20; --bg: #ffffff; --muted: #5d6170; --line: #d5d8e0; --card: #f4f5f8; --accent: #1d4ed8; }\n @media (prefers-color-scheme: dark) { :root { --fg: #eceef3; --bg: #15161a; --muted: #a3a8b6; --line: #343744; --card: #1f2127; --accent: #8fb0ff; } }\n * { box-sizing: border-box; }\n body { margin: 0; min-height: 100vh; display: flex; flex-direction: column; background: var(--bg); color: var(--fg); font: 16px/1.6 system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif; }\n main { flex: 1; width: 100%; max-width: 640px; margin: 0 auto; padding: 64px 16px 32px; text-align: center; }\n h1 { font-size: clamp(2rem, 7vw, 3rem); line-height: 1.15; margin: 0 0 32px; overflow-wrap: anywhere; }\n .stores { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; margin: 0; padding: 0; list-style: none; }\n .store { display: inline-flex; align-items: center; gap: 10px; min-height: 48px; padding: 10px 18px; border: 1px solid var(--line); border-radius: 12px; background: var(--card); color: var(--fg); font-weight: 600; text-decoration: none; }\n .store:hover { border-color: var(--fg); }\n .store:focus-visible, footer a:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }\n .mark { display: inline-block; flex: none; width: 22px; height: 22px; background-color: currentColor; -webkit-mask: none center / contain no-repeat; mask: none center / contain no-repeat; }\n .mark-appstore { -webkit-mask-image: url(appstore.svg); mask-image: url(appstore.svg); }\n .mark-googleplay { -webkit-mask-image: url(googleplay.svg); mask-image: url(googleplay.svg); }\n footer { width: 100%; max-width: 640px; margin: 0 auto; padding: 24px 16px 32px; border-top: 1px solid var(--line); color: var(--muted); font-size: .875rem; text-align: center; }\n footer nav { display: flex; flex-wrap: wrap; gap: 8px 20px; justify-content: center; margin-bottom: 12px; }\n footer a { color: var(--accent); }\n footer p { margin: 0; }\n</style>\n</head>\n<body>\n<main>\n <h1>{{APP_NAME}}</h1>\n <ul class=\"stores\" aria-label=\"Get the app\">\n <!--apple--><li><a class=\"store\" href=\"{{APP_STORE_URL}}\"><span class=\"mark mark-appstore\" aria-hidden=\"true\"></span>App Store</a></li><!--/apple-->\n <!--play--><li><a class=\"store\" href=\"{{PLAY_URL}}\"><span class=\"mark mark-googleplay\" aria-hidden=\"true\"></span>Google Play</a></li><!--/play-->\n </ul>\n</main>\n<footer>\n <nav aria-label=\"Legal and support\">\n <a href=\"{{PRIVACY_URL}}\">Privacy Policy</a>\n <!--terms--><a href=\"{{TERMS_URL}}\">Terms of Use</a><!--/terms-->\n <a href=\"{{SUPPORT_URL}}\">Support</a>\n </nav>\n <p>App Store is a trademark of Apple Inc.; Google Play is a trademark of Google LLC.</p>\n</footer>\n</body>\n</html>\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 <external 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",
|
||||
"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]\n# VERSION is required (\"?\"). GROUP empty = the internal group of apple.testflight.groups (the group `internal: true`);\n# GROUP=<name> picks a TestFlight group that exists (or is listed in apple.testflight.groups).\ndescription: Next game build to TestFlight and Play internal testing\nparams:\n VERSION: \"?\"\n GROUP: \"\"\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 && ${GROUP}\n run: apple testflight distribute --build ${BUILD} --group ${GROUP}\n - id: distribute-internal\n if: apple && !${GROUP} && ${config.testflight.internal}\n run: apple testflight distribute --build ${BUILD} --group ${config.testflight.internal}\n - id: no-group\n if: apple && !${GROUP} && !${config.testflight.internal}\n owner: \"No TestFlight group: pass GROUP=<name> or add a group with internal: true to apple.testflight.groups, then storebound apple testflight distribute --build ${BUILD} --group <name>\"\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 <external 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; `if: ${PARAM}` turns an optional step on by a param (MAC_TAURI=<src-tauri> adds the macOS build, PLAY_TRACK=beta\n# uploads to Play open testing); VERSION is required; `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: \"?\"\n MAC_TAURI: \"\"\n PLAY_TRACK: \"internal\"\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: mac-build\n if: apple && ${MAC_TAURI}\n run: apple build --platform macos --tauri ${MAC_TAURI} --version ${VERSION} --build-number ${BUILD}\n - id: mac-upload\n if: apple && ${MAC_TAURI}\n run: apple upload --platform macos --no-wait --allow-existing\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 ${PLAY_TRACK}\n - id: promote\n if: play && ${PLAY_TRACK} != production\n owner: \"Play Console: promote the ${PLAY_TRACK} release to production (storebound play release --version-code <code> --production completed once you agree)\"\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 ${PLAY_TRACK} release and Send changes for review\"\n",
|
||||
};
|
||||
|
||||
/** The pages shots render / video load (cli/shots/*.html), by file name. */
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ export function validateFlows(flows: Flow[], o: { commands?: Set<string> } = {})
|
|||
if (ids.has(s.id)) e.push(`${at}: id used twice`);
|
||||
ids.add(s.id);
|
||||
for (const t of [s.run, s.sh, s.owner, s.if].filter((x): x is string => typeof x === 'string'))
|
||||
for (const m of t.matchAll(/\$\{([^}]*)\}/g)) if (!vars.has(m[1]!) && !/^env\.[A-Z0-9_]+$/.test(m[1]!)) e.push(`${at}: \${${m[1]}} is not a param or an earlier capture`);
|
||||
for (const m of t.matchAll(/\$\{([^}]*)\}/g)) if (!vars.has(m[1]!) && !/^env\.[A-Z0-9_]+$/.test(m[1]!) && !CONFIG_VARS.includes(m[1]!)) e.push(`${at}: \${${m[1]}} is not a param or an earlier capture`);
|
||||
if (s.capture) vars.add(s.capture);
|
||||
if (s.run !== undefined) {
|
||||
let argv: string[] = [];
|
||||
|
|
@ -230,6 +230,14 @@ export function validateFlows(flows: Flow[], o: { commands?: Set<string> } = {})
|
|||
|
||||
// ---- variables, conditions ----
|
||||
|
||||
/** Variables the runner fills from the config (empty when the config has none), usable in `run` and `if`. */
|
||||
export const CONFIG_VARS = ['config.testflight.internal'];
|
||||
|
||||
/** The required-param marker: `VERSION: "?"` in params means the run needs VERSION=... (no silent default). */
|
||||
export const REQUIRED = '?';
|
||||
/** Params still at the required marker (not given on the command line). Pure. */
|
||||
export function missingParams(vars: Record<string, string>): string[] { return Object.keys(vars).filter(k => vars[k] === REQUIRED); }
|
||||
|
||||
/** ${VAR} and ${env.NAME} substituted; unknown ones stay as written (the plan shows them). */
|
||||
export function substitute(text: string, vars: Record<string, string>, env: Record<string, string | undefined> = {}): string {
|
||||
return text.replace(/\$\{([^}]*)\}/g, (m, k: string) => (k.startsWith('env.') ? env[k.slice(4)] ?? m : vars[k] ?? m));
|
||||
|
|
@ -306,6 +314,39 @@ export function planStep(s: FlowStep, part: PlannedStep['part'], mode: Mode, var
|
|||
return { ...base, kind, argv: addDry ? [...argv, '--dry'] : addYes ? [...argv, '--yes'] : argv, dryAdded: addDry, ...(addYes ? { yesAdded: true } : {}), ...(mode === 'plan' ? { skip: 'plan only' } : {}) };
|
||||
}
|
||||
|
||||
const PRODUCERS = ['apple build', 'play build'];
|
||||
const CONSUMERS = ['apple upload', 'play upload'];
|
||||
const AFTER_UPLOAD = ['apple testflight wait', 'apple testflight distribute'];
|
||||
|
||||
/** Stores whose artifact (IPA / AAB / PKG) or uploaded build only exists after a real earlier step ("apple", "apple:mac", "play"). */
|
||||
export interface DryPending { built: Set<string>; uploaded: Set<string> }
|
||||
|
||||
const storeKey = (argv: string[]): string => {
|
||||
const i = argv.indexOf('--platform');
|
||||
const mac = (i >= 0 && /^(macos|mac)$/i.test(argv[i + 1] ?? '')) || argv.includes('--tauri');
|
||||
return `${argv[0]}${mac ? ':mac' : ''}`;
|
||||
};
|
||||
|
||||
/** Records a step that ran as a dry run: a dry build leaves no IPA / AAB. Pure. */
|
||||
export function noteDry(p: PlannedStep, pending: DryPending): void {
|
||||
if (!p.argv || !p.dryAdded || p.skip) return;
|
||||
if (PRODUCERS.includes(words(p.argv).slice(0, 2).join(' '))) pending.built.add(storeKey(p.argv));
|
||||
}
|
||||
|
||||
/** Why a step is only planned in a dry run: its input (the IPA / AAB, or the uploaded build) would come from an earlier
|
||||
* step that did not really run. null = run it (always null with --apply: a real upload with no IPA still fails). Pure. */
|
||||
export function plannedForDryInput(p: PlannedStep, mode: Mode, pending: DryPending): string | null {
|
||||
if (mode === 'apply' || !p.argv || p.skip) return null;
|
||||
const w = words(p.argv);
|
||||
const k2 = w.slice(0, 2).join(' ');
|
||||
if (CONSUMERS.includes(k2) && !p.argv.some(a => /\.(ipa|aab|pkg)$/.test(a)) && pending.built.has(storeKey(p.argv))) {
|
||||
pending.uploaded.add(storeKey(p.argv));
|
||||
return `planned: would upload the ${k2 === 'play upload' ? 'AAB' : storeKey(p.argv).endsWith(':mac') ? 'PKG' : 'IPA'} the ${w[0]} build step writes (dry run: nothing built)`;
|
||||
}
|
||||
if (AFTER_UPLOAD.includes(w.slice(0, 3).join(' ')) && pending.uploaded.has('apple')) return 'planned: the build is not on App Store Connect in a dry run';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Index of the step to start from: --from <id> (exact, or the first id under a sub-flow prefix). */
|
||||
export function startIndex(steps: FlowStep[], from: string | undefined): number {
|
||||
if (!from) return 0;
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ export interface CertChoice { cert: ApiCert | null; why: string; usable: ApiCert
|
|||
|
||||
/** A certificate is usable when it is of the wanted type, active, valid for 30 more days and this machine holds its
|
||||
* private key (a key file in .storebound/signing or a code signing identity in the keychain). Newest first. */
|
||||
export function pickCertificate(certs: ApiCert[], type: CertType, hasKey: (c: ApiCert) => boolean, now = Date.now()): CertChoice {
|
||||
export function pickCertificate(certs: ApiCert[], type: CertType, hasKey: (c: ApiCert) => boolean, now = Date.now(), platform: string = process.platform): CertChoice {
|
||||
const ofType = certs.filter(c => c.attributes.certificateType === type && c.attributes.activated !== false);
|
||||
const valid = ofType.filter(c => Date.parse(c.attributes.expirationDate ?? '') > now);
|
||||
const expiring = valid.filter(c => Date.parse(c.attributes.expirationDate!) - now < 30 * 86_400_000);
|
||||
|
|
@ -121,7 +121,9 @@ export function pickCertificate(certs: ApiCert[], type: CertType, hasKey: (c: Ap
|
|||
const withoutKey = valid.filter(c => !hasKey(c));
|
||||
const cert = usable[0] ?? null;
|
||||
const why = cert ? `using ${cert.attributes.name ?? cert.attributes.displayName ?? type} (expires ${cert.attributes.expirationDate?.slice(0, 10)})`
|
||||
: withoutKey.length ? `${withoutKey.length} ${type} certificate(s) on the team, but this machine has none of their private keys: a new one is needed (or import the .p12 from the Mac that made it)`
|
||||
: withoutKey.length ? `${withoutKey.length} ${type} certificate(s) on the team, but this machine has none of their private keys: ${platform === 'darwin'
|
||||
? 'a new one is needed (or import the .p12 from the Mac that made it)'
|
||||
: 'this machine cannot build or sign for Apple. The keys live on the Mac that made the certificates: build there (e.g. over ssh to that Mac). Do not create another certificate, Apple caps them per team'}`
|
||||
: `no valid ${type} certificate`;
|
||||
return { cert, why, usable, withoutKey, expiring };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import { rendersCheck } from './shots/check';
|
|||
import { resolvedTablets } from './shots/report';
|
||||
import { runScan } from './lib/scan';
|
||||
import type { ScanFinding } from './lib/scan/rules';
|
||||
import { playClient } from './play/client';
|
||||
import { EDIT_GONE, playClient } from './play/client';
|
||||
import { listOneTimeProducts } from './play/products';
|
||||
import { creativeNote } from './shots/creative';
|
||||
import { creativeWaiting, localCreative } from './apple/creative-assets';
|
||||
|
|
@ -117,7 +117,7 @@ export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
|
|||
const every = await allVersions(c, appId);
|
||||
const editableOf = (x: J) => APPLE_EDITABLE.has(stateOf(x));
|
||||
const withEditable = [...new Set(every.filter(editableOf).map(platformOf))];
|
||||
const platform: AscPlatform = parsePlatform(ctx.args.get('platform')) ?? (withEditable.length === 1 ? withEditable[0]! : withEditable.includes('IOS') || !every.length ? 'IOS' : platformOf(every[0]));
|
||||
const platform: AscPlatform = parsePlatform(ctx.args.get('platform')) ?? (withEditable.length === 1 ? withEditable[0]! : withEditable.includes('IOS') || !every.length || every.some(x => platformOf(x) === 'IOS') ? 'IOS' : platformOf(every[0]));
|
||||
const versions = await c.all(versionsPath(appId, platform, 10));
|
||||
const v: J = (await c.editableVersion(platform)) ?? versions[0] ?? null;
|
||||
const otherPlatformVersions = every.filter(x => platformOf(x) !== platform && editableOf(x)).map(x => ({ versionString: String(x.attributes.versionString), state: stateOf(x), platform: platformOf(x) }));
|
||||
|
|
@ -186,7 +186,7 @@ export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
|
|||
deviceFamilies: platform === 'IOS' ? await deviceFamilyVerdicts(ctx, c, appId) : undefined,
|
||||
build, locales, reviewDetail, ageRating,
|
||||
expectedLocales: config.languages.map(l => l.apple).filter((x): x is string => !!x),
|
||||
requiredDisplayTypes: requiredDisplayTypes(config.apple),
|
||||
requiredDisplayTypes: requiredDisplayTypes(config.apple, platform),
|
||||
iphoneDuo: !!config.apple?.iphoneDuo, play: !!config.play,
|
||||
macVersion: every.some(x => platformOf(x) === 'MAC_OS'), mac: !!config.apple?.mac,
|
||||
priceSchedule: !!sched?.included?.some((x: J) => x.type === 'appPrices'),
|
||||
|
|
@ -202,23 +202,25 @@ export async function collectPlay(ctx: Ctx): Promise<PlaySnapshot> {
|
|||
const { config, root } = ctx.loaded;
|
||||
const play = await playClient(root, config.play!.packageName);
|
||||
const tablets = resolvedTablets(config, ctx.loaded.resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'));
|
||||
const e = await play.call<{ id: string }>('POST', `${play.API}/edits`, {});
|
||||
log('validate', `play: read-only edit ${e.id} (deleted afterwards)`);
|
||||
try {
|
||||
const details = await play.call<{ defaultLanguage?: string; contactWebsite?: string }>('GET', `${play.API}/edits/${e.id}/details`);
|
||||
const raw = (await play.call<{ listings?: { language: string; title?: string; shortDescription?: string; fullDescription?: string }[] }>('GET', `${play.API}/edits/${e.id}/listings`)).listings ?? [];
|
||||
// play.read: one edit under the machine lock, deleted only after every read inside has finished, and opened anew once
|
||||
// when Play invalidates it meanwhile ("This Edit has been deleted"). A bare POST /edits here was neither locked nor
|
||||
// retried, so another storebound process (or the Console) ending the edit failed the screenshot reads with a false blocker.
|
||||
return play.read(async editId => {
|
||||
log('validate', `play: read-only edit ${editId} (deleted afterwards)`);
|
||||
const details = await play.call<{ defaultLanguage?: string; contactWebsite?: string }>('GET', `${play.API}/edits/${editId}/details`);
|
||||
const raw = (await play.call<{ listings?: { language: string; title?: string; shortDescription?: string; fullDescription?: string }[] }>('GET', `${play.API}/edits/${editId}/listings`)).listings ?? [];
|
||||
const listings: PlaySnapshot['listings'] = [];
|
||||
for (const l of raw) {
|
||||
const images: Record<string, number> = {};
|
||||
for (const type of Object.keys(playRequiredImages(tablets)))
|
||||
images[type] = ((await play.call<{ images?: unknown[] }>('GET', `${play.API}/edits/${e.id}/listings/${l.language}/${type}`)).images ?? []).length;
|
||||
images[type] = ((await play.call<{ images?: unknown[] }>('GET', `${play.API}/edits/${editId}/listings/${l.language}/${type}`)).images ?? []).length;
|
||||
listings.push({ ...l, images });
|
||||
}
|
||||
const tracks = ((await play.call<{ tracks?: { track: string; releases?: { status: string; versionCodes?: string[]; userFraction?: number }[] }[] }>('GET', `${play.API}/edits/${e.id}/tracks`)).tracks ?? [])
|
||||
const tracks = ((await play.call<{ tracks?: { track: string; releases?: { status: string; versionCodes?: string[]; userFraction?: number }[] }[] }>('GET', `${play.API}/edits/${editId}/tracks`)).tracks ?? [])
|
||||
.map(t => ({ track: t.track, releases: (t.releases ?? []).map(r => ({ status: r.status, versionCodes: r.versionCodes ?? [], userFraction: r.userFraction })) }));
|
||||
const products = (await listOneTimeProducts(play)).map(p => ({ productId: p.productId, states: (p.purchaseOptions ?? []).map(o => o.state ?? '') }));
|
||||
// the production track's countries (read-only in the API: set in the console, `console plan play-production-countries`)
|
||||
const productionCountries = await play.call<{ countries?: unknown[]; restOfWorld?: boolean }>('GET', `${play.API}/edits/${e.id}/countryAvailability/production`)
|
||||
const productionCountries = await play.call<{ countries?: unknown[]; restOfWorld?: boolean }>('GET', `${play.API}/edits/${editId}/countryAvailability/production`)
|
||||
.then(r => (r.restOfWorld ? -1 : (r.countries ?? []).length)).catch(() => null);
|
||||
const website = details.contactWebsite ?? null;
|
||||
const appAdsLines = config.ads?.appAdsTxt ?? [];
|
||||
|
|
@ -245,9 +247,7 @@ export async function collectPlay(ctx: Ctx): Promise<PlaySnapshot> {
|
|||
expectedProducts: (config.products ?? []).filter(p => sellsOn(config, p, 'play')).map(p => p.id),
|
||||
privacyUrl: await checkUrl(config.app.privacyPolicyUrl), website, appAds, appAdsLines, adsLikely, category: playCategoryOf(config), productionCountries,
|
||||
};
|
||||
} finally {
|
||||
await play.call('DELETE', `${play.API}/edits/${e.id}`).catch(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Scan findings as validate findings: blocker stays blocker, high / medium become warnings; low and owner-accepted
|
||||
|
|
@ -279,7 +279,13 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const access = await brokerFindings(ctx.loaded).catch(e => [{ store: 'play' as const, level: 'warn' as const, code: 'broker-unavailable', msg: (e as Error).message }]);
|
||||
findings.push(...access);
|
||||
try { findings.push(...playRules({ ...(await collectPlay(ctx)), renders: local?.play })); }
|
||||
catch (e) { findings.push({ store: 'play', level: 'blocker', code: 'api', msg: (e as Error).message.slice(0, 600) }, ...rendersFindings('play', local?.play)); }
|
||||
catch (e) {
|
||||
// a Play edit that vanished under the reads is a tool problem (the retry inside play.read failed too), never a store blocker
|
||||
const gone = EDIT_GONE.test((e as Error).message);
|
||||
findings.push(gone
|
||||
? { store: 'play', level: 'warn', code: 'edit-gone', msg: `Play validate could not finish its read-only edit (${(e as Error).message.slice(0, 300)}): a tool problem, not a store finding`, fix: 'storebound validate --play (run it again; close other storebound runs and Console saves first)' }
|
||||
: { store: 'play', level: 'blocker', code: 'api', msg: (e as Error).message.slice(0, 600) }, ...rendersFindings('play', local?.play));
|
||||
}
|
||||
} else if (a.has('play')) warn('validate', 'no play section in the config');
|
||||
// Game Center / Play Games Services: entitlement, APP_ID resource, store state vs the config
|
||||
if (config.games || config.apple) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue