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/**"
- "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
+13 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+230 -96
View File
@@ -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);
});
}
+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);
});