diff --git a/README.md b/README.md index 49ab5aee..cf041325 100644 --- a/README.md +++ b/README.md @@ -210,8 +210,8 @@ step that fails stops the run and prints the command to pick up from there. | --- | --- | --- | | `web` | local | `scripts/build/app.sh`: web bundle, `cap sync`, generated icons and splash screens | | `apk` | local | `assembleRelease` signed with the distribution key, renamed to the path in `zapstore.yaml` | -| `play` | local | `bundleRelease` signed with the upload key, uploaded to a Play track as a draft | -| `ios` | local | `cap build ios` to an archive and IPA, uploaded with `altool` | +| `play` | local | `bundleRelease` signed with the upload key, uploaded to a Play track as a draft (or `PLAY_STATUS`) | +| `ios` | local | `cap build ios` to an archive and IPA, uploaded with `altool`, then attached to the App Store version with its release notes once processed | | `fdroid` | ci | reruns F-Droid's own preparation and build against the tag in a throwaway worktree | | `desktop` | both | `package:desktop:*` for this OS: signed and notarized macOS from a Mac, Linux and Windows from Linux | | `gitea` | both | creates a draft release from the changelog, attaches what this run built, and publishes it once every platform is there | diff --git a/scripts/release/lib/appstore.mjs b/scripts/release/lib/appstore.mjs index da6fa231..127d1eca 100644 --- a/scripts/release/lib/appstore.mjs +++ b/scripts/release/lib/appstore.mjs @@ -1,34 +1,88 @@ -import {copyFile, mkdir, mkdtemp, rm} from "node:fs/promises" +import {sign} from "node:crypto" +import {copyFile, mkdir, mkdtemp, readFile, rm} from "node:fs/promises" import {tmpdir} from "node:os" import {join} from "node:path" +import {MINUTE, int, now} from "@welshman/lib" import {run} from "./shell.mjs" -export const uploadToAppStore = async ({ipa, keyId, issuerId, keyPath}) => { - // altool only reads the api key from a `private_keys` directory beside its working directory or - // under $HOME, so give it a private one rather than leaving the key in the repo or home dir. - const directory = await mkdtemp(join(tmpdir(), "flotilla-appstore-")) +const encode = value => Buffer.from(JSON.stringify(value)).toString("base64url") - try { - await mkdir(join(directory, "private_keys")) - await copyFile(keyPath, join(directory, "private_keys", `AuthKey_${keyId}.p8`)) +export const appStore = async ({keyId, issuerId, keyPath}) => { + const key = await readFile(keyPath, "utf-8") - await run( - "xcrun", - [ - "altool", - "--upload-app", - "-f", - ipa, - "-t", - "ios", - "--apiKey", - keyId, - "--apiIssuer", - issuerId, - ], - {cwd: directory}, - ) - } finally { - await rm(directory, {recursive: true, force: true}) + // Tokens live at most 20 minutes and waiting on a build can take longer, so each call signs anew + const token = () => { + const header = encode({alg: "ES256", kid: keyId, typ: "JWT"}) + const payload = encode({ + iss: issuerId, + iat: now(), + exp: now() + int(15, MINUTE), + aud: "appstoreconnect-v1", + }) + const signature = sign("sha256", Buffer.from(`${header}.${payload}`), { + key, + dsaEncoding: "ieee-p1363", + }) + + return `${header}.${payload}.${signature.toString("base64url")}` + } + + const api = async (method, path, body) => { + const response = await fetch(`https://api.appstoreconnect.apple.com${path}`, { + method, + headers: { + Authorization: `Bearer ${token()}`, + ...(body ? {"Content-Type": "application/json"} : {}), + }, + body: body && JSON.stringify(body), + }) + + if (response.status === 204) { + return undefined + } + + const result = await response.json() + + if (!response.ok) { + const details = result.errors?.map(error => error.detail ?? error.title).join("; ") + + throw new Error(`App Store Connect: ${details ?? JSON.stringify(result)}`) + } + + return result + } + + return { + api, + + upload: async ipa => { + // altool only reads the api key from a `private_keys` directory beside its working directory + // or under $HOME, so give it a private one rather than leaving the key in the repo or home dir + const directory = await mkdtemp(join(tmpdir(), "flotilla-appstore-")) + + try { + await mkdir(join(directory, "private_keys")) + await copyFile(keyPath, join(directory, "private_keys", `AuthKey_${keyId}.p8`)) + + await run( + "xcrun", + [ + "altool", + "--upload-app", + "-f", + ipa, + "-t", + "ios", + "--apiKey", + keyId, + "--apiIssuer", + issuerId, + ], + {cwd: directory}, + ) + } finally { + await rm(directory, {recursive: true, force: true}) + } + }, } } diff --git a/scripts/release/steps/ios.mjs b/scripts/release/steps/ios.mjs index 80294c62..6549f2c8 100644 --- a/scripts/release/steps/ios.mjs +++ b/scripts/release/steps/ios.mjs @@ -1,12 +1,21 @@ -import {readdir} from "node:fs/promises" +import {readFile, readdir} from "node:fs/promises" import {join, resolve} from "node:path" -import {uploadToAppStore} from "../lib/appstore.mjs" -import {followUps, missingEnv, root} from "../lib/context.mjs" +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" import {run} from "../lib/shell.mjs" +const editableStates = [ + "PREPARE_FOR_SUBMISSION", + "DEVELOPER_REJECTED", + "REJECTED", + "METADATA_REJECTED", + "INVALID_BINARY", +] + export default { name: "ios", - title: "Archive the iOS app and upload it to App Store Connect", + title: "Upload the iOS build and attach it to its App Store version", missing: () => [ ...(process.platform === "darwin" ? [] : ["macOS with Xcode"]), ...missingEnv("ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_KEY_PATH"), @@ -17,24 +26,129 @@ export default { "the repo, and set ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_PATH in .env.local.", ], run: async () => { - 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 uploadToAppStore({ - ipa: join(directory, ipa), + 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({ keyId: process.env.ASC_KEY_ID, issuerId: process.env.ASC_ISSUER_ID, keyPath: resolve(root, process.env.ASC_KEY_PATH), }) + const query = params => new URLSearchParams(params).toString() + const [app] = (await store.api("GET", `/v1/apps?${query({"filter[bundleId]": appId})}`)).data - followUps.push( - "App Store Connect: once the build finishes processing, add it to a version and submit for review at https://appstoreconnect.apple.com", - ) + 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}`) + } }, }