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:
commit
17f4413afa
44 changed files with 1989 additions and 231 deletions
32
CHANGELOG.md
32
CHANGELOG.md
|
|
@ -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
2
bunfig.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
[test]
|
||||||
|
preload = ["./tests/helpers/preload.ts"]
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)');
|
||||||
|
|
|
||||||
|
|
@ -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 } } });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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})`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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
47
cli/apple/submission.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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})`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
90
cli/lib/lock.ts
Normal 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
152
cli/lib/upsert.ts
Normal 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; }
|
||||||
|
}
|
||||||
|
|
@ -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)`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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)))
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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}®ionsVersion.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}®ionsVersion.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)}®ionsVersion.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)}®ionsVersion.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);
|
||||||
|
|
|
||||||
|
|
@ -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(',')}`) };
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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()}`);
|
||||||
|
|
|
||||||
|
|
@ -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
103
docs/idempotency.md
Normal 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.
|
||||||
|
|
@ -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`). |
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
404
tests/helpers/fake-stores.ts
Normal file
404
tests/helpers/fake-stores.ts
Normal 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
4
tests/helpers/preload.ts
Normal 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
451
tests/idempotency.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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'])));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue