Merge remote-tracking branch 'origin/main' into aso-plus

# Conflicts:
#	CHANGELOG.md
#	docs/pitfalls.md
#	skills/store-pitfalls/SKILL.md
This commit is contained in:
CRTkafa 2026-10-12 04:13:48 +08:00
commit 17f4413afa
44 changed files with 1989 additions and 231 deletions

View file

@ -26,6 +26,38 @@
- Desktop: an ASO card on the Growth tab (`aso.audit`): keyword field state, keywords ranked, coverage gaps, the - Desktop: an ASO card on the Growth tab (`aso.audit`): keyword field state, keywords ranked, coverage gaps, the
rank trend of the best three keywords (visx, rank 1 at the top), the rank table and the next 3 actions. rank trend of the best three keywords (visx, rank 1 at the top), the rank table and the next 3 actions.
- `examples/demo-app` has listing files (`store/appstore.md`, `store/listing.md`) to try the ASO commands on. - `examples/demo-app` has listing files (`store/appstore.md`, `store/listing.md`) to try the ASO commands on.
- Idempotency: every store write is an upsert; running a command twice, or again after a crash or a lost answer,
leaves exactly one of each thing (table of every write path: `docs/idempotency.md`).
- New `cli/lib/upsert.ts`: `findOrCreate` looks again with a fresh GET right before a create and asks a journal of
recent creates (`.storebound/created.json`, a direct GET by id) so a re-run during the store's list lag reuses what
the last run made; `createOnce` treats 409 / ALREADY_EXISTS as found and never sends a create blindly again after
a timeout or 5xx (it looks first; only keys the store keeps unique get one more try).
- The App Store Connect, Google Play, RevenueCat and `storebound api` clients retry 5xx / network errors only for
requests that are safe twice (GET, PUT, PATCH, DELETE, relationship adds, state actions, price schedules); a POST
create without a clear answer throws `AmbiguousWriteError`. Plan runners treat a 409 duplicate as done and stop on
an unclear create instead of retrying.
- Uses it in `apple iap`, `apple subscriptions` (groups, subscriptions, localizations, promotional offers, offer code
campaigns, custom codes, review screenshot by MD5), `apple offer-codes`, `apple cpp create`, `apple events create`,
`apple ppo create`, `apple testflight groups`, `apple bundle`, `apple review-details` (only changed fields),
`apple release --phased on`, `apple adhoc register` / `profile`, `apple signing ensure` (a lost certificate answer
keeps the key and finds the certificate by its public key), `apple upload` (a COMPLETE build upload is not sent
again, an unfinished one is resumed), `analytics setup`, `reviews send`, `revenuecat sync`, `play subscriptions`
(subscriptions and offers).
- Review submissions (`apple iap-submit`, `cpp submit`, `events submit`): the draft is reused or created once, an
item already in it is not added again.
- Screenshot and preview sets keep the leading files that already match (MD5, position, delivered), replace the rest
in order and fix the order if Apple shows another; a crash midway converges on the next run without duplicates.
- Play edits: one open at a time per run, deleted on every error path, committed only on success, deleted instead of
committed when nothing changed (`ctl.unchanged()`); redone only when nothing went live, never after a commit
without a clear answer. `play images` compares sha256 first and replaces only changed image types; `play release`
reuses a versionCode Play already has and skips identical track releases; `play rollout` commits nothing when the
track is already like that; `play upload` is done when the versionCode is on the track already.
- One writer per app and store: write runs take `.storebound/locks/<store>-<app>.lock` (dry runs and reads do not);
a second run stops with who holds it; a lock of a process that is gone is taken over; `--force-unlock`.
- `tests/idempotency.test.ts` with stateful store doubles (`tests/helpers/fake-stores.ts`): write commands run twice
with zero writes on the second run, a crash in the middle of a screenshot set, IAP and subscription creates whose
answer is lost, 409 duplicates, the list lag after a crash, Play edit error paths and an unclear commit, the lock.
Pitfall 9 in `docs/pitfalls.md` and the store-pitfalls skill.
- Faster `shots capture` (web) and `shots render` (a 22-language game took about 50 min to capture, serially, with a - Faster `shots capture` (web) and `shots render` (a 22-language game took about 50 min to capture, serially, with a
new context and a full game boot per frame): new context and a full game boot per frame):

2
bunfig.toml Normal file
View file

@ -0,0 +1,2 @@
[test]
preload = ["./tests/helpers/preload.ts"]

View file

