diff --git a/CHANGELOG.md b/CHANGELOG.md index 7db9e73..39ef0f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased +- TestFlight: `apple.testflight.description` takes a language id or its App Store locale (`en` or `en-US`; an `en-US` key was skipped silently), `check` names an unknown key, and `distribute --submit-review` stops (dry run: warns) when the app's primary locale has no beta localization (Beta App Review answers 422 MISSING_BETA_APP_LOCALIZATION). ## 0.8.60 ยท 2026-10-29 - `apple listing` creates a new version with the release type of the newest version before it (MANUAL or AFTER_APPROVAL) when `apple.releaseType` is not set; a first version stays MANUAL. The plan names where the type came from, and warns when `apple.releaseType` differs from the previous version's. diff --git a/cli/apple/testflight.ts b/cli/apple/testflight.ts index 8f70951..24f35d4 100644 --- a/cli/apple/testflight.ts +++ b/cli/apple/testflight.ts @@ -319,6 +319,16 @@ export async function run(ctx: Ctx): Promise { if (a.has('submit-review') && review) log(TAG, `beta review: already submitted (${review.attributes?.betaReviewState ?? '?'})`); if (external.length && !a.has('submit-review') && b.detail?.externalBuildState === 'READY_FOR_BETA_SUBMISSION') warn(TAG, 'external testers only get the build after Beta App Review: add --submit-review (asks first) or submit it in App Store Connect'); + // Beta App Review refuses a build whose app has no beta localization in its primary locale (422 + // MISSING_BETA_APP_LOCALIZATION): said before the submit, in the dry run too + if (wantsReview) { + const primary = String((await c.maybe(`/v1/apps/${appId}?fields[apps]=primaryLocale`))?.data?.attributes?.primaryLocale ?? ''); + const have = (await c.all(`/v1/apps/${appId}/betaAppLocalizations?limit=200`)).map((l: J) => String(l.attributes.locale)); + if (primary && !have.includes(primary)) { + const msg = `Beta App Review needs test information in the app's primary locale ${primary} (has: ${have.join(', ') || 'none'}): add it to apple.testflight.description (key ${primary} or its language id), then storebound apple testflight info`; + if (dry) warn(TAG, msg); else throw new Error(msg); + } + } if (dry) { await execute(c.asc, [...reqs, ...(wantsReview ? [betaReviewReq(b.id)] : [])], { dry, tag: TAG }); done(TAG, 'distribute checked (dry run)'); diff --git a/cli/lib/config.ts b/cli/lib/config.ts index cda074e..aea46a9 100644 --- a/cli/lib/config.ts +++ b/cli/lib/config.ts @@ -1210,7 +1210,7 @@ export function validateConfig(c: unknown): string[] { const enc = cfg.apple.usesNonExemptEncryption; if (enc !== undefined && typeof enc !== 'boolean') e.push('apple.usesNonExemptEncryption: true | false'); if (cfg.apple.screenshotTypes && !cfg.apple.screenshotTypes.every(t => /^APP_[A-Z0-9_]+$/.test(t))) e.push('apple.screenshotTypes: display types like APP_IPHONE_67'); - e.push(...validateTestflight(cfg.apple.testflight)); + e.push(...validateTestflight(cfg.apple.testflight, cfg.languages ?? [])); e.push(...validateSigning(cfg.apple.signing), ...validateAppleBuild(cfg.apple.build)); const catalogIds = new Set([...(cfg.products ?? []).map(p => p.id), ...(cfg.subscriptions ?? []).flatMap(g => (g.subscriptions ?? []).map(x => x.id))]); e.push(...validatePromoted(cfg.apple.promoted, catalogIds)); diff --git a/cli/lib/testflight.ts b/cli/lib/testflight.ts index 41c0639..b1bffc6 100644 --- a/cli/lib/testflight.ts +++ b/cli/lib/testflight.ts @@ -54,9 +54,14 @@ export const groupFromApi = (g: { id: string; attributes: Record [l.id, ...(l.apple ? [l.apple] : [])])); + const bad = Object.keys(t.description).filter(k => !known.has(k)); + if (bad.length) e.push(`apple.testflight.description: ${bad.map(k => `'${k}'`).join(', ')} is not a language of the config (use a language id or its App Store locale: ${langs.filter(l => l.apple).map(l => `${l.id} / ${l.apple}`).join(', ')})`); + } const names = new Set(); for (const [i, g] of (t.groups ?? []).entries()) { const at = `apple.testflight.groups[${i}]`; @@ -315,7 +320,8 @@ export function betaDescriptions(t: TfConfig | undefined, langs: { id: string; a const out: Record = {}; for (const l of langs) { if (!l.apple) continue; - const d = typeof t.description === 'string' ? t.description : t.description[l.id]; + // a language id ('en') or its App Store locale ('en-US'): an 'en-US' key was silently skipped (Acme 2026-10-29) + const d = typeof t.description === 'string' ? t.description : (t.description[l.id] ?? t.description[l.apple]); if (d) out[l.apple] = d; } return out; diff --git a/tests/helpers/fake-stores.ts b/tests/helpers/fake-stores.ts index 272ec93..f5497e9 100644 --- a/tests/helpers/fake-stores.ts +++ b/tests/helpers/fake-stores.ts @@ -15,6 +15,7 @@ const UPLOAD_HOST = 'https://upload.example.com/'; /** list relationship -> resource type of its items (parent type / relationship name); `one` for to-one relationships. */ const LISTS: Record = { + 'apps/betaAppLocalizations': { type: 'betaAppLocalizations' }, 'apps/inAppPurchasesV2': { type: 'inAppPurchases' }, 'inAppPurchases/inAppPurchaseLocalizations': { type: 'inAppPurchaseLocalizations' }, 'inAppPurchases/iapPriceSchedule': { type: 'inAppPurchasePriceSchedules', one: true }, diff --git a/tests/tf-description-locale.test.ts b/tests/tf-description-locale.test.ts new file mode 100644 index 0000000..0a52026 --- /dev/null +++ b/tests/tf-description-locale.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from 'bun:test'; +import { betaDescriptions, validateTestflight } from '../cli/lib/testflight'; + +const langs = [{ id: 'en', apple: 'en-US' }, { id: 'tr', apple: 'tr' }]; + +test('beta descriptions take a language id or its App Store locale ("en-US" was skipped silently)', () => { + expect(betaDescriptions({ description: { 'en-US': 'Hello', tr: 'Merhaba' } }, langs)).toEqual({ 'en-US': 'Hello', tr: 'Merhaba' }); + expect(betaDescriptions({ description: { en: 'Hello' } }, langs)).toEqual({ 'en-US': 'Hello' }); +}); + +test('an unknown description key is a config error naming the accepted keys', () => { + expect(validateTestflight({ description: { 'en-US': 'x', de: 'y' } }, langs).join()).toContain("'de' is not a language of the config"); + expect(validateTestflight({ description: { 'en-US': 'x', tr: 'y' } }, langs)).toEqual([]); +});