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:
Jeppe B
2026-07-23 12:59:17 +00:00
committed by GitHub
parent 42352b4c2d
commit 5702d45bc6
6 changed files with 759 additions and 285 deletions
+19 -3
View File
@@ -8,6 +8,8 @@ on:
- "fastlane/**" - "fastlane/**"
- "ios/**" - "ios/**"
- "scripts/mobile/**" - "scripts/mobile/**"
- "tests/node/app-store-connect.test.mjs"
- ".github/workflows/app-store-readiness.yml"
- "Gemfile*" - "Gemfile*"
workflow_dispatch: workflow_dispatch:
@@ -20,8 +22,9 @@ concurrency:
jobs: jobs:
validate: validate:
name: App Store Readiness
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
timeout-minutes: 10 timeout-minutes: 15
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 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 echo 'Gemfile.lock is missing or stale. Download generated-fastlane-lock and commit it.' >&2
exit 1 exit 1
- name: Validate App Store metadata and available assets - name: Install the pinned Fastlane dependency graph
run: node scripts/mobile/validate-app-store.mjs 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 - name: Validate JavaScript syntax
run: | run: |
node --check scripts/mobile/validate-app-store.mjs node --check scripts/mobile/validate-app-store.mjs
node --check scripts/mobile/app-store-connect.mjs node --check scripts/mobile/app-store-connect.mjs
node --check scripts/mobile/create-ios-release-manifest.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 node scripts/mobile/app-store-connect.mjs self-test-jwt
+13 -2
View File
@@ -100,7 +100,7 @@ jobs:
NODE NODE
promote: promote:
name: Sync storefront and prepare manual review name: Sync and verify App Store candidate
needs: resolve needs: resolve
if: needs.resolve.outputs.enabled == 'true' if: needs.resolve.outputs.enabled == 'true'
runs-on: macos-15 runs-on: macos-15
@@ -153,22 +153,33 @@ jobs:
- name: Sync metadata and screenshots without App Review submission - name: Sync metadata and screenshots without App Review submission
run: bundle exec fastlane ios prepare_candidate 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 - name: Read back exact App Store candidate
id: readback id: readback
run: node scripts/mobile/app-store-connect.mjs verify-store-version 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 - name: Write candidate handoff
env: env:
APP_STORE_STATE: ${{ steps.readback.outputs.app_store_state }} APP_STORE_STATE: ${{ steps.readback.outputs.app_store_state }}
APP_STORE_VERSION_ID: ${{ steps.readback.outputs.app_store_version_id }} 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: | run: |
echo "### iOS $IOS_MARKETING_VERSION candidate prepared" >> "$GITHUB_STEP_SUMMARY" echo "### iOS $IOS_MARKETING_VERSION candidate prepared" >> "$GITHUB_STEP_SUMMARY"
echo "- Source: \`$IOS_SOURCE_SHA\`" >> "$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 "- 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 state: \`$APP_STORE_STATE\`" >> "$GITHUB_STEP_SUMMARY"
echo "- App Store version ID: \`$APP_STORE_VERSION_ID\`" >> "$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 "- [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: disabled:
name: Promotion disabled name: Promotion disabled
+26 -20
View File
@@ -1,31 +1,32 @@
# Apple App Store Release Runbook # Apple App Store Release Runbook
This is the operating runbook for the public iOS application and its signed This is the operating runbook for the public iOS application and its signed
GitHub Actions delivery. Public review submission and public release remain GitHub Actions delivery. Public review submission remains a human action in App
human actions in App Store Connect. Store Connect; the approved version releases automatically after Apple approval.
## Storefront record ## Storefront record
Create or reconcile one App Store Connect record: Create or reconcile one App Store Connect record:
| Setting | Value | | Setting | Value |
| --- | --- | | ---------------- | ------------------------------------- |
| Name | Truck Wash Kundeportal | | Name | Truck Wash |
| Bundle ID | `io.truckwash.app` | | Bundle ID | `io.truckwash.app` |
| SKU | `truckwash-ios` | | SKU | `truckwash-ios` |
| Primary language | Danish | | Primary language | Danish |
| Category | Business | | Category | Business |
| Price | Free | | Price | Free |
| Availability | Denmark only | | Availability | Denmark only |
| Support URL | `https://truckwash.io/support` | | Support URL | `https://truckwash.io/support` |
| Privacy URL | `https://truckwash.io/privacy-policy` | | Privacy URL | `https://truckwash.io/privacy-policy` |
| Marketing URL | `https://truckwash.io/` | | 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 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; the product cover physical truck-wash services. Keep iPhone and iPad enabled;
disable Apple-silicon Mac and Vision Pro compatibility until those targets have 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 The Account Holder or Admin must complete these console-only items before the
first candidate: first candidate:
@@ -95,7 +96,9 @@ Configure two GitHub environments:
Private repositories on the Team plan cannot rely on environment required Private repositories on the Team plan cannot rely on environment required
reviewers. Protect `ios-v*` creation/update/deletion with a repository ruleset reviewers. Protect `ios-v*` creation/update/deletion with a repository ruleset
limited to release managers. Manual App Review submission is the final human 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: 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, 4. `iOS App Store Candidate` locates the release manifest for that exact SHA,
verifies the exact processed App Store build, enforces complete screenshots, verifies the exact processed App Store build, enforces complete screenshots,
synchronizes Danish metadata, attaches the existing build, and reads it back. 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, 5. In App Store Connect, review the rendered product page, review account,
privacy/export/age answers, and candidate build. Submit manually. privacy/export/age answers, and candidate build. Submit manually.
6. Release the first Denmark version manually after approval. Use phased release 6. Submit version `1.0.0` for review. Apple releases it automatically after
for later updates unless there is a reason not to. 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 7. Merge the next `ios/release.json` version bump before further delivery after
Apple closes the released version to new builds. Apple closes the released version to new builds.
+1 -1
View File
@@ -46,7 +46,7 @@ platform :ios do
overwrite_screenshots: true, overwrite_screenshots: true,
force: true, force: true,
submit_for_review: false, submit_for_review: false,
automatic_release: false, automatic_release: true,
phased_release: false, phased_release: false,
run_precheck_before_submit: false, run_precheck_before_submit: false,
precheck_include_in_app_purchases: false precheck_include_in_app_purchases: false
+393 -259
View File
@@ -1,285 +1,419 @@
import { createPrivateKey, generateKeyPairSync, sign } from "node:crypto"; import { createPrivateKey, generateKeyPairSync, sign } from "node:crypto";
import { appendFileSync } from "node:fs"; import { appendFileSync } from "node:fs";
import { resolve } from "node:path";
import { argv, env, exit } from "node:process"; import { argv, env, exit } from "node:process";
import { fileURLToPath } from "node:url";
const command = argv[2]; export const APP_STORE_CONNECT_BASE_URL = "https://api.appstoreconnect.apple.com/v1";
const baseUrl = "https://api.appstoreconnect.apple.com/v1"; export const APP_STORE_CONNECT_V2_BASE_URL = "https://api.appstoreconnect.apple.com/v2";
const required = (name) => { export const EXPECTED_RELEASE_TYPE = "AFTER_APPROVAL";
const value = env[name]; export const EXPECTED_AVAILABLE_TERRITORIES = ["DNK"];
if (!value) throw new Error(`Missing ${name}`);
return value; const defaultSleep = (milliseconds) => new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds));
};
const base64url = (value) => Buffer.from(value).toString("base64url"); const base64url = (value) => Buffer.from(value).toString("base64url");
const token = () => { export const appStoreVersionsPath = ({ appId, version, includeBuild = false }) => {
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"))
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;
else payload.sub = "user";
const encodedHeader = base64url(JSON.stringify({ alg: "ES256", kid: keyId, typ: "JWT" }));
const encodedPayload = base64url(JSON.stringify(payload));
const signingInput = `${encodedHeader}.${encodedPayload}`;
const signature = sign("sha256", Buffer.from(signingInput), {
key: createPrivateKey(key),
dsaEncoding: "ieee-p1363",
});
return `${signingInput}.${base64url(signature)}`;
};
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
const request = async (path, options = {}, attempt = 1) => {
const response = await fetch(path.startsWith("http") ? path : `${baseUrl}${path}`, {
...options,
headers: {
Authorization: `Bearer ${token()}`,
"Content-Type": "application/json",
...(options.headers ?? {}),
},
});
const text = await response.text();
let body = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = { raw: text };
}
if (!response.ok) {
if ((response.status === 429 || response.status >= 500) && attempt < 5) {
await sleep(Math.min(30_000, 2 ** attempt * 1_000));
return request(path, options, attempt + 1);
}
const detail =
body?.errors
?.map((error) => error.detail || error.title)
.filter(Boolean)
.join("; ") ||
body?.raw ||
response.statusText;
throw new Error(`App Store Connect ${options.method ?? "GET"} ${path} failed (${response.status}): ${detail}`);
}
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 verifyCredentials = async () => {
const app = await request(`/apps/${encodeURIComponent(appId())}`);
const actualBundleId = app?.data?.attributes?.bundleId;
if (actualBundleId !== bundleId()) {
throw new Error(
`APP_STORE_CONNECT_APP_ID resolves to ${actualBundleId || "an unknown bundle"}, expected ${bundleId()}`
);
}
console.log(`Authenticated to App Store Connect for ${actualBundleId}.`);
};
const allBuildsForVersion = async () => {
const params = new URLSearchParams({ const params = new URLSearchParams({
"filter[app]": appId(),
"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 findExactBuild = async () => {
const builds = await allBuildsForVersion();
return builds.find((build) => String(build?.attributes?.version) === buildNumber()) ?? null;
};
const nextBuildNumber = async () => {
await verifyCredentials();
const storeVersionParams = new URLSearchParams({
"filter[app]": appId(),
"filter[platform]": "IOS", "filter[platform]": "IOS",
"filter[versionString]": version(), "filter[versionString]": version,
limit: "10", limit: "10",
}); });
const storeVersions = await request(`/appStoreVersions?${storeVersionParams}`); if (includeBuild) params.set("include", "build");
const storeVersion = (storeVersions?.data ?? []).find( return `/apps/${encodeURIComponent(appId)}/appStoreVersions?${params}`;
(candidate) => candidate?.attributes?.versionString === version()
);
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`
);
}
const builds = await allBuildsForVersion();
const numbers = builds
.map((build) => Number.parseInt(build?.attributes?.version, 10))
.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}.`);
}; };
const waitForBuild = async () => { export const createAppStoreConnectClient = ({
const deadline = Date.now() + Number(env.APP_STORE_PROCESSING_TIMEOUT_SECONDS || 3_600) * 1_000; environment = env,
let build = null; fetchImpl = globalThis.fetch,
while (Date.now() < deadline) { sleepImpl = defaultSleep,
build = await findExactBuild(); now = () => Date.now(),
const state = build?.attributes?.processingState; logger = console,
if (state === "VALID") return build; tokenProvider,
if (["FAILED", "INVALID"].includes(state)) throw new Error(`App Store Connect processing ended in ${state}`); outputWriter,
console.log( } = {}) => {
build ? `Build ${buildNumber()} is ${state || "processing"}.` : `Waiting for build ${buildNumber()} to appear.` const required = (name) => {
); const value = environment[name];
await sleep(30_000); if (!value) throw new Error(`Missing ${name}`);
} return value;
throw new Error(`Timed out waiting for ${version()} (${buildNumber()}) to process`); };
};
const waitAndDistribute = async () => { const appId = () => required("APP_STORE_CONNECT_APP_ID");
const build = await waitForBuild(); const bundleId = () => environment.IOS_BUNDLE_ID || "io.truckwash.app";
const groupId = required("TESTFLIGHT_INTERNAL_GROUP_ID"); const version = () => required("IOS_MARKETING_VERSION");
const localizationParams = new URLSearchParams({ "filter[build]": build.id, "filter[locale]": "da-DK" }); const buildNumber = () => required("IOS_BUILD_NUMBER");
const localizations = await request(`/betaBuildLocalizations?${localizationParams}`);
const existingLocalization = (localizations?.data ?? [])[0]; const token = () => {
const whatsNew = env.TESTFLIGHT_WHAT_TO_TEST || `Automatisk intern build ${version()} (${buildNumber()}).`; if (tokenProvider) return tokenProvider();
if (existingLocalization) { const keyId = required("APP_STORE_CONNECT_API_KEY_ID");
await request(`/betaBuildLocalizations/${encodeURIComponent(existingLocalization.id)}`, { const key = Buffer.from(required("APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64"), "base64").toString("utf8");
if (!key.includes("PRIVATE KEY")) {
throw new Error("App Store Connect API key is not a base64-encoded .p8 private key");
}
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));
const signingInput = `${encodedHeader}.${encodedPayload}`;
const signature = sign("sha256", Buffer.from(signingInput), {
key: createPrivateKey(key),
dsaEncoding: "ieee-p1363",
});
return `${signingInput}.${base64url(signature)}`;
};
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 fetchImpl(path.startsWith("http") ? path : `${APP_STORE_CONNECT_BASE_URL}${path}`, {
...options,
headers: {
Authorization: `Bearer ${token()}`,
"Content-Type": "application/json",
...(options.headers ?? {}),
},
});
const text = await response.text();
let body = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = { raw: text };
}
if (!response.ok) {
if ((response.status === 429 || response.status >= 500) && attempt < 5) {
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 =
body?.errors
?.map((error) => error.detail || error.title)
.filter(Boolean)
.join("; ") ||
body?.raw ||
response.statusText;
throw new Error(`App Store Connect ${options.method ?? "GET"} ${path} failed (${response.status}): ${detail}`);
}
return body;
};
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 () => {
const app = await request(`/apps/${encodeURIComponent(appId())}`);
const actualBundleId = app?.data?.attributes?.bundleId;
if (actualBundleId !== bundleId()) {
throw new Error(
`APP_STORE_CONNECT_APP_ID resolves to ${actualBundleId || "an unknown bundle"}, expected ${bundleId()}`
);
}
logger.log(`Authenticated to App Store Connect for ${actualBundleId}.`);
return app.data;
};
const allBuildsForVersion = async () => {
const params = new URLSearchParams({
"filter[app]": appId(),
"filter[preReleaseVersion.version]": version(),
limit: "200",
});
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 () => {
const builds = await allBuildsForVersion();
return builds.find((build) => String(build?.attributes?.version) === buildNumber()) ?? null;
};
const nextBuildNumber = async () => {
await verifyCredentials();
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`
);
}
const builds = await allBuildsForVersion();
const numbers = builds
.map((build) => Number.parseInt(build?.attributes?.version, 10))
.filter((number) => Number.isSafeInteger(number) && number > 0);
const next = (numbers.length > 0 ? Math.max(...numbers) : 0) + 1;
writeOutput("build_number", next);
logger.log(`Next App Store Connect build for ${version()} is ${next}.`);
return next;
};
const waitForBuild = async () => {
const deadline = now() + Number(environment.APP_STORE_PROCESSING_TIMEOUT_SECONDS || 3_600) * 1_000;
let build = null;
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}`);
}
logger.log(
build ? `Build ${buildNumber()} is ${state || "processing"}.` : `Waiting for build ${buildNumber()} to appear.`
);
await sleepImpl(30_000);
}
throw new Error(`Timed out waiting for ${version()} (${buildNumber()}) to process`);
};
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 localizations = await request(`/betaBuildLocalizations?${localizationParams}`);
const existingLocalization = (localizations?.data ?? [])[0];
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 },
},
}),
});
} else {
await request("/betaBuildLocalizations", {
method: "POST",
body: JSON.stringify({
data: {
type: "betaBuildLocalizations",
attributes: { locale: "da-DK", whatsNew },
relationships: { build: { data: { type: "builds", id: build.id } } },
},
}),
});
}
const relationship = await request(`/betaGroups/${encodeURIComponent(groupId)}/relationships/builds?limit=200`);
const alreadyAssigned = (relationship?.data ?? []).some((candidate) => candidate.id === build.id);
if (!alreadyAssigned) {
await request(`/betaGroups/${encodeURIComponent(groupId)}/relationships/builds`, {
method: "POST",
body: JSON.stringify({ data: [{ type: "builds", id: build.id }] }),
});
}
writeOutput("app_store_build_id", build.id);
logger.log(
`${
alreadyAssigned ? "Verified" : "Assigned"
} ${version()} (${buildNumber()}) in internal TestFlight group ${groupId}.`
);
return build;
};
const verifyCandidate = async () => {
await verifyCredentials();
const build = await findExactBuild();
if (!build) throw new Error(`App Store Connect does not contain ${version()} (${buildNumber()})`);
if (build.attributes?.processingState !== "VALID") {
throw new Error(`Candidate build is ${build.attributes?.processingState || "not valid"}`);
}
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 ${environment.EXPECTED_APP_STORE_BUILD_ID}`
);
}
writeOutput("app_store_build_id", 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", method: "PATCH",
body: JSON.stringify({
data: { type: "betaBuildLocalizations", id: existingLocalization.id, attributes: { whatsNew } },
}),
});
} else {
await request("/betaBuildLocalizations", {
method: "POST",
body: JSON.stringify({ body: JSON.stringify({
data: { data: {
type: "betaBuildLocalizations", type: "appStoreVersions",
attributes: { locale: "da-DK", whatsNew }, id: storeVersion.id,
relationships: { build: { data: { type: "builds", id: build.id } } }, attributes: { releaseType: EXPECTED_RELEASE_TYPE },
}, },
}), }),
}); });
} writeOutput("app_store_version_id", storeVersion.id);
const relationship = await request(`/betaGroups/${encodeURIComponent(groupId)}/relationships/builds?limit=200`); logger.log(`Configured App Store version ${version()} to release automatically after approval.`);
const alreadyAssigned = (relationship?.data ?? []).some((candidate) => candidate.id === build.id); return storeVersion.id;
if (!alreadyAssigned) {
await request(`/betaGroups/${encodeURIComponent(groupId)}/relationships/builds`, {
method: "POST",
body: JSON.stringify({ data: [{ type: "builds", id: build.id }] }),
});
}
writeOutput("app_store_build_id", build.id);
console.log(
`${
alreadyAssigned ? "Verified" : "Assigned"
} ${version()} (${buildNumber()}) in internal TestFlight group ${groupId}.`
);
};
const verifyCandidate = async () => {
await verifyCredentials();
const build = await findExactBuild();
if (!build) throw new Error(`App Store Connect does not contain ${version()} (${buildNumber()})`);
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) {
throw new Error(
`Candidate App Store build ID ${build.id} does not match release manifest ${env.EXPECTED_APP_STORE_BUILD_ID}`
);
}
writeOutput("app_store_build_id", build.id);
console.log(`Verified exact candidate ${version()} (${buildNumber()}) as ${build.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());
if (!storeVersion) throw new Error(`App Store version ${version()} was not created`);
const buildRelationshipId = storeVersion?.relationships?.build?.data?.id;
const includedBuild = (response?.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()}`);
}
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 ${
storeVersion.attributes?.appStoreState || "unknown state"
}.`
);
};
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,
}; };
try {
const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" }); const verifyStoreVersion = async () => {
env.APP_STORE_CONNECT_API_KEY_ID = "TESTKEY123"; await verifyCredentials();
env.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 = Buffer.from( const { storeVersion, included } = await findStoreVersion({ includeBuild: true });
privateKey.export({ type: "pkcs8", format: "pem" }) if (!storeVersion) throw new Error(`App Store version ${version()} was not created`);
).toString("base64"); const buildRelationshipId = storeVersion?.relationships?.build?.data?.id;
delete env.APP_STORE_CONNECT_ISSUER_ID; const includedBuild = included.find(
const individual = JSON.parse(Buffer.from(token().split(".")[1], "base64url").toString("utf8")); (candidate) => candidate.type === "builds" && candidate.id === buildRelationshipId
if (individual.sub !== "user" || individual.iss !== undefined) );
throw new Error("Individual API JWT claim test failed"); if (!includedBuild || String(includedBuild?.attributes?.version) !== buildNumber()) {
env.APP_STORE_CONNECT_ISSUER_ID = "00000000-0000-0000-0000-000000000000"; throw new Error(`App Store version ${version()} is not attached to build ${buildNumber()}`);
const team = JSON.parse(Buffer.from(token().split(".")[1], "base64url").toString("utf8")); }
if (team.iss !== env.APP_STORE_CONNECT_ISSUER_ID || team.sub !== undefined) if (environment.EXPECTED_APP_STORE_BUILD_ID && includedBuild.id !== environment.EXPECTED_APP_STORE_BUILD_ID) {
throw new Error("Team API JWT claim test failed"); throw new Error(
console.log("App Store Connect individual and team JWT claim tests passed."); `App Store version ${version()} is attached to ${includedBuild.id}, expected ${
} finally { environment.EXPECTED_APP_STORE_BUILD_ID
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 (storeVersion.attributes?.releaseType !== EXPECTED_RELEASE_TYPE) {
if (original.key === undefined) delete env.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64; throw new Error(
else env.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 = original.key; `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");
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 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;
};
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" });
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");
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");
} }
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.");
}; };
const commands = { export const runCli = async (command = argv[2]) => {
"verify-credentials": verifyCredentials, const client = createAppStoreConnectClient();
"next-build-number": nextBuildNumber, const commands = {
"wait-and-distribute": waitAndDistribute, "verify-credentials": client.verifyCredentials,
"verify-candidate": verifyCandidate, "next-build-number": client.nextBuildNumber,
"verify-store-version": verifyStoreVersion, "wait-and-distribute": client.waitAndDistribute,
"self-test-jwt": selfTestJwt, "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]) {
throw new Error(`Usage: node scripts/mobile/app-store-connect.mjs ${Object.keys(commands).join("|")}`);
}
await commands[command]();
}; };
if (!commands[command]) { const isMain = argv[1] && resolve(argv[1]) === fileURLToPath(import.meta.url);
console.error(`Usage: node scripts/mobile/app-store-connect.mjs ${Object.keys(commands).join("|")}`); if (isMain) {
exit(2); runCli().catch((error) => {
console.error(error instanceof Error ? error.message : error);
exit(error?.message?.startsWith("Usage:") ? 2 : 1);
});
} }
commands[command]().catch((error) => {
console.error(error instanceof Error ? error.message : error);
exit(1);
});
+307
View File
@@ -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);
});