@ -10,6 +10,7 @@
// text, screenshots, config): the days before vs after each change. Read-only on both stores except `setup`. // text, screenshots, config): the days before vs after each change. Read-only on both stores except `setup`.
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { createOnce } from './lib/upsert';
import { ascClient, type Asc, type J } from './apple/asc'; import { ascClient, type Asc, type J } from './apple/asc';
import type { Ctx } from './lib/ctx'; import type { Ctx } from './lib/ctx';
import { import {
@ -102,7 +103,9 @@ export async function run(ctx: Ctx): Promise<void> {
if (cur) { done(TAG, `apple: ONGOING report request exists (${cur.id})`); return; } if (cur) { done(TAG, `apple: ONGOING report request exists (${cur.id})`); return; }
const body = { data: { type: 'analyticsReportRequests', attributes: { accessType: 'ONGOING' }, relationships: { app: { data: { type: 'apps', id: appId } } } } }; const body = { data: { type: 'analyticsReportRequests', attributes: { accessType: 'ONGOING' }, relationships: { app: { data: { type: 'apps', id: appId } } } } };
if (dry) { log(TAG, 'would POST /v1/analyticsReportRequests {accessType=ONGOING} (dry)'); return; } if (dry) { log(TAG, 'would POST /v1/analyticsReportRequests {accessType=ONGOING} (dry)'); return; }
await c.asc('POST', '/v1/analyticsReportRequests', body); // one ONGOING request per app: a 409 or an unclear answer is checked by reading it back, never re-sent blindly
await createOnce({ what: 'ONGOING analytics report request', tag: TAG, unique: true, find: () => ongoingRequest(c, appId),
create: async () => (await c.asc('POST', '/v1/analyticsReportRequests', body)).data });
done(TAG, 'apple: ONGOING report request created; the first reports appear after 1-2 days, then daily'); done(TAG, 'apple: ONGOING report request created; the first reports appear after 1-2 days, then daily');
return; return;
} }

View file

@ -1,8 +1,10 @@
// Generic client for every App Store Connect and Google Play operation: the generated rows (apple.gen.ts, play.gen.ts) // Generic client for every App Store Connect and Google Play operation: the generated rows (apple.gen.ts, play.gen.ts)
// plus a few helpers instead of a function per endpoint. Request building is pure (tests cover URLs and parameters); // plus a few helpers instead of a function per endpoint. Request building is pure (tests cover URLs and parameters);
// sending retries 429 / 5xx with backoff (Retry-After honoured) and follows pages. // sending retries 429 (and 5xx / network errors of requests that are safe to send twice: not a POST create) with
// backoff (Retry-After honoured) and follows pages.
// Auth: the App Store Connect team key JWT from cli/apple/asc.ts, the Play service account token from // Auth: the App Store Connect team key JWT from cli/apple/asc.ts, the Play service account token from
// cli/lib/google-auth.ts (one scope per Google API). Credentials only come from env or .storebound/. // cli/lib/google-auth.ts (one scope per Google API). Credentials only come from env or .storebound/.
import { AmbiguousWriteError, retrySafe } from '../lib/upsert';
import { APPLE_OPS, APPLE_SPEC } from './apple.gen'; import { APPLE_OPS, APPLE_SPEC } from './apple.gen';
import { PLAY_APIS, PLAY_OPS } from './play.gen'; import { PLAY_APIS, PLAY_OPS } from './play.gen';
import type { AppleBody, AppleOp, AppleParams, AppleResponse, ParamValue, PlayBody, PlayOp, PlayParams, PlayResponse } from './types'; import type { AppleBody, AppleOp, AppleParams, AppleResponse, ParamValue, PlayBody, PlayOp, PlayParams, PlayResponse } from './types';
@ -207,15 +209,20 @@ export async function send(req: BuiltRequest, auth: Auth, o: SendOptions = {}):
const headers: Record<string, string> = { Authorization: await auth(req) }; const headers: Record<string, string> = { Authorization: await auth(req) };
if (req.contentType) headers['Content-Type'] = req.contentType; if (req.contentType) headers['Content-Type'] = req.contentType;
if (req.accept) headers.Accept = req.accept; if (req.accept) headers.Accept = req.accept;
// 5xx and network errors are retried only for requests that end the same when sent twice: a POST create without
// a clear answer may have been applied, so it is reported (AmbiguousWriteError) and never sent again by itself
const safe = retrySafe(req.method, req.url);
let r: Response; let r: Response;
try { try {
r = await doFetch(req.url, { method: req.method, headers, body: req.body as BodyInit | undefined }); r = await doFetch(req.url, { method: req.method, headers, body: req.body as BodyInit | undefined });
} catch (e) { } catch (e) {
if (!safe) throw new AmbiguousWriteError(`${req.method} ${short(req.url)}: no answer (${(e as Error).message}); not sent again: it may have been applied, check with a GET before you run it again`);
if (attempt >= attempts) throw e; if (attempt >= attempts) throw e;
o.onRetry?.(`${req.method} ${short(req.url)}: ${(e as Error).message}, retry ${attempt}/${attempts - 1}`); o.onRetry?.(`${req.method} ${short(req.url)}: ${(e as Error).message}, retry ${attempt}/${attempts - 1}`);
await sleep(backoffMs(attempt)); await sleep(backoffMs(attempt));
continue; continue;
} }
if (r.status >= 500 && !safe) throw new AmbiguousWriteError(`${req.method} ${short(req.url)} -> ${r.status} ${errorBody(await r.text())}; not sent again: it may have been applied, check with a GET before you run it again`, r.status);
if ((r.status === 429 || r.status >= 500) && attempt < attempts) { if ((r.status === 429 || r.status >= 500) && attempt < attempts) {
const wait = backoffMs(attempt, r.headers.get('retry-after')); const wait = backoffMs(attempt, r.headers.get('retry-after'));
await r.body?.cancel(); await r.body?.cancel();

View file

@ -16,6 +16,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'appCustomProductPageVersions_updateInstance': 'apple/cpp', 'appCustomProductPageVersions_updateInstance': 'apple/cpp',
'appCustomProductPages_appCustomProductPageVersions_getToManyRelated': 'apple/cpp', 'appCustomProductPages_appCustomProductPageVersions_getToManyRelated': 'apple/cpp',
'appCustomProductPages_createInstance': 'apple/cpp', 'appCustomProductPages_createInstance': 'apple/cpp',
'appCustomProductPages_getInstance': 'apple/cpp',
'appCustomProductPages_updateInstance': 'apple/cpp', 'appCustomProductPages_updateInstance': 'apple/cpp',
'appEncryptionDeclarations_getCollection': 'apple/encryption', 'appEncryptionDeclarations_getCollection': 'apple/encryption',
'appEventLocalizations_appEventScreenshots_getToManyRelated': 'apple/events', 'appEventLocalizations_appEventScreenshots_getToManyRelated': 'apple/events',
@ -25,6 +26,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'appEventScreenshots_deleteInstance': 'apple/events', 'appEventScreenshots_deleteInstance': 'apple/events',
'appEventScreenshots_updateInstance': 'apple/events', 'appEventScreenshots_updateInstance': 'apple/events',
'appEvents_createInstance': 'apple/events', 'appEvents_createInstance': 'apple/events',
'appEvents_getInstance': 'apple/events',
'appEvents_localizations_getToManyRelated': 'apple/events', 'appEvents_localizations_getToManyRelated': 'apple/events',
'appInfos_ageRatingDeclaration_getToOneRelated': 'apple/appinfo validate', 'appInfos_ageRatingDeclaration_getToOneRelated': 'apple/appinfo validate',
'appInfos_appInfoLocalizations_getToManyRelated': 'apple/listing validate', 'appInfos_appInfoLocalizations_getToManyRelated': 'apple/listing validate',
@ -32,23 +34,20 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'appInfos_updateInstance': 'apple/appinfo', 'appInfos_updateInstance': 'apple/appinfo',
'appPreviewSets_appPreviews_getToManyRelated': 'apple/previews', 'appPreviewSets_appPreviews_getToManyRelated': 'apple/previews',
'appPreviewSets_createInstance': 'apple/previews', 'appPreviewSets_createInstance': 'apple/previews',
'appPreviews_createInstance': 'apple/previews',
'appPreviews_deleteInstance': 'apple/previews',
'appPreviews_updateInstance': 'apple/previews', 'appPreviews_updateInstance': 'apple/previews',
'appPriceSchedules_createInstance': 'apple/appinfo', 'appPriceSchedules_createInstance': 'apple/appinfo',
'appScreenshotSets_appScreenshots_getToManyRelated': 'apple/ppo apple/screenshots validate', 'appScreenshotSets_appScreenshots_getToManyRelated': 'apple/ppo apple/screenshots validate',
'appScreenshotSets_createInstance': 'apple/screenshots', 'appScreenshotSets_createInstance': 'apple/screenshots',
'appScreenshots_createInstance': 'apple/screenshots',
'appScreenshots_deleteInstance': 'apple/screenshots',
'appScreenshots_updateInstance': 'apple/screenshots',
'appStoreReviewDetails_createInstance': 'apple/review-details', 'appStoreReviewDetails_createInstance': 'apple/review-details',
'appStoreReviewDetails_updateInstance': 'apple/review-details', 'appStoreReviewDetails_updateInstance': 'apple/review-details',
'appStoreVersionExperimentTreatmentLocalizations_appScreenshotSets_getToManyRelated': 'apple/ppo', 'appStoreVersionExperimentTreatmentLocalizations_appScreenshotSets_getToManyRelated': 'apple/ppo',
'appStoreVersionExperimentTreatmentLocalizations_createInstance': 'lib/ppo', 'appStoreVersionExperimentTreatmentLocalizations_createInstance': 'lib/ppo',
'appStoreVersionExperimentTreatments_appStoreVersionExperimentTreatmentLocalizations_getToManyRelated': 'apple/ppo', 'appStoreVersionExperimentTreatments_appStoreVersionExperimentTreatmentLocalizations_getToManyRelated': 'apple/ppo',
'appStoreVersionExperimentTreatments_createInstance': 'lib/ppo', 'appStoreVersionExperimentTreatments_createInstance': 'lib/ppo',
'appStoreVersionExperimentTreatments_getInstance': 'apple/ppo',
'appStoreVersionExperimentsV2_appStoreVersionExperimentTreatments_getToManyRelated': 'apple/ppo', 'appStoreVersionExperimentsV2_appStoreVersionExperimentTreatments_getToManyRelated': 'apple/ppo',
'appStoreVersionExperimentsV2_createInstance': 'lib/ppo', 'appStoreVersionExperimentsV2_createInstance': 'lib/ppo',
'appStoreVersionExperimentsV2_getInstance': 'apple/ppo',
'appStoreVersionExperimentsV2_updateInstance': 'lib/ppo', 'appStoreVersionExperimentsV2_updateInstance': 'lib/ppo',
'appStoreVersionLocalizations_appPreviewSets_getToManyRelated': 'apple/previews', 'appStoreVersionLocalizations_appPreviewSets_getToManyRelated': 'apple/previews',
'appStoreVersionLocalizations_appScreenshotSets_getToManyRelated': 'apple/screenshots validate', 'appStoreVersionLocalizations_appScreenshotSets_getToManyRelated': 'apple/screenshots validate',
@ -75,6 +74,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'apps_betaAppReviewDetail_getToOneRelated': 'apple/testflight', 'apps_betaAppReviewDetail_getToOneRelated': 'apple/testflight',
'apps_betaFeedbackCrashSubmissions_getToManyRelated': 'health', 'apps_betaFeedbackCrashSubmissions_getToManyRelated': 'health',
'apps_betaGroups_getToManyRelated': 'apple/testflight', 'apps_betaGroups_getToManyRelated': 'apple/testflight',
'apps_buildUploads_getToManyRelated': 'apple/upload',
'apps_customerReviews_getToManyRelated': 'reviews', 'apps_customerReviews_getToManyRelated': 'reviews',
'apps_getCollection': 'apple/asc apple/bundle apps lib/store-apps', 'apps_getCollection': 'apple/asc apple/bundle apps lib/store-apps',
'apps_getInstance': 'apple/appinfo apple/status', 'apps_getInstance': 'apple/appinfo apple/status',
@ -98,11 +98,13 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'betaGroups_builds_getToManyRelated': 'apple/testflight', 'betaGroups_builds_getToManyRelated': 'apple/testflight',
'betaGroups_createInstance': 'lib/testflight', 'betaGroups_createInstance': 'lib/testflight',
'betaGroups_getCollection': 'apple/testflight', 'betaGroups_getCollection': 'apple/testflight',
'betaGroups_getInstance': 'apple/testflight',
'betaTesterInvitations_createInstance': 'lib/testflight', 'betaTesterInvitations_createInstance': 'lib/testflight',
'betaTesters_createInstance': 'lib/testflight', 'betaTesters_createInstance': 'lib/testflight',
'betaTesters_getCollection': 'apple/testflight', 'betaTesters_getCollection': 'apple/testflight',
'buildUploadFiles_createInstance': 'lib/xcode', 'buildUploadFiles_createInstance': 'lib/xcode',
'buildUploadFiles_updateInstance': 'lib/xcode', 'buildUploadFiles_updateInstance': 'lib/xcode',
'buildUploads_buildUploadFiles_getToManyRelated': 'apple/upload',
'buildUploads_createInstance': 'lib/xcode', 'buildUploads_createInstance': 'lib/xcode',
'buildUploads_getInstance': 'apple/upload', 'buildUploads_getInstance': 'apple/upload',
'builds_appEncryptionDeclaration_getToOneRelated': 'validate', 'builds_appEncryptionDeclaration_getToOneRelated': 'validate',
@ -132,6 +134,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'inAppPurchaseOfferCodeOneTimeUseCodes_values_getToOneRelated': 'apple/offer-codes', 'inAppPurchaseOfferCodeOneTimeUseCodes_values_getToOneRelated': 'apple/offer-codes',
'inAppPurchaseOfferCodes_createInstance': 'apple/offer-codes', 'inAppPurchaseOfferCodes_createInstance': 'apple/offer-codes',
'inAppPurchaseOfferCodes_customCodes_getToManyRelated': 'apple/offer-codes', 'inAppPurchaseOfferCodes_customCodes_getToManyRelated': 'apple/offer-codes',
'inAppPurchaseOfferCodes_getInstance': 'apple/offer-codes',
'inAppPurchaseOfferCodes_oneTimeUseCodes_getToManyRelated': 'apple/offer-codes', 'inAppPurchaseOfferCodes_oneTimeUseCodes_getToManyRelated': 'apple/offer-codes',
'inAppPurchasePricePoints_equalizations_getToManyRelated': 'apple/offer-codes', 'inAppPurchasePricePoints_equalizations_getToManyRelated': 'apple/offer-codes',
'inAppPurchasePriceSchedules_automaticPrices_getToManyRelated': 'prices', 'inAppPurchasePriceSchedules_automaticPrices_getToManyRelated': 'prices',
@ -141,6 +144,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'inAppPurchaseVersions_createInstance': 'lib/review-submit', 'inAppPurchaseVersions_createInstance': 'lib/review-submit',
'inAppPurchasesV2_appStoreReviewScreenshot_getToOneRelated': 'apple/iap-review validate', 'inAppPurchasesV2_appStoreReviewScreenshot_getToOneRelated': 'apple/iap-review validate',
'inAppPurchasesV2_createInstance': 'apple/iap', 'inAppPurchasesV2_createInstance': 'apple/iap',
'inAppPurchasesV2_getInstance': 'apple/iap',
'inAppPurchasesV2_iapPriceSchedule_getToOneRelated': 'apple/iap apple/iap-review catalog', 'inAppPurchasesV2_iapPriceSchedule_getToOneRelated': 'apple/iap apple/iap-review catalog',
'inAppPurchasesV2_inAppPurchaseAvailability_getToOneRelated': 'apple/iap apple/iap-review', 'inAppPurchasesV2_inAppPurchaseAvailability_getToOneRelated': 'apple/iap apple/iap-review',
'inAppPurchasesV2_inAppPurchaseLocalizations_getToManyRelated': 'apple/iap apple/iap-review catalog', 'inAppPurchasesV2_inAppPurchaseLocalizations_getToManyRelated': 'apple/iap apple/iap-review catalog',
@ -153,17 +157,20 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'profiles_getInstance': 'apple/signing', 'profiles_getInstance': 'apple/signing',
'promotedPurchases_createInstance': 'lib/offers', 'promotedPurchases_createInstance': 'lib/offers',
'promotedPurchases_updateInstance': 'lib/offers', 'promotedPurchases_updateInstance': 'lib/offers',
'reviewSubmissionItems_createInstance': 'apple/events lib/cpp-events lib/review-submit', 'reviewSubmissionItems_createInstance': 'apple/submission lib/cpp-events lib/review-submit',
'reviewSubmissions_createInstance': 'apple/cpp apple/events lib/review-submit', 'reviewSubmissions_createInstance': 'apple/cpp apple/events apple/submission lib/review-submit',
'reviewSubmissions_getCollection': 'apple/cpp apple/events apple/iap-submit apple/status', 'reviewSubmissions_getCollection': 'apple/events apple/iap-submit apple/status apple/submission',
'reviewSubmissions_items_getToManyRelated': 'apple/iap-submit', 'reviewSubmissions_getInstance': 'apple/submission',
'reviewSubmissions_items_getToManyRelated': 'apple/iap-submit apple/submission',
'reviewSubmissions_updateInstance': 'apple/cpp apple/events apple/iap-submit apple/status lib/review-submit', 'reviewSubmissions_updateInstance': 'apple/cpp apple/events apple/iap-submit apple/status lib/review-submit',
'salesReports_getCollection': 'lib/reports', 'salesReports_getCollection': 'lib/reports',
'subscriptionAppStoreReviewScreenshots_createInstance': 'apple/subscriptions', 'subscriptionAppStoreReviewScreenshots_createInstance': 'apple/subscriptions',
'subscriptionAppStoreReviewScreenshots_deleteInstance': 'apple/subscriptions',
'subscriptionAppStoreReviewScreenshots_updateInstance': 'apple/subscriptions', 'subscriptionAppStoreReviewScreenshots_updateInstance': 'apple/subscriptions',
'subscriptionAvailabilities_createInstance': 'apple/subscriptions', 'subscriptionAvailabilities_createInstance': 'apple/subscriptions',
'subscriptionGroupLocalizations_createInstance': 'apple/subscriptions', 'subscriptionGroupLocalizations_createInstance': 'apple/subscriptions',
'subscriptionGroups_createInstance': 'apple/subscriptions', 'subscriptionGroups_createInstance': 'apple/subscriptions',
'subscriptionGroups_getInstance': 'apple/subscriptions',
'subscriptionGroups_subscriptionGroupLocalizations_getToManyRelated': 'apple/subscriptions validate', 'subscriptionGroups_subscriptionGroupLocalizations_getToManyRelated': 'apple/subscriptions validate',
'subscriptionGroups_subscriptions_getToManyRelated': 'apple/iap-submit apple/promoted apple/subscriptions apple/win-back catalog prices validate', 'subscriptionGroups_subscriptions_getToManyRelated': 'apple/iap-submit apple/promoted apple/subscriptions apple/win-back catalog prices validate',
'subscriptionGroups_versions_getToManyRelated': 'apple/iap-submit', 'subscriptionGroups_versions_getToManyRelated': 'apple/iap-submit',
@ -171,11 +178,13 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'subscriptionOfferCodeCustomCodes_createInstance': 'apple/subscriptions', 'subscriptionOfferCodeCustomCodes_createInstance': 'apple/subscriptions',
'subscriptionOfferCodes_createInstance': 'apple/subscriptions', 'subscriptionOfferCodes_createInstance': 'apple/subscriptions',
'subscriptionOfferCodes_customCodes_getToManyRelated': 'apple/subscriptions', 'subscriptionOfferCodes_customCodes_getToManyRelated': 'apple/subscriptions',
'subscriptionOfferCodes_getInstance': 'apple/subscriptions',
'subscriptionPricePoints_equalizations_getToManyRelated': 'apple/subscriptions apple/win-back', 'subscriptionPricePoints_equalizations_getToManyRelated': 'apple/subscriptions apple/win-back',
'subscriptionPromotionalOffers_createInstance': 'apple/subscriptions', 'subscriptionPromotionalOffers_createInstance': 'apple/subscriptions',
'subscriptionVersions_createInstance': 'lib/review-submit', 'subscriptionVersions_createInstance': 'lib/review-submit',
'subscriptions_appStoreReviewScreenshot_getToOneRelated': 'apple/subscriptions validate', 'subscriptions_appStoreReviewScreenshot_getToOneRelated': 'apple/subscriptions validate',
'subscriptions_createInstance': 'apple/subscriptions', 'subscriptions_createInstance': 'apple/subscriptions',
'subscriptions_getInstance': 'apple/subscriptions',
'subscriptions_introductoryOffers_getToManyRelated': 'apple/subscriptions catalog', 'subscriptions_introductoryOffers_getToManyRelated': 'apple/subscriptions catalog',
'subscriptions_offerCodes_getToManyRelated': 'apple/subscriptions catalog', 'subscriptions_offerCodes_getToManyRelated': 'apple/subscriptions catalog',
'subscriptions_pricePoints_getToManyRelated': 'apple/subscriptions apple/win-back', 'subscriptions_pricePoints_getToManyRelated': 'apple/subscriptions apple/win-back',
@ -205,7 +214,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'androidpublisher.edits.insert': 'apps play/client validate', 'androidpublisher.edits.insert': 'apps play/client validate',
'androidpublisher.edits.listings.list': 'play/listing play/status validate', 'androidpublisher.edits.listings.list': 'play/listing play/status validate',
'androidpublisher.edits.listings.update': 'play/listing', 'androidpublisher.edits.listings.update': 'play/listing',
'androidpublisher.edits.tracks.get': 'play/rollout play/upload', 'androidpublisher.edits.tracks.get': 'play/release play/rollout play/upload',
'androidpublisher.edits.tracks.list': 'play/next-version-code play/status validate', 'androidpublisher.edits.tracks.list': 'play/next-version-code play/status validate',
'androidpublisher.edits.tracks.update': 'play/release play/rollout play/upload', 'androidpublisher.edits.tracks.update': 'play/release play/rollout play/upload',
'androidpublisher.edits.validate': 'play/client', 'androidpublisher.edits.validate': 'play/client',
@ -216,11 +225,9 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'androidpublisher.monetization.onetimeproducts.patch': 'play/products prices', 'androidpublisher.monetization.onetimeproducts.patch': 'play/products prices',
'androidpublisher.monetization.onetimeproducts.purchaseOptions.batchUpdateStates': 'play/products', 'androidpublisher.monetization.onetimeproducts.purchaseOptions.batchUpdateStates': 'play/products',
'androidpublisher.monetization.subscriptions.basePlans.activate': 'play/subscriptions', 'androidpublisher.monetization.subscriptions.basePlans.activate': 'play/subscriptions',
'androidpublisher.monetization.subscriptions.basePlans.offers.activate': 'play/subscriptions',
'androidpublisher.monetization.subscriptions.basePlans.offers.create': 'play/subscriptions',
'androidpublisher.monetization.subscriptions.basePlans.offers.list': 'catalog play/subscriptions', 'androidpublisher.monetization.subscriptions.basePlans.offers.list': 'catalog play/subscriptions',
'androidpublisher.monetization.subscriptions.create': 'play/subscriptions', 'androidpublisher.monetization.subscriptions.create': 'play/subscriptions',
'androidpublisher.monetization.subscriptions.get': 'prices', 'androidpublisher.monetization.subscriptions.get': 'play/subscriptions prices',
'androidpublisher.monetization.subscriptions.list': 'catalog play/subscriptions validate', 'androidpublisher.monetization.subscriptions.list': 'catalog play/subscriptions validate',
'androidpublisher.monetization.subscriptions.patch': 'play/subscriptions', 'androidpublisher.monetization.subscriptions.patch': 'play/subscriptions',
'androidpublisher.reviews.list': 'reviews', 'androidpublisher.reviews.list': 'reviews',

View file

@ -32,6 +32,7 @@ import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx'; import { confirm, need } from '../lib/ctx';
import { done, log, printJson, warn } from '../lib/log'; import { done, log, printJson, warn } from '../lib/log';
import { textTable } from '../lib/table'; import { textTable } from '../lib/table';
import { createOnce } from '../lib/upsert';
import { type Asc, ascClient, type J } from './asc'; import { type Asc, ascClient, type J } from './asc';
const TAG = 'adhoc'; const TAG = 'adhoc';
@ -86,8 +87,11 @@ async function register(ctx: Ctx, c: () => Promise<Asc>): Promise<void> {
let failed = 0; let failed = 0;
for (const d of plan.add) { for (const d of plan.add) {
try { try {
await api.asc('POST', '/v1/devices', deviceCreateReq(d)); // a UDID is registered once per team: a 409 or an unclear answer is checked by a direct lookup, never re-sent blindly
log(TAG, `registered ${d.name} (${maskUdid(d.udid)})`); const r = await createOnce({ what: `device ${d.name} (${maskUdid(d.udid)})`, tag: TAG, unique: true,
find: async () => (await api.all(`/v1/devices?filter[udid]=${encodeURIComponent(d.udid)}&limit=10`)).find((x: J) => String(x.attributes?.udid ?? '').toLowerCase() === d.udid.toLowerCase()) ?? null,
create: async () => (await api.asc('POST', '/v1/devices', deviceCreateReq(d))).data });
log(TAG, `${r.created ? 'registered' : 'already registered:'} ${d.name} (${maskUdid(d.udid)})`);
} catch (e) { } catch (e) {
failed++; failed++;
warn(TAG, `${d.name} (${maskUdid(d.udid)}) not registered: ${scrubUdids((e as Error).message, udids)}`); warn(TAG, `${d.name} (${maskUdid(d.udid)}) not registered: ${scrubUdids((e as Error).message, udids)}`);
@ -110,13 +114,16 @@ async function profile(ctx: Ctx, c: () => Promise<Asc>): Promise<void> {
const devices = profileDevices(await registered(api, 'IOS')); const devices = profileDevices(await registered(api, 'IOS'));
if (!devices.length) throw new Error('no enabled iOS devices: register them first (storebound apple adhoc register)'); if (!devices.length) throw new Error('no enabled iOS devices: register them first (storebound apple adhoc register)');
const want = devices.map(d => d.id); const want = devices.map(d => d.id);
const existing = (await api.all(`/v1/bundleIds/${bid.id}/profiles?limit=200`)).filter((p: J) => p.attributes?.profileType === 'IOS_APP_ADHOC' && p.attributes?.name === name); // the profile and the dated ones an earlier run made when it had to replace it ("<name> YYYY-MM-DD"), newest first
const dated = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}( \\d{4}-\\d{2}-\\d{2}( \\d{2}-\\d{2})?)?$`);
const existing = (await api.all(`/v1/bundleIds/${bid.id}/profiles?limit=200`)).filter((p: J) => p.attributes?.profileType === 'IOS_APP_ADHOC' && dated.test(p.attributes?.name ?? ''))
.sort((x: J, y: J) => String(y.attributes.name).localeCompare(String(x.attributes.name)));
let newName = name; let newName = name;
for (const p of existing) { for (const p of existing) {
const has = (await api.all(`/v1/profiles/${p.id}/devices?limit=200`)).map((d: J) => d.id as string); const has = (await api.all(`/v1/profiles/${p.id}/devices?limit=200`)).map((d: J) => d.id as string);
const st = profileCurrent(p.attributes.profileState, has, want); const st = profileCurrent(p.attributes.profileState, has, want);
if (st.current) { done(TAG, `profile "${name}" is current (${has.length} devices, expires ${String(p.attributes.expirationDate ?? '').slice(0, 10)})`); return; } if (st.current) { done(TAG, `profile "${p.attributes.name}" is current (${has.length} devices, expires ${String(p.attributes.expirationDate ?? '').slice(0, 10)})`); return; }
log(TAG, `profile "${name}" is ${p.attributes.profileState === 'ACTIVE' ? `missing ${st.missing} device(s)` : 'invalid'}; profiles cannot be changed, so a new one is created`); log(TAG, `profile "${p.attributes.name}" is ${p.attributes.profileState === 'ACTIVE' ? `missing ${st.missing} device(s)` : 'invalid'}; profiles cannot be changed, so a new one is created`);
newName = `${name} ${new Date().toISOString().slice(0, 10)}`; newName = `${name} ${new Date().toISOString().slice(0, 10)}`;
} }
const classes = new Map<string, number>(); const classes = new Map<string, number>();
@ -124,7 +131,10 @@ async function profile(ctx: Ctx, c: () => Promise<Asc>): Promise<void> {
log(TAG, `plan: create IOS_APP_ADHOC "${newName}" for ${bundleId}: ${devices.length} devices (${[...classes].map(([k, v]) => `${v} ${k}`).join(', ')}), ${certs.length} distribution certificate(s)`); log(TAG, `plan: create IOS_APP_ADHOC "${newName}" for ${bundleId}: ${devices.length} devices (${[...classes].map(([k, v]) => `${v} ${k}`).join(', ')}), ${certs.length} distribution certificate(s)`);
if (ctx.args.has('dry')) { log(TAG, 'dry run: nothing created'); return; } if (ctx.args.has('dry')) { log(TAG, 'dry run: nothing created'); return; }
if (!(await confirm(ctx, `Create the ad-hoc provisioning profile "${newName}"?`))) { log(TAG, 'not confirmed, nothing created'); return; } if (!(await confirm(ctx, `Create the ad-hoc provisioning profile "${newName}"?`))) { log(TAG, 'not confirmed, nothing created'); return; }
const res = (await api.asc('POST', '/v1/profiles', profileCreateReq(newName, bid.id, certs, want))).data; if (existing.some((p: J) => p.attributes.name === newName)) newName = `${name} ${new Date().toISOString().slice(0, 16).replace('T', ' ').replace(':', '-')}`;
const res = (await createOnce<J>({ what: `profile "${newName}"`, tag: TAG,
find: async () => (await api.all(`/v1/bundleIds/${bid.id}/profiles?limit=200`)).find((p: J) => p.attributes?.name === newName) ?? null,
create: async () => (await api.asc('POST', '/v1/profiles', profileCreateReq(newName, bid.id, certs, want))).data })).value;
const content = res?.attributes?.profileContent as string | undefined; const content = res?.attributes?.profileContent as string | undefined;
if (content) { if (content) {
const out = resolve(`build/adhoc/${newName.replace(/[^\w.-]+/g, '_')}.mobileprovision`); const out = resolve(`build/adhoc/${newName.replace(/[^\w.-]+/g, '_')}.mobileprovision`);

View file

@ -4,6 +4,7 @@ import { createHash, createPrivateKey, sign } from 'node:crypto';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { ascKey } from '../lib/credentials'; import { ascKey } from '../lib/credentials';
import { errorBody } from '../lib/http'; import { errorBody } from '../lib/http';
import { AmbiguousWriteError, retrySafe } from '../lib/upsert';
const b64 = (b: Buffer | string): string => Buffer.from(b).toString('base64url'); const b64 = (b: Buffer | string): string => Buffer.from(b).toString('base64url');
export const md5 = (b: Buffer | Uint8Array): string => createHash('md5').update(b).digest('hex'); export const md5 = (b: Buffer | Uint8Array): string => createHash('md5').update(b).digest('hex');
@ -24,6 +25,14 @@ export function setUpToDate(existing: { checksum?: string | null; state?: string
return existing.length === want.length && existing.every((e, i) => e.checksum === want[i] && DELIVERED.includes(e.state ?? '')); return existing.length === want.length && existing.every((e, i) => e.checksum === want[i] && DELIVERED.includes(e.state ?? ''));
} }
/** How many leading assets of a set already hold the wanted files (same MD5 at the same position, delivered): those
* stay when the set is synced. Pure. */
export function keptPrefix(existing: { checksum?: string | null; state?: string | null }[], want: string[]): number {
let k = 0;
while (k < existing.length && k < want.length && existing[k]!.checksum === want[k] && DELIVERED.includes(existing[k]!.state ?? '')) k++;
return k;
}
/** Cached ES256 token getter for the team API key (env or .storebound/): valid 18 minutes, renewed after 15. Shared by /** Cached ES256 token getter for the team API key (env or .storebound/): valid 18 minutes, renewed after 15. Shared by
* ascClient and the generic `storebound api apple` client (cli/api/client.ts). */ * ascClient and the generic `storebound api apple` client (cli/api/client.ts). */
export function ascJwt(root: string): { jwt: () => string; keyId: string } { export function ascJwt(root: string): { jwt: () => string; keyId: string } {
@ -45,12 +54,28 @@ export async function ascClient(root: string, bundleId: string, appIdHint?: stri
const k = ascJwt(root); const k = ascJwt(root);
const jwt = k.jwt; const jwt = k.jwt;
/**
* One API request. 429 is retried (the request was refused, not applied). 5xx and network errors are retried only
* for requests that end the same when sent twice (retrySafe: GET, PATCH, DELETE, relationship adds, the "create or
* replace" singletons); a POST create then throws AmbiguousWriteError instead, so callers look before they create
* again (cli/lib/upsert.ts). A DELETE answered 404 after a retry was applied by the first attempt.
*/
async function asc(method: string, path: string, body?: unknown, attempt = 1): Promise<J> { async function asc(method: string, path: string, body?: unknown, attempt = 1): Promise<J> {
const r = await fetch(path.startsWith('http') ? path : 'https://api.appstoreconnect.apple.com' + path, { const safe = retrySafe(method, path);
method, headers: { Authorization: 'Bearer ' + jwt(), 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, let r: Response;
}); try {
r = await fetch(path.startsWith('http') ? path : 'https://api.appstoreconnect.apple.com' + path, {
method, headers: { Authorization: 'Bearer ' + jwt(), 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined,
});
} catch (e) {
if (safe && attempt < 5) { await Bun.sleep(2000 * attempt); return asc(method, path, body, attempt + 1); }
if (!safe) throw new AmbiguousWriteError(`${method} ${path} -> no answer (${(e as Error).message})`);
throw e;
}
const t = await r.text(); const t = await r.text();
if ((r.status >= 500 || r.status === 429) && attempt < 5) { await Bun.sleep(2000 * attempt); return asc(method, path, body, attempt + 1); } if ((r.status === 429 || (r.status >= 500 && safe)) && attempt < 5) { await Bun.sleep(2000 * attempt); return asc(method, path, body, attempt + 1); }
if (r.status >= 500 && !safe) throw new AmbiguousWriteError(`${method} ${path} -> ${r.status} ${errorBody(t)}`, r.status);
if (r.status === 404 && method === 'DELETE' && attempt > 1) return {};
if (!r.ok) throw new Error(`${method} ${path} -> ${r.status} ${errorBody(t)}`); if (!r.ok) throw new Error(`${method} ${path} -> ${r.status} ${errorBody(t)}`);
return t ? JSON.parse(t) : {}; return t ? JSON.parse(t) : {};
} }
@ -127,5 +152,38 @@ export async function ascClient(root: string, bundleId: string, appIdHint?: stri
return res.id; return res.id;
} }
return { asc, all, maybe, getAs, bytes, app, editableVersion, editableAppInfo, uploadAsset, keyId: k.keyId }; /**
* Makes a screenshot or preview set hold exactly `files`, in this order, touching as little as possible: the leading
* assets that already match stay, everything after them is deleted, the rest is uploaded in order. A crash midway
* leaves a prefix of the wanted files (plus maybe one half-uploaded asset), which the next run keeps or removes, so
* re-runs converge and never duplicate. A reservation with an unknown outcome is cleaned up (the set is read again and
* any asset this run does not know is deleted) and sent once more. At the end the order is read back and, if Apple
* shows another one, set through the set's relationship.
*/
async function syncAssets(o: { set: { type: 'appScreenshotSets' | 'appPreviewSets'; id: string }; existing: J[]; files: string[]; want: string[];
state: (x: J) => string | undefined; attrs?: (file: string) => Record<string, unknown>; onUpload?: (file: string) => void }): Promise<{ uploaded: number; removed: number; kept: number }> {
const asset = o.set.type === 'appScreenshotSets' ? 'appScreenshots' : 'appPreviews';
const rel = { [o.set.type === 'appScreenshotSets' ? 'appScreenshotSet' : 'appPreviewSet']: { data: { type: o.set.type, id: o.set.id } } };
const k = keptPrefix(o.existing.map(x => ({ checksum: x.attributes?.sourceFileChecksum, state: o.state(x) })), o.want);
const ids: string[] = o.existing.slice(0, k).map(x => x.id);
const extra = o.existing.slice(k);
for (const x of extra) await asc('DELETE', `/v1/${asset}/${x.id}`);
for (const f of o.files.slice(k)) {
o.onUpload?.(f);
try { ids.push(await uploadAsset(asset, rel, f, o.attrs?.(f) ?? {})); } catch (e) {
if (!(e as { ambiguous?: boolean }).ambiguous) throw e;
// the reservation may exist: remove whatever this run does not know, then upload this file once more
for (const x of await all(`/v1/${o.set.type}/${o.set.id}/${asset}?limit=50`)) if (!ids.includes(x.id)) await asc('DELETE', `/v1/${asset}/${x.id}`);
ids.push(await uploadAsset(asset, rel, f, o.attrs?.(f) ?? {}));
}
}
if (o.files.length > k) {
const now = (await all(`/v1/${o.set.type}/${o.set.id}/${asset}?limit=50`)).map(x => x.id as string);
if (now.join() !== ids.join() && ids.every(id => now.includes(id)))
await asc('PATCH', `/v1/${o.set.type}/${o.set.id}/relationships/${asset}`, { data: ids.map(id => ({ type: asset, id })) });
}
return { uploaded: o.files.length - k, removed: extra.length, kept: k };
}
return { asc, all, maybe, getAs, bytes, app, editableVersion, editableAppInfo, uploadAsset, syncAssets, keyId: k.keyId };
} }

View file

@ -5,6 +5,7 @@
import type { Ctx } from '../lib/ctx'; import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx'; import { need } from '../lib/ctx';
import { done, log } from '../lib/log'; import { done, log } from '../lib/log';
import { createOnce } from '../lib/upsert';
import { ascClient } from './asc'; import { ascClient } from './asc';
export async function run(ctx: Ctx): Promise<void> { export async function run(ctx: Ctx): Promise<void> {
@ -16,8 +17,11 @@ export async function run(ctx: Ctx): Promise<void> {
if (found.length) { log('apple', `bundle id ${bundleId} already registered (${found[0].attributes.platform})`); } if (found.length) { log('apple', `bundle id ${bundleId} already registered (${found[0].attributes.platform})`); }
else if (ctx.args.has('dry')) { log('apple', `bundle id ${bundleId} missing (dry run, not registering)`); return; } else if (ctx.args.has('dry')) { log('apple', `bundle id ${bundleId} missing (dry run, not registering)`); return; }
else { else {
await c.asc('POST', '/v1/bundleIds', { data: { type: 'bundleIds', attributes: { identifier: bundleId, name: ctx.args.str('name', config.app.name).replace(/[^A-Za-z0-9 ]/g, ' ').trim(), platform: 'IOS' } } }); // identifiers are unique per team: a 409 (or an unclear answer) is checked by a direct lookup, never re-sent blindly
log('apple', `bundle id ${bundleId} registered`); const r = await createOnce({ what: `bundle id ${bundleId}`, tag: 'apple', unique: true,
find: async () => (await c.asc('GET', `/v1/bundleIds?filter[identifier]=${encodeURIComponent(bundleId)}`)).data?.find((b: { attributes: { identifier: string } }) => b.attributes.identifier === bundleId) ?? null,
create: async () => (await c.asc('POST', '/v1/bundleIds', { data: { type: 'bundleIds', attributes: { identifier: bundleId, name: ctx.args.str('name', config.app.name).replace(/[^A-Za-z0-9 ]/g, ' ').trim(), platform: 'IOS' } } })).data });
log('apple', `bundle id ${bundleId} ${r.created ? 'registered' : 'already registered'}`);
} }
const apps = (await c.asc('GET', `/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}`)).data; const apps = (await c.asc('GET', `/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}`)).data;
if (apps.length) log('apple', `app record: ${apps[0].attributes.name} (Apple ID ${apps[0].id}); put it in apple.appId`); if (apps.length) log('apple', `app record: ${apps[0].attributes.name} (Apple ID ${apps[0].id}); put it in apple.appId`);

View file

@ -18,6 +18,8 @@ import { numberedImages } from '../play/images';
import { variantRoot } from '../shots/lib'; import { variantRoot } from '../shots/lib';
import { type Asc, ascClient, type J } from './asc'; import { type Asc, ascClient, type J } from './asc';
import { describeReq, execute, one, type Req } from './jsonapi'; import { describeReq, execute, one, type Req } from './jsonapi';
import { addItem, draftSubmission, findDraft } from './submission';
import { findOrCreate, getById } from '../lib/upsert';
import { displaySets, syncScreenshotSet } from './screenshots'; import { displaySets, syncScreenshotSet } from './screenshots';
const TAG = 'cpp'; const TAG = 'cpp';
@ -35,11 +37,6 @@ async function pagesOf(c: Asc, appId: string): Promise<Page[]> {
return out; return out;
} }
/** A draft review submission of the app (READY_FOR_REVIEW), or the request that creates one. */
async function draftSubmission(c: Asc, appId: string): Promise<string | null> {
return (await c.all(`/v1/reviewSubmissions?filter[app]=${appId}&filter[platform]=IOS&filter[state]=READY_FOR_REVIEW&limit=10`))[0]?.id ?? null;
}
export async function run(ctx: Ctx): Promise<void> { export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded; const { config, root, resolve } = ctx.loaded;
const a = ctx.args; const a = ctx.args;
@ -82,7 +79,18 @@ export async function run(ctx: Ctx): Promise<void> {
if (pages.length + reqs.filter(r => r.path === '/v1/appCustomProductPages').length > CPP_LIMITS.pages) throw new Error(`more than ${CPP_LIMITS.pages} custom product pages`); if (pages.length + reqs.filter(r => r.path === '/v1/appCustomProductPages').length > CPP_LIMITS.pages) throw new Error(`more than ${CPP_LIMITS.pages} custom product pages`);
if (a.has('json')) printJson({ dryRun: dry, requests: reqs.map(r => describeReq(r)) }); if (a.has('json')) printJson({ dryRun: dry, requests: reqs.map(r => describeReq(r)) });
if (!reqs.length) { done(TAG, 'custom product pages up to date'); return; } if (!reqs.length) { done(TAG, 'custom product pages up to date'); return; }
await execute(c.asc, reqs, { dry, tag: TAG }); if (dry) { await execute(c.asc, reqs, { dry, tag: TAG }); done(TAG, `dry run: ${reqs.length} change(s)`); return; }
// page names are not known to be unique on Apple's side: each new page is looked up right before it is created
// (fresh list, then the journal of recent creates for the list lag) and never sent twice
for (const r of reqs) {
if (r.path !== '/v1/appCustomProductPages') { await execute(c.asc, [r], { dry, tag: TAG }); continue; }
const name = (r.body as J).data.attributes.name as string;
const made = await findOrCreate<J>({ what: `custom product page "${name}"`, tag: TAG,
find: async () => (await c.all(`/v1/apps/${appId}/appCustomProductPages?limit=200`)).find((p: J) => p.attributes.name === name) ?? null,
journal: { root, key: `apple:${appId}:cpp:${name}`, byId: id => getById(c.asc, `/v1/appCustomProductPages/${id}`) as Promise<J | null>, idOf: v => v.id },
create: async () => (await c.asc('POST', r.path, r.body)).data });
log(TAG, `${describeReq(r)}: ${made.created ? 'done' : 'exists already'}`);
}
done(TAG, dry ? `dry run: ${reqs.length} change(s)` : `${reqs.length} change(s); next: storebound apple cpp upload --name "<page>"`); done(TAG, dry ? `dry run: ${reqs.length} change(s)` : `${reqs.length} change(s); next: storebound apple cpp upload --name "<page>"`);
return; return;
} }
@ -117,13 +125,13 @@ export async function run(ctx: Ctx): Promise<void> {
const { def, page } = pick(); const { def, page } = pick();
if (!page?.version) throw new Error(`"${def.name}" is not on App Store Connect yet (storebound apple cpp create)`); if (!page?.version) throw new Error(`"${def.name}" is not on App Store Connect yet (storebound apple cpp create)`);
if (!['PREPARE_FOR_SUBMISSION', 'REJECTED'].includes(page.version.state)) { done(TAG, `"${def.name}": version is ${page.version.state}, nothing to submit`); return; } if (!['PREPARE_FOR_SUBMISSION', 'REJECTED'].includes(page.version.state)) { done(TAG, `"${def.name}": version is ${page.version.state}, nothing to submit`); return; }
const draft = await draftSubmission(c, appId); const draft = await findDraft(c, appId);
const create: Req = { method: 'POST', path: '/v1/reviewSubmissions', note: 'new draft review submission', body: { data: { type: 'reviewSubmissions', attributes: { platform: 'IOS' }, relationships: { app: one('apps', appId) } } } }; const create: Req = { method: 'POST', path: '/v1/reviewSubmissions', note: 'new draft review submission', body: { data: { type: 'reviewSubmissions', attributes: { platform: 'IOS' }, relationships: { app: one('apps', appId) } } } };
const item = reviewItemReq(draft ?? '${submission}', 'appCustomProductPageVersion', page.version.id, `custom product page "${def.name}"`); const item = reviewItemReq(draft ?? '${submission}', 'appCustomProductPageVersion', page.version.id, `custom product page "${def.name}"`);
if (dry) { await execute(c.asc, [...(draft ? [] : [create]), item, { method: 'PATCH', path: `/v1/reviewSubmissions/${draft ?? '${submission}'}`, note: 'submit for App Review' }], { dry, tag: TAG }); return; } if (dry) { await execute(c.asc, [...(draft ? [] : [create]), item, { method: 'PATCH', path: `/v1/reviewSubmissions/${draft ?? '${submission}'}`, note: 'submit for App Review' }], { dry, tag: TAG }); return; }
if (!(await confirm(ctx, `Send custom product page "${def.name}" to App Review now?`))) return; if (!(await confirm(ctx, `Send custom product page "${def.name}" to App Review now?`))) return;
const sid = draft ?? ((await c.asc('POST', create.path, create.body)).data.id as string); const sid = draft ?? await draftSubmission(c, appId, root, TAG);
await c.asc('POST', item.path, reviewItemReq(sid, 'appCustomProductPageVersion', page.version.id, '').body); await addItem(c, sid, 'appCustomProductPageVersion', page.version.id, `custom product page "${def.name}"`, TAG);
await c.asc('PATCH', `/v1/reviewSubmissions/${sid}`, { data: { type: 'reviewSubmissions', id: sid, attributes: { submitted: true } } }); await c.asc('PATCH', `/v1/reviewSubmissions/${sid}`, { data: { type: 'reviewSubmissions', id: sid, attributes: { submitted: true } } });
done(TAG, `"${def.name}" submitted (review submission ${sid}); after approval: storebound apple cpp visible --name "${def.name}"`); done(TAG, `"${def.name}" submitted (review submission ${sid}); after approval: storebound apple cpp visible --name "${def.name}"`);
return; return;

View file

@ -15,6 +15,8 @@ import { textTable } from '../lib/table';
import { imageInfo } from '../shots/spec'; import { imageInfo } from '../shots/spec';
import { ascClient, type J } from './asc'; import { ascClient, type J } from './asc';
import { describeReq, execute, one, type Req } from './jsonapi'; import { describeReq, execute, one, type Req } from './jsonapi';
import { addItem, draftSubmission } from './submission';
import { findOrCreate, getById } from '../lib/upsert';
const TAG = 'events'; const TAG = 'events';
const USAGE = 'usage: storebound apple events list | create [--name n] | upload --name n | submit --name n'; const USAGE = 'usage: storebound apple events list | create [--name n] | upload --name n | submit --name n';
@ -69,11 +71,21 @@ export async function run(ctx: Ctx): Promise<void> {
done(TAG, 'dry run: nothing changed'); done(TAG, 'dry run: nothing changed');
return; return;
} }
const out = await execute(c.asc, reqs, { dry: false, tag: TAG }) as J[]; // reference names are not known to be unique on Apple's side: each new event is looked up right before it is
for (const x of created) { // created (fresh list, then the journal of recent creates for the list lag) and never sent twice
const id = out[x.index].data.id as string; for (const [i, r] of reqs.entries()) {
const x = created.find(e => e.index === i);
if (!x) { await execute(c.asc, [r], { dry: false, tag: TAG }); continue; }
const name = x.def.referenceName;
const made = await findOrCreate<J>({ what: `in-app event "${name}"`, tag: TAG,
find: async () => (await c.all(`/v1/apps/${appId}/appEvents?limit=200`)).find((e: J) => e.attributes.referenceName === name) ?? null,
journal: { root, key: `apple:${appId}:event:${name}`, byId: id => getById(c.asc, `/v1/appEvents/${id}`) as Promise<J | null>, idOf: v => v.id },
create: async () => (await c.asc('POST', r.path, r.body)).data });
log(TAG, `${describeReq(r)}: ${made.created ? 'done' : 'exists already'}`);
const id = made.value.id as string;
const want = Object.entries(x.def.listings).flatMap(([lang, t]) => { const locale = localeOf(lang); return locale ? [{ locale, ...t }] : []; }); const want = Object.entries(x.def.listings).flatMap(([lang, t]) => { const locale = localeOf(lang); return locale ? [{ locale, ...t }] : []; });
await execute(c.asc, planEventLocales(id, [], want), { dry: false, tag: TAG }); const have = made.created ? [] : (await c.all(`/v1/appEvents/${id}/localizations?limit=200`)).map((l: J) => ({ id: l.id, ...l.attributes }));
await execute(c.asc, planEventLocales(id, have, want), { dry: false, tag: TAG });
} }
done(TAG, 'in-app events created / updated; next: storebound apple events upload --name "<event>"'); done(TAG, 'in-app events created / updated; next: storebound apple events upload --name "<event>"');
return; return;
@ -96,7 +108,7 @@ export async function run(ctx: Ctx): Promise<void> {
const info = imageInfo(readFileSync(file)); const info = imageInfo(readFileSync(file));
const problems = checkEventMedia(kind, info.w, info.h, info.format); const problems = checkEventMedia(kind, info.w, info.h, info.format);
if (problems.length) throw new Error(`${file}: ${problems.join('; ')}`); if (problems.length) throw new Error(`${file}: ${problems.join('; ')}`);
if (have.some((s: J) => s.attributes.appEventAssetType === kind && s.attributes.fileSize === info.bytes)) { log(TAG, `${loc.attributes.locale} ${kind}: up to date`); continue; } if (have.some((s: J) => s.attributes.appEventAssetType === kind && s.attributes.fileSize === info.bytes && ['COMPLETE', 'UPLOAD_COMPLETE'].includes(s.attributes.assetDeliveryState?.state ?? 'COMPLETE'))) { log(TAG, `${loc.attributes.locale} ${kind}: up to date`); continue; }
if (dry) { log(TAG, `${loc.attributes.locale} ${kind}: would upload ${file} (${info.w}x${info.h})`); n++; continue; } if (dry) { log(TAG, `${loc.attributes.locale} ${kind}: would upload ${file} (${info.w}x${info.h})`); n++; continue; }
for (const s of have.filter((x: J) => x.attributes.appEventAssetType === kind)) await c.asc('DELETE', `/v1/appEventScreenshots/${s.id}`); for (const s of have.filter((x: J) => x.attributes.appEventAssetType === kind)) await c.asc('DELETE', `/v1/appEventScreenshots/${s.id}`);
await c.uploadAsset('appEventScreenshots', { appEventLocalization: { data: { type: 'appEventLocalizations', id: loc.id } } }, file, { appEventAssetType: kind }); await c.uploadAsset('appEventScreenshots', { appEventLocalization: { data: { type: 'appEventLocalizations', id: loc.id } } }, file, { appEventAssetType: kind });
@ -116,8 +128,8 @@ export async function run(ctx: Ctx): Promise<void> {
const create: Req = { method: 'POST', path: '/v1/reviewSubmissions', note: 'new draft review submission', body: { data: { type: 'reviewSubmissions', attributes: { platform: 'IOS' }, relationships: { app: one('apps', appId) } } } }; const create: Req = { method: 'POST', path: '/v1/reviewSubmissions', note: 'new draft review submission', body: { data: { type: 'reviewSubmissions', attributes: { platform: 'IOS' }, relationships: { app: one('apps', appId) } } } };
if (dry) { await execute(c.asc, [...(draft ? [] : [create]), reviewItemReq(draft ?? '${submission}', 'appEvent', ev.id, `in-app event "${def.referenceName}"`), { method: 'PATCH', path: `/v1/reviewSubmissions/${draft ?? '${submission}'}`, note: 'submit for App Review' }], { dry, tag: TAG }); return; } if (dry) { await execute(c.asc, [...(draft ? [] : [create]), reviewItemReq(draft ?? '${submission}', 'appEvent', ev.id, `in-app event "${def.referenceName}"`), { method: 'PATCH', path: `/v1/reviewSubmissions/${draft ?? '${submission}'}`, note: 'submit for App Review' }], { dry, tag: TAG }); return; }
if (!(await confirm(ctx, `Send in-app event "${def.referenceName}" to App Review? Once approved it is published on ${def.schedule.publishStart} by its schedule.`))) return; if (!(await confirm(ctx, `Send in-app event "${def.referenceName}" to App Review? Once approved it is published on ${def.schedule.publishStart} by its schedule.`))) return;
const sid = draft ?? ((await c.asc('POST', create.path, create.body)).data.id as string); const sid = draft ?? await draftSubmission(c, appId, root, TAG);
await c.asc('POST', '/v1/reviewSubmissionItems', reviewItemReq(sid, 'appEvent', ev.id, '').body); await addItem(c, sid, 'appEvent', ev.id, `in-app event "${def.referenceName}"`, TAG);
await c.asc('PATCH', `/v1/reviewSubmissions/${sid}`, { data: { type: 'reviewSubmissions', id: sid, attributes: { submitted: true } } }); await c.asc('PATCH', `/v1/reviewSubmissions/${sid}`, { data: { type: 'reviewSubmissions', id: sid, attributes: { submitted: true } } });
done(TAG, `"${def.referenceName}" submitted (review submission ${sid})`); done(TAG, `"${def.referenceName}" submitted (review submission ${sid})`);
return; return;

View file

@ -14,6 +14,7 @@ import { allSubscriptions } from '../lib/subscriptions';
import { bindIds, latestVersion, newVersionReq, type PIap, planIapSubmission, type PSub, type SubmitState, versionsOf } from '../lib/review-submit'; import { bindIds, latestVersion, newVersionReq, type PIap, planIapSubmission, type PSub, type SubmitState, versionsOf } from '../lib/review-submit';
import { type Asc, ascClient, type J } from './asc'; import { type Asc, ascClient, type J } from './asc';
import { describeReq, execute } from './jsonapi'; import { describeReq, execute } from './jsonapi';
import { addItem, draftSubmission } from './submission';
const TAG = 'iap-submit'; const TAG = 'iap-submit';
@ -95,10 +96,18 @@ export async function run(ctx: Ctx): Promise<void> {
} }
const what = `${plan.items.length} item(s)${plan.appVersion ? `, including app version ${plan.appVersion.versionString}` : ''}`; const what = `${plan.items.length} item(s)${plan.appVersion ? `, including app version ${plan.appVersion.versionString}` : ''}`;
if (!(await confirm(ctx, `Add ${what} to ${state.draft ? 'the draft' : 'a new draft'} review submission?`))) return; if (!(await confirm(ctx, `Add ${what} to ${state.draft ? 'the draft' : 'a new draft'} review submission?`))) return;
let sid = state.draft?.id; // the draft is reused (or created once); the plan left out items already in it (readSubmitState read them), and an
// item the store says is in there already (409) is not added twice (cli/apple/submission.ts)
const sid = state.draft?.id ?? await draftSubmission(c, appId, root, TAG);
for (const r of prepare) { for (const r of prepare) {
const out = await c.asc(r.method, r.path, bindIds(r, { submission: sid }).body); if (r.path === '/v1/reviewSubmissions') continue;
if (r.path === '/v1/reviewSubmissions') sid = out.data.id; if (r.path === '/v1/reviewSubmissionItems') {
const rels = (r.body as J).data.relationships as Record<string, { data: { id: string } }>;
const kind = Object.keys(rels).find(k => k !== 'reviewSubmission')!;
if (await addItem(c, sid, kind, rels[kind]!.data.id, r.note ?? kind, TAG, { checked: true })) log(TAG, `${describeReq(bindIds(r, { submission: sid }))}: done`);
continue;
}
await c.asc(r.method, r.path, bindIds(r, { submission: sid }).body);
log(TAG, `${describeReq(bindIds(r, { submission: sid }))}: done`); log(TAG, `${describeReq(bindIds(r, { submission: sid }))}: done`);
} }
if (a.has('no-submit')) { done(TAG, `draft review submission ${sid} ready: press "Submit for Review" in App Store Connect (Apps > Distribution > App Review)`); return; } if (a.has('no-submit')) { done(TAG, `draft review submission ${sid} ready: press "Submit for Review" in App Store Connect (Apps > Distribution > App Review)`); return; }

View file

@ -15,6 +15,7 @@ import { dryPlan, type PlanRow } from '../lib/dry';
import { done, log, step } from '../lib/log'; import { done, log, step } from '../lib/log';
import { LAG_HINT, reportMissing } from '../lib/store-lag'; import { LAG_HINT, reportMissing } from '../lib/store-lag';
import { appleTerritory, nearestPrices, samePrice } from '../lib/money'; import { appleTerritory, nearestPrices, samePrice } from '../lib/money';
import { createOnce, findOrCreate, getById } from '../lib/upsert';
import { ascClient, type J } from './asc'; import { ascClient, type J } from './asc';
/** Product type -> InAppPurchaseType (POST /v2/inAppPurchases). */ /** Product type -> InAppPurchaseType (POST /v2/inAppPurchases). */
@ -44,26 +45,31 @@ export async function run(ctx: Ctx): Promise<void> {
const created: string[] = []; const created: string[] = [];
for (const [i, p] of products.entries()) { for (const [i, p] of products.entries()) {
const tag = step(i + 1, products.length, p.id); const tag = step(i + 1, products.length, p.id);
const found = await c.asc('GET', `/v1/apps/${appId}/inAppPurchasesV2?filter[productId]=${encodeURIComponent(p.id)}`); const lookup = async (): Promise<J | null> => (await c.asc('GET', `/v1/apps/${appId}/inAppPurchasesV2?filter[productId]=${encodeURIComponent(p.id)}`)).data
let id: string = found.data[0]?.id; ?.find((x: J) => x.attributes?.productId === p.id) ?? null;
if (dry) { rows.push(await planProduct(p, id, found.data[0]?.attributes?.state)); continue; } if (dry) { const f = await lookup(); rows.push(await planProduct(p, f?.id, f?.attributes?.state)); continue; }
if (!id) { // product ids are unique per app: a 409 finds the existing one; the journal covers the list lag after a create
const first = Object.values(p.listings)[0]!; const first = Object.values(p.listings)[0]!;
id = (await c.asc('POST', '/v2/inAppPurchases', { data: { type: 'inAppPurchases', const made = await findOrCreate<J>({ what: `in-app purchase ${p.id}`, tag: 'apple', unique: true, find: lookup,
journal: { root, key: `apple:${bundleId}:iap:${p.id}`, byId: id => getById(c.asc, `/v2/inAppPurchases/${id}`) as Promise<J | null>, idOf: v => v.id },
create: async () => (await c.asc('POST', '/v2/inAppPurchases', { data: { type: 'inAppPurchases',
attributes: { name: p.referenceName ?? first.title, productId: p.id, inAppPurchaseType: APPLE_IAP_TYPE[p.type], attributes: { name: p.referenceName ?? first.title, productId: p.id, inAppPurchaseType: APPLE_IAP_TYPE[p.type],
familySharable: p.familySharable ?? false, ...(p.reviewNote ? { reviewNote: p.reviewNote } : {}) }, familySharable: p.familySharable ?? false, ...(p.reviewNote ? { reviewNote: p.reviewNote } : {}) },
relationships: { app: { data: { type: 'apps', id: appId } } } } })).data.id; relationships: { app: { data: { type: 'apps', id: appId } } } } })).data });
created.push(p.id); const id: string = made.value.id;
log('apple', `${tag}: created`); if (made.created) created.push(p.id);
} else log('apple', `${tag}: exists`); log('apple', `${tag}: ${made.created ? 'created' : 'exists'}`);
const have = new Set((await c.all(`/v2/inAppPurchases/${id}/inAppPurchaseLocalizations`)).map((l: J) => l.attributes.locale)); const locsOf = async () => await c.all(`/v2/inAppPurchases/${id}/inAppPurchaseLocalizations`);
const have = new Set((await locsOf()).map((l: J) => l.attributes.locale));
for (const [langId, l] of Object.entries(p.listings)) { for (const [langId, l] of Object.entries(p.listings)) {
const locale = langs.get(langId)?.apple; const locale = langs.get(langId)?.apple;
if (!locale || have.has(locale)) continue; if (!locale || have.has(locale)) continue;
if ([...l.description].length > APPLE_IAP_LIMITS.description) throw new Error(`${p.id} ${locale}: description over ${APPLE_IAP_LIMITS.description} characters (Apple limit)`); if ([...l.description].length > APPLE_IAP_LIMITS.description) throw new Error(`${p.id} ${locale}: description over ${APPLE_IAP_LIMITS.description} characters (Apple limit)`);
await c.asc('POST', '/v1/inAppPurchaseLocalizations', { data: { type: 'inAppPurchaseLocalizations', attributes: { locale, name: l.title, description: l.description }, await createOnce({ what: `${p.id} localization ${locale}`, tag: 'apple', unique: true,
relationships: { inAppPurchaseV2: { data: { type: 'inAppPurchases', id } } } } }); find: async () => (await locsOf()).find((x: J) => x.attributes.locale === locale) ?? null,
create: async () => (await c.asc('POST', '/v1/inAppPurchaseLocalizations', { data: { type: 'inAppPurchaseLocalizations', attributes: { locale, name: l.title, description: l.description },
relationships: { inAppPurchaseV2: { data: { type: 'inAppPurchases', id } } } } })).data });
log('apple', ` localization ${locale}`); log('apple', ` localization ${locale}`);
} }

View file

@ -1,6 +1,7 @@
// JSON:API request builders for App Store Connect. Pure: commands build `Req`s, print them on --dry and send them // JSON:API request builders for App Store Connect. Pure: commands build `Req`s, print them on --dry and send them
// with the client otherwise. Request / response format: https://developer.apple.com/documentation/appstoreconnectapi // with the client otherwise. Request / response format: https://developer.apple.com/documentation/appstoreconnectapi
import { log } from '../lib/log'; import { log, warn } from '../lib/log';
import { isAmbiguous, isConflict } from '../lib/upsert';
export interface Req { method: 'GET' | 'POST' | 'PATCH' | 'DELETE'; path: string; body?: unknown; note?: string } export interface Req { method: 'GET' | 'POST' | 'PATCH' | 'DELETE'; path: string; body?: unknown; note?: string }
@ -16,13 +17,23 @@ export function describeReq(r: Req, hide: string[] = ['email', 'demoAccountPassw
return `${r.method} ${r.path}${keys.length ? ` {${keys.join(', ')}}` : ''}${r.note ? ` # ${r.note}` : ''}`; return `${r.method} ${r.path}${keys.length ? ` {${keys.join(', ')}}` : ''}${r.note ? ` # ${r.note}` : ''}`;
} }
/** Logs every planned request; sends them in order unless dry. Returns the responses (empty on dry). */ /**
* Logs every planned request; sends them in order unless dry. Returns the responses (empty on dry). The plans come
* from a fresh read of the store, so a create the store refuses as a duplicate (409 already exists) means an earlier
* run made it: it is logged and skipped (its slot in the result is null), never sent again. A create with an unknown
* outcome (timeout, 5xx) stops the run without a retry: running the command again reads the store and plans only
* what is still missing.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function execute(asc: (m: string, p: string, b?: unknown) => Promise<any>, reqs: Req[], o: { dry: boolean; tag: string }): Promise<unknown[]> { export async function execute(asc: (m: string, p: string, b?: unknown) => Promise<any>, reqs: Req[], o: { dry: boolean; tag: string }): Promise<unknown[]> {
const out: unknown[] = []; const out: unknown[] = [];
for (const r of reqs) { for (const [i, r] of reqs.entries()) {
if (o.dry) { log(o.tag, `would ${describeReq(r)}`); continue; } if (o.dry) { log(o.tag, `would ${describeReq(r)}`); continue; }
out.push(await asc(r.method, r.path, r.body)); try { out.push(await asc(r.method, r.path, r.body)); } catch (e) {
if (r.method === 'POST' && isConflict(e)) { warn(o.tag, `${describeReq(r)}: already exists, left as it is`); out.push(null); continue; }
if (isAmbiguous(e)) throw new Error(`${describeReq(r)}: no clear answer (${(e as Error).message.slice(0, 200)}). Not sent again, so nothing is created twice; ${reqs.length - i - 1} later request(s) not sent. Run the command again: it reads the store and does only what is still missing.`);
throw e;
}
log(o.tag, `${describeReq(r)}: done`); log(o.tag, `${describeReq(r)}: done`);
} }
return out; return out;

View file

@ -17,6 +17,7 @@ import { applePoints, pickPoint } from '../lib/subscriptions';
import { pages } from '../prices'; import { pages } from '../prices';
import { ascClient, type J } from './asc'; import { ascClient, type J } from './asc';
import { describeReq, execute, type Req } from './jsonapi'; import { describeReq, execute, type Req } from './jsonapi';
import { findOrCreate, getById } from '../lib/upsert';
const TAG = 'offer-codes'; const TAG = 'offer-codes';
@ -60,7 +61,16 @@ export async function run(ctx: Ctx): Promise<void> {
const body = iapOfferCodeBody(iap.id, oc, await prices(oc)); const body = iapOfferCodeBody(iap.id, oc, await prices(oc));
const req: Req = { method: 'POST', path: '/v1/inAppPurchaseOfferCodes', body, note: `${p.id}: campaign "${oc.name}" (${oc.free ? 'free' : `USD ${oc.usd}`}, ${body.included.length} territories)` }; const req: Req = { method: 'POST', path: '/v1/inAppPurchaseOfferCodes', body, note: `${p.id}: campaign "${oc.name}" (${oc.free ? 'free' : `USD ${oc.usd}`}, ${body.included.length} territories)` };
if (dry) log(TAG, `would ${describeReq(req)}`); if (dry) log(TAG, `would ${describeReq(req)}`);
else { id = ((await execute(c.asc, [req], { dry, tag: TAG }))[0] as J).data.id; active++; } else {
// campaign names are not known to be unique: looked up right before the create, never sent twice
const made = await findOrCreate<J>({ what: `${p.id} campaign "${oc.name}"`, tag: TAG,
find: async () => (await c.all(`/v2/inAppPurchases/${iap.id}/offerCodes?limit=200`)).find((x: J) => x.attributes.name === oc.name) ?? null,
journal: { root, key: `apple:${appId}:iapOfferCode:${p.id}:${oc.name}`, byId: x => getById(c.asc, `/v1/inAppPurchaseOfferCodes/${x}`) as Promise<J | null>, idOf: v => v.id },
create: async () => (await c.asc('POST', req.path, req.body)).data });
id = made.value.id;
log(TAG, `${describeReq(req)}: ${made.created ? 'done' : 'exists already'}`);
if (made.created) active++;
}
} else log(TAG, `${p.id}: campaign "${oc.name}" exists (${cur.attributes.active ? 'active' : 'inactive'}, ${cur.attributes.productionCodeCount ?? 0} production codes)`); } else log(TAG, `${p.id}: campaign "${oc.name}" exists (${cur.attributes.active ? 'active' : 'inactive'}, ${cur.attributes.productionCodeCount ?? 0} production codes)`);
const haveCustom = new Set(cur ? (await c.all(`/v1/inAppPurchaseOfferCodes/${cur.id}/customCodes?limit=200`)).map((x: J) => x.attributes.customCode) : []); const haveCustom = new Set(cur ? (await c.all(`/v1/inAppPurchaseOfferCodes/${cur.id}/customCodes?limit=200`)).map((x: J) => x.attributes.customCode) : []);
for (const cc of oc.customCodes ?? []) if (!haveCustom.has(cc.code)) codeReqs.push({ req: iapCustomCodeReq(id ?? '${new campaign}', cc), product: p.id, campaign: oc.name, oneTime: false }); for (const cc of oc.customCodes ?? []) if (!haveCustom.has(cc.code)) codeReqs.push({ req: iapCustomCodeReq(id ?? '${new campaign}', cc), product: p.id, campaign: oc.name, oneTime: false });
@ -78,8 +88,8 @@ export async function run(ctx: Ctx): Promise<void> {
if (!(await confirm(ctx, `Create ${codeReqs.length} code request(s) (${codeReqs.map(x => x.req.note).join('; ')})? Codes count against the 1 million per quarter and can be redeemed by anyone who gets them.`))) return; if (!(await confirm(ctx, `Create ${codeReqs.length} code request(s) (${codeReqs.map(x => x.req.note).join('; ')})? Codes count against the 1 million per quarter and can be redeemed by anyone who gets them.`))) return;
const dir = resolve('.storebound/offer-codes'); const dir = resolve('.storebound/offer-codes');
for (const x of codeReqs) { for (const x of codeReqs) {
const out = (await execute(c.asc, [x.req], { dry: false, tag: TAG }))[0] as J; const out = (await execute(c.asc, [x.req], { dry: false, tag: TAG }))[0] as J | null;
if (!x.oneTime) continue; if (!x.oneTime || !out) continue;
// the codes of a one-time-use batch (text/csv); saved locally, never printed // the codes of a one-time-use batch (text/csv); saved locally, never printed
const values = await c.bytes(`/v1/inAppPurchaseOfferCodeOneTimeUseCodes/${out.data.id}/values`, 'text/csv'); const values = await c.bytes(`/v1/inAppPurchaseOfferCodeOneTimeUseCodes/${out.data.id}/values`, 'text/csv');
if (!values) { warn(TAG, `${x.product} "${x.campaign}": codes not ready yet; download them in App Store Connect`); continue; } if (!values) { warn(TAG, `${x.product} "${x.campaign}": codes not ready yet; download them in App Store Connect`); continue; }

View file

@ -21,7 +21,8 @@ import { textTable } from '../lib/table';
import { numberedImages } from '../play/images'; import { numberedImages } from '../play/images';
import { variantRoot } from '../shots/lib'; import { variantRoot } from '../shots/lib';
import { type Asc, ascClient, type J } from './asc'; import { type Asc, ascClient, type J } from './asc';
import { execute } from './jsonapi'; import { describeReq, execute } from './jsonapi';
import { findOrCreate, getById } from '../lib/upsert';
import { displaySets, syncScreenshotSet } from './screenshots'; import { displaySets, syncScreenshotSet } from './screenshots';
const TAG = 'ppo'; const TAG = 'ppo';
@ -78,12 +79,29 @@ export async function run(ctx: Ctx): Promise<void> {
let exp = pickExperiment(list, name); let exp = pickExperiment(list, name);
if (!exp) { if (!exp) {
if (dry) { await execute(c.asc, [createExperimentReq(appId, name, traffic)], { dry, tag: TAG }); log(TAG, `then: ${variants.length} treatment(s) x ${locales.length} locale(s)`); done(TAG, 'create checked (dry run)'); return; } if (dry) { await execute(c.asc, [createExperimentReq(appId, name, traffic)], { dry, tag: TAG }); log(TAG, `then: ${variants.length} treatment(s) x ${locales.length} locale(s)`); done(TAG, 'create checked (dry run)'); return; }
const r = (await execute(c.asc, [createExperimentReq(appId, name, traffic)], { dry, tag: TAG }))[0] as J; // test names are not known to be unique: looked up right before the create (and in the journal), never sent twice
exp = experimentFromApi(r.data); const req = createExperimentReq(appId, name, traffic);
const made = await findOrCreate<J>({ what: `experiment "${name}"`, tag: TAG,
find: async () => (await c.all(`/v1/apps/${appId}/appStoreVersionExperimentsV2?limit=50`)).find((x: J) => x.attributes.name === name && !['COMPLETED', 'STOPPED'].includes(x.attributes.state)) ?? null,
journal: { root, key: `apple:${appId}:ppo:${name}`, byId: id => getById(c.asc, `/v2/appStoreVersionExperiments/${id}`) as Promise<J | null>, idOf: v => v.id },
create: async () => (await c.asc('POST', req.path, req.body)).data });
log(TAG, `${describeReq(req)}: ${made.created ? 'done' : 'exists already'}`);
exp = experimentFromApi(made.value);
} else log(TAG, `experiment "${name}": exists (${exp.state})`); } else log(TAG, `experiment "${name}": exists (${exp.state})`);
const ts = await treatments(c, exp.id); const ts = await treatments(c, exp.id);
const created = (await execute(c.asc, planTreatments(exp.id, ts.map((t: J) => ({ id: t.id, name: t.attributes.name })), variants), { dry, tag: TAG })) as J[]; const planned = planTreatments(exp.id, ts.map((t: J) => ({ id: t.id, name: t.attributes.name })), variants);
const all = [...ts, ...created.map(r => r.data)].filter(t => variants.includes(t.attributes.name)); const all: J[] = ts.filter((t: J) => variants.includes(t.attributes.name));
if (dry) await execute(c.asc, planned, { dry, tag: TAG });
else for (const r of planned) {
const tname = (r.body as J).data.attributes.name as string;
const expId = exp.id;
const made = await findOrCreate<J>({ what: `treatment "${tname}"`, tag: TAG,
find: async () => (await treatments(c, expId)).find((t: J) => t.attributes.name === tname) ?? null,
journal: { root, key: `apple:${appId}:ppoTreatment:${expId}:${tname}`, byId: id => getById(c.asc, `/v1/appStoreVersionExperimentTreatments/${id}`) as Promise<J | null>, idOf: v => v.id },
create: async () => (await c.asc('POST', r.path, r.body)).data });
log(TAG, `${describeReq(r)}: ${made.created ? 'done' : 'exists already'}`);
all.push(made.value);
}
for (const t of all) await execute(c.asc, planTreatmentLocales(t.id, (await treatmentLocs(c, t.id)).map((l: J) => l.attributes.locale), locales), { dry, tag: TAG }); for (const t of all) await execute(c.asc, planTreatmentLocales(t.id, (await treatmentLocs(c, t.id)).map((l: J) => l.attributes.locale), locales), { dry, tag: TAG });
done(TAG, dry ? 'create checked (dry run)' : `test "${name}" ready: storebound apple ppo upload --variant ${variants[0]}`); done(TAG, dry ? 'create checked (dry run)' : `test "${name}" ready: storebound apple ppo upload --variant ${variants[0]}`);
return; return;

View file

@ -16,7 +16,8 @@ import { done, log, warn } from '../lib/log';
import { ffprobe } from '../shots/report'; import { ffprobe } from '../shots/report';
import { checkVideo, PREVIEW_RULES, VIDEO_TARGETS } from '../shots/spec'; import { checkVideo, PREVIEW_RULES, VIDEO_TARGETS } from '../shots/spec';
import { timeCode } from '../shots/video'; import { timeCode } from '../shots/video';
import { ascClient, md5, setUpToDate, type J } from './asc'; import { findOrCreate } from '../lib/upsert';
import { ascClient, keptPrefix, md5, setUpToDate, type J } from './asc';
/** Video target folder -> ASC previewType, for this project (iPad only with apple.ipad). */ /** Video target folder -> ASC previewType, for this project (iPad only with apple.ipad). */
export const previewSets = (apple: { ipad?: boolean } | undefined): Record<string, string> => export const previewSets = (apple: { ipad?: boolean } | undefined): Record<string, string> =>
@ -100,15 +101,22 @@ export async function run(ctx: Ctx): Promise<void> {
log('apple', `${tag}: poster frame ${tc}`); log('apple', `${tag}: poster frame ${tc}`);
continue; continue;
} }
if (dry) { log('apple', `${tag}: would replace ${existing.length} with ${fs.length}`); continue; } const kept = keptPrefix(existing.map(p => ({ checksum: p.attributes.sourceFileChecksum, state: state(p) })), want);
if (!set) set = (await c.asc('POST', '/v1/appPreviewSets', { data: { type: 'appPreviewSets', attributes: { previewType: type }, if (dry) { log('apple', `${tag}: would keep ${kept}, replace ${existing.length - kept} with ${fs.length - kept}`); continue; }
relationships: { appStoreVersionLocalization: { data: { type: 'appStoreVersionLocalizations', id: loc.id } } } } })).data; if (!set) {
for (const p of existing) await c.asc('DELETE', `/v1/appPreviews/${p.id}`); const r = await findOrCreate<J>({ what: `${tag} preview set`, tag: 'apple', unique: true,
for (const f of fs) { find: async () => (await c.all(`/v1/appStoreVersionLocalizations/${loc.id}/appPreviewSets`)).find((s: J) => s.attributes.previewType === type) ?? null,
log('apple', `${tag}: uploading ${f.slice(src.length + 1)}`); create: async () => (await c.asc('POST', '/v1/appPreviewSets', { data: { type: 'appPreviewSets', attributes: { previewType: type },
await c.uploadAsset('appPreviews', { appPreviewSet: { data: { type: 'appPreviewSets', id: set.id } } }, f, previewAttrs(f, poster)); relationships: { appStoreVersionLocalization: { data: { type: 'appStoreVersionLocalizations', id: loc.id } } } } })).data });
set = r.value;
if (!r.created) existing.push(...await c.all(`/v1/appPreviewSets/${set.id}/appPreviews`));
} }
log('apple', `${tag}: uploaded ${fs.length} (removed ${existing.length})`); const r = await c.syncAssets({ set: { type: 'appPreviewSets', id: set.id }, existing, files: fs, want, state, attrs: f => previewAttrs(f, poster),
onUpload: f => log('apple', `${tag}: uploading ${f.slice(src.length + 1)}`) });
const tc = timeCode(poster);
for (const p of existing.slice(0, r.kept).filter(p => p.attributes.previewFrameTimeCode && p.attributes.previewFrameTimeCode !== tc))
await c.asc('PATCH', `/v1/appPreviews/${p.id}`, { data: { type: 'appPreviews', id: p.id, attributes: { previewFrameTimeCode: tc } } });
log('apple', `${tag}: uploaded ${r.uploaded}${r.kept ? `, kept ${r.kept}` : ''} (removed ${r.removed})`);
} }
} }
if (dry) return done('apple', 'previews checked (dry run, nothing sent)'); if (dry) return done('apple', 'previews checked (dry run, nothing sent)');

View file

@ -10,6 +10,7 @@ import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx'; import { confirm, need } from '../lib/ctx';
import { done, log } from '../lib/log'; import { done, log } from '../lib/log';
import { type PhasedAction, phasedRequest } from '../lib/release'; import { type PhasedAction, phasedRequest } from '../lib/release';
import { createOnce } from '../lib/upsert';
import { ascClient, type J } from './asc'; import { ascClient, type J } from './asc';
export async function run(ctx: Ctx): Promise<void> { export async function run(ctx: Ctx): Promise<void> {
@ -50,8 +51,11 @@ export async function run(ctx: Ctx): Promise<void> {
if (dry) { log('apple', `${tag}: would ${req.method === 'POST' ? 'turn phased release on' : `set phased release ${req.state}`} (dry)`); return; } if (dry) { log('apple', `${tag}: would ${req.method === 'POST' ? 'turn phased release on' : `set phased release ${req.state}`} (dry)`); return; }
if ((phased === 'resume' || phased === 'complete') && !(await confirm(ctx, `${phased === 'complete' ? 'Release' : 'Resume the phased release of'} ${tag} ${phased === 'complete' ? 'to every user now' : ''}?`))) return; if ((phased === 'resume' || phased === 'complete') && !(await confirm(ctx, `${phased === 'complete' ? 'Release' : 'Resume the phased release of'} ${tag} ${phased === 'complete' ? 'to every user now' : ''}?`))) return;
if (req.method === 'POST') { if (req.method === 'POST') {
await c.asc('POST', '/v1/appStoreVersionPhasedReleases', { data: { type: 'appStoreVersionPhasedReleases', // one phased release per version: a 409 or an unclear answer is checked by reading it back, never re-sent blindly
relationships: { appStoreVersion: { data: { type: 'appStoreVersions', id: v.id } } } } }); await createOnce({ what: `phased release of ${tag}`, tag: 'apple', unique: true,
find: async () => (await c.maybe(`/v1/appStoreVersions/${v.id}/appStoreVersionPhasedRelease`))?.data ?? null,
create: async () => (await c.asc('POST', '/v1/appStoreVersionPhasedReleases', { data: { type: 'appStoreVersionPhasedReleases',
relationships: { appStoreVersion: { data: { type: 'appStoreVersions', id: v.id } } } } })).data });
} else { } else {
await c.asc('PATCH', `/v1/appStoreVersionPhasedReleases/${cur!.id}`, { data: { type: 'appStoreVersionPhasedReleases', id: cur!.id, attributes: { phasedReleaseState: req.state } } }); await c.asc('PATCH', `/v1/appStoreVersionPhasedReleases/${cur!.id}`, { data: { type: 'appStoreVersionPhasedReleases', id: cur!.id, attributes: { phasedReleaseState: req.state } } });
} }

View file

@ -10,6 +10,7 @@ import { need } from '../lib/ctx';
import { reviewContact, reviewDemoAccount } from '../lib/credentials'; import { reviewContact, reviewDemoAccount } from '../lib/credentials';
import { reviewDetailPlan } from '../lib/review-details'; import { reviewDetailPlan } from '../lib/review-details';
import { done, log } from '../lib/log'; import { done, log } from '../lib/log';
import { createOnce } from '../lib/upsert';
import { ascClient } from './asc'; import { ascClient } from './asc';
export async function run(ctx: Ctx): Promise<void> { export async function run(ctx: Ctx): Promise<void> {
@ -34,9 +35,17 @@ export async function run(ctx: Ctx): Promise<void> {
const { attributes, summary } = reviewDetailPlan(config, contact, reviewDemoAccount(root)); const { attributes, summary } = reviewDetailPlan(config, contact, reviewDemoAccount(root));
if (ctx.args.has('dry')) { log('apple', `would set review details (contact ${from ? 'copied from ' + from : 'from credentials'}, ${summary})`); return; } if (ctx.args.has('dry')) { log('apple', `would set review details (contact ${from ? 'copied from ' + from : 'from credentials'}, ${summary})`); return; }
const cur = (await c.maybe(`/v1/appStoreVersions/${version.id}/appStoreReviewDetail`))?.data; const read = async () => (await c.maybe(`/v1/appStoreVersions/${version.id}/appStoreReviewDetail`))?.data ?? null;
if (cur) await c.asc('PATCH', `/v1/appStoreReviewDetails/${cur.id}`, { data: { type: 'appStoreReviewDetails', id: cur.id, attributes } }); const cur = await read();
else await c.asc('POST', '/v1/appStoreReviewDetails', { data: { type: 'appStoreReviewDetails', attributes, // one review detail per version: only changed fields are sent; a new one is created once (a 409 finds it)
relationships: { appStoreVersion: { data: { type: 'appStoreVersions', id: version.id } } } } }); const changed = cur ? Object.fromEntries(Object.entries(attributes).filter(([k, v]) => (cur.attributes?.[k] ?? null) !== (v ?? null))) : attributes;
if (cur && !Object.keys(changed).length) { done('apple', `review details on ${version.attributes.versionString} up to date (${summary})`); return; }
if (cur) await c.asc('PATCH', `/v1/appStoreReviewDetails/${cur.id}`, { data: { type: 'appStoreReviewDetails', id: cur.id, attributes: changed } });
else {
const r = await createOnce({ what: 'App Review details', tag: 'apple', unique: true, find: read,
create: async () => (await c.asc('POST', '/v1/appStoreReviewDetails', { data: { type: 'appStoreReviewDetails', attributes,
relationships: { appStoreVersion: { data: { type: 'appStoreVersions', id: version.id } } } } })).data });
if (!r.created) await c.asc('PATCH', `/v1/appStoreReviewDetails/${r.value.id}`, { data: { type: 'appStoreReviewDetails', id: r.value.id, attributes } });
}
done('apple', `review details set on ${version.attributes.versionString} (${summary})`); done('apple', `review details set on ${version.attributes.versionString} (${summary})`);
} }

View file

@ -14,7 +14,8 @@ import { byAppleLocale, need } from '../lib/ctx';
import { done, log, warn } from '../lib/log'; import { done, log, warn } from '../lib/log';
import { numberedImages } from '../play/images'; import { numberedImages } from '../play/images';
import { variantRoot } from '../shots/lib'; import { variantRoot } from '../shots/lib';
import { type Asc, ascClient, md5, setUpToDate, type J } from './asc'; import { findOrCreate } from '../lib/upsert';
import { type Asc, ascClient, keptPrefix, md5, setUpToDate, type J } from './asc';
/** Render size key -> ASC screenshotDisplayType (iPhone sets). */ /** Render size key -> ASC screenshotDisplayType (iPhone sets). */
export const DISPLAY: Record<string, string> = { ios69: 'APP_IPHONE_67', ios65: 'APP_IPHONE_65' }; export const DISPLAY: Record<string, string> = { ios69: 'APP_IPHONE_67', ios65: 'APP_IPHONE_65' };
@ -33,19 +34,32 @@ export const displaySets = (apple: { ipad?: boolean; iphoneMedium?: boolean } |
export const requiredDisplayTypes = (apple: { ipad?: boolean; screenshotTypes?: string[] } | undefined): string[] => export const requiredDisplayTypes = (apple: { ipad?: boolean; screenshotTypes?: string[] } | undefined): string[] =>
apple?.screenshotTypes ?? ['APP_IPHONE_67', ...(apple?.ipad ? ['APP_IPAD_PRO_3GEN_129'] : [])]; apple?.screenshotTypes ?? ['APP_IPHONE_67', ...(apple?.ipad ? ['APP_IPAD_PRO_3GEN_129'] : [])];
/** Makes one screenshot set (of a version localization, or of a Product Page Optimization treatment localization) /** Makes one screenshot set (of a version localization, a custom product page localization or a Product Page
* hold exactly `files`, in order: left alone when the MD5s match, else emptied and re-uploaded. Creates the set. */ * Optimization treatment localization) hold exactly `files`, in order: left alone when the MD5s match, else the
* matching leading screenshots stay and the rest is replaced (c.syncAssets). The set is created when missing, after a
* fresh look (one set per display type: a 409 finds the existing one). */
export async function syncScreenshotSet(c: Asc, parent: { rel: string; type: string; id: string }, sets: J[], type: string, files: string[], tag: string, dry: boolean): Promise<void> { export async function syncScreenshotSet(c: Asc, parent: { rel: string; type: string; id: string }, sets: J[], type: string, files: string[], tag: string, dry: boolean): Promise<void> {
let set = sets.find(s => s.attributes.screenshotDisplayType === type); let set = sets.find(s => s.attributes.screenshotDisplayType === type);
const existing = set ? await c.all(`/v1/appScreenshotSets/${set.id}/appScreenshots`) : []; const existing = set ? await c.all(`/v1/appScreenshotSets/${set.id}/appScreenshots?limit=50`) : [];
const want = files.map(f => md5(readFileSync(f))); const want = files.map(f => md5(readFileSync(f)));
if (set && setUpToDate(existing.map(s => ({ checksum: s.attributes.sourceFileChecksum, state: s.attributes.assetDeliveryState?.state })), want)) { log('apple', `${tag}: ${files.length} up to date`); return; } const state = (s: J) => s.attributes.assetDeliveryState?.state;
if (dry) { log('apple', `${tag}: would ${set ? `replace ${existing.length}` : 'create the set and upload'} with ${files.length}`); return; } if (set && setUpToDate(existing.map(s => ({ checksum: s.attributes.sourceFileChecksum, state: state(s) })), want)) { log('apple', `${tag}: ${files.length} up to date`); return; }
if (!set) set = (await c.asc('POST', '/v1/appScreenshotSets', { data: { type: 'appScreenshotSets', attributes: { screenshotDisplayType: type }, if (dry) {
relationships: { [parent.rel]: { data: { type: parent.type, id: parent.id } } } } })).data; const k = keptPrefix(existing.map(s => ({ checksum: s.attributes.sourceFileChecksum, state: state(s) })), want);
for (const s of existing) await c.asc('DELETE', `/v1/appScreenshots/${s.id}`); log('apple', `${tag}: would ${set ? `keep ${k}, replace ${existing.length - k}` : 'create the set and upload'} with ${files.length - k} new`);
for (const f of files) await c.uploadAsset('appScreenshots', { appScreenshotSet: { data: { type: 'appScreenshotSets', id: set.id } } }, f); return;
log('apple', `${tag}: uploaded ${files.length} (removed ${existing.length})`); }
if (!set) {
const find = async () => (await c.all(`/v1/${parent.type}/${parent.id}/appScreenshotSets?limit=50`)).find((s: J) => s.attributes.screenshotDisplayType === type) ?? null;
const r = await findOrCreate<J>({ what: `${tag} screenshot set`, tag: 'apple', unique: true, find,
create: async () => (await c.asc('POST', '/v1/appScreenshotSets', { data: { type: 'appScreenshotSets', attributes: { screenshotDisplayType: type },
relationships: { [parent.rel]: { data: { type: parent.type, id: parent.id } } } } })).data });
set = r.value;
// a set found now (made by an earlier run the first read missed) may already hold screenshots
if (!r.created) existing.push(...await c.all(`/v1/appScreenshotSets/${set.id}/appScreenshots?limit=50`));
}
const r = await c.syncAssets({ set: { type: 'appScreenshotSets', id: set.id }, existing, files, want, state });
log('apple', `${tag}: uploaded ${r.uploaded}${r.kept ? `, kept ${r.kept}` : ''} (removed ${r.removed})`);
} }
export async function run(ctx: Ctx): Promise<void> { export async function run(ctx: Ctx): Promise<void> {

View file

@ -23,6 +23,7 @@ import {
import { textTable } from '../lib/table'; import { textTable } from '../lib/table';
import { type Asc, ascClient, type J } from './asc'; import { type Asc, ascClient, type J } from './asc';
import { describeReq, execute, type Req } from './jsonapi'; import { describeReq, execute, type Req } from './jsonapi';
import { createOnce, isAmbiguous, upsertTiming } from '../lib/upsert';
const TAG = 'signing'; const TAG = 'signing';
const USAGE = 'usage: storebound apple signing status | ensure [--dry] [--keychain [path]] [--no-install]'; const USAGE = 'usage: storebound apple signing status | ensure [--dry] [--keychain [path]] [--no-install]';
@ -194,9 +195,23 @@ export async function run(ctx: Ctx): Promise<void> {
const csr = buildCsr(key, { commonName: `storebound ${config.app.name}`.slice(0, 64) }); const csr = buildCsr(key, { commonName: `storebound ${config.app.name}`.slice(0, 64) });
created = (await c.asc('POST', '/v1/certificates', certificateReq(certType, csrContent(csr)).body)).data; created = (await c.asc('POST', '/v1/certificates', certificateReq(certType, csrContent(csr)).body)).data;
} catch (e) { } catch (e) {
rmSync(pending, { force: true }); if (isAmbiguous(e)) {
const why = explainCertError(e, certType); // the certificate may exist: it is the one whose public key is this pending key's. The key stays on disk either
throw new Error(why ? `${(e as Error).message}\n\n${why}` : (e as Error).message); // way (a later run finds the certificate through it), so no certificate is ever orphaned or made twice.
const mine = spkiOfKey(readFileSync(pending, 'utf8'));
let found: J | undefined;
for (let i = 0; i < 4 && !found; i++) {
if (i) await upsertTiming.sleep(upsertTiming.waitMs * i);
found = (await c.all(`/v1/certificates?filter[certificateType]=${certType}&limit=200`)).find((x: ApiCert) => !!x.attributes.certificateContent && certSpki(x.attributes.certificateContent) === mine);
}
if (!found) throw new Error(`${(e as Error).message}\n\nThe certificate request got no clear answer and App Store Connect does not list a certificate for the new key (yet). The key is kept in ${pending.replace(root + '/', '')}; run \`storebound apple signing ensure\` again in a minute: it finds the certificate through that key instead of making another one.`);
created = found;
log(TAG, 'the certificate request went through (found by its public key)');
} else {
rmSync(pending, { force: true });
const why = explainCertError(e, certType);
throw new Error(why ? `${(e as Error).message}\n\n${why}` : (e as Error).message);
}
} }
const keyFile = join(dir, `${created.id}.key`), cerFile = join(dir, `${created.id}.cer`); const keyFile = join(dir, `${created.id}.key`), cerFile = join(dir, `${created.id}.cer`);
renameSync(pending, keyFile); renameSync(pending, keyFile);
@ -212,7 +227,14 @@ export async function run(ctx: Ctx): Promise<void> {
const p = planProfile(list, { bundleId: id, bundleIdId: b.id, certId: cert!.id, type: profileType }); const p = planProfile(list, { bundleId: id, bundleIdId: b.id, certId: cert!.id, type: profileType });
let prof: ApiProfile; let prof: ApiProfile;
if (p.create) { if (p.create) {
const r = (await c.asc('POST', p.create.path, p.create.body)).data; // an unclear answer is checked by reading the bundle id's profiles again (one active profile with this
// certificate is reused), never re-sent blindly
const r = (await createOnce<J>({ what: `${id} profile`, tag: TAG,
find: async () => {
const again = planProfile(await profilesOf(c, b.id, profileType), { bundleId: id, bundleIdId: b.id, certId: cert!.id, type: profileType });
return again.reuse ? { id: again.reuse.id, attributes: (await c.asc('GET', `/v1/profiles/${again.reuse.id}`)).data.attributes } : null;
},
create: async () => (await c.asc('POST', p.create!.path, p.create!.body)).data })).value;
prof = { id: r.id, attributes: r.attributes }; prof = { id: r.id, attributes: r.attributes };
log(TAG, `${id}: profile "${prof.attributes.name}" created (${prof.attributes.uuid})`); log(TAG, `${id}: profile "${prof.attributes.name}" created (${prof.attributes.uuid})`);
} else { } else {

47
cli/apple/submission.ts Normal file
View file

@ -0,0 +1,47 @@
// The app's one draft review submission and the items in it, without duplicates: a draft (READY_FOR_REVIEW) is
// reused, a new one is created only after a fresh look (Apple keeps one open draft per app and platform), and an item
// already in the draft is not added again (a 409 or a re-read finds it). Used by iap-submit, cpp submit and events
// submit. Submitting itself (PATCH submitted: true) stays the owner's confirmed step in those commands.
import { log } from '../lib/log';
import { createOnce, findOrCreate } from '../lib/upsert';
import type { Asc, J } from './asc';
import { one } from './jsonapi';
/** The draft review submission of the app (READY_FOR_REVIEW, iOS), or null. */
export async function findDraft(c: Asc, appId: string): Promise<string | null> {
return (await c.all(`/v1/reviewSubmissions?filter[app]=${appId}&filter[platform]=IOS&filter[state]=READY_FOR_REVIEW&limit=10`))[0]?.id ?? null;
}
/** The draft's id: the existing one, else a new draft created once. */
export async function draftSubmission(c: Asc, appId: string, root: string, tag: string): Promise<string> {
const r = await findOrCreate<string>({ what: 'draft review submission', tag, unique: true,
find: () => findDraft(c, appId),
journal: { root, key: `apple:${appId}:reviewSubmission:draft`, idOf: id => id,
byId: async id => { const d = (await c.maybe(`/v1/reviewSubmissions/${id}`))?.data; return d?.attributes?.state === 'READY_FOR_REVIEW' ? id : null; } },
create: async () => (await c.asc('POST', '/v1/reviewSubmissions', { data: { type: 'reviewSubmissions', attributes: { platform: 'IOS' }, relationships: { app: one('apps', appId) } } })).data.id as string });
if (r.created) log(tag, `draft review submission ${r.value} created`);
return r.value;
}
const TYPE: Record<string, string> = {
appStoreVersion: 'appStoreVersions', inAppPurchaseVersion: 'inAppPurchaseVersions', subscriptionVersion: 'subscriptionVersions',
subscriptionGroupVersion: 'subscriptionGroupVersions', appCustomProductPageVersion: 'appCustomProductPageVersions', appEvent: 'appEvents',
appStoreVersionExperiment: 'appStoreVersionExperiments',
};
/** Whether the draft already holds `id` as `kind` (e.g. inAppPurchaseVersion). */
export async function inDraft(c: Asc, sid: string, kind: string, id: string): Promise<boolean> {
const items = await c.all(`/v1/reviewSubmissions/${sid}/items?include=${kind}&limit=200`);
return items.some((it: J) => it.relationships?.[kind]?.data?.id === id);
}
/** Adds one item to the draft unless it is in there already (`checked`: the caller read the draft's items a moment
* ago). true when it was added now. */
export async function addItem(c: Asc, sid: string, kind: string, id: string, label: string, tag: string, o: { checked?: boolean } = {}): Promise<boolean> {
if (!o.checked && await inDraft(c, sid, kind, id)) { log(tag, `${label}: already in draft ${sid}`); return false; }
const r = await createOnce({ what: `${label} in draft ${sid}`, tag, unique: true,
find: async () => ((await inDraft(c, sid, kind, id)) ? id : null),
create: async () => (await c.asc('POST', '/v1/reviewSubmissionItems', { data: { type: 'reviewSubmissionItems',
relationships: { reviewSubmission: one('reviewSubmissions', sid), [kind]: one(TYPE[kind] ?? `${kind}s`, id) } } })).data?.id ?? id });
return r.created;
}

View file

@ -25,7 +25,8 @@ import {
type ApplePoint, pickPoint, type ApplePoint, pickPoint,
} from '../lib/subscriptions'; } from '../lib/subscriptions';
import { pages } from '../prices'; import { pages } from '../prices';
import { type Asc, ascClient, type J } from './asc'; import { createOnce, findOrCreate, getById } from '../lib/upsert';
import { type Asc, ascClient, md5, type J } from './asc';
const TAG = 'apple'; const TAG = 'apple';
@ -68,7 +69,8 @@ export async function run(ctx: Ctx): Promise<void> {
.map(g => ({ ...g, subscriptions: g.subscriptions.filter(s => (!only.length || only.includes(s.id)) && sellsOn(config, s, 'apple')) })).filter(g => g.subscriptions.length); .map(g => ({ ...g, subscriptions: g.subscriptions.filter(s => (!only.length || only.includes(s.id)) && sellsOn(config, s, 'apple')) })).filter(g => g.subscriptions.length);
const dry = ctx.args.has('dry'); const dry = ctx.args.has('dry');
const csv = subscriptionCsv(ctx, 'apple'); const csv = subscriptionCsv(ctx, 'apple');
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId); const bundleId = need(config.apple?.bundleId, 'apple.bundleId');
const c = await ascClient(root, bundleId, config.apple?.appId);
const appId = await c.app(); const appId = await c.app();
log(TAG, 'reading subscription groups, subscriptions, localizations, prices, offers'); log(TAG, 'reading subscription groups, subscriptions, localizations, prices, offers');
const state = await readState(c, appId); const state = await readState(c, appId);
@ -87,33 +89,57 @@ export async function run(ctx: Ctx): Promise<void> {
const territories: string[] = (await c.all('/v1/territories?limit=200')).map((t: J) => t.id); const territories: string[] = (await c.all('/v1/territories?limit=200')).map((t: J) => t.id);
for (const g of groups) { for (const g of groups) {
let gid = state.groups.find(x => x.referenceName === g.name)?.id; let gid = state.groups.find(x => x.referenceName === g.name)?.id;
if (!gid) { gid = (await c.asc('POST', '/v1/subscriptionGroups', appleGroupBody(appId, g))).data.id as string; log(TAG, `group "${g.name}" created`); } if (!gid) {
const gLocs = state.groups.find(x => x.id === gid)?.locales ?? []; const r = await findOrCreate<J>({ what: `subscription group "${g.name}"`, tag: TAG, unique: true,
find: async () => (await c.all(`/v1/apps/${appId}/subscriptionGroups?limit=200`)).find((x: J) => x.attributes.referenceName === g.name) ?? null,
journal: { root, key: `apple:${bundleId}:subscriptionGroup:${g.name}`, byId: id => getById(c.asc, `/v1/subscriptionGroups/${id}`) as Promise<J | null>, idOf: v => v.id },
create: async () => (await c.asc('POST', '/v1/subscriptionGroups', appleGroupBody(appId, g))).data });
gid = r.value.id as string;
log(TAG, `group "${g.name}" ${r.created ? 'created' : 'exists'}`);
}
const groupId = gid;
const gLocs = state.groups.find(x => x.id === gid)?.locales ?? (await c.all(`/v1/subscriptionGroups/${gid}/subscriptionGroupLocalizations?limit=200`)).map((l: J) => l.attributes.locale);
for (const [lang, l] of Object.entries(g.listings)) { for (const [lang, l] of Object.entries(g.listings)) {
const loc = locale.get(lang); const loc = locale.get(lang);
if (!loc || gLocs.includes(loc)) continue; if (!loc || gLocs.includes(loc)) continue;
await c.asc('POST', '/v1/subscriptionGroupLocalizations', appleGroupLocBody(gid, loc, l)); await createOnce({ what: `group "${g.name}" ${loc}`, tag: TAG, unique: true,
find: async () => (await c.all(`/v1/subscriptionGroups/${groupId}/subscriptionGroupLocalizations?limit=200`)).find((x: J) => x.attributes.locale === loc) ?? null,
create: async () => (await c.asc('POST', '/v1/subscriptionGroupLocalizations', appleGroupLocBody(groupId, loc, l))).data });
log(TAG, ` group ${loc}`); log(TAG, ` group ${loc}`);
} }
for (const [i, s] of g.subscriptions.entries()) { for (const [i, s] of g.subscriptions.entries()) {
const tag = step(i + 1, g.subscriptions.length, s.id); const tag = step(i + 1, g.subscriptions.length, s.id);
const cur = state.subscriptions.find(x => x.productId === s.id); const cur = state.subscriptions.find(x => x.productId === s.id);
let id = cur?.id; let id = cur?.id;
if (!id) { id = (await c.asc('POST', '/v1/subscriptions', appleSubscriptionBody(gid, s))).data.id as string; log(TAG, `${tag}: created`); } let subLocs = cur?.locales;
if (!id) {
const r = await findOrCreate<J>({ what: `subscription ${s.id}`, tag: TAG, unique: true,
find: async () => (await c.all(`/v1/subscriptionGroups/${groupId}/subscriptions?limit=200`)).find((x: J) => x.attributes.productId === s.id) ?? null,
journal: { root, key: `apple:${bundleId}:subscription:${s.id}`, byId: x => getById(c.asc, `/v1/subscriptions/${x}`) as Promise<J | null>, idOf: v => v.id },
create: async () => (await c.asc('POST', '/v1/subscriptions', appleSubscriptionBody(groupId, s))).data });
id = r.value.id as string;
if (!r.created) subLocs = (await c.all(`/v1/subscriptions/${id}/subscriptionLocalizations?limit=200`)).map((l: J) => l.attributes.locale);
log(TAG, `${tag}: ${r.created ? 'created' : 'exists'}`);
}
const subId = id;
for (const [lang, l] of Object.entries(s.listings)) { for (const [lang, l] of Object.entries(s.listings)) {
const loc = locale.get(lang); const loc = locale.get(lang);
if (!loc || cur?.locales.includes(loc)) continue; if (!loc || subLocs?.includes(loc)) continue;
await c.asc('POST', '/v1/subscriptionLocalizations', appleSubLocBody(id, loc, l)); await createOnce({ what: `${s.id} ${loc}`, tag: TAG, unique: true,
find: async () => (await c.all(`/v1/subscriptions/${subId}/subscriptionLocalizations?limit=200`)).find((x: J) => x.attributes.locale === loc) ?? null,
create: async () => (await c.asc('POST', '/v1/subscriptionLocalizations', appleSubLocBody(subId, loc, l))).data });
log(TAG, ` ${loc}`); log(TAG, ` ${loc}`);
} }
if (!cur?.availability) { await c.asc('POST', '/v1/subscriptionAvailabilities', appleAvailabilityBody(id, territories)); log(TAG, ` available in ${territories.length} territories + new ones`); } if (!cur?.availability && !(await c.maybe(`/v1/subscriptions/${id}/subscriptionAvailability`))?.data) { await c.asc('POST', '/v1/subscriptionAvailabilities', appleAvailabilityBody(id, territories)); log(TAG, ` available in ${territories.length} territories + new ones`); }
// prices: USA point -> equalizations -> overrides / CSV; only what differs from today's prices // prices: USA point -> equalizations -> overrides / CSV; only what differs from today's prices
const usaPoints = applePoints(await pages(c, `/v1/subscriptions/${id}/pricePoints?filter[territory]=USA&include=territory&limit=200`)); const usaPoints = applePoints(await pages(c, `/v1/subscriptions/${id}/pricePoints?filter[territory]=USA&include=territory&limit=200`));
const usa = pickPoint(usaPoints, 'USA', s.usd); const usa = pickPoint(usaPoints, 'USA', s.usd);
const equalized = applePoints(await pages(c, `/v1/subscriptionPricePoints/${usa.id}/equalizations?include=territory&limit=200`)); const equalized = applePoints(await pages(c, `/v1/subscriptionPricePoints/${usa.id}/equalizations?include=territory&limit=200`));
const known: ApplePoint[] = [usa, ...equalized]; const known: ApplePoint[] = [usa, ...equalized];
const current = cur?.hasPrices ? appleSubPrices(await pages(c, `/v1/subscriptions/${id}/prices?include=subscriptionPricePoint,territory&limit=200`)) : []; // a subscription the first read missed (list lag) is read live, so nothing is sent twice
const hasPrices = cur ? cur.hasPrices : !!(await c.asc('GET', `/v1/subscriptions/${id}/prices?limit=1`)).data?.length;
const current = hasPrices ? appleSubPrices(await pages(c, `/v1/subscriptions/${id}/prices?include=subscriptionPricePoint,territory&limit=200`)) : [];
const changes = applePriceChanges(current, appleWantedPrices(s, equalized, csv), csv.filter(r => r.product === s.id)); const changes = applePriceChanges(current, appleWantedPrices(s, equalized, csv), csv.filter(r => r.product === s.id));
if (!changes.length) log(TAG, ' prices up to date'); if (!changes.length) log(TAG, ' prices up to date');
else if (current.length && !liveOk) warn(TAG, ` ${changes.length} price change(s) skipped: run in a terminal to confirm`); else if (current.length && !liveOk) warn(TAG, ` ${changes.length} price change(s) skipped: run in a terminal to confirm`);
@ -140,27 +166,42 @@ export async function run(ctx: Ctx): Promise<void> {
const eq = applePoints(await pages(c, `/v1/subscriptionPricePoints/${ip.id}/equalizations?include=territory&limit=200`)); const eq = applePoints(await pages(c, `/v1/subscriptionPricePoints/${ip.id}/equalizations?include=territory&limit=200`));
return [{ territory: 'USA', pointId: ip.id }, ...eq.filter(p => p.territory !== 'USA').map(p => ({ territory: p.territory, pointId: p.id }))]; return [{ territory: 'USA', pointId: ip.id }, ...eq.filter(p => p.territory !== 'USA').map(p => ({ territory: p.territory, pointId: p.id }))];
}; };
if (s.intro && !cur?.introTerritories) { if (s.intro && !(cur ? cur.introTerritories : (await c.asc('GET', `/v1/subscriptions/${id}/introductoryOffers?limit=1`)).data?.length)) {
const offers = await offerPrices(s.intro); const offers = await offerPrices(s.intro);
await c.asc('PATCH', `/v1/subscriptions/${id}`, appleIntroPatch(id, s, offers)); await c.asc('PATCH', `/v1/subscriptions/${id}`, appleIntroPatch(id, s, offers));
log(TAG, ` introductory offer in ${offers.length} territories`); log(TAG, ` introductory offer in ${offers.length} territories`);
} }
for (const o of s.offers ?? []) { for (const o of s.offers ?? []) {
if (cur?.promoOffers?.includes(o.id)) continue; if (cur?.promoOffers?.includes(o.id)) continue;
await c.asc('POST', '/v1/subscriptionPromotionalOffers', applePromoOfferBody(id, s, o, await offerPrices(o))); // offer codes (the offer's identifier) are unique per subscription: a 409 finds the existing one
log(TAG, ` promotional offer ${o.id}`); const r = await createOnce({ what: `${s.id} promotional offer ${o.id}`, tag: TAG, unique: true,
find: async () => (await c.all(`/v1/subscriptions/${subId}/promotionalOffers?limit=200`)).find((x: J) => x.attributes.offerCode === o.id) ?? null,
create: async () => (await c.asc('POST', '/v1/subscriptionPromotionalOffers', applePromoOfferBody(subId, s, o, await offerPrices(o)))).data });
if (r.created) log(TAG, ` promotional offer ${o.id}`);
} }
for (const oc of s.offerCodes ?? []) { for (const oc of s.offerCodes ?? []) {
let ocId: string | undefined; let ocId: string | undefined;
if (!cur?.offerCodes?.includes(oc.name)) { if (!cur?.offerCodes?.includes(oc.name)) {
ocId = (await c.asc('POST', '/v1/subscriptionOfferCodes', appleOfferCodeBody(id, s, oc, await offerPrices(oc)))).data.id as string; // campaign names are not known to be unique: look right before, never send the create twice
log(TAG, ` offer code campaign "${oc.name}"`); const r = await findOrCreate<J>({ what: `${s.id} offer code campaign "${oc.name}"`, tag: TAG,
find: async () => (await c.all(`/v1/subscriptions/${subId}/offerCodes?limit=200`)).find((x: J) => x.attributes.name === oc.name) ?? null,
journal: { root, key: `apple:${bundleId}:subscriptionOfferCode:${s.id}:${oc.name}`, byId: x => getById(c.asc, `/v1/subscriptionOfferCodes/${x}`) as Promise<J | null>, idOf: v => v.id },
create: async () => (await c.asc('POST', '/v1/subscriptionOfferCodes', appleOfferCodeBody(subId, s, oc, await offerPrices(oc)))).data });
ocId = r.value.id as string;
if (r.created) log(TAG, ` offer code campaign "${oc.name}"`);
} }
if (oc.customCodes?.length) await customCodes(c, ctx, id, s, oc, ocId); if (oc.customCodes?.length) await customCodes(c, ctx, subId, s, oc, ocId);
} }
if (s.reviewScreenshot && !cur?.reviewScreenshot) { if (s.reviewScreenshot) {
await c.uploadAsset('subscriptionAppStoreReviewScreenshots', { subscription: { data: { type: 'subscriptions', id } } }, resolve(s.reviewScreenshot)); // one screenshot per subscription: kept when Apple has these bytes (MD5) and did not fail them, else replaced
log(TAG, ` review screenshot ${s.reviewScreenshot}`); const file = resolve(s.reviewScreenshot);
const shot = cur?.reviewScreenshot || !cur ? (await c.maybe(`/v1/subscriptions/${subId}/appStoreReviewScreenshot`))?.data : null;
const ok = shot && shot.attributes?.sourceFileChecksum === md5(readFileSync(file)) && shot.attributes?.assetDeliveryState?.state !== 'FAILED';
if (!ok) {
if (shot) await c.asc('DELETE', `/v1/subscriptionAppStoreReviewScreenshots/${shot.id}`);
await c.uploadAsset('subscriptionAppStoreReviewScreenshots', { subscription: { data: { type: 'subscriptions', id: subId } } }, file);
log(TAG, ` review screenshot ${s.reviewScreenshot}`);
}
} }
} }
} }
@ -178,5 +219,11 @@ async function customCodes(c: Asc, ctx: Ctx, subId: string, s: Subscription, oc:
const todo = oc.customCodes!.filter(cc => !have.has(cc.code)); const todo = oc.customCodes!.filter(cc => !have.has(cc.code));
if (!todo.length) return; if (!todo.length) return;
if (!(await confirm({ ...ctx, args: { ...ctx.args, has: (n: string) => n !== 'yes' && ctx.args.has(n) } }, `Create ${todo.length} custom offer code(s) for ${s.id} (${todo.map(x => `${x.code} x${x.count}`).join(', ')})? Anyone with a code can redeem it.`))) return; if (!(await confirm({ ...ctx, args: { ...ctx.args, has: (n: string) => n !== 'yes' && ctx.args.has(n) } }, `Create ${todo.length} custom offer code(s) for ${s.id} (${todo.map(x => `${x.code} x${x.count}`).join(', ')})? Anyone with a code can redeem it.`))) return;
for (const cc of todo) { await c.asc('POST', '/v1/subscriptionOfferCodeCustomCodes', appleCustomCodeBody(id, cc)); log(TAG, ` custom code ${cc.code} (${cc.count})`); } for (const cc of todo) {
// a custom code is unique: a 409 finds the existing one
await createOnce({ what: `custom code ${cc.code}`, tag: TAG, unique: true,
find: async () => (await c.all(`/v1/subscriptionOfferCodes/${id}/customCodes?limit=200`)).find((x: J) => x.attributes.customCode === cc.code) ?? null,
create: async () => (await c.asc('POST', '/v1/subscriptionOfferCodeCustomCodes', appleCustomCodeBody(id, cc))).data });
log(TAG, ` custom code ${cc.code} (${cc.count})`);
}
} }

View file

@ -27,7 +27,8 @@ import {
parseTesters, pickBuild, planBetaInfo, planBetaReviewDetail, planDistribute, planGroups, planTesters, planWhatToTest, whatToTestFromListing, parseTesters, pickBuild, planBetaInfo, planBetaReviewDetail, planDistribute, planGroups, planTesters, planWhatToTest, whatToTestFromListing,
} from '../lib/testflight'; } from '../lib/testflight';
import { type Asc, ascClient, type J } from './asc'; import { type Asc, ascClient, type J } from './asc';
import { execute } from './jsonapi'; import { describeReq, execute } from './jsonapi';
import { findOrCreate, getById } from '../lib/upsert';
const TAG = 'testflight'; const TAG = 'testflight';
const USAGE = 'usage: storebound apple testflight status | groups | testers | what-to-test --build N | info | distribute --build N --group <names> | wait --build N | expire --keep N | --older-than D'; const USAGE = 'usage: storebound apple testflight status | groups | testers | what-to-test --build N | info | distribute --build N --group <names> | wait --build N | expire --keep N | --older-than D';
@ -138,7 +139,16 @@ export async function run(ctx: Ctx): Promise<void> {
const plan = planGroups(appId, await groups(c, appId), tf?.groups ?? []); const plan = planGroups(appId, await groups(c, appId), tf?.groups ?? []);
if (!tf?.groups?.length) warn(TAG, 'apple.testflight.groups is empty: nothing to create'); if (!tf?.groups?.length) warn(TAG, 'apple.testflight.groups is empty: nothing to create');
for (const n of plan.existing) log(TAG, `group "${n}": exists`); for (const n of plan.existing) log(TAG, `group "${n}": exists`);
await execute(c.asc, plan.create, { dry, tag: TAG }); if (dry) await execute(c.asc, plan.create, { dry, tag: TAG });
else for (const r of plan.create) {
// looked up right before the create (fresh list, then the journal for the list lag); a 409 finds it
const name = (r.body as J).data.attributes.name as string;
const made = await findOrCreate<J>({ what: `beta group "${name}"`, tag: TAG, unique: true,
find: async () => (await c.all(`/v1/apps/${appId}/betaGroups?limit=200`)).find((g: J) => g.attributes.name === name) ?? null,
journal: { root, key: `apple:${appId}:betaGroup:${name}`, byId: id => getById(c.asc, `/v1/betaGroups/${id}`) as Promise<J | null>, idOf: v => v.id },
create: async () => (await c.asc('POST', r.path, r.body)).data });
log(TAG, `${describeReq(r)}: ${made.created ? 'done' : 'exists already'}`);
}
done(TAG, dry ? `groups checked (dry run): ${plan.create.length} to create` : `groups done (${plan.create.length} created)`); done(TAG, dry ? `groups checked (dry run): ${plan.create.length} to create` : `groups done (${plan.create.length} created)`);
return; return;
} }

View file

@ -23,6 +23,7 @@ import { BUILD_WAIT_EXIT } from '../lib/build-ops';
import { type Asc, ascClient, type J } from './asc'; import { type Asc, ascClient, type J } from './asc';
import { waitForBuild, waitMessage } from './testflight'; import { waitForBuild, waitMessage } from './testflight';
import { describeReq } from './jsonapi'; import { describeReq } from './jsonapi';
import { createOnce } from '../lib/upsert';
const TAG = 'upload'; const TAG = 'upload';
@ -44,9 +45,20 @@ function slice(path: string, offset: number, length: number): Buffer {
async function apiUpload(c: Asc, appId: string, ipa: string, info: ReturnType<typeof ipaInfo>, deadline: number): Promise<void> { async function apiUpload(c: Asc, appId: string, ipa: string, info: ReturnType<typeof ipaInfo>, deadline: number): Promise<void> {
const size = statSync(ipa).size; const size = statSync(ipa).size;
const reqs = buildUploadReqs(appId, info, basename(ipa), size); const reqs = buildUploadReqs(appId, info, basename(ipa), size);
const up = (await c.asc('POST', reqs.upload.path, reqs.upload.body)).data; // one build upload per version + build number: an earlier one that did not fail is resumed, never doubled
log(TAG, `build upload ${up.id} created`); const prior = (await uploadsOf(c, appId, info)).find((u: J) => u.attributes?.state?.state !== 'FAILED');
const file = (await c.asc('POST', reqs.file(up.id).path, reqs.file(up.id).body)).data; const made = prior ? { value: prior, created: false } : await createOnce<J>({ what: `build upload ${info.version} (${info.build})`, tag: TAG,
find: async () => (await uploadsOf(c, appId, info)).find((u: J) => u.attributes?.state?.state !== 'FAILED') ?? null,
create: async () => (await c.asc('POST', reqs.upload.path, reqs.upload.body)).data });
const up = made.value;
log(TAG, made.created ? `build upload ${up.id} created` : `build upload ${up.id} exists (${up.attributes?.state?.state ?? '?'}): resuming it`);
const filesOf = async () => (await c.all(`/v1/buildUploads/${up.id}/buildUploadFiles?limit=10`)).filter((f: J) => f.attributes?.fileName === basename(ipa));
const fileNow = made.created ? undefined : (await filesOf())[0];
const file = fileNow ?? (await createOnce<J>({ what: `file reservation of build upload ${up.id}`, tag: TAG,
find: async () => (await filesOf())[0] ?? null,
create: async () => (await c.asc('POST', reqs.file(up.id).path, reqs.file(up.id).body)).data })).value;
if (fileNow && !(fileNow.attributes?.uploadOperations ?? []).length)
throw new Error(`build upload ${up.id} already has ${basename(ipa)} (${fileNow.attributes?.assetDeliveryState?.state ?? 'state unknown'}) and App Store Connect gives no upload parts for it: wait for it (storebound apple testflight wait --build ${info.build}), or raise the build number (storebound apple build)`);
const ops = (file.attributes.uploadOperations ?? []) as J[]; const ops = (file.attributes.uploadOperations ?? []) as J[];
log(TAG, `uploading ${(size / 1048576).toFixed(1)} MB in ${ops.length} part(s)`); log(TAG, `uploading ${(size / 1048576).toFixed(1)} MB in ${ops.length} part(s)`);
let sent = 0; let sent = 0;
@ -82,6 +94,10 @@ async function apiUpload(c: Asc, appId: string, ipa: string, info: ReturnType<ty
} }
} }
/** Build uploads of this version + build number (any state), newest first. */
const uploadsOf = (c: Asc, appId: string, info: { version: string; build: string }): Promise<J[]> =>
c.all(`/v1/apps/${appId}/buildUploads?filter[cfBundleShortVersionString]=${encodeURIComponent(info.version)}&filter[cfBundleVersion]=${encodeURIComponent(info.build)}&filter[platform]=IOS&limit=20`);
async function altoolUpload(root: string, ipa: string): Promise<void> { async function altoolUpload(root: string, ipa: string): Promise<void> {
if (process.platform !== 'darwin' || !Bun.which('xcrun')) throw new Error('--via altool needs macOS with Xcode; use --via api elsewhere'); if (process.platform !== 'darwin' || !Bun.which('xcrun')) throw new Error('--via altool needs macOS with Xcode; use --via api elsewhere');
const k = ascKeyFile(root); const k = ascKeyFile(root);
@ -122,6 +138,16 @@ export async function run(ctx: Ctx): Promise<void> {
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`);
const numbers = builds.map(b => String(b.attributes.version)); const numbers = builds.map(b => String(b.attributes.version));
const next = nextBuildNumber(numbers); 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`);
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.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`); 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 reqs = buildUploadReqs(appId, info, basename(ipa), size);

View file

@ -130,6 +130,9 @@ async function main(): Promise<void> {
if (!key) { console.error(`unknown command: ${argv.slice(0, 2).join(' ')}\n`); help(); process.exit(2); } if (!key) { console.error(`unknown command: ${argv.slice(0, 2).join(' ')}\n`); help(); process.exit(2); }
const args = parseArgs(argv.slice(key.split(' ').length)); const args = parseArgs(argv.slice(key.split(' ').length));
const loaded = await loadConfig(args.get('config')); const loaded = await loadConfig(args.get('config'));
// one writer per app and store (cli/lib/lock.ts): dry runs and reads take no lock
const { acquireLock, lockKeys } = await import('./lib/lock');
for (const k of lockKeys(key, args, loaded.config)) acquireLock(loaded.root, k, argv.filter(x => x !== '--force-unlock').join(' '), { force: args.has('force-unlock') });
await (await COMMANDS[key]![0]()).run({ loaded, args }); await (await COMMANDS[key]![0]()).run({ loaded, args });
} }

View file

@ -15,7 +15,7 @@ export function parseArgs(argv: string[], booleans: string[] = []): Args {
const repeated = new Map<string, string[]>(); const repeated = new Map<string, string[]>();
const set = (k: string, v: string) => { flags.set(k, v); repeated.set(k, [...(repeated.get(k) ?? []), v]); }; const set = (k: string, v: string) => { flags.set(k, v); repeated.set(k, [...(repeated.get(k) ?? []), v]); };
const _: string[] = []; const _: string[] = [];
const bool = new Set(['dry', 'yes', 'help', 'json', 'verbose', 'all-pages', 'apply', 'resume', 'force', 'include-drafts', ...booleans]); const bool = new Set(['dry', 'yes', 'help', 'json', 'verbose', 'all-pages', 'apply', 'resume', 'force', 'force-unlock', 'include-drafts', ...booleans]);
for (let i = 0; i < argv.length; i++) { for (let i = 0; i < argv.length; i++) {
const a = argv[i]!; const a = argv[i]!;
if (!a.startsWith('--')) { _.push(a); continue; } if (!a.startsWith('--')) { _.push(a); continue; }

90
cli/lib/lock.ts Normal file
View file

@ -0,0 +1,90 @@
// One writer per app and store: a write command takes .storebound/locks/<store>-<app>.lock before it changes
// anything, so two storebound runs (two terminals, an agent and a CI job, a flow and a hand run) never write to the
// same app at once and race each other into duplicates. Dry runs and reads take no lock.
// A lock whose process is gone (same machine) is taken over; a live one stops the run with who holds it. A lock
// left by a crash on another machine (shared folder) stays until --force-unlock or 12 hours.
import { hostname } from 'node:os';
import { mkdirSync, openSync, readFileSync, rmSync, writeSync, closeSync } from 'node:fs';
import { join } from 'node:path';
import type { ShipConfig } from './config';
import type { Args } from './args';
export interface LockInfo { pid: number; host: string; command: string; startedAt: string }
const STALE_MS = 12 * 3600_000;
export const lockPath = (root: string, key: string): string => join(root, '.storebound', 'locks', `${key.replace(/[^A-Za-z0-9._-]+/g, '_')}.lock`);
function alive(pid: number): boolean {
try { process.kill(pid, 0); return true; } catch (e) { return (e as NodeJS.ErrnoException).code === 'EPERM'; }
}
/** Whether a lock may be taken over: its process is gone (same host) or it is older than 12 hours. Pure apart from
* the process check. */
export function staleLock(l: LockInfo | null, now = Date.now(), host = hostname(), isAlive = alive): boolean {
if (!l) return true;
if (now - Date.parse(l.startedAt) > STALE_MS) return true;
return l.host === host && !isAlive(l.pid);
}
const held = new Set<string>();
let hooked = false;
/** Takes the lock `key` for `command` (`apple iap`); throws with the holder when another run has it. Released when the
* process exits. `force` removes a lock first (--force-unlock). */
export function acquireLock(root: string, key: string, command: string, o: { force?: boolean } = {}): () => void {
const path = lockPath(root, key);
mkdirSync(join(root, '.storebound', 'locks'), { recursive: true });
if (o.force) rmSync(path, { force: true });
for (let attempt = 0; ; attempt++) {
try {
const fd = openSync(path, 'wx', 0o600);
const info: LockInfo = { pid: process.pid, host: hostname(), command, startedAt: new Date().toISOString() };
writeSync(fd, JSON.stringify(info) + '\n');
closeSync(fd);
break;
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== 'EEXIST' || attempt > 2) throw e;
let cur: LockInfo | null = null;
try { cur = JSON.parse(readFileSync(path, 'utf8')) as LockInfo; } catch { /* half written or unreadable: treat as stale */ }
if (!staleLock(cur)) {
throw new Error(`another storebound run is writing to ${key.replace(/^(\w+)-/, '$1 ')}: \`storebound ${cur!.command}\` (pid ${cur!.pid} on ${cur!.host}, since ${cur!.startedAt.slice(0, 16).replace('T', ' ')} UTC). `
+ `Two writers at once can create duplicates, so this run stops. Wait for it to finish; if it is no longer running, re-run with --force-unlock (removes ${join('.storebound', 'locks', path.split('/').pop()!)}).`);
}
rmSync(path, { force: true });
}
}
held.add(path);
if (!hooked) { hooked = true; process.on('exit', () => { for (const p of held) rmSync(p, { force: true }); }); }
return () => { rmSync(path, { force: true }); held.delete(path); };
}
/** Read-only subcommands and flags: no lock. */
const READ_SUBS = new Set(['status', 'list', 'devices', 'diff', 'export', 'pull', 'wait', 'options', 'coverage', 'report', 'campaigns', 'check', 'help']);
/**
* The lock keys a command run needs: none for dry runs and reads, `apple-<bundle id>` / `play-<package>` for store
* writes, both for `prices import`, `revenuecat-<project>` for revenuecat sync. Pure.
*/
export function lockKeys(command: string, args: Args, config: ShipConfig): string[] {
// a dry run never writes, so it never waits for (or blocks) a writer
const readOnly = args.has('dry');
if (readOnly) return [];
const sub = args._[0];
const apple = config.apple?.bundleId ? [`apple-${config.apple.bundleId}`] : [];
const play = config.play?.packageName ? [`play-${config.play.packageName}`] : [];
if (command.startsWith('apple ')) {
if (['apple status', 'apple next-build', 'apple build'].includes(command)) return [];
if (command === 'apple listing' && sub === 'pull') return [];
if (sub && READ_SUBS.has(sub)) return [];
if (command === 'apple adhoc' && ['options', 'export'].includes(sub ?? '')) return [];
return apple;
}
if (command.startsWith('play ')) {
if (['play status', 'play next-version-code', 'play build'].includes(command)) return [];
if (command === 'play listing' && sub === 'pull') return [];
return play;
}
if (command === 'prices') return sub === 'import' && !args.has('json') ? [...apple, ...play] : [];
if (command === 'revenuecat') return sub === 'sync' ? [`revenuecat-${config.apple?.bundleId ?? config.play?.packageName ?? 'project'}`] : [];
return [];
}

152
cli/lib/upsert.ts Normal file
View file

@ -0,0 +1,152 @@
// Idempotent writes against the stores. Every create in storebound goes through here or follows the same rules
// (docs/idempotency.md):
// 1. Look the thing up by its stable key first (product id, locale, version string, display type, name) and update or
// leave it instead of creating it again.
// 2. Right before a create, look again with a fresh GET (findOrCreate), and ask a journal of recent creates
// (.storebound/created.json) for an id the store's list may not show yet: a direct GET by id is not subject to the
// minute of list lag right after a create.
// 3. A create answered with 409 / ALREADY_EXISTS is "found": look it up and use it, never create again (createOnce).
// 4. A create whose outcome is unknown (timeout, connection reset, 5xx on a POST) is never sent blindly again: look
// first; only when the store itself refuses duplicates of that key (unique) is a second attempt safe (its 409
// then finds the first one). Otherwise stop with a message: re-running reads the store and converges.
// Retries of 429 / 5xx in the API clients are only for requests that end the same when sent twice (retrySafe).
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { log, warn } from './log';
/** A write whose outcome is unknown: it may or may not have been applied (timeout, connection reset, 5xx answer to a
* POST). The API clients throw this instead of retrying such a request. */
export class AmbiguousWriteError extends Error {
readonly ambiguous = true;
constructor(message: string, readonly status: number | null = null) { super(message); this.name = 'AmbiguousWriteError'; }
}
export const isAmbiguous = (e: unknown): boolean => e instanceof AmbiguousWriteError || (e as { ambiguous?: boolean } | null)?.ambiguous === true;
/**
* The store refused a create because the thing already exists: Google ALREADY_EXISTS / 409, App Store Connect 409 with
* a "duplicate / already / unique / in use / taken" detail (Apple also answers 409 for plain validation errors, which
* are not conflicts), RevenueCat 409. Pure.
*/
export function isConflict(e: unknown): boolean {
const s = String((e as Error)?.message ?? e);
if (/ALREADY_EXISTS/.test(s)) return true;
if (!/(-> |: )409\b/.test(s)) return false;
return /duplicate|already|unique|in use|taken|exists/i.test(s.replace(/^.*?409/, ''));
}
/** POSTs that end the same when sent twice: relationship adds, Google custom actions that set a state, and the App
* Store "create or replace" singletons (price schedules, availability). */
const IDEMPOTENT_POST: RegExp[] = [
/\/relationships\//,
/:(validate|convertRegionPrices|batchUpdateStates|activate|deactivate|query|batchGet|reply)(\?|$)/,
/\/edits(\?|$)/, // a new edit; an unused one expires on its own and changes nothing
/\/dataSafety(\?|$)/,
/^\/v1\/(inAppPurchasePriceSchedules|appPriceSchedules|inAppPurchaseAvailabilities|subscriptionAvailabilities)(\?|$)/,
/^\/v2\/appAvailabilities(\?|$)/,
];
/** Whether a request may be sent again after a 5xx or a network error. GET, HEAD, PUT, PATCH and DELETE set a state;
* a POST creates unless its path is a known idempotent action. `path` may be a full URL. Pure. */
export function retrySafe(method: string, path: string): boolean {
const m = method.toUpperCase();
if (m !== 'POST') return true;
const p = path.replace(/^https:\/\/[^/]+/, '').replace(/^\/(androidpublisher|upload\/androidpublisher)\/v3\/applications\/[^/]+/, '');
return IDEMPOTENT_POST.some(re => re.test(p));
}
/** Waits between re-checks (STOREBOUND_UPSERT_WAIT_MS; the test preload sets 0). */
export const upsertTiming = { waitMs: Number(process.env.STOREBOUND_UPSERT_WAIT_MS ?? 3000), tries: 4, sleep: (ms: number): Promise<void> => (ms > 0 ? Bun.sleep(ms) : Promise.resolve()) };
export interface CreateOnce<T> {
/** what is being created, for log lines and errors: `in-app purchase com.example.gems` */
what: string;
/** the create request; returns the created thing */
create: () => Promise<T>;
/** a fresh lookup by the stable key (GET with a filter, or the list), null when it is not there */
find: () => Promise<T | null | undefined>;
/** the store refuses a second one with this key (409): a create may then be tried again after an unknown outcome */
unique?: boolean;
/** log tag */
tag?: string;
}
async function recheck<T>(o: CreateOnce<T>): Promise<T | null> {
for (let i = 0; i < upsertTiming.tries; i++) {
const v = await o.find();
if (v) return v;
if (i < upsertTiming.tries - 1) await upsertTiming.sleep(upsertTiming.waitMs * (i + 1));
}
return null;
}
/** One create, never two: a conflict finds the existing one, an unknown outcome looks before (maybe) trying again. */
export async function createOnce<T>(o: CreateOnce<T>, attempt = 1): Promise<{ value: T; created: boolean }> {
try {
return { value: await o.create(), created: true };
} catch (e) {
const msg = (e as Error).message;
if (isConflict(e)) {
const v = await recheck(o);
if (v) { log(o.tag ?? 'upsert', `${o.what}: already exists, using it (not created again)`); return { value: v, created: false }; }
throw new Error(`${o.what}: the store says it already exists but does not list it yet (its lists lag right after a create). Nothing was created twice; run the command again in a minute.\n${msg}`);
}
if (!isAmbiguous(e)) throw e;
warn(o.tag ?? 'upsert', `${o.what}: no clear answer to the create (${msg.slice(0, 160)}); checking whether it exists before anything else`);
const v = await recheck(o);
if (v) { log(o.tag ?? 'upsert', `${o.what}: the create went through`); return { value: v, created: true }; }
if (o.unique && attempt < 2) return createOnce(o, attempt + 1);
throw new Error(`${o.what}: the create got no clear answer and the store does not list it (yet). It was not sent again, so nothing is created twice: run the command again in a minute (it reads the store first).\n${msg}`);
}
}
// ---------------------------------------------------------------------------------------------- journal of creates
/** Creates of the last day, keyed by store + kind + stable key: { id, at }. Lives in .storebound/created.json. */
type Journal = Record<string, { id: string; at: string }>;
const JOURNAL_MS = 24 * 3600_000;
const journalPath = (root: string): string => join(root, '.storebound', 'created.json');
export function readJournal(root: string, now = Date.now()): Journal {
try {
const j = JSON.parse(readFileSync(journalPath(root), 'utf8')) as Journal;
return Object.fromEntries(Object.entries(j).filter(([, v]) => v && now - Date.parse(v.at) < JOURNAL_MS));
} catch { return {}; }
}
export function remember(root: string, key: string, id: string, now = new Date()): void {
try {
const j = readJournal(root, now.getTime());
j[key] = { id, at: now.toISOString() };
mkdirSync(join(root, '.storebound'), { recursive: true });
const tmp = journalPath(root) + `.${process.pid}.tmp`;
writeFileSync(tmp, JSON.stringify(j, null, 2) + '\n');
renameSync(tmp, journalPath(root));
} catch (e) { warn('upsert', `could not note the create in .storebound/created.json: ${(e as Error).message}`); }
}
export interface FindOrCreate<T> extends CreateOnce<T> {
/** journal of recent creates: guards against the list lag right after a create of an earlier run */
journal?: { root: string; key: string; byId: (id: string) => Promise<T | null | undefined>; idOf: (v: T) => string };
}
/** Looks with a fresh GET, then in the journal (direct GET by id), and only then creates (createOnce). */
export async function findOrCreate<T>(o: FindOrCreate<T>): Promise<{ value: T; created: boolean }> {
const found = await o.find();
if (found) return { value: found, created: false };
if (o.journal && existsSync(join(o.journal.root, '.storebound'))) {
const hit = readJournal(o.journal.root)[o.journal.key];
if (hit) {
const v = await o.journal.byId(hit.id).catch(() => null);
if (v) { log(o.tag ?? 'upsert', `${o.what}: created ${hit.at.slice(11, 16)} UTC by an earlier run (${hit.id}); the store's list does not show it yet, using it`); return { value: v, created: false }; }
}
}
const r = await createOnce(o);
if (o.journal && r.created) remember(o.journal.root, o.journal.key, o.journal.idOf(r.value));
return r;
}
/** Apple: GET of one resource by id, null on 404. For FindOrCreate.journal.byId. */
export async function getById(asc: (m: string, p: string) => Promise<{ data?: unknown }>, path: string): Promise<unknown | null> {
try { return (await asc('GET', path)).data ?? null; } catch (e) { if (/-> 404/.test(String(e))) return null; throw e; }
}

