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
+393 -259
View File
@@ -1,285 +1,419 @@
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";
const required = (name) => {
const value = env[name];
if (!value) throw new Error(`Missing ${name}`);
return value;
};
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");
const token = () => {
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 () => {
export const appStoreVersionsPath = ({ appId, version, includeBuild = false }) => {
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[versionString]": version(),
"filter[versionString]": version,
limit: "10",
});
const storeVersions = await request(`/appStoreVersions?${storeVersionParams}`);
const storeVersion = (storeVersions?.data ?? []).find(
(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}.`);
if (includeBuild) params.set("include", "build");
return `/apps/${encodeURIComponent(appId)}/appStoreVersions?${params}`;
};
const waitForBuild = async () => {
const deadline = Date.now() + Number(env.APP_STORE_PROCESSING_TIMEOUT_SECONDS || 3_600) * 1_000;
let build = null;
while (Date.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(
build ? `Build ${buildNumber()} is ${state || "processing"}.` : `Waiting for build ${buildNumber()} to appear.`
);
await sleep(30_000);
}
throw new Error(`Timed out waiting for ${version()} (${buildNumber()}) to process`);
};
export const createAppStoreConnectClient = ({
environment = env,
fetchImpl = globalThis.fetch,
sleepImpl = defaultSleep,
now = () => Date.now(),
logger = console,
tokenProvider,
outputWriter,
} = {}) => {
const required = (name) => {
const value = environment[name];
if (!value) throw new Error(`Missing ${name}`);
return value;
};
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 = env.TESTFLIGHT_WHAT_TO_TEST || `Automatisk intern build ${version()} (${buildNumber()}).`;
if (existingLocalization) {
await request(`/betaBuildLocalizations/${encodeURIComponent(existingLocalization.id)}`, {
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")) {
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",
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 } } },
type: "appStoreVersions",
id: storeVersion.id,
attributes: { releaseType: EXPECTED_RELEASE_TYPE },
},
}),
});
}
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);
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,
writeOutput("app_store_version_id", storeVersion.id);
logger.log(`Configured App Store version ${version()} to release automatically after approval.`);
return storeVersion.id;
};
try {
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(
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)
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;
const verifyStoreVersion = async () => {
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 = 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");
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 = {
"verify-credentials": verifyCredentials,
"next-build-number": nextBuildNumber,
"wait-and-distribute": waitAndDistribute,
"verify-candidate": verifyCandidate,
"verify-store-version": verifyStoreVersion,
"self-test-jwt": selfTestJwt,
export const runCli = async (command = argv[2]) => {
const client = createAppStoreConnectClient();
const commands = {
"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]) {
throw new Error(`Usage: node scripts/mobile/app-store-connect.mjs ${Object.keys(commands).join("|")}`);
}
await commands[command]();
};
if (!commands[command]) {
console.error(`Usage: node scripts/mobile/app-store-connect.mjs ${Object.keys(commands).join("|")}`);
exit(2);
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(error?.message?.startsWith("Usage:") ? 2 : 1);
});
}
commands[command]().catch((error) => {
console.error(error instanceof Error ? error.message : error);
exit(1);
});