2026-09-24 21:25:47 +00:00
|
|
|
import {readFile, readdir} from "node:fs/promises"
|
2026-09-24 02:47:46 +00:00
|
|
|
import {join, resolve} from "node:path"
|
2026-09-24 21:25:47 +00:00
|
|
|
import {HOUR, ago, ms, now, sleep} from "@welshman/lib"
|
|
|
|
|
import {appStore} from "../lib/appstore.mjs"
|
|
|
|
|
import {followUps, missingEnv, notes, root, version} from "../lib/context.mjs"
|
2026-09-24 02:47:46 +00:00
|
|
|
import {run} from "../lib/shell.mjs"
|
|
|
|
|
|
2026-09-24 21:25:47 +00:00
|
|
|
const editableStates = [
|
|
|
|
|
"PREPARE_FOR_SUBMISSION",
|
|
|
|
|
"DEVELOPER_REJECTED",
|
|
|
|
|
"REJECTED",
|
|
|
|
|
"METADATA_REJECTED",
|
|
|
|
|
"INVALID_BINARY",
|
|
|
|
|
]
|
|
|
|
|
|
2026-09-24 02:47:46 +00:00
|
|
|
export default {
|
|
|
|
|
name: "ios",
|
2026-09-24 21:25:47 +00:00
|
|
|
title: "Upload the iOS build and attach it to its App Store version",
|
2026-09-24 02:47:46 +00:00
|
|
|
missing: () => [
|
|
|
|
|
...(process.platform === "darwin" ? [] : ["macOS with Xcode"]),
|
|
|
|
|
...missingEnv("ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_KEY_PATH"),
|
|
|
|
|
],
|
|
|
|
|
setup: [
|
|
|
|
|
"App Store Connect -> Users and Access -> Integrations -> App Store Connect API, generate a",
|
|
|
|
|
"team key with the App Manager role. Download the .p8 (only offered once), keep it outside",
|
|
|
|
|
"the repo, and set ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_PATH in .env.local.",
|
|
|
|
|
],
|
|
|
|
|
run: async () => {
|
2026-09-24 21:25:47 +00:00
|
|
|
const {appId} = JSON.parse(
|
|
|
|
|
await readFile(join(root, "ios/App/App/capacitor.config.json"), "utf-8"),
|
|
|
|
|
)
|
|
|
|
|
const pbxproj = await readFile(join(root, "ios/App/App.xcodeproj/project.pbxproj"), "utf-8")
|
|
|
|
|
const buildNumber = pbxproj.match(/CURRENT_PROJECT_VERSION = (\d+);/)[1]
|
|
|
|
|
const store = await appStore({
|
2026-09-24 02:47:46 +00:00
|
|
|
keyId: process.env.ASC_KEY_ID,
|
|
|
|
|
issuerId: process.env.ASC_ISSUER_ID,
|
|
|
|
|
keyPath: resolve(root, process.env.ASC_KEY_PATH),
|
|
|
|
|
})
|
2026-09-24 21:25:47 +00:00
|
|
|
const query = params => new URLSearchParams(params).toString()
|
|
|
|
|
const [app] = (await store.api("GET", `/v1/apps?${query({"filter[bundleId]": appId})}`)).data
|
2026-09-24 02:47:46 +00:00
|
|
|
|
2026-09-24 21:25:47 +00:00
|
|
|
if (!app) {
|
|
|
|
|
throw new Error(`App Store Connect has no app with the bundle id ${appId}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const findBuild = async () =>
|
|
|
|
|
(
|
|
|
|
|
await store.api(
|
|
|
|
|
"GET",
|
|
|
|
|
`/v1/builds?${query({
|
|
|
|
|
"filter[app]": app.id,
|
|
|
|
|
"filter[version]": buildNumber,
|
|
|
|
|
"filter[preReleaseVersion.version]": version,
|
|
|
|
|
})}`,
|
|
|
|
|
)
|
|
|
|
|
).data[0]
|
|
|
|
|
|
|
|
|
|
// App Store Connect never takes a build number twice, so a rerun uses the build already there
|
|
|
|
|
if (!(await findBuild())) {
|
|
|
|
|
await run("npx", ["cap", "build", "ios"], {cwd: root})
|
|
|
|
|
|
|
|
|
|
const directory = join(root, "ios/App/output")
|
|
|
|
|
const ipa = (await readdir(directory)).find(file => file.endsWith(".ipa"))
|
|
|
|
|
|
|
|
|
|
if (!ipa) {
|
|
|
|
|
throw new Error(`No ipa was exported to ${directory}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await store.upload(join(directory, ipa))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const started = now()
|
|
|
|
|
let build = await findBuild()
|
|
|
|
|
|
|
|
|
|
while (build?.attributes.processingState !== "VALID") {
|
|
|
|
|
if (["INVALID", "FAILED"].includes(build?.attributes.processingState)) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Build ${buildNumber} failed processing; bump CURRENT_PROJECT_VERSION and run pnpm release:local ios`,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (started < ago(HOUR)) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Build ${buildNumber} is still processing after an hour; rerun this step later`,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`Waiting for build ${buildNumber} to finish processing`)
|
|
|
|
|
await sleep(ms(30))
|
|
|
|
|
build = await findBuild()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let [appStoreVersion] = (
|
|
|
|
|
await store.api(
|
|
|
|
|
"GET",
|
|
|
|
|
`/v1/apps/${app.id}/appStoreVersions?${query({
|
|
|
|
|
"filter[platform]": "IOS",
|
|
|
|
|
"filter[versionString]": version,
|
|
|
|
|
})}`,
|
|
|
|
|
)
|
|
|
|
|
).data
|
|
|
|
|
|
|
|
|
|
if (!appStoreVersion) {
|
|
|
|
|
appStoreVersion = (
|
|
|
|
|
await store.api("POST", "/v1/appStoreVersions", {
|
|
|
|
|
data: {
|
|
|
|
|
type: "appStoreVersions",
|
|
|
|
|
attributes: {platform: "IOS", versionString: version},
|
|
|
|
|
relationships: {app: {data: {type: "apps", id: app.id}}},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
).data
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const {appVersionState} = appStoreVersion.attributes
|
|
|
|
|
|
|
|
|
|
if (editableStates.includes(appVersionState)) {
|
|
|
|
|
const localizations = (
|
|
|
|
|
await store.api(
|
|
|
|
|
"GET",
|
|
|
|
|
`/v1/appStoreVersions/${appStoreVersion.id}/appStoreVersionLocalizations`,
|
|
|
|
|
)
|
|
|
|
|
).data
|
|
|
|
|
|
|
|
|
|
for (const localization of localizations) {
|
|
|
|
|
await store.api("PATCH", `/v1/appStoreVersionLocalizations/${localization.id}`, {
|
|
|
|
|
data: {
|
|
|
|
|
type: "appStoreVersionLocalizations",
|
|
|
|
|
id: localization.id,
|
|
|
|
|
attributes: {whatsNew: notes.slice(0, 4000)},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await store.api("PATCH", `/v1/appStoreVersions/${appStoreVersion.id}/relationships/build`, {
|
|
|
|
|
data: {type: "builds", id: build.id},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
followUps.push(
|
|
|
|
|
`App Store Connect: ${version} has build ${buildNumber} and its release notes, submit it for review at https://appstoreconnect.apple.com`,
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
const attached = await store.api("GET", `/v1/appStoreVersions/${appStoreVersion.id}/build`)
|
|
|
|
|
|
|
|
|
|
if (attached.data?.id !== build.id) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`${version} is ${appVersionState} with a build other than ${buildNumber}; change it in App Store Connect`,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`${version} is already ${appVersionState} with build ${buildNumber}`)
|
|
|
|
|
}
|
2026-09-24 02:47:46 +00:00
|
|
|
},
|
|
|
|
|
}
|