View file

@ -3,6 +3,7 @@ import { playServiceAccount } from '../lib/credentials';
import { tokenSource } from '../lib/google-auth'; import { tokenSource } from '../lib/google-auth';
import { log, warn } from '../lib/log'; import { log, warn } from '../lib/log';
import { errorBody } from '../lib/http'; import { errorBody } from '../lib/http';
import { AmbiguousWriteError, isAmbiguous, retrySafe } from '../lib/upsert';
export type PlayClient = Awaited<ReturnType<typeof playClient>>; export type PlayClient = Awaited<ReturnType<typeof playClient>>;
@ -11,41 +12,88 @@ export async function playClient(root: string, packageName: string) {
const API = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${packageName}`; const API = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${packageName}`;
const UPLOAD = `https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/${packageName}`; const UPLOAD = `https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/${packageName}`;
/** JSON call (object body) or media upload (Uint8Array body + Content-Type). Throws with the API's text. */ /**
* JSON call (object body) or media upload (Uint8Array body + Content-Type). Throws with the API's text. 429 is
* retried (refused, not applied); 5xx and network errors only for requests that end the same when sent twice
* (retrySafe). A create or upload POST then throws AmbiguousWriteError instead of being sent again.
*/
async function call<T = unknown>(method: string, url: string, body?: unknown, headers: Record<string, string> = {}): Promise<T> { async function call<T = unknown>(method: string, url: string, body?: unknown, headers: Record<string, string> = {}): Promise<T> {
const bin = body instanceof Uint8Array; const bin = body instanceof Uint8Array;
const safe = retrySafe(method, url);
const short = (): string => url.replace(API, '').replace(UPLOAD, '');
for (let attempt = 0; ; attempt++) { for (let attempt = 0; ; attempt++) {
const r = await fetch(url, { let r: Response;
method, try {
headers: { Authorization: 'Bearer ' + (await token()), ...(body === undefined || bin ? {} : { 'Content-Type': 'application/json' }), ...headers }, r = await fetch(url, {
body: body === undefined ? undefined : bin ? (body as unknown as BodyInit) : JSON.stringify(body), method,
}); headers: { Authorization: 'Bearer ' + (await token()), ...(body === undefined || bin ? {} : { 'Content-Type': 'application/json' }), ...headers },
body: body === undefined ? undefined : bin ? (body as unknown as BodyInit) : JSON.stringify(body),
});
} catch (e) {
if (safe && attempt < 4) { await Bun.sleep(2000 * (attempt + 1)); continue; }
if (!safe) throw new AmbiguousWriteError(`${method} ${short()} -> no answer (${(e as Error).message})`);
throw e;
}
const text = await r.text(); const text = await r.text();
if (r.ok) return (text ? JSON.parse(text) : {}) as T; if (r.ok) return (text ? JSON.parse(text) : {}) as T;
if ((r.status === 429 || r.status >= 500) && attempt < 4) { await Bun.sleep(2000 * (attempt + 1)); continue; } if ((r.status === 429 || (r.status >= 500 && safe)) && attempt < 4) { await Bun.sleep(2000 * (attempt + 1)); continue; }
const short = url.replace(API, '').replace(UPLOAD, '');
// A wrong path casing answers with an HTML 404 page, not JSON: say so instead of dumping the HTML. // A wrong path casing answers with an HTML 404 page, not JSON: say so instead of dumping the HTML.
const msg = /^\s*</.test(text) ? `(HTML ${r.status} page: wrong path or casing?)` : errorBody(text); const msg = /^\s*</.test(text) ? `(HTML ${r.status} page: wrong path or casing?)` : errorBody(text);
throw new Error(`${method} ${short} -> ${r.status} ${msg}`); if (r.status >= 500 && !safe) throw new AmbiguousWriteError(`${method} ${short()} -> ${r.status} ${msg}`, r.status);
if (r.status === 404 && method === 'DELETE' && attempt > 0) return {} as T;
throw new Error(`${method} ${short()} -> ${r.status} ${msg}`);
} }
} }
async function editOnce<T>(fn: (id: string) => Promise<T>, o: { dry?: boolean; commitQuery?: string }): Promise<T> { /** At most one open edit per run: a second one would be invalidated by the first commit (or invalidate it). */
let openEdit: string | null = null;
async function openOne(): Promise<string> {
if (openEdit) throw new Error(`a Play edit (${openEdit}) is already open in this run: storebound never opens a second one`);
const e = await call<{ id: string }>('POST', `${API}/edits`, {}); const e = await call<{ id: string }>('POST', `${API}/edits`, {});
log('play', `edit ${e.id} opened`); openEdit = e.id;
return e.id;
}
async function closeOne(id: string, what: string): Promise<void> {
try { await call('DELETE', `${API}/edits/${id}`); } catch (err) {
// an edit that was committed or expired is gone already; anything else is reported (Play drops it later)
if (!/-> 404|not found|has been deleted|already been committed/i.test(String(err))) warn('play', `${what} edit ${id} not deleted: ${(err as Error).message.slice(0, 200)}`);
} finally { if (openEdit === id) openEdit = null; }
}
/** Thrown when a commit got no clear answer: the edit may be live, so it is never redone. */
class CommitUnknown extends Error { readonly noRedo = true; }
/** Controls of an edit: `unchanged()` when fn found nothing to change; the edit is then deleted, not committed. */
interface EditCtl { unchanged(): void }
async function editOnce<T>(fn: (id: string, ctl: EditCtl) => Promise<T>, o: { dry?: boolean; commitQuery?: string }): Promise<T> {
const id = await openOne();
log('play', `edit ${id} opened`);
let same = false;
let committing = false;
try { try {
const out = await fn(e.id); const out = await fn(id, { unchanged: () => { same = true; } });
if (o.dry) { if (o.dry) {
await call('POST', `${API}/edits/${e.id}:validate`); await call('POST', `${API}/edits/${id}:validate`);
log('play', 'dry run: edit validated, discarding'); log('play', 'dry run: edit validated, discarding');
await call('DELETE', `${API}/edits/${e.id}`); await closeOne(id, 'dry run');
} else if (same) {
log('play', 'nothing to change: edit discarded, not committed');
await closeOne(id, 'unchanged');
} else { } else {
await call('POST', `${API}/edits/${e.id}:commit${o.commitQuery ?? ''}`); committing = true;
log('play', `edit ${e.id} committed`); try { await call('POST', `${API}/edits/${id}:commit${o.commitQuery ?? ''}`); } catch (err) {
if (!isAmbiguous(err)) throw err;
openEdit = null;
throw new CommitUnknown(`the commit of edit ${id} got no clear answer (${(err as Error).message.slice(0, 200)}): it may be live. Not redone, so nothing is applied twice; check with \`storebound play status\` (or the dry run of this command) before running it again.`);
}
openEdit = null;
log('play', `edit ${id} committed`);
} }
return out; return out;
} catch (err) { } catch (err) {
await call('DELETE', `${API}/edits/${e.id}`).catch(() => {}); // nothing of an uncommitted edit is live: delete it (also after a failed commit, which leaves the edit open)
if (!(err instanceof CommitUnknown)) await closeOne(id, committing ? 'failed commit' : 'failed');
throw err; throw err;
} }
} }
@ -57,21 +105,23 @@ export async function playClient(root: string, packageName: string) {
* A failed delete is reported (Play drops an unused edit on its own after a while, but it should not linger). * A failed delete is reported (Play drops an unused edit on its own after a while, but it should not linger).
*/ */
async read<T>(fn: (id: string) => Promise<T>): Promise<T> { async read<T>(fn: (id: string) => Promise<T>): Promise<T> {
const e = await call<{ id: string }>('POST', `${API}/edits`, {}); const id = await openOne();
try { return await fn(e.id); } finally { try { return await fn(id); } finally { await closeOne(id, 'read-only'); }
await call('DELETE', `${API}/edits/${e.id}`).catch(err => warn('play', `read-only edit not deleted: ${(err as Error).message.slice(0, 200)}`));
}
}, },
/** /**
* Opens an edit, runs fn, then commits (or validates and discards when dry). Any Console save or other edit commit * Opens an edit, runs fn, then commits (or validates and discards when dry). Any Console save or other edit commit
* invalidates an open edit ("A change was made to the application outside of this Edit"); then the whole edit is * invalidates an open edit ("A change was made to the application outside of this Edit"); then the whole edit is
* redone, up to `retries` times. Keep edits small (e.g. one per language for images) so a retry is cheap. * redone, up to `retries` times. Keep edits small (e.g. one per language for images) so a retry is cheap.
*/ */
async edit<T>(fn: (id: string) => Promise<T>, o: { dry?: boolean; commitQuery?: string; retries?: number } = {}): Promise<T> { async edit<T>(fn: (id: string, ctl: EditCtl) => Promise<T>, o: { dry?: boolean; commitQuery?: string; retries?: number } = {}): Promise<T> {
for (let left = o.retries ?? 3; ; left--) { for (let left = o.retries ?? 3; ; left--) {
try { return await editOnce(fn, o); } catch (err) { try { return await editOnce(fn, o); } catch (err) {
if (left <= 0 || !/outside of this Edit|Edit has been deleted|edit.*(expired|not found)/i.test(String(err))) throw err; // redo only what never went live: an edit invalidated before or at its commit, or a request inside the edit
log('play', `app changed outside the edit (Console?), redoing it (${left} retries left)`); // without a clear answer (the edit was deleted, so nothing of it applies). Never after an unclear commit.
if (left <= 0 || (err as { noRedo?: boolean }).noRedo) throw err;
const outside = /outside of this Edit|Edit has been deleted|edit.*(expired|not found)/i.test(String(err));
if (!outside && !isAmbiguous(err)) throw err;
log('play', `${outside ? 'app changed outside the edit (Console?)' : 'a request got no clear answer; the edit was discarded'}, redoing it (${left} retries left)`);
} }
} }
}, },

View file

@ -3,8 +3,9 @@
// storebound play images [--dry] [--only en-US,de-DE] [--src store/renders] [--variant b] // storebound play images [--dry] [--only en-US,de-DE] [--src store/renders] [--variant b]
// Per Play language, from <renders>/<language id>/: icon (play.icon, 512x512), featureGraphic (feature/1024x500.*), // Per Play language, from <renders>/<language id>/: icon (play.icon, 512x512), featureGraphic (feature/1024x500.*),
// phoneScreenshots (play/NN.*), sevenInchScreenshots (tab7/NN.*), tenInchScreenshots (tab10/NN.*). A .jpg next to a // phoneScreenshots (play/NN.*), sevenInchScreenshots (tab7/NN.*), tenInchScreenshots (tab10/NN.*). A .jpg next to a
// .png wins (JPEG q92 uploads about 4x faster; `storebound shots jpeg` makes them). Each image type is cleared // .png wins (JPEG q92 uploads about 4x faster; `storebound shots jpeg` makes them). Play's images are compared with
// before uploading, so re-runs are idempotent. --variant <id> uploads <renders>/<id>/<lang>/... (an A/B variant from // the local files first (sha256, in order): only a changed image type is cleared and uploaded, in one edit per
// language that is committed whole or not at all, so a re-run changes nothing and never doubles an image. --variant <id> uploads <renders>/<id>/<lang>/... (an A/B variant from
// shots.variants) to the main listing: Play store listing experiments themselves are set up in Play Console (no API). // shots.variants) to the main listing: Play store listing experiments themselves are set up in Play Console (no API).
// Uploads are slow: run in the background with a log file. // Uploads are slow: run in the background with a log file.
// --dry is read-only: one throwaway edit, the images Play has now (their sha256) compared with the local files per // --dry is read-only: one throwaway edit, the images Play has now (their sha256) compared with the local files per
@ -71,36 +72,51 @@ export async function run(ctx: Ctx): Promise<void> {
log('play', `${codes.length} languages, ${[...sets.values()][0]?.reduce((n, [, f]) => n + f.length, 0) ?? 0} images each`); log('play', `${codes.length} languages, ${[...sets.values()][0]?.reduce((n, [, f]) => n + f.length, 0) ?? 0} images each`);
const play = await playClient(root, pkg); const play = await playClient(root, pkg);
// one read-only edit: what Play has now (sha256 per image, in order) against the local files, per language and type
const plan = new Map<string, ImageChange[]>();
await play.read(async id => {
for (const code of codes) {
const out: ImageChange[] = [];
for (const [type, files] of sets.get(code)!) {
const remote = (await play.call<{ images?: { sha256?: string }[] }>('GET', `${play.API}/edits/${id}/listings/${code}/${type}`)).images ?? [];
out.push(imageChange(type, remote, files.map(sha256)));
}
plan.set(code, out);
}
});
const changedTypes = (code: string) => plan.get(code)!.filter(c => c.change === 'replace');
if (ctx.args.has('dry')) { if (ctx.args.has('dry')) {
let changed = 0; let changed = 0;
await play.read(async id => { for (const [i, code] of codes.entries()) {
for (const [i, code] of codes.entries()) { const rep = changedTypes(code);
const out: ImageChange[] = []; changed += rep.length;
for (const [type, files] of sets.get(code)!) { log('play', `${step(i + 1, codes.length, code)}: ${rep.length ? 'would replace ' + rep.map(c => `${c.type} (${c.remote} -> ${c.local})`).join(', ') : 'unchanged'}`);
const remote = (await play.call<{ images?: { sha256?: string }[] }>('GET', `${play.API}/edits/${id}/listings/${code}/${type}`)).images ?? []; }
out.push(imageChange(type, remote, files.map(sha256)));
}
const rep = out.filter(c => c.change === 'replace');
changed += rep.length;
log('play', `${step(i + 1, codes.length, code)}: ${rep.length ? 'would replace ' + rep.map(c => `${c.type} (${c.remote} -> ${c.local})`).join(', ') : 'unchanged'}`);
}
});
done('play', `dry run: ${changed ? changed + ' image set(s) would be replaced' : 'every image set is already on Play'}; read-only, nothing uploaded`); done('play', `dry run: ${changed ? changed + ' image set(s) would be replaced' : 'every image set is already on Play'}; read-only, nothing uploaded`);
return; return;
} }
for (const [i, code] of codes.entries()) { // only languages with a changed image type get an edit, and in it only the changed types are cleared and uploaded.
// The edit is all or nothing: a failure (or an upload without a clear answer) deletes it, and the redo starts from
// what Play has, so a re-run never doubles an image.
const todo = codes.filter(code => changedTypes(code).length);
if (!todo.length) { done('play', 'every image set is already on Play: nothing uploaded'); return; }
for (const [i, code] of todo.entries()) {
const set = sets.get(code)!; const set = sets.get(code)!;
await play.edit(async id => { await play.edit(async (id, ctl) => {
let n = 0; let n = 0, types = 0;
await Promise.all(set.map(async ([type, files]) => { await Promise.all(set.map(async ([type, files]) => {
await play.call('DELETE', `${play.API}/edits/${id}/listings/${code}/${type}`); const remote = (await play.call<{ images?: { sha256?: string }[] }>('GET', `${play.API}/edits/${id}/listings/${code}/${type}`)).images ?? [];
if (imageChange(type, remote, files.map(sha256)).change === 'same') return;
types++;
if (remote.length) await play.call('DELETE', `${play.API}/edits/${id}/listings/${code}/${type}`);
for (const f of files) { for (const f of files) {
await play.call('POST', `${play.UPLOAD}/edits/${id}/listings/${code}/${type}?uploadType=media`, new Uint8Array(readFileSync(f)), await play.call('POST', `${play.UPLOAD}/edits/${id}/listings/${code}/${type}?uploadType=media`, new Uint8Array(readFileSync(f)),
{ 'Content-Type': /\.png$/i.test(f) ? 'image/png' : 'image/jpeg' }); { 'Content-Type': /\.png$/i.test(f) ? 'image/png' : 'image/jpeg' });
n++; n++;
} }
})); }));
log('play', `${step(i + 1, codes.length, code)}: ${n} images`); if (!types) ctl.unchanged();
log('play', `${step(i + 1, todo.length, code)}: ${n} images in ${types} set(s)`);
}); });
} }
done('play', 'images done'); done('play', 'images done');

View file

@ -10,10 +10,26 @@ import { readFileSync, statSync } from 'node:fs';
import type { Ctx } from '../lib/ctx'; import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx'; import { confirm, need } from '../lib/ctx';
import { done, log } from '../lib/log'; import { done, log } from '../lib/log';
import { aabInfo } from '../lib/gradle';
import { playClient } from './client'; import { playClient } from './client';
import { usedVersionCodes } from './next-version-code';
import { readPlayCopy } from './listing'; import { readPlayCopy } from './listing';
const TRACKS = ['internal', 'alpha', 'beta', 'production']; const TRACKS = ['internal', 'alpha', 'beta', 'production'];
export interface PlayTrackRelease { versionCodes?: string[]; status?: string; userFraction?: number; name?: string; releaseNotes?: { language: string; text: string }[] }
/** The track holds exactly this release already (one release, same codes, status, fraction, name and notes): a PUT
* would change nothing. Pure. */
export function sameRelease(cur: PlayTrackRelease[], want: PlayTrackRelease): boolean {
if (cur.length !== 1) return false;
const c = cur[0]!;
const notes = (n?: { language: string; text: string }[]) => JSON.stringify([...(n ?? [])].sort((x, y) => x.language.localeCompare(y.language)).map(x => [x.language, x.text.trim()]));
return (c.versionCodes ?? []).map(String).join() === (want.versionCodes ?? []).join() && c.status === want.status
&& (want.userFraction === undefined || Math.abs((c.userFraction ?? -1) - want.userFraction) < 1e-9)
&& (want.name === undefined || c.name === want.name)
&& (want.releaseNotes === undefined || notes(c.releaseNotes) === notes(want.releaseNotes));
}
const STATUSES = ['completed', 'draft', 'inProgress', 'halted']; const STATUSES = ['completed', 'draft', 'inProgress', 'halted'];
export async function run(ctx: Ctx): Promise<void> { export async function run(ctx: Ctx): Promise<void> {
@ -33,23 +49,37 @@ export async function run(ctx: Ctx): Promise<void> {
const notes = readPlayCopy(ctx).filter(c => c.whatsNew).map(c => ({ language: c.lang, text: c.whatsNew })); const notes = readPlayCopy(ctx).filter(c => c.whatsNew).map(c => ({ language: c.lang, text: c.whatsNew }));
const play = await playClient(root, pkg); const play = await playClient(root, pkg);
try { try {
await play.edit(async id => { await play.edit(async (id, ctl) => {
let code = fixed ?? ''; let code = fixed ?? '';
let uploaded = false;
if (aab) { if (aab) {
log('play', `uploading ${aab} (${(statSync(aab).size / 1048576).toFixed(1)} MB), this can take minutes`); // a versionCode Play already has (an earlier run uploaded it) is reused, never uploaded twice
const b = await play.call<{ versionCode: number }>('POST', `${play.UPLOAD}/edits/${id}/bundles?uploadType=media`, const info = aabInfo(aab);
new Uint8Array(readFileSync(aab)), { 'Content-Type': 'application/octet-stream' }); if ((await usedVersionCodes(play, id)).includes(info.versionCode)) {
code = String(b.versionCode); code = String(info.versionCode);
log('play', `uploaded versionCode ${code}`); log('play', `versionCode ${code} is on Play already: not uploaded again`);
} else {
log('play', `uploading ${aab} (${(statSync(aab).size / 1048576).toFixed(1)} MB), this can take minutes`);
const b = await play.call<{ versionCode: number }>('POST', `${play.UPLOAD}/edits/${id}/bundles?uploadType=media`,
new Uint8Array(readFileSync(aab)), { 'Content-Type': 'application/octet-stream' });
code = String(b.versionCode);
uploaded = true;
log('play', `uploaded versionCode ${code}`);
}
} }
let changed = 0;
for (const t of tracks) { for (const t of tracks) {
const release: Record<string, unknown> = { versionCodes: [code], status: t.status }; const release: PlayTrackRelease = { versionCodes: [code], status: t.status };
if (notes.length) release.releaseNotes = notes; if (notes.length) release.releaseNotes = notes;
if (a.get('name')) release.name = a.get('name'); if (a.get('name')) release.name = a.get('name');
if (t.status === 'inProgress') release.userFraction = Number(fraction ?? 0.1); if (t.status === 'inProgress') release.userFraction = Number(fraction ?? 0.1);
const cur = (await play.call<{ releases?: PlayTrackRelease[] }>('GET', `${play.API}/edits/${id}/tracks/${t.track}`).catch(() => ({ releases: [] }))).releases ?? [];
if (sameRelease(cur, release)) { log('play', `${t.track}: already ${t.status} ${code}, unchanged`); continue; }
await play.call('PUT', `${play.API}/edits/${id}/tracks/${t.track}`, { track: t.track, releases: [release] }); await play.call('PUT', `${play.API}/edits/${id}/tracks/${t.track}`, { track: t.track, releases: [release] });
changed++;
log('play', `${t.track} <- ${code} (${t.status}, notes in ${notes.length} languages)`); log('play', `${t.track} <- ${code} (${t.status}, notes in ${notes.length} languages)`);
} }
if (!changed && !uploaded) ctl.unchanged();
}, { dry: a.has('dry'), retries: aab ? 0 : 3 }); }, { dry: a.has('dry'), retries: aab ? 0 : 3 });
} catch (e) { } catch (e) {
if (/draft app|status draft/i.test(String(e))) if (/draft app|status draft/i.test(String(e)))

View file

@ -10,6 +10,13 @@ import { done, log } from '../lib/log';
import { type PlayRelease, type RolloutAction, rolloutReleases } from '../lib/release'; import { type PlayRelease, type RolloutAction, rolloutReleases } from '../lib/release';
import { playClient } from './client'; import { playClient } from './client';
/** Same releases on the track, whatever their order and key order. Pure. */
export function sameReleases(a: PlayRelease[], b: PlayRelease[]): boolean {
const canon = (x: unknown): unknown => (Array.isArray(x) ? x.map(canon) : x && typeof x === 'object' ? Object.fromEntries(Object.entries(x).sort(([k], [l]) => k.localeCompare(l)).map(([k, v]) => [k, canon(v)])) : x);
const key = (rs: PlayRelease[]) => JSON.stringify(rs.map(r => JSON.stringify(canon(r))).sort());
return key(a) === key(b);
}
export function rolloutAction(a: Ctx['args']): RolloutAction { export function rolloutAction(a: Ctx['args']): RolloutAction {
const picked = ['halt', 'resume', 'complete'].filter(k => a.has(k)); const picked = ['halt', 'resume', 'complete'].filter(k => a.has(k));
if (picked.length > 1) throw new Error('pick one of --halt, --resume, --complete'); if (picked.length > 1) throw new Error('pick one of --halt, --resume, --complete');
@ -30,10 +37,12 @@ export async function run(ctx: Ctx): Promise<void> {
const dry = ctx.args.has('dry'); const dry = ctx.args.has('dry');
const play = await playClient(root, need(config.play?.packageName, 'play.packageName')); const play = await playClient(root, need(config.play?.packageName, 'play.packageName'));
let confirmed = dry || action.kind === 'halt' || track !== 'production'; let confirmed = dry || action.kind === 'halt' || track !== 'production';
await play.edit(async id => { await play.edit(async (id, ctl) => {
const cur = await play.call<{ releases?: PlayRelease[] }>('GET', `${play.API}/edits/${id}/tracks/${track}`); const cur = await play.call<{ releases?: PlayRelease[] }>('GET', `${play.API}/edits/${id}/tracks/${track}`);
const next = rolloutReleases(cur.releases ?? [], action); const next = rolloutReleases(cur.releases ?? [], action);
log('play', `${track}: ${next.summary}${dry ? ' (dry)' : ''}`); log('play', `${track}: ${next.summary}${dry ? ' (dry)' : ''}`);
// already like that (a re-run, or the same command twice): nothing is committed
if (sameReleases(next.releases, cur.releases ?? [])) { ctl.unchanged(); return; }
if (!confirmed) confirmed = await confirm(ctx, `${track}: ${next.summary}. Apply?`); if (!confirmed) confirmed = await confirm(ctx, `${track}: ${next.summary}. Apply?`);
if (!confirmed) throw new Error('not confirmed, nothing changed'); if (!confirmed) throw new Error('not confirmed, nothing changed');
await play.call('PUT', `${play.API}/edits/${id}/tracks/${track}`, { track, releases: next.releases }); await play.call('PUT', `${play.API}/edits/${id}/tracks/${track}`, { track, releases: next.releases });

View file

@ -20,7 +20,13 @@ import {
playSubscriptionBody, playSubscriptionBody,
} from '../lib/subscriptions'; } from '../lib/subscriptions';
import { subscriptionCsv } from '../apple/subscriptions'; import { subscriptionCsv } from '../apple/subscriptions';
import { playClient } from './client'; import { createOnce } from '../lib/upsert';
import { playClient, type PlayClient } from './client';
/** GET of one Play resource, null on 404. */
export async function getOrNull<T>(play: PlayClient, url: string): Promise<T | null> {
try { return await play.call<T>('GET', url); } catch (e) { if (/-> 404/.test(String(e))) return null; throw e; }
}
const TAG = 'play'; const TAG = 'play';
interface Converted { convertedRegionPrices?: Record<string, { regionCode: string; price: Money }>; convertedOtherRegionsPrice?: { usdPrice: Money; eurPrice: Money }; regionVersion?: { version: string } } interface Converted { convertedRegionPrices?: Record<string, { regionCode: string; price: Money }>; convertedOtherRegionsPrice?: { usdPrice: Money; eurPrice: Money }; regionVersion?: { version: string } }
@ -86,8 +92,12 @@ export async function run(ctx: Ctx): Promise<void> {
const id = encodeURIComponent(s.id); const id = encodeURIComponent(s.id);
const cur = existing.get(s.id); const cur = existing.get(s.id);
if (diff.create) { if (diff.create) {
await play.call('POST', `${play.API}/subscriptions?productId=${id}&regionsVersion.version=${v}`, body); // product ids are unique: a 409 (or an unclear answer, then a fresh GET) finds the existing one, never a second
log(TAG, `${s.id}: created`); const r = await createOnce({ what: `subscription ${s.id}`, tag: TAG, unique: true,
find: () => getOrNull<PlaySubscription>(play, `${play.API}/subscriptions/${id}`),
create: () => play.call<PlaySubscription>('POST', `${play.API}/subscriptions?productId=${id}&regionsVersion.version=${v}`, body) });
log(TAG, `${s.id}: ${r.created ? 'created' : 'exists already (made by an earlier run); run again to compare it with the config'}`);
if (!r.created) continue;
} else if (diff.listings.length || diff.basePlan !== 'same') { } else if (diff.listings.length || diff.basePlan !== 'same') {
if (diff.basePlan === 'changed' && diff.priceChanges.length && !liveOk) warn(TAG, `${s.id}: ${diff.priceChanges.length} price change(s) skipped (run in a terminal to confirm)`); if (diff.basePlan === 'changed' && diff.priceChanges.length && !liveOk) warn(TAG, `${s.id}: ${diff.priceChanges.length} price change(s) skipped (run in a terminal to confirm)`);
else { else {
@ -107,10 +117,14 @@ export async function run(ctx: Ctx): Promise<void> {
offerRegions = { regions: Object.values(oc.convertedRegionPrices).map(r => ({ regionCode: r.regionCode, price: r.price })), other: oc.convertedOtherRegionsPrice }; offerRegions = { regions: Object.values(oc.convertedRegionPrices).map(r => ({ regionCode: r.regionCode, price: r.price })), other: oc.convertedOtherRegionsPrice };
} }
const ob = playOfferBody(pkg, s, regions, offerRegions, extra); const ob = playOfferBody(pkg, s, regions, offerRegions, extra);
await play.call('POST', `${play.API}/subscriptions/${id}/basePlans/${basePlanId(s)}/offers?offerId=${encodeURIComponent(ob.offerId)}&regionsVersion.version=${v}`, ob); const offerUrl = `${play.API}/subscriptions/${id}/basePlans/${basePlanId(s)}/offers`;
await play.call('POST', `${play.API}/subscriptions/${id}/basePlans/${basePlanId(s)}/offers/${encodeURIComponent(ob.offerId)}:activate`, // offer ids are unique per base plan: a 409 or an unclear answer finds the existing offer, never a second one
{ packageName: pkg, productId: s.id, basePlanId: basePlanId(s), offerId: ob.offerId }); const r = await createOnce({ what: `${s.id} offer ${ob.offerId}`, tag: TAG, unique: true,
log(TAG, `${s.id}: offer ${ob.offerId} created and activated`); find: () => getOrNull<{ state?: string }>(play, `${offerUrl}/${encodeURIComponent(ob.offerId)}`),
create: () => play.call<{ state?: string }>('POST', `${offerUrl}?offerId=${encodeURIComponent(ob.offerId)}&regionsVersion.version=${v}`, ob) });
if (r.value.state !== 'ACTIVE')
await play.call('POST', `${offerUrl}/${encodeURIComponent(ob.offerId)}:activate`, { packageName: pkg, productId: s.id, basePlanId: basePlanId(s), offerId: ob.offerId });
log(TAG, `${s.id}: offer ${ob.offerId} ${r.created ? 'created and activated' : 'exists already, active'}`);
}; };
if (s.intro && offerMissing) await createOffer(s.intro); if (s.intro && offerMissing) await createOffer(s.intro);
for (const o of (s.offers ?? []).filter(x => extraMissing.includes(x.id))) await createOffer(o, o); for (const o of (s.offers ?? []).filter(x => extraMissing.includes(x.id))) await createOffer(o, o);

View file

@ -48,6 +48,12 @@ export async function run(ctx: Ctx): Promise<void> {
used: await usedVersionCodes(play, id), used: await usedVersionCodes(play, id),
current: (await play.call<{ releases?: { status: string; versionCodes?: string[] }[] }>('GET', `${play.API}/edits/${id}/tracks/${track}`).catch(() => ({ releases: [] }))).releases ?? [], current: (await play.call<{ releases?: { status: string; versionCodes?: string[] }[] }>('GET', `${play.API}/edits/${id}/tracks/${track}`).catch(() => ({ releases: [] }))).releases ?? [],
})); }));
// an earlier run did it already (the bundle is on Play and on this track): nothing to send again
if (used.includes(info.versionCode) && current.some(r => (r.versionCodes ?? []).includes(String(info.versionCode)) && r.status === status)) {
if (json) { printJson({ aab: relative(root, aab), packageName: info.packageName, versionCode: info.versionCode, track, status, uploaded: true, alreadyThere: true }); return; }
done(TAG, `versionCode ${info.versionCode} is on ${track} (${status}) already: nothing uploaded`);
return;
}
if (used.includes(info.versionCode)) throw new Error(`versionCode ${info.versionCode} is already on Google Play: rebuild with --version-code ${nextVersionCode(used)} (storebound play build)`); if (used.includes(info.versionCode)) throw new Error(`versionCode ${info.versionCode} is already on Google Play: rebuild with --version-code ${nextVersionCode(used)} (storebound play build)`);
const summary = { aab: relative(root, aab), packageName: info.packageName, versionCode: info.versionCode, versionName: info.versionName ?? null, sizeMB: Math.round(size / 104857.6) / 10, const summary = { aab: relative(root, aab), packageName: info.packageName, versionCode: info.versionCode, versionName: info.versionName ?? null, sizeMB: Math.round(size / 104857.6) / 10,
track, status, notes: notes.map(n => n.language), replaces: current.map(r => `${r.status} ${(r.versionCodes ?? []).join(',')}`) }; track, status, notes: notes.map(n => n.language), replaces: current.map(r => `${r.status} ${(r.versionCodes ?? []).join(',')}`) };

View file

@ -22,6 +22,7 @@
// POST .../packages/{id}/actions/attach_products // POST .../packages/{id}/actions/attach_products
import { credentialValue } from './lib/credentials'; import { credentialValue } from './lib/credentials';
import { confirm, type Ctx, need } from './lib/ctx'; import { confirm, type Ctx, need } from './lib/ctx';
import { AmbiguousWriteError, createOnce } from './lib/upsert';
import { errorBody } from './lib/http'; import { errorBody } from './lib/http';
import { done, log, printJson, warn } from './lib/log'; import { done, log, printJson, warn } from './lib/log';
import { import {
@ -46,11 +47,18 @@ export function rcClient(key: string, o: { retries?: number } = {}): RcClient {
const u = new URL(url, ORIGIN); const u = new URL(url, ORIGIN);
if (u.origin !== ORIGIN) throw new Error(`RevenueCat: refusing to send the key to ${u.origin}`); if (u.origin !== ORIGIN) throw new Error(`RevenueCat: refusing to send the key to ${u.origin}`);
for (let attempt = 0; ; attempt++) { for (let attempt = 0; ; attempt++) {
const r = await fetch(u, { let r: Response;
method, signal: AbortSignal.timeout(30_000), try {
headers: { 'Authorization': `Bearer ${key}`, 'Accept': 'application/json', ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, r = await fetch(u, {
body: body === undefined ? undefined : JSON.stringify(body), method, signal: AbortSignal.timeout(30_000),
}); headers: { 'Authorization': `Bearer ${key}`, 'Accept': 'application/json', ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch (e) {
// a POST without an answer may have been applied: callers look before sending it again (createOnce)
if (method === 'POST') throw new AmbiguousWriteError(redact(`RevenueCat ${method} ${u.pathname}: no answer (${(e as Error).message})`));
throw e;
}
if (r.status === 429 && attempt < (o.retries ?? 3)) { if (r.status === 429 && attempt < (o.retries ?? 3)) {
const s = Math.min(60, Math.max(0, Number(r.headers.get('retry-after') ?? '5') || 0)); const s = Math.min(60, Math.max(0, Number(r.headers.get('retry-after') ?? '5') || 0));
warn(TAG, `rate limited, waiting ${s}s`); warn(TAG, `rate limited, waiting ${s}s`);
@ -59,6 +67,7 @@ export function rcClient(key: string, o: { retries?: number } = {}): RcClient {
continue; continue;
} }
const text = await r.text(); const text = await r.text();
if (!r.ok && r.status >= 500 && method === 'POST') throw new AmbiguousWriteError(redact(`RevenueCat ${method} ${u.pathname}: ${r.status} ${errorBody(text, 500)}`), r.status);
if (!r.ok) { if (!r.ok) {
const hint = r.status === 401 || r.status === 403 ? ' (REVENUECAT_SECRET_KEY: a v2 secret key with project configuration permissions for this project)' : ''; const hint = r.status === 401 || r.status === 403 ? ' (REVENUECAT_SECRET_KEY: a v2 secret key with project configuration permissions for this project)' : '';
throw new Error(redact(`RevenueCat ${method} ${u.pathname}: ${r.status} ${errorBody(text, 500)}${hint}`)); throw new Error(redact(`RevenueCat ${method} ${u.pathname}: ${r.status} ${errorBody(text, 500)}${hint}`));
@ -172,15 +181,22 @@ export async function execute(c: RcClient, projectId: string, plan: RcStep[], st
let offering = current?.id ?? ''; let offering = current?.id ?? '';
const pkgId = new Map((current?.packages ?? []).map(p => [p.lookup_key, p.id] as const)); const pkgId = new Map((current?.packages ?? []).map(p => [p.lookup_key, p.id] as const));
const pid = (k: string) => { const id = productId.get(k); if (!id) throw new Error(`RevenueCat product ${k} was not created`); return id; }; const pid = (k: string) => { const id = productId.get(k); if (!id) throw new Error(`RevenueCat product ${k} was not created`); return id; };
// creates go through createOnce: a 409 (lookup key / store identifier taken) or an unclear answer finds the existing
// one by its key with a fresh list, never a second one; attaching and is_current are the same when sent twice
const once = async (what: string, list: string, match: (x: J) => boolean, create: () => Promise<J>): Promise<string> =>
(await createOnce<J>({ what, tag: TAG, unique: true, find: async () => (await c.all(list)).find(match) ?? null, create })).value.id as string;
for (const [i, s] of plan.entries()) { for (const [i, s] of plan.entries()) {
log(TAG, `${i + 1}/${plan.length} ${s.what}`); log(TAG, `${i + 1}/${plan.length} ${s.what}`);
switch (s.op) { switch (s.op) {
case 'create-product': productId.set(s.key, (await c.post(`${P}/products`, s.body)).id); break; case 'create-product': productId.set(s.key, await once(s.what, `${P}/products?limit=100`, x => x.app_id === s.body.app_id && x.store_identifier === s.body.store_identifier, () => c.post(`${P}/products`, s.body))); break;
case 'create-entitlement': entId.set(s.lookupKey, (await c.post(`${P}/entitlements`, s.body)).id); break; case 'create-entitlement': entId.set(s.lookupKey, await once(s.what, `${P}/entitlements?limit=100`, x => x.lookup_key === s.lookupKey, () => c.post(`${P}/entitlements`, s.body))); break;
case 'attach-entitlement': case 'attach-entitlement':
await c.post(`${P}/entitlements/${encodeURIComponent(entId.get(s.lookupKey)!)}/actions/attach_products`, { product_ids: s.products.map(pid) }); break; await c.post(`${P}/entitlements/${encodeURIComponent(entId.get(s.lookupKey)!)}/actions/attach_products`, { product_ids: s.products.map(pid) }); break;
case 'create-offering': offering = (await c.post(`${P}/offerings`, s.body)).id; break; case 'create-offering': offering = await once(s.what, `${P}/offerings?limit=100`, x => x.lookup_key === s.lookupKey, () => c.post(`${P}/offerings`, s.body)); break;
case 'create-package': pkgId.set(s.lookupKey, (await c.post(`${P}/offerings/${encodeURIComponent(offering)}/packages`, s.body)).id); break; case 'create-package': {
const pkgs = `${P}/offerings/${encodeURIComponent(offering)}/packages`;
pkgId.set(s.lookupKey, await once(s.what, `${pkgs}?limit=100`, x => x.lookup_key === s.lookupKey, () => c.post(pkgs, s.body))); break;
}
case 'attach-package': case 'attach-package':
await c.post(`${P}/packages/${encodeURIComponent(pkgId.get(s.lookupKey)!)}/actions/attach_products`, { products: s.products.map(k => ({ product_id: pid(k), eligibility_criteria: 'all' })) }); break; await c.post(`${P}/packages/${encodeURIComponent(pkgId.get(s.lookupKey)!)}/actions/attach_products`, { products: s.products.map(k => ({ product_id: pid(k), eligibility_criteria: 'all' })) }); break;
case 'set-current': await c.post(`${P}/offerings/${encodeURIComponent(offering)}`, { is_current: true }); break; case 'set-current': await c.post(`${P}/offerings/${encodeURIComponent(offering)}`, { is_current: true }); break;

View file

@ -14,6 +14,7 @@
// Google Play's API only returns reviews created or edited in the last 7 days. // Google Play's API only returns reviews created or edited in the last 7 days.
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join, relative, resolve } from 'node:path'; import { join, relative, resolve } from 'node:path';
import { isConflict } from './lib/upsert';
import { ascClient, type J } from './apple/asc'; import { ascClient, type J } from './apple/asc';
import type { Ctx } from './lib/ctx'; import type { Ctx } from './lib/ctx';
import { confirm } from './lib/ctx'; import { confirm } from './lib/ctx';
@ -138,8 +139,14 @@ export async function run(ctx: Ctx): Promise<void> {
for (const d of plan.send) { for (const d of plan.send) {
const req = replyRequest(d, config.play?.packageName); const req = replyRequest(d, config.play?.packageName);
try { try {
if (d.store === 'apple') await c!.asc(req.method, req.url, req.body); // Play's :reply replaces the reply (safe to send again); Apple keeps one response per review and answers a
else await play!.call(req.method, req.url, req.body); // second one with 409: then an earlier run sent it (and stopped before noting it), so it is noted now
if (d.store === 'apple') {
try { await c!.asc(req.method, req.url, req.body); } catch (e) {
if (!isConflict(e)) throw e;
warn(TAG, `${d.file.split('/').pop()}: the review already has a response on the App Store (an earlier run sent it); noted as sent, not sent again`);
}
} else await play!.call(req.method, req.url, req.body);
writeFileSync(d.file, markSent(texts.get(d.file)!, new Date().toISOString())); writeFileSync(d.file, markSent(texts.get(d.file)!, new Date().toISOString()));
sent++; sent++;
log(TAG, `sent ${d.file.split('/').pop()}`); log(TAG, `sent ${d.file.split('/').pop()}`);

View file

@ -12,8 +12,8 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| API | operations | GET | other | wrapped by a command | owner only | | API | operations | GET | other | wrapped by a command | owner only |
|---|---:|---:|---:|---:|---:| |---|---:|---:|---:|---:|---:|
| App Store Connect API 4.5.1 | 1303 | 826 | 477 | 187 | 166 | | App Store Connect API 4.5.1 | 1303 | 826 | 477 | 196 | 166 |
| Google Play Android Developer API v3 (revision 20261008) | 145 | 45 | 100 | 34 | 49 | | Google Play Android Developer API v3 (revision 20261008) | 145 | 45 | 100 | 32 | 49 |
| Google Play Developer Reporting API v1beta1 (revision 20261008) | 25 | 15 | 10 | 6 | 0 | | Google Play Developer Reporting API v1beta1 (revision 20261008) | 25 | 15 | 10 | 6 | 0 |
## App Store Connect API 4.5.1 ## App Store Connect API 4.5.1
@ -187,7 +187,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `appCustomProductPages_appCustomProductPageVersions_getToManyRelationship` | GET | `/v1/appCustomProductPages/{id}/relationships/appCustomProductPageVersions` | read | | | `appCustomProductPages_appCustomProductPageVersions_getToManyRelationship` | GET | `/v1/appCustomProductPages/{id}/relationships/appCustomProductPageVersions` | read | |
| `appCustomProductPages_createInstance` | POST | `/v1/appCustomProductPages` | write (dry by default) | apple/cpp | | `appCustomProductPages_createInstance` | POST | `/v1/appCustomProductPages` | write (dry by default) | apple/cpp |
| `appCustomProductPages_deleteInstance` | DELETE | `/v1/appCustomProductPages/{id}` | owner only (typed confirm) | | | `appCustomProductPages_deleteInstance` | DELETE | `/v1/appCustomProductPages/{id}` | owner only (typed confirm) | |
| `appCustomProductPages_getInstance` | GET | `/v1/appCustomProductPages/{id}` | read | | | `appCustomProductPages_getInstance` | GET | `/v1/appCustomProductPages/{id}` | read | apple/cpp |
| `appCustomProductPages_updateInstance` | PATCH | `/v1/appCustomProductPages/{id}` | write (dry by default) | apple/cpp | | `appCustomProductPages_updateInstance` | PATCH | `/v1/appCustomProductPages/{id}` | write (dry by default) | apple/cpp |
| **appEncryptionDeclarationDocuments** (3) | | | | | | **appEncryptionDeclarationDocuments** (3) | | | | |
| `appEncryptionDeclarationDocuments_createInstance` | POST | `/v1/appEncryptionDeclarationDocuments` | write (dry by default) | | | `appEncryptionDeclarationDocuments_createInstance` | POST | `/v1/appEncryptionDeclarationDocuments` | write (dry by default) | |
@ -226,7 +226,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| **appEvents** (6) | | | | | | **appEvents** (6) | | | | |
| `appEvents_createInstance` | POST | `/v1/appEvents` | write (dry by default) | apple/events | | `appEvents_createInstance` | POST | `/v1/appEvents` | write (dry by default) | apple/events |
| `appEvents_deleteInstance` | DELETE | `/v1/appEvents/{id}` | owner only (typed confirm) | | | `appEvents_deleteInstance` | DELETE | `/v1/appEvents/{id}` | owner only (typed confirm) | |
| `appEvents_getInstance` | GET | `/v1/appEvents/{id}` | read | | | `appEvents_getInstance` | GET | `/v1/appEvents/{id}` | read | apple/events |
| `appEvents_localizations_getToManyRelated` | GET | `/v1/appEvents/{id}/localizations` | read | apple/events | | `appEvents_localizations_getToManyRelated` | GET | `/v1/appEvents/{id}/localizations` | read | apple/events |
| `appEvents_localizations_getToManyRelationship` | GET | `/v1/appEvents/{id}/relationships/localizations` | read | | | `appEvents_localizations_getToManyRelationship` | GET | `/v1/appEvents/{id}/relationships/localizations` | read | |
| `appEvents_updateInstance` | PATCH | `/v1/appEvents/{id}` | write (dry by default) | | | `appEvents_updateInstance` | PATCH | `/v1/appEvents/{id}` | write (dry by default) | |
@ -264,8 +264,8 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `appPreviewSets_deleteInstance` | DELETE | `/v1/appPreviewSets/{id}` | owner only (typed confirm) | | | `appPreviewSets_deleteInstance` | DELETE | `/v1/appPreviewSets/{id}` | owner only (typed confirm) | |
| `appPreviewSets_getInstance` | GET | `/v1/appPreviewSets/{id}` | read | | | `appPreviewSets_getInstance` | GET | `/v1/appPreviewSets/{id}` | read | |
| **appPreviews** (4) | | | | | | **appPreviews** (4) | | | | |
| `appPreviews_createInstance` | POST | `/v1/appPreviews` | write (dry by default) | apple/previews | | `appPreviews_createInstance` | POST | `/v1/appPreviews` | write (dry by default) | |
| `appPreviews_deleteInstance` | DELETE | `/v1/appPreviews/{id}` | owner only (typed confirm) | apple/previews | | `appPreviews_deleteInstance` | DELETE | `/v1/appPreviews/{id}` | owner only (typed confirm) | |
| `appPreviews_getInstance` | GET | `/v1/appPreviews/{id}` | read | | | `appPreviews_getInstance` | GET | `/v1/appPreviews/{id}` | read | |
| `appPreviews_updateInstance` | PATCH | `/v1/appPreviews/{id}` | write (dry by default) | apple/previews | | `appPreviews_updateInstance` | PATCH | `/v1/appPreviews/{id}` | write (dry by default) | apple/previews |
| **appPricePoints** (3) | | | | | | **appPricePoints** (3) | | | | |
@ -289,10 +289,10 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `appScreenshotSets_deleteInstance` | DELETE | `/v1/appScreenshotSets/{id}` | owner only (typed confirm) | | | `appScreenshotSets_deleteInstance` | DELETE | `/v1/appScreenshotSets/{id}` | owner only (typed confirm) | |
| `appScreenshotSets_getInstance` | GET | `/v1/appScreenshotSets/{id}` | read | | | `appScreenshotSets_getInstance` | GET | `/v1/appScreenshotSets/{id}` | read | |
| **appScreenshots** (4) | | | | | | **appScreenshots** (4) | | | | |
| `appScreenshots_createInstance` | POST | `/v1/appScreenshots` | write (dry by default) | apple/screenshots | | `appScreenshots_createInstance` | POST | `/v1/appScreenshots` | write (dry by default) | |
| `appScreenshots_deleteInstance` | DELETE | `/v1/appScreenshots/{id}` | owner only (typed confirm) | apple/screenshots | | `appScreenshots_deleteInstance` | DELETE | `/v1/appScreenshots/{id}` | owner only (typed confirm) | |
| `appScreenshots_getInstance` | GET | `/v1/appScreenshots/{id}` | read | | | `appScreenshots_getInstance` | GET | `/v1/appScreenshots/{id}` | read | |
| `appScreenshots_updateInstance` | PATCH | `/v1/appScreenshots/{id}` | write (dry by default) | apple/screenshots | | `appScreenshots_updateInstance` | PATCH | `/v1/appScreenshots/{id}` | write (dry by default) | |
| **appStoreReviewAttachments** (4) | | | | | | **appStoreReviewAttachments** (4) | | | | |
| `appStoreReviewAttachments_createInstance` | POST | `/v1/appStoreReviewAttachments` | write (dry by default) | | | `appStoreReviewAttachments_createInstance` | POST | `/v1/appStoreReviewAttachments` | write (dry by default) | |
| `appStoreReviewAttachments_deleteInstance` | DELETE | `/v1/appStoreReviewAttachments/{id}` | owner only (typed confirm) | | | `appStoreReviewAttachments_deleteInstance` | DELETE | `/v1/appStoreReviewAttachments/{id}` | owner only (typed confirm) | |
@ -319,14 +319,14 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `appStoreVersionExperimentTreatments_appStoreVersionExperimentTreatmentLocalizations_getToManyRelationship` | GET | `/v1/appStoreVersionExperimentTreatments/{id}/relationships/appStoreVersionExperimentTreatmentLocalizations` | read | | | `appStoreVersionExperimentTreatments_appStoreVersionExperimentTreatmentLocalizations_getToManyRelationship` | GET | `/v1/appStoreVersionExperimentTreatments/{id}/relationships/appStoreVersionExperimentTreatmentLocalizations` | read | |
| `appStoreVersionExperimentTreatments_createInstance` | POST | `/v1/appStoreVersionExperimentTreatments` | write (dry by default) | lib/ppo | | `appStoreVersionExperimentTreatments_createInstance` | POST | `/v1/appStoreVersionExperimentTreatments` | write (dry by default) | lib/ppo |
| `appStoreVersionExperimentTreatments_deleteInstance` | DELETE | `/v1/appStoreVersionExperimentTreatments/{id}` | owner only (typed confirm) | | | `appStoreVersionExperimentTreatments_deleteInstance` | DELETE | `/v1/appStoreVersionExperimentTreatments/{id}` | owner only (typed confirm) | |
| `appStoreVersionExperimentTreatments_getInstance` | GET | `/v1/appStoreVersionExperimentTreatments/{id}` | read | | | `appStoreVersionExperimentTreatments_getInstance` | GET | `/v1/appStoreVersionExperimentTreatments/{id}` | read | apple/ppo |
| `appStoreVersionExperimentTreatments_updateInstance` | PATCH | `/v1/appStoreVersionExperimentTreatments/{id}` | write (dry by default) | | | `appStoreVersionExperimentTreatments_updateInstance` | PATCH | `/v1/appStoreVersionExperimentTreatments/{id}` | write (dry by default) | |
| **appStoreVersionExperiments** (12) | | | | | | **appStoreVersionExperiments** (12) | | | | |
| `appStoreVersionExperimentsV2_appStoreVersionExperimentTreatments_getToManyRelated` | GET | `/v2/appStoreVersionExperiments/{id}/appStoreVersionExperimentTreatments` | read | apple/ppo | | `appStoreVersionExperimentsV2_appStoreVersionExperimentTreatments_getToManyRelated` | GET | `/v2/appStoreVersionExperiments/{id}/appStoreVersionExperimentTreatments` | read | apple/ppo |
| `appStoreVersionExperimentsV2_appStoreVersionExperimentTreatments_getToManyRelationship` | GET | `/v2/appStoreVersionExperiments/{id}/relationships/appStoreVersionExperimentTreatments` | read | | | `appStoreVersionExperimentsV2_appStoreVersionExperimentTreatments_getToManyRelationship` | GET | `/v2/appStoreVersionExperiments/{id}/relationships/appStoreVersionExperimentTreatments` | read | |
| `appStoreVersionExperimentsV2_createInstance` | POST | `/v2/appStoreVersionExperiments` | write (dry by default) | lib/ppo | | `appStoreVersionExperimentsV2_createInstance` | POST | `/v2/appStoreVersionExperiments` | write (dry by default) | lib/ppo |
| `appStoreVersionExperimentsV2_deleteInstance` | DELETE | `/v2/appStoreVersionExperiments/{id}` | owner only (typed confirm) | | | `appStoreVersionExperimentsV2_deleteInstance` | DELETE | `/v2/appStoreVersionExperiments/{id}` | owner only (typed confirm) | |
| `appStoreVersionExperimentsV2_getInstance` | GET | `/v2/appStoreVersionExperiments/{id}` | read | | | `appStoreVersionExperimentsV2_getInstance` | GET | `/v2/appStoreVersionExperiments/{id}` | read | apple/ppo |
| `appStoreVersionExperimentsV2_updateInstance` | PATCH | `/v2/appStoreVersionExperiments/{id}` | owner only (typed confirm) | lib/ppo | | `appStoreVersionExperimentsV2_updateInstance` | PATCH | `/v2/appStoreVersionExperiments/{id}` | owner only (typed confirm) | lib/ppo |
| `appStoreVersionExperiments_appStoreVersionExperimentTreatments_getToManyRelated` | GET | `/v1/appStoreVersionExperiments/{id}/appStoreVersionExperimentTreatments` | read | | | `appStoreVersionExperiments_appStoreVersionExperimentTreatments_getToManyRelated` | GET | `/v1/appStoreVersionExperiments/{id}/appStoreVersionExperimentTreatments` | read | |
| `appStoreVersionExperiments_appStoreVersionExperimentTreatments_getToManyRelationship` | GET | `/v1/appStoreVersionExperiments/{id}/relationships/appStoreVersionExperimentTreatments` | read | | | `appStoreVersionExperiments_appStoreVersionExperimentTreatments_getToManyRelationship` | GET | `/v1/appStoreVersionExperiments/{id}/relationships/appStoreVersionExperimentTreatments` | read | |
@ -443,7 +443,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `apps_betaLicenseAgreement_getToOneRelationship` | GET | `/v1/apps/{id}/relationships/betaLicenseAgreement` | read | | | `apps_betaLicenseAgreement_getToOneRelationship` | GET | `/v1/apps/{id}/relationships/betaLicenseAgreement` | read | |
| `apps_betaTesterUsages_getMetrics` | GET | `/v1/apps/{id}/metrics/betaTesterUsages` | read | | | `apps_betaTesterUsages_getMetrics` | GET | `/v1/apps/{id}/metrics/betaTesterUsages` | read | |
| `apps_betaTesters_deleteToManyRelationship` | DELETE | `/v1/apps/{id}/relationships/betaTesters` | owner only (typed confirm) | | | `apps_betaTesters_deleteToManyRelationship` | DELETE | `/v1/apps/{id}/relationships/betaTesters` | owner only (typed confirm) | |
| `apps_buildUploads_getToManyRelated` | GET | `/v1/apps/{id}/buildUploads` | read | | | `apps_buildUploads_getToManyRelated` | GET | `/v1/apps/{id}/buildUploads` | read | apple/upload |
| `apps_buildUploads_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/buildUploads` | read | | | `apps_buildUploads_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/buildUploads` | read | |
| `apps_builds_getToManyRelated` | GET | `/v1/apps/{id}/builds` | read | | | `apps_builds_getToManyRelated` | GET | `/v1/apps/{id}/builds` | read | |
| `apps_builds_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/builds` | read | | | `apps_builds_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/builds` | read | |
@ -571,7 +571,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `betaGroups_createInstance` | POST | `/v1/betaGroups` | write (dry by default) | lib/testflight | | `betaGroups_createInstance` | POST | `/v1/betaGroups` | write (dry by default) | lib/testflight |
| `betaGroups_deleteInstance` | DELETE | `/v1/betaGroups/{id}` | owner only (typed confirm) | | | `betaGroups_deleteInstance` | DELETE | `/v1/betaGroups/{id}` | owner only (typed confirm) | |
| `betaGroups_getCollection` | GET | `/v1/betaGroups` | read | apple/testflight | | `betaGroups_getCollection` | GET | `/v1/betaGroups` | read | apple/testflight |
| `betaGroups_getInstance` | GET | `/v1/betaGroups/{id}` | read | | | `betaGroups_getInstance` | GET | `/v1/betaGroups/{id}` | read | apple/testflight |
| `betaGroups_publicLinkUsages_getMetrics` | GET | `/v1/betaGroups/{id}/metrics/publicLinkUsages` | read | | | `betaGroups_publicLinkUsages_getMetrics` | GET | `/v1/betaGroups/{id}/metrics/publicLinkUsages` | read | |
| `betaGroups_updateInstance` | PATCH | `/v1/betaGroups/{id}` | write (dry by default) | | | `betaGroups_updateInstance` | PATCH | `/v1/betaGroups/{id}` | write (dry by default) | |
| **betaLicenseAgreements** (5) | | | | | | **betaLicenseAgreements** (5) | | | | |
@ -627,7 +627,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `buildUploadFiles_getInstance` | GET | `/v1/buildUploadFiles/{id}` | read | | | `buildUploadFiles_getInstance` | GET | `/v1/buildUploadFiles/{id}` | read | |
| `buildUploadFiles_updateInstance` | PATCH | `/v1/buildUploadFiles/{id}` | write (dry by default) | lib/xcode | | `buildUploadFiles_updateInstance` | PATCH | `/v1/buildUploadFiles/{id}` | write (dry by default) | lib/xcode |
| **buildUploads** (5) | | | | | | **buildUploads** (5) | | | | |
| `buildUploads_buildUploadFiles_getToManyRelated` | GET | `/v1/buildUploads/{id}/buildUploadFiles` | read | | | `buildUploads_buildUploadFiles_getToManyRelated` | GET | `/v1/buildUploads/{id}/buildUploadFiles` | read | apple/upload |
| `buildUploads_buildUploadFiles_getToManyRelationship` | GET | `/v1/buildUploads/{id}/relationships/buildUploadFiles` | read | | | `buildUploads_buildUploadFiles_getToManyRelationship` | GET | `/v1/buildUploads/{id}/relationships/buildUploadFiles` | read | |
| `buildUploads_createInstance` | POST | `/v1/buildUploads` | write (dry by default) | lib/xcode | | `buildUploads_createInstance` | POST | `/v1/buildUploads` | write (dry by default) | lib/xcode |
| `buildUploads_deleteInstance` | DELETE | `/v1/buildUploads/{id}` | owner only (typed confirm) | | | `buildUploads_deleteInstance` | DELETE | `/v1/buildUploads/{id}` | owner only (typed confirm) | |
@ -1194,7 +1194,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `inAppPurchaseOfferCodes_createInstance` | POST | `/v1/inAppPurchaseOfferCodes` | write (dry by default) | apple/offer-codes | | `inAppPurchaseOfferCodes_createInstance` | POST | `/v1/inAppPurchaseOfferCodes` | write (dry by default) | apple/offer-codes |
| `inAppPurchaseOfferCodes_customCodes_getToManyRelated` | GET | `/v1/inAppPurchaseOfferCodes/{id}/customCodes` | read | apple/offer-codes | | `inAppPurchaseOfferCodes_customCodes_getToManyRelated` | GET | `/v1/inAppPurchaseOfferCodes/{id}/customCodes` | read | apple/offer-codes |
| `inAppPurchaseOfferCodes_customCodes_getToManyRelationship` | GET | `/v1/inAppPurchaseOfferCodes/{id}/relationships/customCodes` | read | | | `inAppPurchaseOfferCodes_customCodes_getToManyRelationship` | GET | `/v1/inAppPurchaseOfferCodes/{id}/relationships/customCodes` | read | |
| `inAppPurchaseOfferCodes_getInstance` | GET | `/v1/inAppPurchaseOfferCodes/{id}` | read | | | `inAppPurchaseOfferCodes_getInstance` | GET | `/v1/inAppPurchaseOfferCodes/{id}` | read | apple/offer-codes |
| `inAppPurchaseOfferCodes_oneTimeUseCodes_getToManyRelated` | GET | `/v1/inAppPurchaseOfferCodes/{id}/oneTimeUseCodes` | read | apple/offer-codes | | `inAppPurchaseOfferCodes_oneTimeUseCodes_getToManyRelated` | GET | `/v1/inAppPurchaseOfferCodes/{id}/oneTimeUseCodes` | read | apple/offer-codes |
| `inAppPurchaseOfferCodes_oneTimeUseCodes_getToManyRelationship` | GET | `/v1/inAppPurchaseOfferCodes/{id}/relationships/oneTimeUseCodes` | read | | | `inAppPurchaseOfferCodes_oneTimeUseCodes_getToManyRelationship` | GET | `/v1/inAppPurchaseOfferCodes/{id}/relationships/oneTimeUseCodes` | read | |
| `inAppPurchaseOfferCodes_prices_getToManyRelated` | GET | `/v1/inAppPurchaseOfferCodes/{id}/prices` | read | | | `inAppPurchaseOfferCodes_prices_getToManyRelated` | GET | `/v1/inAppPurchaseOfferCodes/{id}/prices` | read | |
@ -1230,7 +1230,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `inAppPurchasesV2_content_getToOneRelationship` | GET | `/v2/inAppPurchases/{id}/relationships/content` | read | | | `inAppPurchasesV2_content_getToOneRelationship` | GET | `/v2/inAppPurchases/{id}/relationships/content` | read | |
| `inAppPurchasesV2_createInstance` | POST | `/v2/inAppPurchases` | write (dry by default) | apple/iap | | `inAppPurchasesV2_createInstance` | POST | `/v2/inAppPurchases` | write (dry by default) | apple/iap |
| `inAppPurchasesV2_deleteInstance` | DELETE | `/v2/inAppPurchases/{id}` | owner only (typed confirm) | | | `inAppPurchasesV2_deleteInstance` | DELETE | `/v2/inAppPurchases/{id}` | owner only (typed confirm) | |
| `inAppPurchasesV2_getInstance` | GET | `/v2/inAppPurchases/{id}` | read | | | `inAppPurchasesV2_getInstance` | GET | `/v2/inAppPurchases/{id}` | read | apple/iap |
| `inAppPurchasesV2_iapPriceSchedule_getToOneRelated` | GET | `/v2/inAppPurchases/{id}/iapPriceSchedule` | read | apple/iap, apple/iap-review, catalog | | `inAppPurchasesV2_iapPriceSchedule_getToOneRelated` | GET | `/v2/inAppPurchases/{id}/iapPriceSchedule` | read | apple/iap, apple/iap-review, catalog |
| `inAppPurchasesV2_iapPriceSchedule_getToOneRelationship` | GET | `/v2/inAppPurchases/{id}/relationships/iapPriceSchedule` | read | | | `inAppPurchasesV2_iapPriceSchedule_getToOneRelationship` | GET | `/v2/inAppPurchases/{id}/relationships/iapPriceSchedule` | read | |
| `inAppPurchasesV2_images_getToManyRelated` | GET | `/v2/inAppPurchases/{id}/images` | read | | | `inAppPurchasesV2_images_getToManyRelated` | GET | `/v2/inAppPurchases/{id}/images` | read | |
@ -1304,14 +1304,14 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `promotedPurchases_getInstance` | GET | `/v1/promotedPurchases/{id}` | read | | | `promotedPurchases_getInstance` | GET | `/v1/promotedPurchases/{id}` | read | |
| `promotedPurchases_updateInstance` | PATCH | `/v1/promotedPurchases/{id}` | write (dry by default) | lib/offers | | `promotedPurchases_updateInstance` | PATCH | `/v1/promotedPurchases/{id}` | write (dry by default) | lib/offers |
| **reviewSubmissionItems** (3) | | | | | | **reviewSubmissionItems** (3) | | | | |
| `reviewSubmissionItems_createInstance` | POST | `/v1/reviewSubmissionItems` | write (dry by default) | apple/events, lib/cpp-events, lib/review-submit | | `reviewSubmissionItems_createInstance` | POST | `/v1/reviewSubmissionItems` | write (dry by default) | apple/submission, lib/cpp-events, lib/review-submit |
| `reviewSubmissionItems_deleteInstance` | DELETE | `/v1/reviewSubmissionItems/{id}` | owner only (typed confirm) | | | `reviewSubmissionItems_deleteInstance` | DELETE | `/v1/reviewSubmissionItems/{id}` | owner only (typed confirm) | |
| `reviewSubmissionItems_updateInstance` | PATCH | `/v1/reviewSubmissionItems/{id}` | write (dry by default) | | | `reviewSubmissionItems_updateInstance` | PATCH | `/v1/reviewSubmissionItems/{id}` | write (dry by default) | |
| **reviewSubmissions** (6) | | | | | | **reviewSubmissions** (6) | | | | |
| `reviewSubmissions_createInstance` | POST | `/v1/reviewSubmissions` | owner only (typed confirm) | apple/cpp, apple/events, lib/review-submit | | `reviewSubmissions_createInstance` | POST | `/v1/reviewSubmissions` | owner only (typed confirm) | apple/cpp, apple/events, apple/submission, lib/review-submit |
| `reviewSubmissions_getCollection` | GET | `/v1/reviewSubmissions` | read | apple/cpp, apple/events, apple/iap-submit, apple/status | | `reviewSubmissions_getCollection` | GET | `/v1/reviewSubmissions` | read | apple/events, apple/iap-submit, apple/status, apple/submission |
| `reviewSubmissions_getInstance` | GET | `/v1/reviewSubmissions/{id}` | read | | | `reviewSubmissions_getInstance` | GET | `/v1/reviewSubmissions/{id}` | read | apple/submission |
| `reviewSubmissions_items_getToManyRelated` | GET | `/v1/reviewSubmissions/{id}/items` | read | apple/iap-submit | | `reviewSubmissions_items_getToManyRelated` | GET | `/v1/reviewSubmissions/{id}/items` | read | apple/iap-submit, apple/submission |
| `reviewSubmissions_items_getToManyRelationship` | GET | `/v1/reviewSubmissions/{id}/relationships/items` | read | | | `reviewSubmissions_items_getToManyRelationship` | GET | `/v1/reviewSubmissions/{id}/relationships/items` | read | |
| `reviewSubmissions_updateInstance` | PATCH | `/v1/reviewSubmissions/{id}` | owner only (typed confirm) | apple/cpp, apple/events, apple/iap-submit, apple/status, lib/review-submit | | `reviewSubmissions_updateInstance` | PATCH | `/v1/reviewSubmissions/{id}` | owner only (typed confirm) | apple/cpp, apple/events, apple/iap-submit, apple/status, lib/review-submit |
| **routingAppCoverages** (4) | | | | | | **routingAppCoverages** (4) | | | | |
@ -1344,7 +1344,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `scmRepositories_pullRequests_getToManyRelationship` | GET | `/v1/scmRepositories/{id}/relationships/pullRequests` | read | | | `scmRepositories_pullRequests_getToManyRelationship` | GET | `/v1/scmRepositories/{id}/relationships/pullRequests` | read | |
| **subscriptionAppStoreReviewScreenshots** (4) | | | | | | **subscriptionAppStoreReviewScreenshots** (4) | | | | |
| `subscriptionAppStoreReviewScreenshots_createInstance` | POST | `/v1/subscriptionAppStoreReviewScreenshots` | write (dry by default) | apple/subscriptions | | `subscriptionAppStoreReviewScreenshots_createInstance` | POST | `/v1/subscriptionAppStoreReviewScreenshots` | write (dry by default) | apple/subscriptions |
| `subscriptionAppStoreReviewScreenshots_deleteInstance` | DELETE | `/v1/subscriptionAppStoreReviewScreenshots/{id}` | owner only (typed confirm) | | | `subscriptionAppStoreReviewScreenshots_deleteInstance` | DELETE | `/v1/subscriptionAppStoreReviewScreenshots/{id}` | owner only (typed confirm) | apple/subscriptions |
| `subscriptionAppStoreReviewScreenshots_getInstance` | GET | `/v1/subscriptionAppStoreReviewScreenshots/{id}` | read | | | `subscriptionAppStoreReviewScreenshots_getInstance` | GET | `/v1/subscriptionAppStoreReviewScreenshots/{id}` | read | |
| `subscriptionAppStoreReviewScreenshots_updateInstance` | PATCH | `/v1/subscriptionAppStoreReviewScreenshots/{id}` | write (dry by default) | apple/subscriptions | | `subscriptionAppStoreReviewScreenshots_updateInstance` | PATCH | `/v1/subscriptionAppStoreReviewScreenshots/{id}` | write (dry by default) | apple/subscriptions |
| **subscriptionAvailabilities** (4) | | | | | | **subscriptionAvailabilities** (4) | | | | |
@ -1374,7 +1374,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| **subscriptionGroups** (10) | | | | | | **subscriptionGroups** (10) | | | | |
| `subscriptionGroups_createInstance` | POST | `/v1/subscriptionGroups` | write (dry by default) | apple/subscriptions | | `subscriptionGroups_createInstance` | POST | `/v1/subscriptionGroups` | write (dry by default) | apple/subscriptions |
| `subscriptionGroups_deleteInstance` | DELETE | `/v1/subscriptionGroups/{id}` | owner only (typed confirm) | | | `subscriptionGroups_deleteInstance` | DELETE | `/v1/subscriptionGroups/{id}` | owner only (typed confirm) | |
| `subscriptionGroups_getInstance` | GET | `/v1/subscriptionGroups/{id}` | read | | | `subscriptionGroups_getInstance` | GET | `/v1/subscriptionGroups/{id}` | read | apple/subscriptions |
| `subscriptionGroups_subscriptionGroupLocalizations_getToManyRelated` | GET | `/v1/subscriptionGroups/{id}/subscriptionGroupLocalizations` | read | apple/subscriptions, validate | | `subscriptionGroups_subscriptionGroupLocalizations_getToManyRelated` | GET | `/v1/subscriptionGroups/{id}/subscriptionGroupLocalizations` | read | apple/subscriptions, validate |
| `subscriptionGroups_subscriptionGroupLocalizations_getToManyRelationship` | GET | `/v1/subscriptionGroups/{id}/relationships/subscriptionGroupLocalizations` | read | | | `subscriptionGroups_subscriptionGroupLocalizations_getToManyRelationship` | GET | `/v1/subscriptionGroups/{id}/relationships/subscriptionGroupLocalizations` | read | |
| `subscriptionGroups_subscriptions_getToManyRelated` | GET | `/v1/subscriptionGroups/{id}/subscriptions` | read | apple/iap-submit, apple/promoted, apple/subscriptions, apple/win-back, catalog, prices, validate | | `subscriptionGroups_subscriptions_getToManyRelated` | GET | `/v1/subscriptionGroups/{id}/subscriptions` | read | apple/iap-submit, apple/promoted, apple/subscriptions, apple/win-back, catalog, prices, validate |
@ -1417,7 +1417,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `subscriptionOfferCodes_createInstance` | POST | `/v1/subscriptionOfferCodes` | write (dry by default) | apple/subscriptions | | `subscriptionOfferCodes_createInstance` | POST | `/v1/subscriptionOfferCodes` | write (dry by default) | apple/subscriptions |
| `subscriptionOfferCodes_customCodes_getToManyRelated` | GET | `/v1/subscriptionOfferCodes/{id}/customCodes` | read | apple/subscriptions | | `subscriptionOfferCodes_customCodes_getToManyRelated` | GET | `/v1/subscriptionOfferCodes/{id}/customCodes` | read | apple/subscriptions |
| `subscriptionOfferCodes_customCodes_getToManyRelationship` | GET | `/v1/subscriptionOfferCodes/{id}/relationships/customCodes` | read | | | `subscriptionOfferCodes_customCodes_getToManyRelationship` | GET | `/v1/subscriptionOfferCodes/{id}/relationships/customCodes` | read | |
| `subscriptionOfferCodes_getInstance` | GET | `/v1/subscriptionOfferCodes/{id}` | read | | | `subscriptionOfferCodes_getInstance` | GET | `/v1/subscriptionOfferCodes/{id}` | read | apple/subscriptions |
| `subscriptionOfferCodes_oneTimeUseCodes_getToManyRelated` | GET | `/v1/subscriptionOfferCodes/{id}/oneTimeUseCodes` | read | | | `subscriptionOfferCodes_oneTimeUseCodes_getToManyRelated` | GET | `/v1/subscriptionOfferCodes/{id}/oneTimeUseCodes` | read | |
| `subscriptionOfferCodes_oneTimeUseCodes_getToManyRelationship` | GET | `/v1/subscriptionOfferCodes/{id}/relationships/oneTimeUseCodes` | read | | | `subscriptionOfferCodes_oneTimeUseCodes_getToManyRelationship` | GET | `/v1/subscriptionOfferCodes/{id}/relationships/oneTimeUseCodes` | read | |
| `subscriptionOfferCodes_prices_getToManyRelated` | GET | `/v1/subscriptionOfferCodes/{id}/prices` | read | | | `subscriptionOfferCodes_prices_getToManyRelated` | GET | `/v1/subscriptionOfferCodes/{id}/prices` | read | |
@ -1461,7 +1461,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `subscriptions_appStoreReviewScreenshot_getToOneRelationship` | GET | `/v1/subscriptions/{id}/relationships/appStoreReviewScreenshot` | read | | | `subscriptions_appStoreReviewScreenshot_getToOneRelationship` | GET | `/v1/subscriptions/{id}/relationships/appStoreReviewScreenshot` | read | |
| `subscriptions_createInstance` | POST | `/v1/subscriptions` | write (dry by default) | apple/subscriptions | | `subscriptions_createInstance` | POST | `/v1/subscriptions` | write (dry by default) | apple/subscriptions |
| `subscriptions_deleteInstance` | DELETE | `/v1/subscriptions/{id}` | owner only (typed confirm) | | | `subscriptions_deleteInstance` | DELETE | `/v1/subscriptions/{id}` | owner only (typed confirm) | |
| `subscriptions_getInstance` | GET | `/v1/subscriptions/{id}` | read | | | `subscriptions_getInstance` | GET | `/v1/subscriptions/{id}` | read | apple/subscriptions |
| `subscriptions_images_getToManyRelated` | GET | `/v1/subscriptions/{id}/images` | read | | | `subscriptions_images_getToManyRelated` | GET | `/v1/subscriptions/{id}/images` | read | |
| `subscriptions_images_getToManyRelationship` | GET | `/v1/subscriptions/{id}/relationships/images` | read | | | `subscriptions_images_getToManyRelationship` | GET | `/v1/subscriptions/{id}/relationships/images` | read | |
| `subscriptions_introductoryOffers_deleteToManyRelationship` | DELETE | `/v1/subscriptions/{id}/relationships/introductoryOffers` | owner only (typed confirm) | | | `subscriptions_introductoryOffers_deleteToManyRelationship` | DELETE | `/v1/subscriptions/{id}/relationships/introductoryOffers` | owner only (typed confirm) | |
@ -1607,7 +1607,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `androidpublisher.edits.testers.update` | PUT | `androidpublisher/v3/applications/{packageName}/edits/{editId}/testers/{track}` | owner only (typed confirm) | | | `androidpublisher.edits.testers.update` | PUT | `androidpublisher/v3/applications/{packageName}/edits/{editId}/testers/{track}` | owner only (typed confirm) | |
| **androidpublisher.edits.tracks** (5) | | | | | | **androidpublisher.edits.tracks** (5) | | | | |
| `androidpublisher.edits.tracks.create` | POST | `androidpublisher/v3/applications/{packageName}/edits/{editId}/tracks` | write (dry by default) | | | `androidpublisher.edits.tracks.create` | POST | `androidpublisher/v3/applications/{packageName}/edits/{editId}/tracks` | write (dry by default) | |
| `androidpublisher.edits.tracks.get` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}/tracks/{track}` | read | play/rollout, play/upload | | `androidpublisher.edits.tracks.get` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}/tracks/{track}` | read | play/release, play/rollout, play/upload |
| `androidpublisher.edits.tracks.list` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}/tracks` | read | play/next-version-code, play/status, validate | | `androidpublisher.edits.tracks.list` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}/tracks` | read | play/next-version-code, play/status, validate |
| `androidpublisher.edits.tracks.patch` | PATCH | `androidpublisher/v3/applications/{packageName}/edits/{editId}/tracks/{track}` | write (dry by default) | | | `androidpublisher.edits.tracks.patch` | PATCH | `androidpublisher/v3/applications/{packageName}/edits/{editId}/tracks/{track}` | write (dry by default) | |
| `androidpublisher.edits.tracks.update` | PUT | `androidpublisher/v3/applications/{packageName}/edits/{editId}/tracks/{track}` | write (dry by default) | play/release, play/rollout, play/upload | | `androidpublisher.edits.tracks.update` | PUT | `androidpublisher/v3/applications/{packageName}/edits/{editId}/tracks/{track}` | write (dry by default) | play/release, play/rollout, play/upload |
@ -1663,7 +1663,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `androidpublisher.monetization.subscriptions.batchUpdate` | POST | `androidpublisher/v3/applications/{packageName}/subscriptions:batchUpdate` | write (dry by default) | | | `androidpublisher.monetization.subscriptions.batchUpdate` | POST | `androidpublisher/v3/applications/{packageName}/subscriptions:batchUpdate` | write (dry by default) | |
| `androidpublisher.monetization.subscriptions.create` | POST | `androidpublisher/v3/applications/{packageName}/subscriptions` | write (dry by default) | play/subscriptions | | `androidpublisher.monetization.subscriptions.create` | POST | `androidpublisher/v3/applications/{packageName}/subscriptions` | write (dry by default) | play/subscriptions |
| `androidpublisher.monetization.subscriptions.delete` | DELETE | `androidpublisher/v3/applications/{packageName}/subscriptions/{productId}` | owner only (typed confirm) | | | `androidpublisher.monetization.subscriptions.delete` | DELETE | `androidpublisher/v3/applications/{packageName}/subscriptions/{productId}` | owner only (typed confirm) | |
| `androidpublisher.monetization.subscriptions.get` | GET | `androidpublisher/v3/applications/{packageName}/subscriptions/{productId}` | read | prices | | `androidpublisher.monetization.subscriptions.get` | GET | `androidpublisher/v3/applications/{packageName}/subscriptions/{productId}` | read | play/subscriptions, prices |
| `androidpublisher.monetization.subscriptions.list` | GET | `androidpublisher/v3/applications/{packageName}/subscriptions` | read | catalog, play/subscriptions, validate | | `androidpublisher.monetization.subscriptions.list` | GET | `androidpublisher/v3/applications/{packageName}/subscriptions` | read | catalog, play/subscriptions, validate |
| `androidpublisher.monetization.subscriptions.patch` | PATCH | `androidpublisher/v3/applications/{packageName}/subscriptions/{productId}` | write (dry by default) | play/subscriptions | | `androidpublisher.monetization.subscriptions.patch` | PATCH | `androidpublisher/v3/applications/{packageName}/subscriptions/{productId}` | write (dry by default) | play/subscriptions |
| **androidpublisher.monetization.subscriptions.basePlans** (6) | | | | | | **androidpublisher.monetization.subscriptions.basePlans** (6) | | | | |
@ -1674,11 +1674,11 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `androidpublisher.monetization.subscriptions.basePlans.delete` | DELETE | `androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}` | owner only (typed confirm) | | | `androidpublisher.monetization.subscriptions.basePlans.delete` | DELETE | `androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}` | owner only (typed confirm) | |
| `androidpublisher.monetization.subscriptions.basePlans.migratePrices` | POST | `androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}:migratePrices` | owner only (typed confirm) | | | `androidpublisher.monetization.subscriptions.basePlans.migratePrices` | POST | `androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}:migratePrices` | owner only (typed confirm) | |
| **androidpublisher.monetization.subscriptions.basePlans.offers** (10) | | | | | | **androidpublisher.monetization.subscriptions.basePlans.offers** (10) | | | | |
| `androidpublisher.monetization.subscriptions.basePlans.offers.activate` | POST | `androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}/offers/{offerId}:activate` | owner only (typed confirm) | play/subscriptions | | `androidpublisher.monetization.subscriptions.basePlans.offers.activate` | POST | `androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}/offers/{offerId}:activate` | owner only (typed confirm) | |
| `androidpublisher.monetization.subscriptions.basePlans.offers.batchGet` | POST | `androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}/offers:batchGet` | read | | | `androidpublisher.monetization.subscriptions.basePlans.offers.batchGet` | POST | `androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}/offers:batchGet` | read | |
| `androidpublisher.monetization.subscriptions.basePlans.offers.batchUpdate` | POST | `androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}/offers:batchUpdate` | write (dry by default) | | | `androidpublisher.monetization.subscriptions.basePlans.offers.batchUpdate` | POST | `androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}/offers:batchUpdate` | write (dry by default) | |
| `androidpublisher.monetization.subscriptions.basePlans.offers.batchUpdateStates` | POST | `androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}/offers:batchUpdateStates` | owner only (typed confirm) | | | `androidpublisher.monetization.subscriptions.basePlans.offers.batchUpdateStates` | POST | `androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}/offers:batchUpdateStates` | owner only (typed confirm) | |
| `androidpublisher.monetization.subscriptions.basePlans.offers.create` | POST | `androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}/offers` | write (dry by default) | play/subscriptions | | `androidpublisher.monetization.subscriptions.basePlans.offers.create` | POST | `androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}/offers` | write (dry by default) | |
| `androidpublisher.monetization.subscriptions.basePlans.offers.deactivate` | POST | `androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}/offers/{offerId}:deactivate` | owner only (typed confirm) | | | `androidpublisher.monetization.subscriptions.basePlans.offers.deactivate` | POST | `androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}/offers/{offerId}:deactivate` | owner only (typed confirm) | |
| `androidpublisher.monetization.subscriptions.basePlans.offers.delete` | DELETE | `androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}/offers/{offerId}` | owner only (typed confirm) | | | `androidpublisher.monetization.subscriptions.basePlans.offers.delete` | DELETE | `androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}/offers/{offerId}` | owner only (typed confirm) | |
| `androidpublisher.monetization.subscriptions.basePlans.offers.get` | GET | `androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}/offers/{offerId}` | read | | | `androidpublisher.monetization.subscriptions.basePlans.offers.get` | GET | `androidpublisher/v3/applications/{packageName}/subscriptions/{productId}/basePlans/{basePlanId}/offers/{offerId}` | read | |

103
docs/idempotency.md Normal file
View file

@ -0,0 +1,103 @@
# Idempotency: every write converges
storebound must never create duplicates or leave a live store half changed. Every write command is an upsert: run it
twice, or again after a crash or a lost answer, and the store holds exactly one of each thing. This page lists every
write path, the key it matches on, and what a second run does.
## The rules (all write paths)
1. **Read first, by a stable key.** Product id, locale, display type + position (MD5), version string, name, UDID,
email. What matches is left alone or updated; only what is missing is created.
2. **Look again right before a create** (`findOrCreate` in `cli/lib/upsert.ts`): a fresh GET, then the journal of
recent creates in `.storebound/created.json` (one day). The stores' lists lag for a minute after a create; a direct
GET by the journaled id does not, so a re-run during the lag reuses what the last run made.
3. **409 / ALREADY_EXISTS means found** (`createOnce`): the existing one is looked up and used, the create is never
sent again. Apple also answers 409 for plain validation errors; those are not treated as found (`isConflict`).
4. **An unclear answer is never retried blindly.** A POST that times out or gets a 5xx may have been applied. The API
clients throw `AmbiguousWriteError` instead of retrying it; `createOnce` then looks it up. Only when the store
refuses duplicates of that key (unique) is one more create safe (its 409 finds the first). Otherwise the run stops
with "run it again in a minute": the next run reads the store first.
5. **Retries (429, 5xx, network) only for requests that end the same when sent twice** (`retrySafe`): GET, PUT, PATCH,
DELETE, relationship adds, Google state actions (`:activate`, `:batchUpdateStates`, `:validate`), the App Store
"create or replace" singletons (price schedules, availability). A DELETE that answers 404 after a retry counts as
done. `storebound api` follows the same rule.
6. **Play edits are all or nothing.** One edit open at a time per run (a second is refused); every error path deletes
the edit; commit only on success; nothing to change = the edit is deleted, not committed. An edit is redone only
when nothing of it went live (invalidated by a Console save, or a request inside it had no clear answer). A commit
without a clear answer is never redone: the run stops and says to check first.
7. **Asset sets keep what matches.** Screenshots and previews: the leading files that already match (MD5, same
position, delivered) stay, the rest is deleted and uploaded in order, the order is read back and fixed. A crash
midway leaves a prefix; the re-run keeps it and finishes. A reservation without a clear answer is cleaned up (the
set is re-read, unknown assets deleted) before the file is sent again.
8. **One writer per app and store.** Write runs take `.storebound/locks/<store>-<app>.lock` (dry runs and reads do
not). A second run stops with who holds the lock; a lock of a process that is gone is taken over; `--force-unlock`
removes one left by a crash on another machine (also stale after 12 hours).
Tests: `tests/idempotency.test.ts` runs the write commands twice against stateful store doubles
(`tests/helpers/fake-stores.ts`) and asserts the second run makes zero writes, plus the crash and lost-answer cases.
## Commands
Status: **ok** = converges, second run writes nothing (tested where marked T). **writes** = converges but sends the
same values again (no duplicate; noted below). **owner** = a public or irreversible step that asks first.
| command | key | on re-run | status |
|---|---|---|---|
| `apple bundle` | bundle identifier | found by filter, 409 = found | ok T |
| `apple iap` | product id; locale; one schedule / availability per product | product: findOrCreate (journal for list lag), unique (lost answer: look, then one retry); localizations: createOnce; schedule and availability only when missing | ok T (twice, lost answer before / after, 409, crash + lag) |
| `apple iap-review` | one review screenshot per product (MD5 + not FAILED) | kept when the bytes match, else deleted and uploaded | ok |
| `apple subscriptions` | group reference name; product id; locale; promo offer code; campaign name; custom code | groups and subscriptions: findOrCreate + journal; localizations / offers / codes: createOnce; prices compared per territory, only differences sent; a subscription found late is read live (prices, intro) | ok T (twice, lost answer) |
| `apple offer-codes` | campaign name per product; custom code; one-time batches by count | campaign: findOrCreate + journal (names not known unique: no blind retry); codes: 409 = exists | ok T |
| `apple win-back` | offer id per subscription | only missing offers; 409 = exists | ok (owner: asks) |
| `apple promoted` | product per promoted purchase | plan from the store; order PATCH only when it differs | ok |
| `apple preorder` | territory | plan from the store | ok (owner) |
| `apple cpp create` | page name; locale | page: findOrCreate + journal; locales from a fresh read, 409 = exists | ok T |
| `apple cpp upload` / `apple ppo upload` / `apple screenshots` | display type; position + MD5 | set: findOrCreate (unique, 409 = found); screenshots: matching prefix kept, rest replaced in order | ok T (twice, crash on the 3rd file) |
| `apple cpp submit` / `apple events submit` / `apple iap-submit` | the app's draft submission; version id per item | draft reused or created once (journal); an item in the draft is not added again | ok T (iap-submit) (owner: submit asks) |
| `apple events create` | reference name; locale | event: findOrCreate + journal; localizations planned from a fresh read | ok T |
| `apple events upload` | asset type per locale (size + delivered) | kept when the same size is delivered, else replaced | ok |
| `apple ppo create` | test name (open tests only); treatment name; locale | findOrCreate + journal for test and treatments | ok T |
| `apple previews` | preview type; position + MD5; poster time code | like screenshots; poster PATCH only where it differs | ok |
| `apple testflight groups` | group name | findOrCreate + journal, 409 = found | ok T |
| `apple testflight testers` | email (masked) per group | only missing testers / memberships; 409 = exists | ok (owner: asks) |
| `apple testflight what-to-test` / `info` | locale; fields | POST missing locales, PATCH changed fields only | ok |
| `apple testflight distribute` | group per build | relationship adds (same when sent twice); beta review once per build (409 = exists) | ok (owner) |
| `apple testflight expire` | build | only unexpired builds planned | ok (owner) |
| `apple review-details` | one detail per version | only changed fields PATCHed; create once (409 = found) | ok T |
| `apple release --phased` | one phased release per version | NONE when already in that state; create once | ok T |
| `apple encryption` | build without an answer | only unanswered builds | ok T |
| `apple adhoc register` | UDID | only unregistered; 409 / lost answer: direct lookup by UDID | ok T |
| `apple adhoc profile` | profile name (+ dated names of earlier runs) | a current profile (also a dated one) ends the run; a new name never collides | ok |
| `apple signing ensure` | certificate with a local key (SPKI); profile with that certificate | a lost certificate answer keeps the pending key and finds the certificate by its public key (now or on the next run); profiles: createOnce, re-read | ok |
| `apple upload` | build upload per version + build number | a COMPLETE upload is not sent again (only the wait); an unfinished one is resumed; reservations createOnce | ok |
| `apple listing` | version (editable); locale | see "open points" | writes |
| `apple appinfo` | categories, age rating, rights, price, availability | PATCHes every run (same values); price / availability only when missing | writes |
| `analytics setup` | the ONGOING request | exists = done; createOnce | ok |
| `reviews send` | review id (local `sent:` mark) | Apple 409 (already answered) = noted as sent; Play `:reply` replaces | ok (owner) |
| `play products` | product id | PATCH `allowMissing` (create or update) only when it differs; activation only when not ACTIVE | ok T |
| `play subscriptions` | product id; base plan id; offer id | create: 409 = found (createOnce); PATCH only differences; activation only when needed | ok T (twice, lost answer) |
| `play images` | language + image type, sha256 in order | one read edit compares; only changed types are cleared and uploaded, in one edit per language | ok T |
| `play listing` | language | see "open points" | writes |
| `play release` | versionCode; track release | a versionCode Play has is reused, not uploaded; an identical track release is not PUT; nothing changed = edit deleted | ok T |
| `play upload` | versionCode + track | already on that track with that status = done | ok |
| `play rollout` | track releases | the same releases = edit deleted, not committed | ok T |
| `play data-safety` | the form | Play has no read API for it: the same CSV is sent again (a set, no duplicates) | writes |
| `play share` | none (a new link each time) | a new internal sharing artifact per run (asks first; links expire in 60 days) | owner |
| `prices import` | product x store x region | diff first; only changed product / store pairs written (`--reconvert` always rewrites Play regions) | ok |
| `revenuecat sync` | store identifier + app; lookup keys | plan from a fresh read; creates through createOnce (409 = found by key); attaches are the same twice | ok |
| `ads apple bid / budget / pause` | campaign / keyword | PUT of a value | ok (owner) |
| `web init` | file content | unchanged files are not written | ok |
| `flow run --apply` | steps (state file) | each step is one of the commands above; `--resume` skips finished steps | ok |
| `console fill` | none | fills fields in a visible browser, never presses Save / Submit / Next | ok (never saves) |
| `storebound api <write>` | none (one raw request) | a POST without a clear answer is not retried; check with a GET first | raw |
## Open points (owned by another change)
- `apple listing`: the version is matched by "the editable version", not by its version string, and a new one is
created without findOrCreate; localizations are POSTed without `createOnce` (a 409 stops the run), and the build is
re-attached (PATCH) every run. Should use findOrCreate for the version and localizations, and skip the build PATCH
when it is attached already.
- `play listing`: every run opens an edit, PUTs details and every listing and commits, also when nothing changed. Should
compare like its dry run (`listingChanges`, `detailsChanges`) and call `ctl.unchanged()` when nothing differs.
- `apple appinfo`: categories, age rating and content rights are PATCHed every run. Should compare with the current
values first.

View file

@ -9,8 +9,9 @@ fails without the fix.
| # | date | lesson | guard | | # | date | lesson | guard |
|---|---|---|---| |---|---|---|---|
| 10 | 2026-10 | Google Play has no public search API, so there is no Play keyword rank or Play competitor list to read without scraping. | `aso` reads App Store search only (iTunes Search API); `aso audit` covers Play through the listing text (short description, density); the aso-basics skill says so. | | 11 | 2026-10 | Google Play has no public search API, so there is no Play keyword rank or Play competitor list to read without scraping. | `aso` reads App Store search only (iTunes Search API); `aso audit` covers Play through the listing text (short description, density); the aso-basics skill says so. |
| 9 | 2026-10 | The iTunes Search API order is close to, not equal to, App Store search (no personalisation, no search ads, no in-app event rows) and it has no subtitle field. | `aso rank` / `aso competitors` / `aso audit` label ranks as a trend and show no competitor subtitles; popularity only from Apple Ads, never estimated (`cli/lib/aso-intel.ts`). | | 10 | 2026-10 | The iTunes Search API order is close to, not equal to, App Store search (no personalisation, no search ads, no in-app event rows) and it has no subtitle field. | `aso rank` / `aso competitors` / `aso audit` label ranks as a trend and show no competitor subtitles; popularity only from Apple Ads, never estimated (`cli/lib/aso-intel.ts`). |
| 9 | 2026-10 | A retried or repeated write can duplicate on a live store: a POST create retried after a timeout or 5xx, a re-run during the list lag right after a create, two runs at once, a Play edit redone after an unclear commit, a crash in the middle of a screenshot set. | Every write is an upsert (`cli/lib/upsert.ts`: findOrCreate with a fresh re-check and a journal of recent creates, createOnce treating 409 as found, no blind retry of a create); API clients retry only requests that are safe twice; Play edits are all or nothing; asset sets keep the matching prefix; one lock per app and store (`cli/lib/lock.ts`, `--force-unlock`). Table: `docs/idempotency.md`; `tests/idempotency.test.ts` runs every write twice and asserts zero writes on the second run. |
| 8 | 2026-10 | Capacitor plugins in a monorepo live in `apps/native/node_modules`, so `scan` missed them. | `scan` / `privacy-manifest` resolve workspace packages (`cli/lib/scan/project.ts`, `cli/lib/node-packages.ts`). | | 8 | 2026-10 | Capacitor plugins in a monorepo live in `apps/native/node_modules`, so `scan` missed them. | `scan` / `privacy-manifest` resolve workspace packages (`cli/lib/scan/project.ts`, `cli/lib/node-packages.ts`). |
| 7 | 2026-10 | Console UIs: the App Store Connect sign-in runs in an `idmsa.apple.com` iframe; "User Access" is a plain heading, not a labeled group; Chrome drops session cookies without `--restore-last-session`. | `console` session and fill (`cli/console/`), `tests/console-*.test.ts`. | | 7 | 2026-10 | Console UIs: the App Store Connect sign-in runs in an `idmsa.apple.com` iframe; "User Access" is a plain heading, not a labeled group; Chrome drops session cookies without `--restore-last-session`. | `console` session and fill (`cli/console/`), `tests/console-*.test.ts`. |
| 6 | 2026-10 | Screenshot fonts: only part of the glyphs a language needs were available, so Turkish / Polish / Vietnamese characters fell back to another font silently. | `shots fonts` / `shots check` check every language's characters against the fonts (`tests/glyphs.test.ts`). | | 6 | 2026-10 | Screenshot fonts: only part of the glyphs a language needs were available, so Turkish / Polish / Vietnamese characters fell back to another font silently. | `shots fonts` / `shots check` check every language's characters against the fonts (`tests/glyphs.test.ts`). |

View file

@ -1,6 +1,6 @@
--- ---
name: store-pitfalls name: store-pitfalls
description: Known pitfalls with App Store Connect, Google Play and storebound itself, learned from real releases, and what to do about each - store lists that lag right after a create, dry runs that print a count instead of a plan, App Store names taken on a storefront, keyword limits in bytes, Data safety answers without code evidence, missing font glyphs in screenshots, console sign-in and sessions, Capacitor monorepo plugins, App Store search ranks that are only a trend, Play without a search API. Read it before any store task, and when a store command does something you did not expect. description: Known pitfalls with App Store Connect, Google Play and storebound itself, learned from real releases, and what to do about each - store lists that lag right after a create, dry runs that print a count instead of a plan, App Store names taken on a storefront, keyword limits in bytes, Data safety answers without code evidence, missing font glyphs in screenshots, console sign-in and sessions, Capacitor monorepo plugins, duplicates from retries, re-runs and parallel runs, App Store search ranks that are only a trend, Play without a search API. Read it before any store task, and when a store command does something you did not expect.
--- ---
# Store pitfalls # Store pitfalls
@ -67,7 +67,23 @@ Each entry: **symptom** → cause → what to do → what guards it now. The ful
- **Do:** run `scan` from the project root; check that the stack line lists the plugins. - **Do:** run `scan` from the project root; check that the stack line lists the plugins.
- **Guard:** `storebound scan` and `privacy-manifest` resolve packages through workspace folders too. - **Guard:** `storebound scan` and `privacy-manifest` resolve packages through workspace folders too.
## 9. "We rank #4 for this keyword" (from the Search API) ## 9. A second run (or a retry) creates a duplicate
- **Symptom:** two in-app purchases or subscription groups with the same name, a screenshot twice in a set, an
offer code campaign or a custom product page twice, after a timeout, a crash, a re-run a minute later, or two runs
at once.
- **Cause:** a POST create retried after a timeout or a 5xx may have been applied the first time; the store's lists lag
right after a create, so a re-run does not see what the last run made; two writers race; a Play edit redone after
a commit whose answer was lost.
- **Do:** just run the command again: every write command is an upsert and converges (`docs/idempotency.md`). Never
retry a create by hand (`storebound api`) without a GET first. If a run says another storebound run holds the lock,
wait for it; use `--force-unlock` only when that run is really gone. Never run two write commands for the same app
in parallel.
- **Guard:** `cli/lib/upsert.ts` (findOrCreate: fresh re-check + journal `.storebound/created.json`; createOnce: 409
= found, an unclear answer is looked up, never re-sent blindly), retries only for requests that are safe twice,
Play edits deleted on every error and never redone after an unclear commit, screenshot / preview sets keep the
matching prefix in order, `.storebound/locks/` per app and store. `tests/idempotency.test.ts`.
## 10. "We rank #4 for this keyword" (from the Search API)
- **Symptom:** `aso rank` says #4, the owner's phone shows the app lower (or higher) in App Store search. - **Symptom:** `aso rank` says #4, the owner's phone shows the app lower (or higher) in App Store search.
- **Cause:** the iTunes Search API order is close to, not equal to, App Store search: no personalisation, no search - **Cause:** the iTunes Search API order is close to, not equal to, App Store search: no personalisation, no search
ads, no in-app event rows. It has no subtitle field either. ads, no in-app event rows. It has no subtitle field either.
@ -76,7 +92,7 @@ Each entry: **symptom** → cause → what to do → what guards it now. The ful
- **Guard:** `aso rank`, `aso competitors` and `aso audit` say it in their output; `aso suggest` shows popularity - **Guard:** `aso rank`, `aso competitors` and `aso audit` say it in their output; `aso suggest` shows popularity
"unknown" without Apple Ads credentials. "unknown" without Apple Ads credentials.
## 10. "Check our Google Play keyword ranks" ## 11. "Check our Google Play keyword ranks"
- **Symptom:** the owner asks for Play ranks or Play competitors per keyword. - **Symptom:** the owner asks for Play ranks or Play competitors per keyword.
- **Cause:** Google Play has no public search API; reading Play search pages would be scraping. - **Cause:** Google Play has no public search API; reading Play search pages would be scraping.
- **Do:** say so. Measure Play through the Play Console acquisition reports (search terms) and `analytics snapshot` - **Do:** say so. Measure Play through the Play Console acquisition reports (search terms) and `analytics snapshot`

View file

@ -0,0 +1,404 @@
// Stateful doubles of App Store Connect and Google Play for the idempotency tests (tests/idempotency.test.ts): what a
// command creates is there on the next read, so running a command twice shows whether the second run writes again.
// Shapes follow the recorded answers in store-mock.ts and the APIs' documented JSON:API / androidpublisher formats;
// every id and name is a placeholder. Faults can be injected per request (a 5xx after the write was applied = an
// unclear answer; a 5xx before it; a failing upload part), and new resources can be hidden from list reads for a
// while (the stores' list lag right after a create). Nothing here reaches the network.
import { json, type Call } from './store-mock';
export type R = { type: string; id: string; attributes: Record<string, unknown>; rels: Record<string, string | string[]>; order: number; hiddenReads?: number };
export interface Fault { method: string; match: RegExp; when: 'before' | 'after'; status?: number; times?: number }
const ASC = 'https://api.appstoreconnect.apple.com';
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<string, { type: string; one?: boolean }> = {
'apps/inAppPurchasesV2': { type: 'inAppPurchases' },
'inAppPurchases/inAppPurchaseLocalizations': { type: 'inAppPurchaseLocalizations' },
'inAppPurchases/iapPriceSchedule': { type: 'inAppPurchasePriceSchedules', one: true },
'inAppPurchases/inAppPurchaseAvailability': { type: 'inAppPurchaseAvailabilities', one: true },
'inAppPurchases/appStoreReviewScreenshot': { type: 'inAppPurchaseAppStoreReviewScreenshots', one: true },
'inAppPurchases/offerCodes': { type: 'inAppPurchaseOfferCodes' },
'inAppPurchaseOfferCodes/customCodes': { type: 'inAppPurchaseOfferCodeCustomCodes' },
'inAppPurchaseOfferCodes/oneTimeUseCodes': { type: 'inAppPurchaseOfferCodeOneTimeUseCodes' },
'apps/appStoreVersions': { type: 'appStoreVersions' },
'appStoreVersions/appStoreVersionLocalizations': { type: 'appStoreVersionLocalizations' },
'appStoreVersions/appStoreReviewDetail': { type: 'appStoreReviewDetails', one: true },
'appStoreVersions/appStoreVersionPhasedRelease': { type: 'appStoreVersionPhasedReleases', one: true },
'appStoreVersionLocalizations/appScreenshotSets': { type: 'appScreenshotSets' },
'appStoreVersionLocalizations/appPreviewSets': { type: 'appPreviewSets' },
'appScreenshotSets/appScreenshots': { type: 'appScreenshots' },
'appPreviewSets/appPreviews': { type: 'appPreviews' },
'apps/appCustomProductPages': { type: 'appCustomProductPages' },
'appCustomProductPages/appCustomProductPageVersions': { type: 'appCustomProductPageVersions' },
'appCustomProductPageVersions/appCustomProductPageLocalizations': { type: 'appCustomProductPageLocalizations' },
'appCustomProductPageLocalizations/appScreenshotSets': { type: 'appScreenshotSets' },
'apps/appEvents': { type: 'appEvents' },
'appEvents/localizations': { type: 'appEventLocalizations' },
'apps/betaGroups': { type: 'betaGroups' },
'apps/subscriptionGroups': { type: 'subscriptionGroups' },
'subscriptionGroups/subscriptionGroupLocalizations': { type: 'subscriptionGroupLocalizations' },
'subscriptionGroups/subscriptions': { type: 'subscriptions' },
'subscriptions/subscriptionLocalizations': { type: 'subscriptionLocalizations' },
'subscriptions/subscriptionAvailability': { type: 'subscriptionAvailabilities', one: true },
'subscriptions/appStoreReviewScreenshot': { type: 'subscriptionAppStoreReviewScreenshots', one: true },
'subscriptions/promotionalOffers': { type: 'subscriptionPromotionalOffers' },
'subscriptions/offerCodes': { type: 'subscriptionOfferCodes' },
'subscriptions/prices': { type: 'subscriptionPrices' },
'subscriptions/introductoryOffers': { type: 'subscriptionIntroductoryOffers' },
'subscriptionOfferCodes/customCodes': { type: 'subscriptionOfferCodeCustomCodes' },
'apps/appStoreVersionExperimentsV2': { type: 'appStoreVersionExperiments' },
'appStoreVersionExperiments/appStoreVersionExperimentTreatments': { type: 'appStoreVersionExperimentTreatments' },
'appStoreVersionExperimentTreatments/appStoreVersionExperimentTreatmentLocalizations': { type: 'appStoreVersionExperimentTreatmentLocalizations' },
'appStoreVersionExperimentTreatmentLocalizations/appScreenshotSets': { type: 'appScreenshotSets' },
'reviewSubmissions/items': { type: 'reviewSubmissionItems' },
'apps/buildUploads': { type: 'buildUploads' },
'inAppPurchases/versions': { type: 'inAppPurchaseVersions' },
'subscriptions/versions': { type: 'subscriptionVersions' },
'subscriptionGroups/versions': { type: 'subscriptionGroupVersions' },
};
/** Stores refuse a second resource with the same key (409 ENTITY_ERROR ... already exists). */
const UNIQUE: Record<string, (r: R) => string> = {
inAppPurchases: r => String(r.attributes.productId),
inAppPurchaseLocalizations: r => `${r.rels.inAppPurchaseV2}|${r.attributes.locale}`,
appScreenshotSets: r => `${Object.values(r.rels).join()}|${r.attributes.screenshotDisplayType}`,
appPreviewSets: r => `${Object.values(r.rels).join()}|${r.attributes.previewType}`,
betaGroups: r => String(r.attributes.name),
subscriptionGroups: r => String(r.attributes.referenceName),
subscriptions: r => String(r.attributes.productId),
subscriptionLocalizations: r => `${r.rels.subscription}|${r.attributes.locale}`,
subscriptionGroupLocalizations: r => `${r.rels.subscriptionGroup}|${r.attributes.locale}`,
appCustomProductPageLocalizations: r => `${r.rels.appCustomProductPageVersion}|${r.attributes.locale}`,
appEventLocalizations: r => `${r.rels.appEvent}|${r.attributes.locale}`,
appStoreReviewDetails: r => String(r.rels.appStoreVersion),
devices: r => String(r.attributes.udid).toLowerCase(),
reviewSubmissions: r => `${r.rels.app}|${r.attributes.state}`,
appStoreVersionPhasedReleases: r => String(r.rels.appStoreVersion),
bundleIds: r => String(r.attributes.identifier),
};
const ASSETS = /Screenshots$|^appPreviews$|^appScreenshots$/;
/** Attributes a new resource of a type starts with. */
const DEFAULTS: Record<string, Record<string, unknown>> = {
inAppPurchases: { state: 'MISSING_METADATA' }, subscriptions: { state: 'MISSING_METADATA' }, reviewSubmissions: { state: 'READY_FOR_REVIEW' },
appCustomProductPageVersions: { state: 'PREPARE_FOR_SUBMISSION' }, devices: { status: 'ENABLED', deviceClass: 'IPHONE' },
appStoreVersionPhasedReleases: { phasedReleaseState: 'INACTIVE' }, appEvents: { eventState: 'DRAFT' }, appStoreVersionExperiments: { state: 'PREPARE_FOR_SUBMISSION' },
};
const singular = (t: string): string => t.replace(/s$/, '');
export class FakeAsc {
res = new Map<string, R>();
next = 1;
/** new resources of these types stay out of list reads for that many reads */
lag: Record<string, number> = {};
/** every uploaded part, by asset id */
parts: string[] = [];
constructor(readonly appId = '6400000101') {
this.put({ type: 'apps', id: appId, attributes: { bundleId: 'com.example.notes', name: 'Example Notes' }, rels: {} });
for (const t of ['USA', 'DEU', 'TUR']) this.put({ type: 'territories', id: t, attributes: { currency: t === 'USA' ? 'USD' : t === 'DEU' ? 'EUR' : 'TRY' }, rels: {} });
}
put(r: Omit<R, 'order'>): R { const x = { ...r, order: this.next++ } as R; this.res.set(x.id, x); return x; }
all(type: string): R[] { return [...this.res.values()].filter(r => r.type === type).sort((a, b) => a.order - b.order); }
view(r: R): Record<string, unknown> {
const relationships: Record<string, unknown> = {};
for (const [k, v] of Object.entries(r.rels)) relationships[k] = { data: Array.isArray(v) ? v.map(id => ({ type: this.res.get(id)?.type, id })) : { type: this.res.get(v)?.type, id: v } };
return { type: r.type, id: r.id, attributes: r.attributes, relationships };
}
children(parent: string, type: string): R[] {
return this.all(type).filter(r => Object.values(r.rels).some(v => (Array.isArray(v) ? v.includes(parent) : v === parent)));
}
private visible(rs: R[]): R[] {
return rs.filter(r => { if (r.hiddenReads && r.hiddenReads > 0) { r.hiddenReads--; return false; } return true; });
}
private filtered(rs: R[], q: URLSearchParams): R[] {
let out = rs;
for (const [k, v] of q) {
const m = /^filter\[([\w.]+)\]$/.exec(k);
if (!m) continue;
const f = m[1]!, vals = v.split(',');
if (f.includes('.')) continue;
out = out.filter(r => (f in r.attributes ? vals.includes(String(r.attributes[f])) : Object.entries(r.rels).some(([rk, rv]) => (rk === f || rk === singular(f)) && vals.some(x => (Array.isArray(rv) ? rv.includes(x) : rv === x)))));
}
return out;
}
create(body: { data: { type: string; attributes?: Record<string, unknown>; relationships?: Record<string, { data: unknown }> }; included?: { type: string; id: string; attributes?: Record<string, unknown>; relationships?: Record<string, { data: unknown }> }[] }): R | Response {
const temp = new Map<string, string>();
const idFor = (t: string) => `${singular(t).replace(/[A-Z]/g, c => c.toLowerCase()).slice(0, 12)}-${this.next}`;
for (const inc of body.included ?? []) temp.set(inc.id, `${idFor(inc.type)}-${temp.size}`);
const relsOf = (rel?: Record<string, { data: unknown }>): Record<string, string | string[]> => Object.fromEntries(Object.entries(rel ?? {}).filter(([, v]) => v?.data).map(([k, v]) => [k,
Array.isArray(v.data) ? (v.data as { id: string }[]).map(d => temp.get(d.id) ?? d.id) : temp.get((v.data as { id: string }).id) ?? (v.data as { id: string }).id]));
const d = body.data;
const main: Omit<R, 'order'> = { type: d.type, id: idFor(d.type), attributes: { ...DEFAULTS[d.type], ...(d.attributes ?? {}) }, rels: relsOf(d.relationships) };
const u = UNIQUE[d.type];
if (u && this.all(d.type).some(r => u(r) === u(main as R)))
return json({ errors: [{ status: '409', code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE', title: 'The provided entity includes an attribute with a value that has already been used', detail: 'already exists' }] }, 409);
if (ASSETS.test(d.type) && d.attributes?.fileName) {
main.attributes.uploadOperations = [{ method: 'PUT', url: `${UPLOAD_HOST}${main.id}`, offset: 0, length: Number(d.attributes.fileSize ?? 0), requestHeaders: [{ name: 'Content-Type', value: 'image/png' }] }];
main.attributes.assetDeliveryState = { state: 'AWAITING_UPLOAD' };
}
const r = this.put(main);
if (this.lag[d.type]) r.hiddenReads = this.lag[d.type];
for (const inc of body.included ?? []) {
// included resources are linked back to whatever points at them
this.put({ type: inc.type, id: temp.get(inc.id)!, attributes: { ...DEFAULTS[inc.type], ...(inc.attributes ?? {}) }, rels: relsOf(inc.relationships) });
}
for (const holder of [r, ...(body.included ?? []).map(i => this.res.get(temp.get(i.id)!)!)]) {
for (const [k, v] of Object.entries(holder.rels)) for (const cid of Array.isArray(v) ? v : [v]) {
const child = this.res.get(cid);
if (child && [...temp.values()].includes(cid) && !child.rels[singular(holder.type)]) child.rels[singular(holder.type)] = holder.id;
void k;
}
}
return r;
}
handle(method: string, path: string, body: unknown): Response {
const u = new URL(ASC + path);
const parts = u.pathname.split('/').filter(Boolean); // v1, type, id?, rel?
const [, type, id, rel, rel2] = parts;
if (method === 'GET') {
if (type === 'territories') return json({ data: this.all('territories').map(r => this.view(r)), links: {} });
if (rel === 'pricePoints') {
const t = u.searchParams.get('filter[territory]') ?? 'USA';
return json({ data: [0.99, 1.99, 2.99, 4.99, 9.99].map(p => ({ type: 'inAppPurchasePricePoints', id: `pp-${t}-${p}`, attributes: { customerPrice: String(p) }, relationships: { territory: { data: { type: 'territories', id: t } } } })),
included: [{ type: 'territories', id: t, attributes: { currency: 'USD' } }], links: {} });
}
if (rel === 'equalizations') {
// every other territory at the same price (enough for the comparisons the commands make)
const [, , price] = id!.split('-');
return json({ data: ['DEU', 'TUR'].map(t => ({ type: 'subscriptionPricePoints', id: `pp-${t}-${price}`, attributes: { customerPrice: price }, relationships: { territory: { data: { type: 'territories', id: t } } } })), links: {} });
}
if (!id) return json({ data: this.visible(this.filtered(this.all(type!), u.searchParams)).map(r => this.view(r)), links: {} });
const self = this.res.get(id);
if (!rel) return self && self.type === type ? json({ data: this.view(self) }) : json({ errors: [{ status: '404' }] }, 404);
if (rel === 'relationships') return json({ data: this.children(id, rel2!).map(r => ({ type: r.type, id: r.id })) });
const l = LISTS[`${type}/${rel}`];
if (!l) return json({ errors: [{ status: '599', detail: `fake: no list ${type}/${rel}` }] }, 599);
const kids = this.filtered(this.children(id, l.type), u.searchParams);
if (l.one) {
const one = kids[0];
if (!one) return json({ errors: [{ status: '404' }] }, 404);
const included = (u.searchParams.get('include') ?? '').split(',').filter(Boolean).flatMap(() => [...this.res.values()].filter(r => Object.values(r.rels).some(v => v === one.id || (Array.isArray(v) && v.includes(one.id))) && r.id !== one.id).map(r => this.view(r)));
return json({ data: this.view(one), included });
}
const shown = this.visible(kids);
// price lists carry their price points (customerPrice from the placeholder id pp-<territory>-<price>)
const points = [...new Set(shown.map(r => r.rels.subscriptionPricePoint ?? r.rels.inAppPurchasePricePoint).filter((x): x is string => typeof x === 'string'))];
const included = points.map(pid => ({ type: 'subscriptionPricePoints', id: pid, attributes: { customerPrice: pid.split('-')[2] } }));
return json({ data: shown.map(r => this.view(r)), included, links: {} });
}
if (method === 'POST') {
if (rel === 'relationships') { return new Response(null, { status: 204 }); }
const out = this.create(body as Parameters<FakeAsc['create']>[0]);
return out instanceof Response ? out : json({ data: this.view(out) }, 201);
}
if (method === 'PATCH') {
if (rel === 'relationships' && type && id) {
// order of a screenshot / preview set
const ids = ((body as { data: { id: string }[] }).data ?? []).map(d => d.id);
const base = Math.min(...ids.map(i => this.res.get(i)?.order ?? 0));
ids.forEach((i, k) => { const r = this.res.get(i); if (r) r.order = base + k; });
return new Response(null, { status: 204 });
}
const self = id ? this.res.get(id) : undefined;
if (!self) return json({ errors: [{ status: '404' }] }, 404);
const attrs = (body as { data: { attributes?: Record<string, unknown> } }).data.attributes ?? {};
// inline creates (subscription prices, introductory offers) become children of the patched resource
for (const inc of (body as { included?: { type: string; attributes?: Record<string, unknown>; relationships?: Record<string, { data: { id: string } }> }[] }).included ?? [])
this.put({ type: inc.type, id: `${inc.type.slice(0, 8)}-${this.next}`, attributes: { ...(inc.attributes ?? {}) },
rels: { [singular(self.type)]: self.id, ...Object.fromEntries(Object.entries(inc.relationships ?? {}).map(([k, v]) => [k, v.data.id])) } });
if (attrs.uploaded === true && ASSETS.test(self.type)) {
const { uploaded: _u, ...rest } = attrs;
Object.assign(self.attributes, rest, { assetDeliveryState: { state: this.parts.includes(self.id) ? 'COMPLETE' : 'FAILED' } });
delete self.attributes.uploadOperations;
} else Object.assign(self.attributes, attrs);
return json({ data: this.view(self) });
}
if (method === 'DELETE') {
if (!id || !this.res.has(id)) return json({ errors: [{ status: '404' }] }, 404);
this.res.delete(id);
return new Response(null, { status: 204 });
}
return json({ errors: [{ status: '599' }] }, 599);
}
}
// ------------------------------------------------------------------------------------------------------------ Play
type PlayState = { details: Record<string, unknown>; listings: Record<string, Record<string, unknown>>; images: Record<string, string[]>; tracks: Record<string, { releases: unknown[] }>; bundles: number[] };
const clone = <T>(x: T): T => JSON.parse(JSON.stringify(x)) as T;
export class FakePlay {
live: PlayState = { details: {}, listings: {}, images: {}, tracks: {}, bundles: [] };
edits = new Map<string, PlayState>();
committed: string[] = [];
products = new Map<string, Record<string, unknown>>();
subs = new Map<string, Record<string, unknown>>();
offers = new Map<string, Record<string, unknown>>();
next = 1;
/** versionCode the next bundle upload reports */
bundleCode = 42;
handle(method: string, url: string, body: unknown): Response {
const u = new URL(url);
const p = u.pathname.replace(/^\/(upload\/)?androidpublisher\/v3\/applications\/[^/]+/, '');
const err = (code: number, status: string, message: string) => json({ error: { code, status, message } }, code);
let m: RegExpExecArray | null;
if (method === 'POST' && p === '/edits') { const id = `edit-${this.next++}`; this.edits.set(id, clone(this.live)); return json({ id, expiryTimeSeconds: '9999999999' }); }
if ((m = /^\/edits\/([^/:]+)(.*)$/.exec(p))) {
const [, id, rest] = m;
const e = this.edits.get(id!);
if (!e) return err(404, 'NOT_FOUND', 'Edit has been deleted');
if (method === 'DELETE' && rest === '') { this.edits.delete(id!); return new Response(null, { status: 204 }); }
if (method === 'POST' && rest === ':validate') return json({ id });
if (method === 'POST' && rest === ':commit') { this.live = e; this.edits.delete(id!); this.committed.push(id!); return json({ id }); }
if (rest === '/details') { if (method === 'PUT') e.details = body as Record<string, unknown>; return json(e.details); }
if (rest === '/listings') return json({ listings: Object.values(e.listings) });
if ((m = /^\/listings\/([^/]+)$/.exec(rest!))) { if (method === 'PUT') e.listings[m[1]!] = body as Record<string, unknown>; return json(e.listings[m[1]!] ?? {}); }
if ((m = /^\/listings\/([^/]+)\/(\w+)$/.exec(rest!))) {
const k = `${m[1]}/${m[2]}`;
if (method === 'DELETE') { e.images[k] = []; return json({ deleted: [] }); }
if (method === 'POST') { const h = new Bun.CryptoHasher('sha256').update(body as Uint8Array).digest('hex'); (e.images[k] ??= []).push(h); return json({ image: { sha256: h } }); }
return json({ images: (e.images[k] ?? []).map(sha256 => ({ sha256 })) });
}
if (rest === '/tracks') return json({ tracks: Object.entries(e.tracks).map(([track, t]) => ({ track, ...t })) });
if ((m = /^\/tracks\/(\w+)$/.exec(rest!))) {
if (method === 'PUT') e.tracks[m[1]!] = { releases: (body as { releases: unknown[] }).releases };
return json({ track: m[1], releases: e.tracks[m[1]!]?.releases ?? [] });
}
if (rest === '/bundles') {
if (method === 'POST') { const code = this.bundleCode; e.bundles.push(code); return json({ versionCode: code }); }
return json({ bundles: e.bundles.map(versionCode => ({ versionCode })) });
}
if (rest === '/apks') return json({ apks: [] });
return err(599, 'UNKNOWN', `fake play: ${method} ${p}`);
}
if (method === 'POST' && p === '/pricing:convertRegionPrices') {
const usd = body as { price: { units?: string; nanos?: number } };
const price = { currencyCode: 'USD', units: usd.price.units ?? '0', nanos: usd.price.nanos ?? 0 };
return json({ convertedRegionPrices: { US: { regionCode: 'US', price }, DE: { regionCode: 'DE', price: { ...price, currencyCode: 'EUR' } } },
convertedOtherRegionsPrice: { usdPrice: price, eurPrice: { ...price, currencyCode: 'EUR' } }, regionVersion: { version: '2022/02' } });
}
if (method === 'GET' && p === '/oneTimeProducts') return json({ oneTimeProducts: [...this.products.values()] });
if ((m = /^\/(?:oneTimeProducts|onetimeproducts)\/([^/:]+)$/.exec(p))) {
const id = decodeURIComponent(m[1]!);
if (method === 'PATCH') {
const cur = this.products.get(id);
if (!cur && u.searchParams.get('allowMissing') !== 'true') return err(404, 'NOT_FOUND', 'no product');
const b = clone(body as Record<string, unknown>);
const mask = (u.searchParams.get('updateMask') ?? '').split(',');
const next = cur ? { ...cur, ...(mask.includes('listings') ? { listings: b.listings } : {}), ...(mask.includes('purchaseOptions') ? { purchaseOptions: b.purchaseOptions } : {}) } : b;
for (const o of (next.purchaseOptions as { state?: string; purchaseOptionId: string }[] | undefined) ?? []) {
const was = (cur?.purchaseOptions as { state?: string; purchaseOptionId: string }[] | undefined)?.find(x => x.purchaseOptionId === o.purchaseOptionId)?.state;
o.state = was ?? 'DRAFT';
}
this.products.set(id, next);
return json(next);
}
const cur = this.products.get(id);
return cur ? json(cur) : err(404, 'NOT_FOUND', 'no product');
}
if ((m = /^\/oneTimeProducts\/([^/]+)\/purchaseOptions:batchUpdateStates$/.exec(p))) {
const cur = this.products.get(decodeURIComponent(m[1]!));
for (const o of (cur?.purchaseOptions as { state?: string }[] | undefined) ?? []) o.state = 'ACTIVE';
return json({});
}
if (method === 'GET' && p === '/subscriptions') return json({ subscriptions: [...this.subs.values()] });
if (method === 'POST' && p === '/subscriptions') {
const id = u.searchParams.get('productId')!;
if (this.subs.has(id)) return err(409, 'ALREADY_EXISTS', `Subscription ${id} already exists`);
const b = clone(body as Record<string, unknown>);
for (const bp of (b.basePlans as { state?: string }[] | undefined) ?? []) bp.state = 'DRAFT';
this.subs.set(id, b);
return json(b);
}
if ((m = /^\/subscriptions\/([^/:]+)$/.exec(p))) {
const id = decodeURIComponent(m[1]!);
const cur = this.subs.get(id);
if (!cur) return err(404, 'NOT_FOUND', 'no subscription');
if (method === 'PATCH') {
const b = clone(body as Record<string, unknown>);
const states = new Map(((cur.basePlans as { basePlanId: string; state?: string }[] | undefined) ?? []).map(x => [x.basePlanId, x.state]));
for (const bp of (b.basePlans as { basePlanId: string; state?: string }[] | undefined) ?? []) bp.state = states.get(bp.basePlanId) ?? 'DRAFT';
this.subs.set(id, { ...cur, ...b });
}
return json(this.subs.get(id));
}
if ((m = /^\/subscriptions\/([^/]+)\/basePlans\/([^/:]+):activate$/.exec(p))) {
const cur = this.subs.get(decodeURIComponent(m[1]!));
for (const bp of (cur?.basePlans as { basePlanId: string; state?: string }[] | undefined) ?? []) if (bp.basePlanId === m[2]) bp.state = 'ACTIVE';
return json(cur ?? {});
}
if ((m = /^\/subscriptions\/([^/]+)\/basePlans\/([^/]+)\/offers$/.exec(p))) {
const key = `${decodeURIComponent(m[1]!)}/${m[2]}`;
if (method === 'GET') return json({ subscriptionOffers: [...this.offers.entries()].filter(([k]) => k.startsWith(key + '/')).map(([, v]) => v) });
const oid = u.searchParams.get('offerId')!;
if (this.offers.has(`${key}/${oid}`)) return err(409, 'ALREADY_EXISTS', `Offer ${oid} already exists`);
const b = { ...clone(body as Record<string, unknown>), state: 'DRAFT' };
this.offers.set(`${key}/${oid}`, b);
return json(b);
}
if ((m = /^\/subscriptions\/([^/]+)\/basePlans\/([^/]+)\/offers\/([^/:]+)(:activate)?$/.exec(p))) {
const o = this.offers.get(`${decodeURIComponent(m[1]!)}/${m[2]}/${decodeURIComponent(m[3]!)}`);
if (!o) return err(404, 'NOT_FOUND', 'no offer');
if (m[4]) o.state = 'ACTIVE';
return json(o);
}
if (method === 'POST' && p === '/dataSafety') return json({});
return err(599, 'UNKNOWN', `fake play: ${method} ${p}`);
}
}
// ------------------------------------------------------------------------------------------------- fetch + counting
export interface Stores { asc: FakeAsc; play: FakePlay; calls: Call[]; faults: Fault[]; failPart?: (n: number) => boolean }
const realFetch = globalThis.fetch;
/** Installs both fakes as fetch. Faults apply in order (`times`, default 1). */
export function installStores(): Stores {
const s: Stores = { asc: new FakeAsc(), play: new FakePlay(), calls: [], faults: [] };
let partNo = 0;
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input instanceof Request ? input.url : input);
const method = (init?.method ?? 'GET').toUpperCase();
const raw = init?.body;
let body: unknown = raw;
if (typeof raw === 'string') { try { body = JSON.parse(raw); } catch { body = raw; } }
if (url === 'https://oauth2.googleapis.com/token') return json({ access_token: 'ya29.test', expires_in: 3600 });
const path = url.replace(/^https:\/\/[^/]+/, '');
s.calls.push({ method, url, path, body });
const f = s.faults.find(x => (x.times ?? 1) > 0 && x.method === method && x.match.test(decodeURIComponent(path)));
if (f) f.times = (f.times ?? 1) - 1;
if (f?.when === 'before') return json({ errors: [{ status: String(f.status ?? 503), detail: 'injected' }] }, f.status ?? 503);
let res: Response;
if (url.startsWith(UPLOAD_HOST)) {
partNo++;
if (s.failPart?.(partNo)) return new Response('injected part failure', { status: 400 });
s.asc.parts.push(url.slice(UPLOAD_HOST.length));
res = new Response(null, { status: 200 });
} else if (url.startsWith(ASC)) res = s.asc.handle(method, path, body);
else if (url.startsWith('https://androidpublisher.googleapis.com/')) res = s.play.handle(method, url, body instanceof Uint8Array ? body : body);
else res = new Response(`fake: no route ${method} ${url}`, { status: 599 });
if (f?.when === 'after') return json({ errors: [{ status: String(f.status ?? 502), detail: 'injected after the write' }] }, f.status ?? 502);
return res;
}) as typeof fetch;
return s;
}
export function uninstallStores(): void { globalThis.fetch = realFetch; }
/** Store writes in a call log: every non-GET request, except Play's edit bookkeeping (opening and deleting a
* throwaway edit, validating it) and the price conversion, which change nothing on the store. */
export function writes(calls: Call[]): Call[] {
return calls.filter(c => c.method !== 'GET' && !(c.method === 'POST' && /\/edits$/.test(c.path)) && !(c.method === 'DELETE' && /\/edits\/[^/]+$/.test(c.path))
&& !/:validate$|pricing:convertRegionPrices$/.test(c.path));
}

4
tests/helpers/preload.ts Normal file
View file

@ -0,0 +1,4 @@
// Loaded before every test file (bunfig.toml): no real waiting between the re-checks of cli/lib/upsert.ts or for
// the store's list to settle after a create (cli/apple/iap.ts).
process.env.STOREBOUND_UPSERT_WAIT_MS = '0';
process.env.STOREBOUND_LIST_SETTLE_MS ??= '0';

451
tests/idempotency.test.ts Normal file
View file

@ -0,0 +1,451 @@
// Idempotency of every store write (docs/idempotency.md): each write command runs twice against stateful doubles of
// App Store Connect and Google Play (tests/helpers/fake-stores.ts) and the second run must not write anything. Plus
// the failure cases: a crash in the middle of a screenshot set, a create whose answer is lost (5xx after the store
// applied it), a create the store refuses as a duplicate (409), the list lag right after a create, Play edits on
// error paths and an unclear commit, the lock between two runs, and the retry rules of the API clients.
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { hostname } from 'node:os';
import { join } from 'node:path';
import type { ShipConfig } from '../cli/lib/config';
import { parseArgs } from '../cli/lib/args';
import { acquireLock, lockKeys, staleLock } from '../cli/lib/lock';
import { AmbiguousWriteError, createOnce, findOrCreate, isConflict, readJournal, retrySafe, upsertTiming } from '../cli/lib/upsert';
import { keptPrefix } from '../cli/apple/asc';
import { sameRelease } from '../cli/play/release';
import { sameReleases } from '../cli/play/rollout';
import { playClient } from '../cli/play/client';
import { run as appleIap } from '../cli/apple/iap';
import { run as appleScreenshots } from '../cli/apple/screenshots';
import { run as appleSubscriptions } from '../cli/apple/subscriptions';
import { run as appleCpp } from '../cli/apple/cpp';
import { run as appleEvents } from '../cli/apple/events';
import { run as appleTestflight } from '../cli/apple/testflight';
import { run as appleReviewDetails } from '../cli/apple/review-details';
import { run as appleBundle } from '../cli/apple/bundle';
import { run as appleOfferCodes } from '../cli/apple/offer-codes';
import { run as applePpo } from '../cli/apple/ppo';
import { run as appleIapSubmit } from '../cli/apple/iap-submit';
import { run as appleRelease } from '../cli/apple/release';
import { run as appleEncryption } from '../cli/apple/encryption';
import { run as appleAdhoc } from '../cli/apple/adhoc';
import { run as playProducts } from '../cli/play/products';
import { run as playSubscriptions } from '../cli/play/subscriptions';
import { run as playImages } from '../cli/play/images';
import { run as playRollout } from '../cli/play/rollout';
import { run as playRelease } from '../cli/play/release';
import { ascEnv, BASE, capture, playEnv, project, restore } from './helpers/store-mock';
import { installStores, type Stores, uninstallStores, writes } from './helpers/fake-stores';
let s: Stores;
beforeAll(() => { ascEnv(); playEnv(); });
afterAll(restore);
beforeEach(() => { upsertTiming.waitMs = 0; s = installStores(); });
afterEach(() => { uninstallStores(); });
const APP = '6400000101';
const LISTING = '# Play\n\n## English\n<!-- play: en-US -->\n\n### Play short description\n```\nNotes.\n```\n\n### Play full description\n```\nNotes for everyone.\n```\n\n### What\'s new\n```\nFixes.\n```\n';
const png = (n: number): Uint8Array => new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, n, n + 1, n + 2]);
const CONFIG: ShipConfig = {
...BASE,
languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }, { id: 'de', apple: 'de-DE', play: 'de-DE' }],
products: [
{ id: 'com.example.notes.pro', type: 'non_consumable', usd: 2.99, listings: { en: { title: 'Pro', description: 'Every feature.' }, de: { title: 'Pro', description: 'Alles.' } } },
{ id: 'com.example.notes.tip', type: 'consumable', usd: 0.99, listings: { en: { title: 'Tip', description: 'Say thanks.' } },
offerCodes: [{ name: 'Launch', free: true, eligibility: ['NON_SPENDER'] } as never] },
],
apple: {
...BASE.apple!, testflight: { groups: [{ name: 'Friends' }, { name: 'Public', internal: false } as never] } as never,
customProductPages: [{ name: 'Spring', promotionalText: { en: 'Spring sale' } } as never],
events: [{ referenceName: 'Spring event', badge: 'SPECIAL_EVENT', listings: { en: { name: 'Spring', shortDescription: 'Short', longDescription: 'Long text' } },
schedule: { publishStart: '2030-03-01T00:00:00Z', eventStart: '2030-03-02T00:00:00Z', eventEnd: '2030-03-09T00:00:00Z' } } as never],
},
};
/** Runs a command twice; returns the writes of each run. The second must be empty. */
async function twice(run: () => Promise<void>): Promise<{ first: string[]; second: string[] }> {
const a = s.calls.length;
const r1 = await capture(run);
if (r1.exit) throw new Error(`first run exit ${r1.exit}:\n${r1.out}\n${r1.err}`);
const b = s.calls.length;
const r2 = await capture(run);
if (r2.exit) throw new Error(`second run exit ${r2.exit}:\n${r2.out}\n${r2.err}`);
const show = (from: number, to?: number) => writes(s.calls.slice(from, to)).map(c => `${c.method} ${decodeURIComponent(c.path).replace(/\?.*/, '')}`);
return { first: show(a, b), second: show(b) };
}
const count = (type: string): number => s.asc.all(type).length;
describe('App Store Connect: a second run writes nothing', () => {
test('apple iap: product, localizations, price schedule, availability once', async () => {
const p = project(CONFIG);
const r = await twice(() => appleIap(p.ctx([])));
expect(r.first).toContain('POST /v2/inAppPurchases');
expect(r.second).toEqual([]);
expect(count('inAppPurchases')).toBe(2);
expect(count('inAppPurchaseLocalizations')).toBe(3);
expect(count('inAppPurchasePriceSchedules')).toBe(2);
expect(count('inAppPurchaseAvailabilities')).toBe(2);
});
test('apple screenshots: sets and screenshots once, in order', async () => {
const files: Record<string, Uint8Array> = {};
for (const l of ['en', 'de']) for (const d of ['ios69', 'ios65']) for (const n of [1, 2, 3]) files[`store/renders/${l}/${d}/0${n}.png`] = png(n + (d === 'ios65' ? 10 : 0) + (l === 'de' ? 20 : 0));
const p = project(CONFIG, files);
const v = s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, rels: { app: APP } });
for (const loc of ['en-US', 'de-DE']) s.asc.put({ type: 'appStoreVersionLocalizations', id: `loc-${loc}`, attributes: { locale: loc }, rels: { appStoreVersion: v.id } });
const r = await twice(() => appleScreenshots(p.ctx([])));
expect(r.first.filter(x => x === 'POST /v1/appScreenshotSets')).toHaveLength(4);
expect(r.second).toEqual([]);
expect(count('appScreenshotSets')).toBe(4);
expect(count('appScreenshots')).toBe(12);
});
test('apple cpp create, events create, testflight groups, review-details, bundle, offer-codes, ppo create', async () => {
const p = project({ ...CONFIG, shots: { variants: { b: {} } } as never }, { '.storebound/credentials.json': JSON.stringify({ reviewContact: { firstName: 'Ex', lastName: 'Ample', phone: '+1 555 0100', email: 'review@example.com' } }) });
s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, rels: { app: APP } });
s.asc.put({ type: 'inAppPurchases', id: 'iap-tip', attributes: { productId: 'com.example.notes.tip', state: 'READY_TO_SUBMIT' }, rels: { app: APP } });
for (const [name, run] of [
['cpp', () => appleCpp(p.ctx(['create']))],
['events', () => appleEvents(p.ctx(['create']))],
['testflight', () => appleTestflight(p.ctx(['groups']))],
['review-details', () => appleReviewDetails(p.ctx([]))],
['bundle', () => appleBundle(p.ctx([]))],
['offer-codes', () => appleOfferCodes(p.ctx([]))],
['ppo', () => applePpo(p.ctx(['create', '--variant', 'b']))],
] as [string, () => Promise<void>][]) {
const r = await twice(run);
expect([name, r.first.length > 0]).toEqual([name, true]);
expect([name, r.second]).toEqual([name, []]);
}
expect(count('appCustomProductPages')).toBe(1);
expect(count('appEvents')).toBe(1);
expect(count('appEventLocalizations')).toBe(1);
expect(count('betaGroups')).toBe(2);
expect(count('appStoreReviewDetails')).toBe(1);
expect(count('bundleIds')).toBe(1);
expect(count('inAppPurchaseOfferCodes')).toBe(1);
expect(count('appStoreVersionExperiments')).toBe(1);
expect(count('appStoreVersionExperimentTreatments')).toBe(1);
});
});
describe('App Store Connect: more commands, twice', () => {
test('apple subscriptions: group, subscription, localizations, availability, prices once', async () => {
const cfg: ShipConfig = { ...CONFIG, subscriptions: [{ name: 'Pro', listings: { en: { name: 'Pro' } }, subscriptions: [
{ id: 'com.example.notes.monthly', period: 'P1M', level: 1, usd: 4.99, listings: { en: { title: 'Monthly', description: 'Pro every month.' }, de: { title: 'Monatlich', description: 'Pro jeden Monat.' } } },
] } as never] };
const p = project(cfg);
const r = await twice(() => appleSubscriptions(p.ctx(['--yes'])));
expect(r.first).toContain('POST /v1/subscriptions');
expect(r.second).toEqual([]);
expect([count('subscriptionGroups'), count('subscriptions'), count('subscriptionLocalizations'), count('subscriptionAvailabilities'), count('subscriptionPrices')]).toEqual([1, 1, 2, 1, 3]);
});
test('apple iap-submit --no-submit: one draft, the item once', async () => {
const p = project({ ...CONFIG, products: [CONFIG.products![1]!] });
s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'READY_FOR_SALE', platform: 'IOS' }, rels: { app: APP } });
s.asc.put({ type: 'inAppPurchases', id: 'iap-old', attributes: { productId: 'com.example.notes.old', inAppPurchaseType: 'CONSUMABLE', state: 'APPROVED' }, rels: { app: APP } });
s.asc.put({ type: 'inAppPurchases', id: 'iap-tip', attributes: { productId: 'com.example.notes.tip', inAppPurchaseType: 'CONSUMABLE', state: 'READY_TO_SUBMIT' }, rels: { app: APP } });
s.asc.put({ type: 'inAppPurchaseVersions', id: 'iapv-tip', attributes: { version: 1, state: 'PREPARE_FOR_SUBMISSION' }, rels: { inAppPurchase: 'iap-tip' } });
const r = await twice(() => appleIapSubmit(p.ctx(['--no-submit', '--yes'])));
expect(r.first).toEqual(['POST /v1/reviewSubmissions', 'POST /v1/reviewSubmissionItems']);
expect(r.second).toEqual([]);
expect([count('reviewSubmissions'), count('reviewSubmissionItems')]).toEqual([1, 1]);
});
test('apple release --phased on, apple encryption, apple adhoc register', async () => {
const p = project({ ...CONFIG, apple: { ...CONFIG.apple!, usesNonExemptEncryption: false } }, { '.storebound/devices.csv': 'name,udid,platform\nTest phone,00008101-001A2B3C4D5E6F70,IOS\n' });
s.asc.put({ type: 'appStoreVersions', id: 'ver-2', attributes: { versionString: '1.1.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, rels: { app: APP } });
s.asc.put({ type: 'builds', id: 'b-7', attributes: { version: '7', usesNonExemptEncryption: null, expired: false }, rels: { app: APP } });
for (const [name, run] of [
['release', () => appleRelease(p.ctx(['--phased', 'on']))],
['encryption', () => appleEncryption(p.ctx([]))],
['adhoc', () => appleAdhoc(p.ctx(['register', '--yes']))],
] as [string, () => Promise<void>][]) {
const r = await twice(run);
expect([name, r.first.length]).toEqual([name, 1]);
expect([name, r.second]).toEqual([name, []]);
}
expect([count('appStoreVersionPhasedReleases'), count('devices')]).toEqual([1, 1]);
});
});
describe('Google Play: a second run writes nothing', () => {
test('play products: created, activated, then unchanged', async () => {
const p = project(CONFIG);
const r = await twice(() => playProducts(p.ctx([])));
expect(r.first.some(x => x.startsWith('PATCH /androidpublisher/v3/applications/com.example.notes/onetimeproducts/'))).toBe(true);
expect(r.second).toEqual([]);
expect(s.play.products.size).toBe(2);
});
test('play images: uploaded once; the second run compares and commits nothing', async () => {
const files: Record<string, string | Uint8Array> = { 'store/listing.md': LISTING, 'store/icons/play-512.png': png(1) };
files['store/renders/en/feature/1024x500.png'] = png(2);
for (const [sub, base] of [['play', 10], ['tab7', 20], ['tab10', 30]] as const) for (const n of [1, 2]) files[`store/renders/en/${sub}/0${n}.png`] = png(base + n);
const p = project(CONFIG, files);
const r = await twice(() => playImages(p.ctx([])));
expect(r.first.filter(x => x.startsWith('POST /upload/'))).toHaveLength(8);
expect(r.second).toEqual([]);
expect(s.play.committed).toHaveLength(1);
expect(s.play.edits.size).toBe(0);
});
test('play subscriptions: created with base plan and intro offer once', async () => {
const cfg: ShipConfig = { ...CONFIG, subscriptions: [{ name: 'Pro', listings: { en: { name: 'Pro' } }, subscriptions: [
{ id: 'com.example.notes.monthly', period: 'P1M', level: 1, usd: 4.99, listings: { en: { title: 'Monthly', description: 'Pro every month.', benefits: ['Sync'] } }, intro: { type: 'free', duration: 'P1W' } },
] } as never] };
const p = project(cfg);
const r = await twice(() => playSubscriptions(p.ctx(['--yes'])));
expect(r.first.some(x => x.endsWith('/subscriptions'))).toBe(true);
expect(r.second).toEqual([]);
expect(s.play.subs.size).toBe(1);
expect(s.play.offers.size).toBe(1);
});
test('play rollout and play release --version-code: the same state is not committed again', async () => {
s.play.live.tracks.production = { releases: [{ versionCodes: ['41'], status: 'inProgress', userFraction: 0.1 }] };
s.play.live.bundles = [41, 42];
const p = project(CONFIG, { 'store/listing.md': LISTING });
let r = await twice(() => playRollout(p.ctx(['--fraction', '0.2', '--yes'])));
expect(r.first).toContain('PUT /androidpublisher/v3/applications/com.example.notes/edits/edit-1/tracks/production');
expect(r.second).toEqual([]);
r = await twice(() => playRelease(p.ctx(['--version-code', '42', '--internal', 'completed'])));
expect(r.first.some(x => x.endsWith('/tracks/internal'))).toBe(true);
expect(r.second).toEqual([]);
expect(s.play.edits.size).toBe(0);
});
});
describe('crash midway and unclear answers', () => {
test('screenshot set: an upload that dies on the 3rd file leaves a prefix; the re-run finishes it without duplicates, in order', async () => {
const files: Record<string, Uint8Array> = {};
for (const n of [1, 2, 3, 4]) files[`store/renders/en/ios69/0${n}.png`] = png(n);
const cfg = { ...CONFIG, languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }] };
const p = project(cfg, files);
const v = s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, rels: { app: APP } });
s.asc.put({ type: 'appStoreVersionLocalizations', id: 'loc-en', attributes: { locale: 'en-US' }, rels: { appStoreVersion: v.id } });
// an older set: two screenshots, the first already the wanted 01.png
const set = s.asc.put({ type: 'appScreenshotSets', id: 'set-old', attributes: { screenshotDisplayType: 'APP_IPHONE_67' }, rels: { appStoreVersionLocalization: 'loc-en' } });
const md5 = (b: Uint8Array) => new Bun.CryptoHasher('md5').update(b).digest('hex');
s.asc.put({ type: 'appScreenshots', id: 'shot-a', attributes: { sourceFileChecksum: md5(png(1)), assetDeliveryState: { state: 'COMPLETE' }, fileName: '01.png' }, rels: { appScreenshotSet: set.id } });
s.asc.put({ type: 'appScreenshots', id: 'shot-x', attributes: { sourceFileChecksum: 'stale', assetDeliveryState: { state: 'COMPLETE' }, fileName: 'old.png' }, rels: { appScreenshotSet: set.id } });
s.failPart = n => n === 2; // the 2nd uploaded part (03.png) fails
const r1 = await capture(() => appleScreenshots(p.ctx(['--only', 'en-US']))).catch(e => ({ out: String(e), err: '', exit: 1 }));
expect(r1.out + r1.err).toContain('03.png part 0 -> 400');
expect(s.asc.children(set.id, 'appScreenshots').map(x => x.id)).toContain('shot-a');
s.failPart = undefined;
const r2 = await capture(() => appleScreenshots(p.ctx(['--only', 'en-US'])));
expect(r2.exit).toBe(null);
const shots = s.asc.children(set.id, 'appScreenshots');
expect(shots.map(x => x.attributes.sourceFileChecksum)).toEqual([1, 2, 3, 4].map(n => md5(png(n))));
expect(shots.every(x => (x.attributes.assetDeliveryState as { state: string }).state === 'COMPLETE')).toBe(true);
expect(shots[0]!.id).toBe('shot-a'); // the matching first screenshot was kept, not uploaded again
expect(count('appScreenshotSets')).toBe(1);
const before = s.calls.length;
await capture(() => appleScreenshots(p.ctx(['--only', 'en-US'])));
expect(writes(s.calls.slice(before))).toEqual([]);
});
test('IAP create: a 5xx after Apple created it is found, not created again', async () => {
const p = project({ ...CONFIG, products: [CONFIG.products![0]!] });
s.faults.push({ method: 'POST', match: /^\/v2\/inAppPurchases$/, when: 'after', status: 502 });
const r = await capture(() => appleIap(p.ctx([])));
expect(r.exit).toBe(null);
expect(r.err).toContain('no clear answer');
expect(count('inAppPurchases')).toBe(1);
expect(s.calls.filter(c => c.method === 'POST' && c.path === '/v2/inAppPurchases')).toHaveLength(1);
expect(count('inAppPurchaseLocalizations')).toBe(2);
});
test('IAP create: a 5xx before anything happened is tried once more (product ids are unique, so that is safe)', async () => {
const p = project({ ...CONFIG, products: [CONFIG.products![0]!] });
s.faults.push({ method: 'POST', match: /^\/v2\/inAppPurchases$/, when: 'before', status: 503 });
const r = await capture(() => appleIap(p.ctx([])));
expect(r.exit).toBe(null);
expect(count('inAppPurchases')).toBe(1);
expect(s.calls.filter(c => c.method === 'POST' && c.path === '/v2/inAppPurchases')).toHaveLength(2);
});
test('IAP create: crash right after the create (process gone); the re-run during the list lag uses the journal, no duplicate', async () => {
const p = project({ ...CONFIG, products: [CONFIG.products![0]!] });
// first run: the product is created, then the run dies on the localization
s.faults.push({ method: 'POST', match: /^\/v1\/inAppPurchaseLocalizations$/, when: 'before', status: 400 });
s.asc.lag.inAppPurchases = 50;
await capture(() => appleIap(p.ctx([]))).catch(e => expect(String(e)).toContain('injected'));
expect(count('inAppPurchases')).toBe(1);
expect(Object.keys(readJournal(p.root))).toEqual(['apple:com.example.notes:iap:com.example.notes.pro']);
const r = await capture(() => appleIap(p.ctx([])));
expect(r.out).toContain('does not show it yet, using it');
expect(count('inAppPurchases')).toBe(1);
expect(count('inAppPurchaseLocalizations')).toBe(2);
});
test('a 409 duplicate is treated as found (no second create, no error)', async () => {
const p = project({ ...CONFIG, products: [CONFIG.products![0]!] });
s.asc.put({ type: 'inAppPurchases', id: 'iap-old', attributes: { productId: 'com.example.notes.pro', state: 'MISSING_METADATA' }, rels: { app: APP }, hiddenReads: 1 } as never);
const r = await capture(() => appleIap(p.ctx([])));
expect(r.exit).toBe(null);
expect(r.out).toContain('already exists, using it');
expect(count('inAppPurchases')).toBe(1);
});
test('apple subscriptions: a lost answer on the subscription create is found again; the second run creates nothing', async () => {
const cfg: ShipConfig = { ...CONFIG, subscriptions: [{ name: 'Pro', listings: { en: { name: 'Pro' } }, subscriptions: [
{ id: 'com.example.notes.monthly', period: 'P1M', level: 1, usd: 4.99, listings: { en: { title: 'Monthly', description: 'Pro every month.' } } },
] } as never] };
const p = project(cfg);
s.faults.push({ method: 'POST', match: /^\/v1\/subscriptions$/, when: 'after', status: 504 });
await capture(() => appleSubscriptions(p.ctx(['--yes'])));
expect(count('subscriptions')).toBe(1);
expect(count('subscriptionGroups')).toBe(1);
const before = s.calls.length;
await capture(() => appleSubscriptions(p.ctx(['--yes'])));
expect(writes(s.calls.slice(before)).filter(c => c.method === 'POST' && /subscription(Group)?s$|Localizations$/.test(c.path))).toEqual([]);
expect(count('subscriptions')).toBe(1);
expect(count('subscriptionLocalizations')).toBe(1);
});
test('Play subscription create: 409 ALREADY_EXISTS is found, offers are not created twice', async () => {
const cfg: ShipConfig = { ...CONFIG, subscriptions: [{ name: 'Pro', listings: { en: { name: 'Pro' } }, subscriptions: [
{ id: 'com.example.notes.yearly', period: 'P1Y', level: 1, usd: 29.99, listings: { en: { title: 'Yearly', description: 'Pro every year.', benefits: ['Sync'] } } },
] } as never] };
const p = project(cfg);
s.faults.push({ method: 'POST', match: /\/subscriptions\?productId=/, when: 'after', status: 500 });
const r = await capture(() => playSubscriptions(p.ctx(['--yes'])));
expect(r.err).toContain('no clear answer');
expect(s.play.subs.size).toBe(1);
expect(s.calls.filter(c => c.method === 'POST' && /\/subscriptions\?productId=/.test(c.path))).toHaveLength(1);
});
});
describe('Play edits', () => {
test('an error inside the edit deletes it; nothing is committed; no second edit is ever open', async () => {
const play = await playClient(project().root, 'com.example.notes');
await expect(play.edit(async () => { throw new Error('boom'); })).rejects.toThrow('boom');
expect(s.play.edits.size).toBe(0);
expect(s.play.committed).toEqual([]);
await expect(play.edit(async () => { await play.read(async () => 1); })).rejects.toThrow('already open');
expect(s.play.edits.size).toBe(0);
});
test('an upload without a clear answer inside an edit: the edit is discarded and redone from scratch, one image', async () => {
const play = await playClient(project().root, 'com.example.notes');
s.faults.push({ method: 'POST', match: /phoneScreenshots\?uploadType=media/, when: 'after', status: 503 });
await play.edit(async id => {
await play.call('POST', `${play.UPLOAD}/edits/${id}/listings/en-US/phoneScreenshots?uploadType=media`, png(1), { 'Content-Type': 'image/png' });
});
expect(s.play.live.images['en-US/phoneScreenshots']).toHaveLength(1);
expect(s.play.committed).toHaveLength(1);
expect(s.play.edits.size).toBe(0);
});
test('a commit without a clear answer is never redone', async () => {
const play = await playClient(project().root, 'com.example.notes');
s.faults.push({ method: 'POST', match: /:commit$/, when: 'after', status: 502 });
let runs = 0;
await expect(play.edit(async id => { runs++; await play.call('PUT', `${play.API}/edits/${id}/details`, { defaultLanguage: 'en-US' }); })).rejects.toThrow('may be live');
expect(runs).toBe(1);
expect(s.play.committed).toHaveLength(1);
});
test('unchanged(): the edit is deleted, not committed', async () => {
const play = await playClient(project().root, 'com.example.notes');
await play.edit(async (_id, ctl) => { ctl.unchanged(); });
expect(s.play.committed).toEqual([]);
expect(s.play.edits.size).toBe(0);
});
});
describe('helpers', () => {
test('retrySafe: POST creates are not retried, idempotent actions are', () => {
expect(retrySafe('GET', '/v1/apps')).toBe(true);
expect(retrySafe('PATCH', '/v1/apps/1')).toBe(true);
expect(retrySafe('DELETE', '/v1/appScreenshots/1')).toBe(true);
expect(retrySafe('POST', '/v2/inAppPurchases')).toBe(false);
expect(retrySafe('POST', '/v1/appScreenshots')).toBe(false);
expect(retrySafe('POST', '/v1/betaGroups/1/relationships/builds')).toBe(true);
expect(retrySafe('POST', '/v1/inAppPurchasePriceSchedules')).toBe(true);
const play = 'https://androidpublisher.googleapis.com/androidpublisher/v3/applications/com.example.notes';
expect(retrySafe('POST', `${play}/edits`)).toBe(true);
expect(retrySafe('POST', `${play}/edits/e1:commit`)).toBe(false);
expect(retrySafe('POST', `${play}/edits/e1:validate`)).toBe(true);
expect(retrySafe('POST', `${play}/pricing:convertRegionPrices`)).toBe(true);
expect(retrySafe('POST', `${play}/subscriptions?productId=x`)).toBe(false);
expect(retrySafe('POST', 'https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/com.example.notes/edits/e1/bundles?uploadType=media')).toBe(false);
});
test('isConflict: duplicates only, not Apple validation errors', () => {
expect(isConflict(new Error('POST /v2/inAppPurchases -> 409 {"errors":[{"code":"ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE","detail":"already been used"}]}'))).toBe(true);
expect(isConflict(new Error('POST /v2/inAppPurchases -> 409 {"errors":[{"code":"ENTITY_ERROR.ATTRIBUTE.INVALID","detail":"name is too long"}]}'))).toBe(false);
expect(isConflict(new Error('POST /subscriptions -> 409 {"error":{"status":"ALREADY_EXISTS"}}'))).toBe(true);
expect(isConflict(new Error('RevenueCat POST /v2/projects/p/products: 409 {"message":"resource already exists"}'))).toBe(true);
expect(isConflict(new Error('GET /v1/apps -> 404'))).toBe(false);
});
test('createOnce / findOrCreate: unknown outcome without a unique key stops instead of creating twice', async () => {
let made = 0;
const lost = () => { made++; throw new AmbiguousWriteError('POST /v1/appEvents -> 504'); };
await expect(createOnce({ what: 'event', find: async () => null, create: lost })).rejects.toThrow('not sent again');
expect(made).toBe(1);
made = 0;
await expect(createOnce({ what: 'group', unique: true, find: async () => null, create: lost })).rejects.toThrow('not sent again');
expect(made).toBe(2);
const root = project().root;
const one = await findOrCreate({ what: 'x', find: async () => null, create: async () => ({ id: 'n1' }),
journal: { root, key: 'k', byId: async () => null, idOf: v => v.id } });
expect(one).toEqual({ value: { id: 'n1' }, created: true });
const again = await findOrCreate({ what: 'x', find: async () => null, create: async () => { throw new Error('must not create'); },
journal: { root, key: 'k', byId: async id => ({ id }), idOf: v => v.id } });
expect(again).toEqual({ value: { id: 'n1' }, created: false });
});
test('keptPrefix, sameRelease, sameReleases', () => {
const d = (checksum: string, state = 'COMPLETE') => ({ checksum, state });
expect(keptPrefix([d('a'), d('b'), d('x')], ['a', 'b', 'c'])).toBe(2);
expect(keptPrefix([d('a', 'AWAITING_UPLOAD'), d('b')], ['a', 'b'])).toBe(0);
expect(keptPrefix([], ['a'])).toBe(0);
const notes = [{ language: 'en-US', text: 'Fixes' }];
expect(sameRelease([{ versionCodes: ['42'], status: 'completed', releaseNotes: notes }], { versionCodes: ['42'], status: 'completed', releaseNotes: notes })).toBe(true);
expect(sameRelease([{ versionCodes: ['42'], status: 'draft' }], { versionCodes: ['42'], status: 'completed' })).toBe(false);
expect(sameReleases([{ status: 'completed', versionCodes: ['1'] }, { versionCodes: ['2'], status: 'inProgress', userFraction: 0.2 }],
[{ userFraction: 0.2, status: 'inProgress', versionCodes: ['2'] }, { versionCodes: ['1'], status: 'completed' }])).toBe(true);
});
});
describe('one writer per app and store', () => {
test('a second run is refused with the holder; a dead holder is taken over; --force-unlock removes it', () => {
const root = project().root;
const release = acquireLock(root, 'apple-com.example.notes', 'apple iap');
expect(() => acquireLock(root, 'apple-com.example.notes', 'apple screenshots')).toThrow('`storebound apple iap`');
release();
acquireLock(root, 'apple-com.example.notes', 'apple screenshots')();
// a lock held by another live process
mkdirSync(join(root, '.storebound', 'locks'), { recursive: true });
const file = join(root, '.storebound', 'locks', 'play-com.example.notes.lock');
writeFileSync(file, JSON.stringify({ pid: process.ppid, host: hostname(), command: 'play images', startedAt: new Date().toISOString() }));
expect(() => acquireLock(root, 'play-com.example.notes', 'play listing')).toThrow('another storebound run is writing to play com.example.notes: `storebound play images`');
const free = acquireLock(root, 'play-com.example.notes', 'play listing', { force: true });
expect(JSON.parse(readFileSync(file, 'utf8')).command).toBe('play listing');
free();
expect(existsSync(file)).toBe(false);
expect(staleLock({ pid: 999999999, host: 'h', command: 'x', startedAt: new Date().toISOString() }, Date.now(), 'h', () => false)).toBe(true);
expect(staleLock({ pid: 1, host: 'other', command: 'x', startedAt: new Date().toISOString() }, Date.now(), 'h', () => false)).toBe(false);
expect(staleLock({ pid: 1, host: 'other', command: 'x', startedAt: new Date(Date.now() - 13 * 3600_000).toISOString() }, Date.now(), 'h')).toBe(true);
});
test('which runs take a lock', () => {
const k = (cmd: string, argv: string[]) => lockKeys(cmd, parseArgs(argv), CONFIG);
expect(k('apple iap', [])).toEqual(['apple-com.example.notes']);
expect(k('apple iap', ['--dry'])).toEqual([]);
expect(k('apple testflight', ['status'])).toEqual([]);
expect(k('apple listing', ['pull'])).toEqual([]);
expect(k('play images', [])).toEqual(['play-com.example.notes']);
expect(k('play status', [])).toEqual([]);
expect(k('prices', ['import'])).toEqual(['apple-com.example.notes', 'play-com.example.notes']);
expect(k('prices', ['export'])).toEqual([]);
expect(k('validate', [])).toEqual([]);
});
});

View file

@ -154,6 +154,8 @@ describe('IAP review submission plan', () => {
{ method: 'POST', match: /^\/v1\/reviewSubmissions$/, answer: { data: { type: 'reviewSubmissions', id: 'rs-new', attributes: { state: 'READY_FOR_REVIEW' } } } }, { method: 'POST', match: /^\/v1\/reviewSubmissions$/, answer: { data: { type: 'reviewSubmissions', id: 'rs-new', attributes: { state: 'READY_FOR_REVIEW' } } } },
{ method: 'POST', match: /^\/v1\/reviewSubmissionItems$/, answer: { data: { type: 'reviewSubmissionItems', id: 'it-1' } } }, { method: 'POST', match: /^\/v1\/reviewSubmissionItems$/, answer: { data: { type: 'reviewSubmissionItems', id: 'it-1' } } },
{ method: 'PATCH', match: /^\/v1\/reviewSubmissions\/rs-new$/, answer: { data: { type: 'reviewSubmissions', id: 'rs-new', attributes: { state: 'WAITING_FOR_REVIEW' } } } }, { method: 'PATCH', match: /^\/v1\/reviewSubmissions\/rs-new$/, answer: { data: { type: 'reviewSubmissions', id: 'rs-new', attributes: { state: 'WAITING_FOR_REVIEW' } } } },
// the journal of recent creates (.storebound/created.json) names rs-new: read by id, it was submitted, so a new draft is made
{ match: /^\/v1\/reviewSubmissions\/rs-new$/, answer: { data: { type: 'reviewSubmissions', id: 'rs-new', attributes: { state: 'WAITING_FOR_REVIEW' } } } },
]); ]);
let calls = routes(); let calls = routes();
let r = await capture(() => iapSubmit(p.ctx(['--dry', '--json']))); let r = await capture(() => iapSubmit(p.ctx(['--dry', '--json'])));