fix(ios): harden App Store release automation (#221)
## Summary - replace the unsupported top-level App Store version collection with Apple's app-scoped version endpoint - add tested release-policy and availability readback for exact version/build, `AFTER_APPROVAL`, Denmark only, no preorder, and no automatic future territories - strengthen the stable `App Store Readiness` check and align Fastlane/candidate handoff with the approved 1.0.0 release policy ## Task contract `truckwash-ios-release-20260723` — R4 (`ci-policy`, `release-policy`, `credential-handling`, `branch-protection-or-rules`, `mobile-store-submission`). The user explicitly approved implementation, protected-master delivery, and the App Store release path. ## Changed files - App Store Connect client and dependency-free Node tests - App Store readiness and candidate workflows - Fastlane candidate release configuration - Apple App Store release runbook ## Verification - `node --test tests/node/app-store-connect.test.mjs` — 10 passed - `node scripts/mobile/validate-app-store.mjs --strict` — passed - `node scripts/mobile/check-permissions.mjs` — passed - App Store product-readiness Vitest — 5 passed - ESLint on changed Node files — passed - workflow YAML parsing — passed - `git diff --check` — passed - local Fastlane validation unavailable because Ruby/Bundler is not installed on this host; `App Store Readiness` runs it on GitHub ## Release target - iOS App Store - bundle `io.truckwash.app` - version `1.0.0` - App Store Connect app `6792777794` - Denmark only - automatic release after approval - no preorder or phased release for 1.0.0 The repository App Store automation switch remains disabled until this change is merged and credential health is reverified.
This commit is contained in:
@@ -8,6 +8,8 @@ on:
|
||||
- "fastlane/**"
|
||||
- "ios/**"
|
||||
- "scripts/mobile/**"
|
||||
- "tests/node/app-store-connect.test.mjs"
|
||||
- ".github/workflows/app-store-readiness.yml"
|
||||
- "Gemfile*"
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -20,8 +22,9 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: App Store Readiness
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -61,12 +64,25 @@ jobs:
|
||||
echo 'Gemfile.lock is missing or stale. Download generated-fastlane-lock and commit it.' >&2
|
||||
exit 1
|
||||
|
||||
- name: Validate App Store metadata and available assets
|
||||
run: node scripts/mobile/validate-app-store.mjs
|
||||
- name: Install the pinned Fastlane dependency graph
|
||||
run: bundle install --jobs 4 --retry 3
|
||||
|
||||
- name: Validate strict App Store metadata and candidate assets
|
||||
run: node scripts/mobile/validate-app-store.mjs --strict
|
||||
|
||||
- name: Validate native mobile permissions
|
||||
run: node scripts/mobile/check-permissions.mjs
|
||||
|
||||
- name: Test App Store Connect automation
|
||||
run: node --test tests/node/app-store-connect.test.mjs
|
||||
|
||||
- name: Validate Fastlane configuration
|
||||
run: bundle exec fastlane lanes
|
||||
|
||||
- name: Validate JavaScript syntax
|
||||
run: |
|
||||
node --check scripts/mobile/validate-app-store.mjs
|
||||
node --check scripts/mobile/app-store-connect.mjs
|
||||
node --check scripts/mobile/create-ios-release-manifest.mjs
|
||||
node --check tests/node/app-store-connect.test.mjs
|
||||
node scripts/mobile/app-store-connect.mjs self-test-jwt
|
||||
|
||||
@@ -100,7 +100,7 @@ jobs:
|
||||
NODE
|
||||
|
||||
promote:
|
||||
name: Sync storefront and prepare manual review
|
||||
name: Sync and verify App Store candidate
|
||||
needs: resolve
|
||||
if: needs.resolve.outputs.enabled == 'true'
|
||||
runs-on: macos-15
|
||||
@@ -153,22 +153,33 @@ jobs:
|
||||
- name: Sync metadata and screenshots without App Review submission
|
||||
run: bundle exec fastlane ios prepare_candidate
|
||||
|
||||
- name: Configure automatic release after approval
|
||||
run: node scripts/mobile/app-store-connect.mjs configure-release-policy
|
||||
|
||||
- name: Read back exact App Store candidate
|
||||
id: readback
|
||||
run: node scripts/mobile/app-store-connect.mjs verify-store-version
|
||||
|
||||
- name: Verify Denmark-only availability and no preorder
|
||||
id: availability
|
||||
run: node scripts/mobile/app-store-connect.mjs verify-availability
|
||||
|
||||
- name: Write candidate handoff
|
||||
env:
|
||||
APP_STORE_STATE: ${{ steps.readback.outputs.app_store_state }}
|
||||
APP_STORE_VERSION_ID: ${{ steps.readback.outputs.app_store_version_id }}
|
||||
RELEASE_TYPE: ${{ steps.readback.outputs.release_type }}
|
||||
AVAILABLE_TERRITORIES: ${{ steps.availability.outputs.available_territories }}
|
||||
run: |
|
||||
echo "### iOS $IOS_MARKETING_VERSION candidate prepared" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Source: \`$IOS_SOURCE_SHA\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Exact tested build: \`$IOS_BUILD_NUMBER\` (\`$EXPECTED_APP_STORE_BUILD_ID\`)" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- App Store state: \`$APP_STORE_STATE\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- App Store version ID: \`$APP_STORE_VERSION_ID\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Release policy: \`$RELEASE_TYPE\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Availability: \`$AVAILABLE_TERRITORIES\` only; preorder disabled" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- [Open the app in App Store Connect](https://appstoreconnect.apple.com/apps/$APP_STORE_CONNECT_APP_ID/appstore)" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- App Review submission and public release remain manual in App Store Connect." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- App Review submission remains manual; Apple will release automatically after approval." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
disabled:
|
||||
name: Promotion disabled
|
||||
|
||||
+16
-10
@@ -1,16 +1,16 @@
|
||||
# Apple App Store Release Runbook
|
||||
|
||||
This is the operating runbook for the public iOS application and its signed
|
||||
GitHub Actions delivery. Public review submission and public release remain
|
||||
human actions in App Store Connect.
|
||||
GitHub Actions delivery. Public review submission remains a human action in App
|
||||
Store Connect; the approved version releases automatically after Apple approval.
|
||||
|
||||
## Storefront record
|
||||
|
||||
Create or reconcile one App Store Connect record:
|
||||
|
||||
| Setting | Value |
|
||||
| --- | --- |
|
||||
| Name | Truck Wash Kundeportal |
|
||||
| ---------------- | ------------------------------------- |
|
||||
| Name | Truck Wash |
|
||||
| Bundle ID | `io.truckwash.app` |
|
||||
| SKU | `truckwash-ios` |
|
||||
| Primary language | Danish |
|
||||
@@ -20,12 +20,13 @@ Create or reconcile one App Store Connect record:
|
||||
| Support URL | `https://truckwash.io/support` |
|
||||
| Privacy URL | `https://truckwash.io/privacy-policy` |
|
||||
| Marketing URL | `https://truckwash.io/` |
|
||||
| Release | Manual after approval |
|
||||
| Release | Automatically after approval |
|
||||
|
||||
Use the standard Apple EULA and do not configure in-app purchases. Payments in
|
||||
the product cover physical truck-wash services. Keep iPhone and iPad enabled;
|
||||
disable Apple-silicon Mac and Vision Pro compatibility until those targets have
|
||||
been tested deliberately.
|
||||
been tested deliberately. Do not enable preorder or phased release for version
|
||||
`1.0.0`, and disable automatic availability in newly added territories.
|
||||
|
||||
The Account Holder or Admin must complete these console-only items before the
|
||||
first candidate:
|
||||
@@ -95,7 +96,9 @@ Configure two GitHub environments:
|
||||
Private repositories on the Team plan cannot rely on environment required
|
||||
reviewers. Protect `ios-v*` creation/update/deletion with a repository ruleset
|
||||
limited to release managers. Manual App Review submission is the final human
|
||||
approval.
|
||||
approval. App Store Connect API readback must show `AFTER_APPROVAL`, Denmark
|
||||
(`DNK`) as the only available territory, preorder disabled, and automatic
|
||||
future territories disabled.
|
||||
|
||||
Environment secrets:
|
||||
|
||||
@@ -179,11 +182,14 @@ delivery automatically. Stale or proofless releases do not sign or upload.
|
||||
4. `iOS App Store Candidate` locates the release manifest for that exact SHA,
|
||||
verifies the exact processed App Store build, enforces complete screenshots,
|
||||
synchronizes Danish metadata, attaches the existing build, and reads it back.
|
||||
It does not rebuild, submit for review, or release publicly.
|
||||
It also writes and verifies automatic release after approval, then verifies
|
||||
Denmark-only availability and no preorder. It does not rebuild or submit for
|
||||
review.
|
||||
5. In App Store Connect, review the rendered product page, review account,
|
||||
privacy/export/age answers, and candidate build. Submit manually.
|
||||
6. Release the first Denmark version manually after approval. Use phased release
|
||||
for later updates unless there is a reason not to.
|
||||
6. Submit version `1.0.0` for review. Apple releases it automatically after
|
||||
approval. Do not use phased release for `1.0.0`; use phased release for later
|
||||
updates unless there is a reason not to.
|
||||
7. Merge the next `ios/release.json` version bump before further delivery after
|
||||
Apple closes the released version to new builds.
|
||||
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ platform :ios do
|
||||
overwrite_screenshots: true,
|
||||
force: true,
|
||||
submit_for_review: false,
|
||||
automatic_release: false,
|
||||
automatic_release: true,
|
||||
phased_release: false,
|
||||
run_precheck_before_submit: false,
|
||||
precheck_include_in_app_purchases: false
|
||||
|
||||
@@ -1,24 +1,57 @@
|
||||
import { createPrivateKey, generateKeyPairSync, sign } from "node:crypto";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { argv, env, exit } from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const command = argv[2];
|
||||
const baseUrl = "https://api.appstoreconnect.apple.com/v1";
|
||||
export const APP_STORE_CONNECT_BASE_URL = "https://api.appstoreconnect.apple.com/v1";
|
||||
export const APP_STORE_CONNECT_V2_BASE_URL = "https://api.appstoreconnect.apple.com/v2";
|
||||
export const EXPECTED_RELEASE_TYPE = "AFTER_APPROVAL";
|
||||
export const EXPECTED_AVAILABLE_TERRITORIES = ["DNK"];
|
||||
|
||||
const defaultSleep = (milliseconds) => new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds));
|
||||
const base64url = (value) => Buffer.from(value).toString("base64url");
|
||||
|
||||
export const appStoreVersionsPath = ({ appId, version, includeBuild = false }) => {
|
||||
const params = new URLSearchParams({
|
||||
"filter[platform]": "IOS",
|
||||
"filter[versionString]": version,
|
||||
limit: "10",
|
||||
});
|
||||
if (includeBuild) params.set("include", "build");
|
||||
return `/apps/${encodeURIComponent(appId)}/appStoreVersions?${params}`;
|
||||
};
|
||||
|
||||
export const createAppStoreConnectClient = ({
|
||||
environment = env,
|
||||
fetchImpl = globalThis.fetch,
|
||||
sleepImpl = defaultSleep,
|
||||
now = () => Date.now(),
|
||||
logger = console,
|
||||
tokenProvider,
|
||||
outputWriter,
|
||||
} = {}) => {
|
||||
const required = (name) => {
|
||||
const value = env[name];
|
||||
const value = environment[name];
|
||||
if (!value) throw new Error(`Missing ${name}`);
|
||||
return value;
|
||||
};
|
||||
const base64url = (value) => Buffer.from(value).toString("base64url");
|
||||
|
||||
const appId = () => required("APP_STORE_CONNECT_APP_ID");
|
||||
const bundleId = () => environment.IOS_BUNDLE_ID || "io.truckwash.app";
|
||||
const version = () => required("IOS_MARKETING_VERSION");
|
||||
const buildNumber = () => required("IOS_BUILD_NUMBER");
|
||||
|
||||
const token = () => {
|
||||
if (tokenProvider) return tokenProvider();
|
||||
const keyId = required("APP_STORE_CONNECT_API_KEY_ID");
|
||||
const key = Buffer.from(required("APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64"), "base64").toString("utf8");
|
||||
if (!key.includes("PRIVATE KEY"))
|
||||
if (!key.includes("PRIVATE KEY")) {
|
||||
throw new Error("App Store Connect API key is not a base64-encoded .p8 private key");
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = { aud: "appstoreconnect-v1", iat: now, exp: now + 1_200 };
|
||||
if (env.APP_STORE_CONNECT_ISSUER_ID) payload.iss = env.APP_STORE_CONNECT_ISSUER_ID;
|
||||
}
|
||||
const issuedAt = Math.floor(now() / 1_000);
|
||||
const payload = { aud: "appstoreconnect-v1", iat: issuedAt, exp: issuedAt + 1_200 };
|
||||
if (environment.APP_STORE_CONNECT_ISSUER_ID) payload.iss = environment.APP_STORE_CONNECT_ISSUER_ID;
|
||||
else payload.sub = "user";
|
||||
const encodedHeader = base64url(JSON.stringify({ alg: "ES256", kid: keyId, typ: "JWT" }));
|
||||
const encodedPayload = base64url(JSON.stringify(payload));
|
||||
@@ -30,10 +63,14 @@ const token = () => {
|
||||
return `${signingInput}.${base64url(signature)}`;
|
||||
};
|
||||
|
||||
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
const writeOutput = (key, value) => {
|
||||
if (outputWriter) outputWriter(key, String(value));
|
||||
else if (environment.GITHUB_OUTPUT) appendFileSync(environment.GITHUB_OUTPUT, `${key}=${value}\n`);
|
||||
else logger.log(`${key}=${value}`);
|
||||
};
|
||||
|
||||
const request = async (path, options = {}, attempt = 1) => {
|
||||
const response = await fetch(path.startsWith("http") ? path : `${baseUrl}${path}`, {
|
||||
const response = await fetchImpl(path.startsWith("http") ? path : `${APP_STORE_CONNECT_BASE_URL}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token()}`,
|
||||
@@ -50,7 +87,11 @@ const request = async (path, options = {}, attempt = 1) => {
|
||||
}
|
||||
if (!response.ok) {
|
||||
if ((response.status === 429 || response.status >= 500) && attempt < 5) {
|
||||
await sleep(Math.min(30_000, 2 ** attempt * 1_000));
|
||||
const retryAfter = Number.parseInt(response.headers?.get?.("retry-after") || "", 10);
|
||||
const delay = Number.isSafeInteger(retryAfter)
|
||||
? Math.min(30_000, retryAfter * 1_000)
|
||||
: Math.min(30_000, 2 ** attempt * 1_000);
|
||||
await sleepImpl(delay);
|
||||
return request(path, options, attempt + 1);
|
||||
}
|
||||
const detail =
|
||||
@@ -65,14 +106,17 @@ const request = async (path, options = {}, attempt = 1) => {
|
||||
return body;
|
||||
};
|
||||
|
||||
const appId = () => required("APP_STORE_CONNECT_APP_ID");
|
||||
const bundleId = () => env.IOS_BUNDLE_ID || "io.truckwash.app";
|
||||
const version = () => required("IOS_MARKETING_VERSION");
|
||||
const buildNumber = () => required("IOS_BUILD_NUMBER");
|
||||
|
||||
const writeOutput = (key, value) => {
|
||||
if (env.GITHUB_OUTPUT) appendFileSync(env.GITHUB_OUTPUT, `${key}=${value}\n`);
|
||||
else console.log(`${key}=${value}`);
|
||||
const collectPages = async (path) => {
|
||||
let url = path;
|
||||
const data = [];
|
||||
const included = [];
|
||||
while (url) {
|
||||
const page = await request(url);
|
||||
data.push(...(page?.data ?? []));
|
||||
included.push(...(page?.included ?? []));
|
||||
url = page?.links?.next ?? null;
|
||||
}
|
||||
return { data, included };
|
||||
};
|
||||
|
||||
const verifyCredentials = async () => {
|
||||
@@ -83,7 +127,8 @@ const verifyCredentials = async () => {
|
||||
`APP_STORE_CONNECT_APP_ID resolves to ${actualBundleId || "an unknown bundle"}, expected ${bundleId()}`
|
||||
);
|
||||
}
|
||||
console.log(`Authenticated to App Store Connect for ${actualBundleId}.`);
|
||||
logger.log(`Authenticated to App Store Connect for ${actualBundleId}.`);
|
||||
return app.data;
|
||||
};
|
||||
|
||||
const allBuildsForVersion = async () => {
|
||||
@@ -92,14 +137,19 @@ const allBuildsForVersion = async () => {
|
||||
"filter[preReleaseVersion.version]": version(),
|
||||
limit: "200",
|
||||
});
|
||||
let url = `${baseUrl}/builds?${params}`;
|
||||
const builds = [];
|
||||
while (url) {
|
||||
const page = await request(url);
|
||||
builds.push(...(page?.data ?? []));
|
||||
url = page?.links?.next ?? null;
|
||||
}
|
||||
return builds;
|
||||
const response = await collectPages(`/builds?${params}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const allStoreVersions = async ({ includeBuild = false } = {}) =>
|
||||
collectPages(appStoreVersionsPath({ appId: appId(), version: version(), includeBuild }));
|
||||
|
||||
const findStoreVersion = async ({ includeBuild = false } = {}) => {
|
||||
const response = await allStoreVersions({ includeBuild });
|
||||
return {
|
||||
storeVersion: response.data.find((candidate) => candidate?.attributes?.versionString === version()),
|
||||
included: response.included,
|
||||
};
|
||||
};
|
||||
|
||||
const findExactBuild = async () => {
|
||||
@@ -109,16 +159,7 @@ const findExactBuild = async () => {
|
||||
|
||||
const nextBuildNumber = async () => {
|
||||
await verifyCredentials();
|
||||
const storeVersionParams = new URLSearchParams({
|
||||
"filter[app]": appId(),
|
||||
"filter[platform]": "IOS",
|
||||
"filter[versionString]": version(),
|
||||
limit: "10",
|
||||
});
|
||||
const storeVersions = await request(`/appStoreVersions?${storeVersionParams}`);
|
||||
const storeVersion = (storeVersions?.data ?? []).find(
|
||||
(candidate) => candidate?.attributes?.versionString === version()
|
||||
);
|
||||
const { storeVersion } = await findStoreVersion();
|
||||
if (storeVersion?.attributes?.appStoreState === "READY_FOR_SALE") {
|
||||
throw new Error(
|
||||
`App Store version ${version()} is already released; bump ios/release.json before delivering another master build`
|
||||
@@ -130,21 +171,24 @@ const nextBuildNumber = async () => {
|
||||
.filter((number) => Number.isSafeInteger(number) && number > 0);
|
||||
const next = (numbers.length > 0 ? Math.max(...numbers) : 0) + 1;
|
||||
writeOutput("build_number", next);
|
||||
console.log(`Next App Store Connect build for ${version()} is ${next}.`);
|
||||
logger.log(`Next App Store Connect build for ${version()} is ${next}.`);
|
||||
return next;
|
||||
};
|
||||
|
||||
const waitForBuild = async () => {
|
||||
const deadline = Date.now() + Number(env.APP_STORE_PROCESSING_TIMEOUT_SECONDS || 3_600) * 1_000;
|
||||
const deadline = now() + Number(environment.APP_STORE_PROCESSING_TIMEOUT_SECONDS || 3_600) * 1_000;
|
||||
let build = null;
|
||||
while (Date.now() < deadline) {
|
||||
while (now() < deadline) {
|
||||
build = await findExactBuild();
|
||||
const state = build?.attributes?.processingState;
|
||||
if (state === "VALID") return build;
|
||||
if (["FAILED", "INVALID"].includes(state)) throw new Error(`App Store Connect processing ended in ${state}`);
|
||||
console.log(
|
||||
if (["FAILED", "INVALID"].includes(state)) {
|
||||
throw new Error(`App Store Connect processing ended in ${state}`);
|
||||
}
|
||||
logger.log(
|
||||
build ? `Build ${buildNumber()} is ${state || "processing"}.` : `Waiting for build ${buildNumber()} to appear.`
|
||||
);
|
||||
await sleep(30_000);
|
||||
await sleepImpl(30_000);
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${version()} (${buildNumber()}) to process`);
|
||||
};
|
||||
@@ -152,15 +196,22 @@ const waitForBuild = async () => {
|
||||
const waitAndDistribute = async () => {
|
||||
const build = await waitForBuild();
|
||||
const groupId = required("TESTFLIGHT_INTERNAL_GROUP_ID");
|
||||
const localizationParams = new URLSearchParams({ "filter[build]": build.id, "filter[locale]": "da-DK" });
|
||||
const localizationParams = new URLSearchParams({
|
||||
"filter[build]": build.id,
|
||||
"filter[locale]": "da-DK",
|
||||
});
|
||||
const localizations = await request(`/betaBuildLocalizations?${localizationParams}`);
|
||||
const existingLocalization = (localizations?.data ?? [])[0];
|
||||
const whatsNew = env.TESTFLIGHT_WHAT_TO_TEST || `Automatisk intern build ${version()} (${buildNumber()}).`;
|
||||
const whatsNew = environment.TESTFLIGHT_WHAT_TO_TEST || `Automatisk intern build ${version()} (${buildNumber()}).`;
|
||||
if (existingLocalization) {
|
||||
await request(`/betaBuildLocalizations/${encodeURIComponent(existingLocalization.id)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
data: { type: "betaBuildLocalizations", id: existingLocalization.id, attributes: { whatsNew } },
|
||||
data: {
|
||||
type: "betaBuildLocalizations",
|
||||
id: existingLocalization.id,
|
||||
attributes: { whatsNew },
|
||||
},
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
@@ -184,11 +235,12 @@ const waitAndDistribute = async () => {
|
||||
});
|
||||
}
|
||||
writeOutput("app_store_build_id", build.id);
|
||||
console.log(
|
||||
logger.log(
|
||||
`${
|
||||
alreadyAssigned ? "Verified" : "Assigned"
|
||||
} ${version()} (${buildNumber()}) in internal TestFlight group ${groupId}.`
|
||||
);
|
||||
return build;
|
||||
};
|
||||
|
||||
const verifyCandidate = async () => {
|
||||
@@ -198,88 +250,170 @@ const verifyCandidate = async () => {
|
||||
if (build.attributes?.processingState !== "VALID") {
|
||||
throw new Error(`Candidate build is ${build.attributes?.processingState || "not valid"}`);
|
||||
}
|
||||
if (env.EXPECTED_APP_STORE_BUILD_ID && build.id !== env.EXPECTED_APP_STORE_BUILD_ID) {
|
||||
if (environment.EXPECTED_APP_STORE_BUILD_ID && build.id !== environment.EXPECTED_APP_STORE_BUILD_ID) {
|
||||
throw new Error(
|
||||
`Candidate App Store build ID ${build.id} does not match release manifest ${env.EXPECTED_APP_STORE_BUILD_ID}`
|
||||
`Candidate App Store build ID ${build.id} does not match release manifest ${environment.EXPECTED_APP_STORE_BUILD_ID}`
|
||||
);
|
||||
}
|
||||
writeOutput("app_store_build_id", build.id);
|
||||
console.log(`Verified exact candidate ${version()} (${buildNumber()}) as ${build.id}.`);
|
||||
logger.log(`Verified exact candidate ${version()} (${buildNumber()}) as ${build.id}.`);
|
||||
return build;
|
||||
};
|
||||
|
||||
const configureReleasePolicy = async () => {
|
||||
await verifyCredentials();
|
||||
const { storeVersion } = await findStoreVersion();
|
||||
if (!storeVersion) throw new Error(`App Store version ${version()} was not created`);
|
||||
if (storeVersion.attributes?.appStoreState === "READY_FOR_SALE") {
|
||||
throw new Error(`App Store version ${version()} is already released and cannot change release policy`);
|
||||
}
|
||||
await request(`/appStoreVersions/${encodeURIComponent(storeVersion.id)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "appStoreVersions",
|
||||
id: storeVersion.id,
|
||||
attributes: { releaseType: EXPECTED_RELEASE_TYPE },
|
||||
},
|
||||
}),
|
||||
});
|
||||
writeOutput("app_store_version_id", storeVersion.id);
|
||||
logger.log(`Configured App Store version ${version()} to release automatically after approval.`);
|
||||
return storeVersion.id;
|
||||
};
|
||||
|
||||
const verifyStoreVersion = async () => {
|
||||
const params = new URLSearchParams({
|
||||
"filter[app]": appId(),
|
||||
"filter[platform]": "IOS",
|
||||
"filter[versionString]": version(),
|
||||
include: "build",
|
||||
limit: "10",
|
||||
});
|
||||
const response = await request(`/appStoreVersions?${params}`);
|
||||
const storeVersion = (response?.data ?? []).find((candidate) => candidate?.attributes?.versionString === version());
|
||||
await verifyCredentials();
|
||||
const { storeVersion, included } = await findStoreVersion({ includeBuild: true });
|
||||
if (!storeVersion) throw new Error(`App Store version ${version()} was not created`);
|
||||
const buildRelationshipId = storeVersion?.relationships?.build?.data?.id;
|
||||
const includedBuild = (response?.included ?? []).find(
|
||||
const includedBuild = included.find(
|
||||
(candidate) => candidate.type === "builds" && candidate.id === buildRelationshipId
|
||||
);
|
||||
if (!includedBuild || String(includedBuild?.attributes?.version) !== buildNumber()) {
|
||||
throw new Error(`App Store version ${version()} is not attached to build ${buildNumber()}`);
|
||||
}
|
||||
if (environment.EXPECTED_APP_STORE_BUILD_ID && includedBuild.id !== environment.EXPECTED_APP_STORE_BUILD_ID) {
|
||||
throw new Error(
|
||||
`App Store version ${version()} is attached to ${includedBuild.id}, expected ${
|
||||
environment.EXPECTED_APP_STORE_BUILD_ID
|
||||
}`
|
||||
);
|
||||
}
|
||||
if (storeVersion.attributes?.releaseType !== EXPECTED_RELEASE_TYPE) {
|
||||
throw new Error(
|
||||
`App Store version ${version()} release type is ${
|
||||
storeVersion.attributes?.releaseType || "unknown"
|
||||
}, expected ${EXPECTED_RELEASE_TYPE}`
|
||||
);
|
||||
}
|
||||
writeOutput("app_store_version_id", storeVersion.id);
|
||||
writeOutput("app_store_state", storeVersion.attributes?.appStoreState || "UNKNOWN");
|
||||
console.log(
|
||||
`Verified App Store version ${version()} with exact build ${buildNumber()} in ${
|
||||
writeOutput("release_type", storeVersion.attributes.releaseType);
|
||||
logger.log(
|
||||
`Verified App Store version ${version()} with exact build ${buildNumber()} and ${EXPECTED_RELEASE_TYPE} release policy in ${
|
||||
storeVersion.attributes?.appStoreState || "unknown state"
|
||||
}.`
|
||||
);
|
||||
return storeVersion;
|
||||
};
|
||||
|
||||
const selfTestJwt = async () => {
|
||||
const original = {
|
||||
keyId: env.APP_STORE_CONNECT_API_KEY_ID,
|
||||
issuer: env.APP_STORE_CONNECT_ISSUER_ID,
|
||||
key: env.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64,
|
||||
const verifyAvailability = async () => {
|
||||
await verifyCredentials();
|
||||
const availability = await request(`/apps/${encodeURIComponent(appId())}/appAvailabilityV2`);
|
||||
const availabilityId = availability?.data?.id;
|
||||
if (!availabilityId) throw new Error("App Store availability was not configured");
|
||||
if (availability?.data?.attributes?.availableInNewTerritories !== false) {
|
||||
throw new Error("App Store availability must not automatically include new territories");
|
||||
}
|
||||
const params = new URLSearchParams({ include: "territory", limit: "200" });
|
||||
const territories = await collectPages(
|
||||
`${APP_STORE_CONNECT_V2_BASE_URL}/appAvailabilities/${encodeURIComponent(
|
||||
availabilityId
|
||||
)}/territoryAvailabilities?${params}`
|
||||
);
|
||||
const available = territories.data
|
||||
.filter((territory) => territory?.attributes?.available === true)
|
||||
.map((territory) => territory?.relationships?.territory?.data?.id)
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
if (territories.data.some((territory) => territory?.attributes?.preOrderEnabled === true)) {
|
||||
throw new Error("App Store preorder must remain disabled for version 1.0.0");
|
||||
}
|
||||
if (JSON.stringify(available) !== JSON.stringify(EXPECTED_AVAILABLE_TERRITORIES)) {
|
||||
throw new Error(
|
||||
`App Store availability is ${
|
||||
available.join(", ") || "empty"
|
||||
}, expected Denmark only (${EXPECTED_AVAILABLE_TERRITORIES.join(", ")})`
|
||||
);
|
||||
}
|
||||
writeOutput("available_territories", available.join(","));
|
||||
logger.log("Verified Denmark-only App Store availability with preorder disabled.");
|
||||
return available;
|
||||
};
|
||||
try {
|
||||
|
||||
return {
|
||||
request,
|
||||
verifyCredentials,
|
||||
allBuildsForVersion,
|
||||
allStoreVersions,
|
||||
findExactBuild,
|
||||
nextBuildNumber,
|
||||
waitForBuild,
|
||||
waitAndDistribute,
|
||||
verifyCandidate,
|
||||
configureReleasePolicy,
|
||||
verifyStoreVersion,
|
||||
verifyAvailability,
|
||||
token,
|
||||
};
|
||||
};
|
||||
|
||||
export const selfTestJwt = async () => {
|
||||
const environment = {};
|
||||
const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
||||
env.APP_STORE_CONNECT_API_KEY_ID = "TESTKEY123";
|
||||
env.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 = Buffer.from(
|
||||
environment.APP_STORE_CONNECT_API_KEY_ID = "TESTKEY123";
|
||||
environment.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 = Buffer.from(
|
||||
privateKey.export({ type: "pkcs8", format: "pem" })
|
||||
).toString("base64");
|
||||
delete env.APP_STORE_CONNECT_ISSUER_ID;
|
||||
const individual = JSON.parse(Buffer.from(token().split(".")[1], "base64url").toString("utf8"));
|
||||
if (individual.sub !== "user" || individual.iss !== undefined)
|
||||
const individual = JSON.parse(
|
||||
Buffer.from(createAppStoreConnectClient({ environment }).token().split(".")[1], "base64url").toString("utf8")
|
||||
);
|
||||
if (individual.sub !== "user" || individual.iss !== undefined) {
|
||||
throw new Error("Individual API JWT claim test failed");
|
||||
env.APP_STORE_CONNECT_ISSUER_ID = "00000000-0000-0000-0000-000000000000";
|
||||
const team = JSON.parse(Buffer.from(token().split(".")[1], "base64url").toString("utf8"));
|
||||
if (team.iss !== env.APP_STORE_CONNECT_ISSUER_ID || team.sub !== undefined)
|
||||
throw new Error("Team API JWT claim test failed");
|
||||
console.log("App Store Connect individual and team JWT claim tests passed.");
|
||||
} finally {
|
||||
if (original.keyId === undefined) delete env.APP_STORE_CONNECT_API_KEY_ID;
|
||||
else env.APP_STORE_CONNECT_API_KEY_ID = original.keyId;
|
||||
if (original.issuer === undefined) delete env.APP_STORE_CONNECT_ISSUER_ID;
|
||||
else env.APP_STORE_CONNECT_ISSUER_ID = original.issuer;
|
||||
if (original.key === undefined) delete env.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64;
|
||||
else env.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 = original.key;
|
||||
}
|
||||
environment.APP_STORE_CONNECT_ISSUER_ID = "00000000-0000-0000-0000-000000000000";
|
||||
const team = JSON.parse(
|
||||
Buffer.from(createAppStoreConnectClient({ environment }).token().split(".")[1], "base64url").toString("utf8")
|
||||
);
|
||||
if (team.iss !== environment.APP_STORE_CONNECT_ISSUER_ID || team.sub !== undefined) {
|
||||
throw new Error("Team API JWT claim test failed");
|
||||
}
|
||||
console.log("App Store Connect individual and team JWT claim tests passed.");
|
||||
};
|
||||
|
||||
export const runCli = async (command = argv[2]) => {
|
||||
const client = createAppStoreConnectClient();
|
||||
const commands = {
|
||||
"verify-credentials": verifyCredentials,
|
||||
"next-build-number": nextBuildNumber,
|
||||
"wait-and-distribute": waitAndDistribute,
|
||||
"verify-candidate": verifyCandidate,
|
||||
"verify-store-version": verifyStoreVersion,
|
||||
"verify-credentials": client.verifyCredentials,
|
||||
"next-build-number": client.nextBuildNumber,
|
||||
"wait-and-distribute": client.waitAndDistribute,
|
||||
"verify-candidate": client.verifyCandidate,
|
||||
"configure-release-policy": client.configureReleasePolicy,
|
||||
"verify-store-version": client.verifyStoreVersion,
|
||||
"verify-availability": client.verifyAvailability,
|
||||
"self-test-jwt": selfTestJwt,
|
||||
};
|
||||
|
||||
if (!commands[command]) {
|
||||
console.error(`Usage: node scripts/mobile/app-store-connect.mjs ${Object.keys(commands).join("|")}`);
|
||||
exit(2);
|
||||
throw new Error(`Usage: node scripts/mobile/app-store-connect.mjs ${Object.keys(commands).join("|")}`);
|
||||
}
|
||||
await commands[command]();
|
||||
};
|
||||
|
||||
commands[command]().catch((error) => {
|
||||
const isMain = argv[1] && resolve(argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (isMain) {
|
||||
runCli().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
exit(1);
|
||||
exit(error?.message?.startsWith("Usage:") ? 2 : 1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
APP_STORE_CONNECT_BASE_URL,
|
||||
APP_STORE_CONNECT_V2_BASE_URL,
|
||||
EXPECTED_RELEASE_TYPE,
|
||||
appStoreVersionsPath,
|
||||
createAppStoreConnectClient,
|
||||
} from "../../scripts/mobile/app-store-connect.mjs";
|
||||
|
||||
const baseEnvironment = (overrides = {}) => ({
|
||||
APP_STORE_CONNECT_APP_ID: "6792777794",
|
||||
IOS_BUNDLE_ID: "io.truckwash.app",
|
||||
IOS_MARKETING_VERSION: "1.0.0",
|
||||
IOS_BUILD_NUMBER: "8",
|
||||
EXPECTED_APP_STORE_BUILD_ID: "build-8",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const jsonResponse = (body, status = 200, headers = {}) => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: status === 200 ? "OK" : "Failure",
|
||||
headers: { get: (name) => headers[name.toLowerCase()] ?? null },
|
||||
text: async () => (body === null ? "" : JSON.stringify(body)),
|
||||
});
|
||||
|
||||
const makeClient = ({ environment = baseEnvironment(), handler, sleeps = [], outputs = [] }) => {
|
||||
const calls = [];
|
||||
const client = createAppStoreConnectClient({
|
||||
environment,
|
||||
tokenProvider: () => "private-test-token",
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return handler(url, options, calls.length);
|
||||
},
|
||||
sleepImpl: async (milliseconds) => sleeps.push(milliseconds),
|
||||
outputWriter: (key, value) => outputs.push([key, value]),
|
||||
logger: { log() {} },
|
||||
});
|
||||
return { client, calls, sleeps, outputs };
|
||||
};
|
||||
|
||||
const appResponse = () =>
|
||||
jsonResponse({ data: { type: "apps", id: "6792777794", attributes: { bundleId: "io.truckwash.app" } } });
|
||||
|
||||
test("constructs the supported app-scoped App Store version URL", () => {
|
||||
const path = appStoreVersionsPath({ appId: "6792777794", version: "1.0.0", includeBuild: true });
|
||||
assert.match(path, /^\/apps\/6792777794\/appStoreVersions\?/u);
|
||||
assert.match(path, /filter%5Bplatform%5D=IOS/u);
|
||||
assert.match(path, /filter%5BversionString%5D=1\.0\.0/u);
|
||||
assert.match(path, /include=build/u);
|
||||
assert.doesNotMatch(path, /filter%5Bapp%5D/u);
|
||||
});
|
||||
|
||||
test("allocates the next build number across paginated App Store results", async () => {
|
||||
const { client, calls, outputs } = makeClient({
|
||||
handler: (url) => {
|
||||
if (url.endsWith("/apps/6792777794")) return appResponse();
|
||||
if (url.includes("/apps/6792777794/appStoreVersions?")) {
|
||||
return jsonResponse({ data: [], links: { next: null } });
|
||||
}
|
||||
if (url.includes("/builds?") && !url.includes("cursor=next")) {
|
||||
return jsonResponse({
|
||||
data: [{ attributes: { version: "3" } }, { attributes: { version: "invalid" } }],
|
||||
links: { next: `${APP_STORE_CONNECT_BASE_URL}/builds?cursor=next` },
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/builds?cursor=next")) {
|
||||
return jsonResponse({ data: [{ attributes: { version: "7" } }], links: { next: null } });
|
||||
}
|
||||
throw new Error(`Unexpected URL ${url}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(await client.nextBuildNumber(), 8);
|
||||
assert.deepEqual(outputs, [["build_number", "8"]]);
|
||||
assert.equal(calls.filter(({ url }) => url.includes("/builds?")).length, 2);
|
||||
});
|
||||
|
||||
test("refuses to allocate another build for a released store version", async () => {
|
||||
const { client } = makeClient({
|
||||
handler: (url) => {
|
||||
if (url.endsWith("/apps/6792777794")) return appResponse();
|
||||
if (url.includes("/appStoreVersions?")) {
|
||||
return jsonResponse({
|
||||
data: [{ id: "version-1", attributes: { versionString: "1.0.0", appStoreState: "READY_FOR_SALE" } }],
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected URL ${url}`);
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(client.nextBuildNumber(), /already released/u);
|
||||
});
|
||||
|
||||
test("configures automatic release after approval on the exact store version", async () => {
|
||||
const { client, calls, outputs } = makeClient({
|
||||
handler: (url, options) => {
|
||||
if (url.endsWith("/apps/6792777794")) return appResponse();
|
||||
if (url.includes("/apps/6792777794/appStoreVersions?")) {
|
||||
return jsonResponse({
|
||||
data: [{ id: "version-1", attributes: { versionString: "1.0.0", appStoreState: "PREPARE_FOR_SUBMISSION" } }],
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/appStoreVersions/version-1") && options.method === "PATCH") {
|
||||
return jsonResponse({ data: { id: "version-1", type: "appStoreVersions" } });
|
||||
}
|
||||
throw new Error(`Unexpected URL ${url}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(await client.configureReleasePolicy(), "version-1");
|
||||
const patch = calls.find(({ options }) => options.method === "PATCH");
|
||||
assert.deepEqual(JSON.parse(patch.options.body), {
|
||||
data: {
|
||||
type: "appStoreVersions",
|
||||
id: "version-1",
|
||||
attributes: { releaseType: EXPECTED_RELEASE_TYPE },
|
||||
},
|
||||
});
|
||||
assert.deepEqual(outputs, [["app_store_version_id", "version-1"]]);
|
||||
});
|
||||
|
||||
test("reads back the exact bundle, version, build ID, build number, and release policy", async () => {
|
||||
const { client, outputs } = makeClient({
|
||||
handler: (url) => {
|
||||
if (url.endsWith("/apps/6792777794")) return appResponse();
|
||||
if (url.includes("/apps/6792777794/appStoreVersions?")) {
|
||||
return jsonResponse({
|
||||
data: [
|
||||
{
|
||||
id: "version-1",
|
||||
attributes: {
|
||||
versionString: "1.0.0",
|
||||
appStoreState: "PREPARE_FOR_SUBMISSION",
|
||||
releaseType: "AFTER_APPROVAL",
|
||||
},
|
||||
relationships: { build: { data: { type: "builds", id: "build-8" } } },
|
||||
},
|
||||
],
|
||||
included: [{ type: "builds", id: "build-8", attributes: { version: "8" } }],
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected URL ${url}`);
|
||||
},
|
||||
});
|
||||
|
||||
const storeVersion = await client.verifyStoreVersion();
|
||||
assert.equal(storeVersion.id, "version-1");
|
||||
assert.deepEqual(outputs, [
|
||||
["app_store_version_id", "version-1"],
|
||||
["app_store_state", "PREPARE_FOR_SUBMISSION"],
|
||||
["release_type", "AFTER_APPROVAL"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("fails readback when the exact build or automatic release policy drifts", async () => {
|
||||
const response = (releaseType = "MANUAL") =>
|
||||
jsonResponse({
|
||||
data: [
|
||||
{
|
||||
id: "version-1",
|
||||
attributes: { versionString: "1.0.0", appStoreState: "PREPARE_FOR_SUBMISSION", releaseType },
|
||||
relationships: { build: { data: { type: "builds", id: "build-9" } } },
|
||||
},
|
||||
],
|
||||
included: [{ type: "builds", id: "build-9", attributes: { version: "9" } }],
|
||||
});
|
||||
const { client } = makeClient({
|
||||
handler: (url) => {
|
||||
if (url.endsWith("/apps/6792777794")) return appResponse();
|
||||
if (url.includes("/appStoreVersions?")) return response();
|
||||
throw new Error(`Unexpected URL ${url}`);
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(client.verifyStoreVersion(), /not attached to build 8/u);
|
||||
|
||||
const { client: policyClient } = makeClient({
|
||||
handler: (url) => {
|
||||
if (url.endsWith("/apps/6792777794")) return appResponse();
|
||||
if (url.includes("/appStoreVersions?")) {
|
||||
return jsonResponse({
|
||||
data: [
|
||||
{
|
||||
id: "version-1",
|
||||
attributes: {
|
||||
versionString: "1.0.0",
|
||||
appStoreState: "PREPARE_FOR_SUBMISSION",
|
||||
releaseType: "MANUAL",
|
||||
},
|
||||
relationships: { build: { data: { type: "builds", id: "build-8" } } },
|
||||
},
|
||||
],
|
||||
included: [{ type: "builds", id: "build-8", attributes: { version: "8" } }],
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected URL ${url}`);
|
||||
},
|
||||
});
|
||||
await assert.rejects(policyClient.verifyStoreVersion(), /expected AFTER_APPROVAL/u);
|
||||
});
|
||||
|
||||
test("verifies Denmark-only availability with preorder and future territories disabled", async () => {
|
||||
const { client, calls, outputs } = makeClient({
|
||||
handler: (url) => {
|
||||
if (url.endsWith("/apps/6792777794")) return appResponse();
|
||||
if (url.endsWith("/apps/6792777794/appAvailabilityV2")) {
|
||||
return jsonResponse({
|
||||
data: {
|
||||
type: "appAvailabilities",
|
||||
id: "availability-1",
|
||||
attributes: { availableInNewTerritories: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.startsWith(`${APP_STORE_CONNECT_V2_BASE_URL}/appAvailabilities/availability-1/`)) {
|
||||
return jsonResponse({
|
||||
data: [
|
||||
{
|
||||
type: "territoryAvailabilities",
|
||||
id: "availability-dnk",
|
||||
attributes: { available: true, preOrderEnabled: false },
|
||||
relationships: { territory: { data: { type: "territories", id: "DNK" } } },
|
||||
},
|
||||
{
|
||||
type: "territoryAvailabilities",
|
||||
id: "availability-swe",
|
||||
attributes: { available: false, preOrderEnabled: false },
|
||||
relationships: { territory: { data: { type: "territories", id: "SWE" } } },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected URL ${url}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(await client.verifyAvailability(), ["DNK"]);
|
||||
assert.deepEqual(outputs, [["available_territories", "DNK"]]);
|
||||
assert.equal(calls.at(-1).url.includes("include=territory"), true);
|
||||
});
|
||||
|
||||
test("rejects broader availability, preorder, or automatic future territories", async () => {
|
||||
const makeAvailabilityClient = (attributes, territories = []) =>
|
||||
makeClient({
|
||||
handler: (url) => {
|
||||
if (url.endsWith("/apps/6792777794")) return appResponse();
|
||||
if (url.endsWith("/apps/6792777794/appAvailabilityV2")) {
|
||||
return jsonResponse({ data: { id: "availability-1", attributes } });
|
||||
}
|
||||
return jsonResponse({ data: territories });
|
||||
},
|
||||
}).client;
|
||||
|
||||
await assert.rejects(
|
||||
makeAvailabilityClient({ availableInNewTerritories: true }).verifyAvailability(),
|
||||
/must not automatically include new territories/u
|
||||
);
|
||||
await assert.rejects(
|
||||
makeAvailabilityClient({ availableInNewTerritories: false }, [
|
||||
{
|
||||
attributes: { available: true, preOrderEnabled: true },
|
||||
relationships: { territory: { data: { id: "DNK" } } },
|
||||
},
|
||||
]).verifyAvailability(),
|
||||
/preorder must remain disabled/u
|
||||
);
|
||||
await assert.rejects(
|
||||
makeAvailabilityClient(
|
||||
{ availableInNewTerritories: false },
|
||||
["DNK", "SWE"].map((id) => ({
|
||||
attributes: { available: true, preOrderEnabled: false },
|
||||
relationships: { territory: { data: { id } } },
|
||||
}))
|
||||
).verifyAvailability(),
|
||||
/expected Denmark only/u
|
||||
);
|
||||
});
|
||||
|
||||
test("retries transient responses without exposing the bearer token in errors", async () => {
|
||||
const { client, calls, sleeps } = makeClient({
|
||||
handler: (_url, _options, attempt) => {
|
||||
if (attempt === 1) return jsonResponse({ errors: [{ detail: "try later" }] }, 429, { "retry-after": "1" });
|
||||
return jsonResponse({ errors: [{ detail: "permission denied" }] }, 403);
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(client.request("/apps/6792777794"), (error) => {
|
||||
assert.match(error.message, /permission denied/u);
|
||||
assert.doesNotMatch(error.message, /private-test-token/u);
|
||||
return true;
|
||||
});
|
||||
assert.equal(calls.length, 2);
|
||||
assert.deepEqual(sleeps, [1_000]);
|
||||
assert.equal(calls[0].options.headers.Authorization, "Bearer private-test-token");
|
||||
});
|
||||
|
||||
test("rejects an App Store app ID that resolves to another bundle", async () => {
|
||||
const { client } = makeClient({
|
||||
handler: () => jsonResponse({ data: { attributes: { bundleId: "com.example.other" } } }),
|
||||
});
|
||||
|
||||
await assert.rejects(client.verifyCredentials(), /expected io\.truckwash\.app/u);
|
||||
});
|
||||
Reference in New Issue
Block a user