Attach the uploaded iOS build to its App Store version with the changelog as its release notes

This commit is contained in:
Jon Staab 2026-09-24 14:25:47 -07:00
parent ee93bad6b4
commit 6ae38287cb
3 changed files with 214 additions and 46 deletions

View file

@ -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 |

View file

@ -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})
}
},
}
}

View file

@ -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}`)
}
},
}