Attach the uploaded iOS build to its App Store version with the changelog as its release notes
This commit is contained in:
parent
ee93bad6b4
commit
6ae38287cb
3 changed files with 214 additions and 46 deletions
|
|
@ -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 |
|
| `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` |
|
| `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 |
|
| `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` |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `gitea` | both | creates a draft release from the changelog, attaches what this run built, and publishes it once every platform is there |
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,63 @@
|
||||||
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 {tmpdir} from "node:os"
|
||||||
import {join} from "node:path"
|
import {join} from "node:path"
|
||||||
|
import {MINUTE, int, now} from "@welshman/lib"
|
||||||
import {run} from "./shell.mjs"
|
import {run} from "./shell.mjs"
|
||||||
|
|
||||||
export const uploadToAppStore = async ({ipa, keyId, issuerId, keyPath}) => {
|
const encode = value => Buffer.from(JSON.stringify(value)).toString("base64url")
|
||||||
// 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.
|
export const appStore = async ({keyId, issuerId, keyPath}) => {
|
||||||
|
const key = await readFile(keyPath, "utf-8")
|
||||||
|
|
||||||
|
// 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-"))
|
const directory = await mkdtemp(join(tmpdir(), "flotilla-appstore-"))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -31,4 +83,6 @@ export const uploadToAppStore = async ({ipa, keyId, issuerId, keyPath}) => {
|
||||||
} finally {
|
} finally {
|
||||||
await rm(directory, {recursive: true, force: true})
|
await rm(directory, {recursive: true, force: true})
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,21 @@
|
||||||
import {readdir} from "node:fs/promises"
|
import {readFile, readdir} from "node:fs/promises"
|
||||||
import {join, resolve} from "node:path"
|
import {join, resolve} from "node:path"
|
||||||
import {uploadToAppStore} from "../lib/appstore.mjs"
|
import {HOUR, ago, ms, now, sleep} from "@welshman/lib"
|
||||||
import {followUps, missingEnv, root} from "../lib/context.mjs"
|
import {appStore} from "../lib/appstore.mjs"
|
||||||
|
import {followUps, missingEnv, notes, root, version} from "../lib/context.mjs"
|
||||||
import {run} from "../lib/shell.mjs"
|
import {run} from "../lib/shell.mjs"
|
||||||
|
|
||||||
|
const editableStates = [
|
||||||
|
"PREPARE_FOR_SUBMISSION",
|
||||||
|
"DEVELOPER_REJECTED",
|
||||||
|
"REJECTED",
|
||||||
|
"METADATA_REJECTED",
|
||||||
|
"INVALID_BINARY",
|
||||||
|
]
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "ios",
|
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: () => [
|
missing: () => [
|
||||||
...(process.platform === "darwin" ? [] : ["macOS with Xcode"]),
|
...(process.platform === "darwin" ? [] : ["macOS with Xcode"]),
|
||||||
...missingEnv("ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_KEY_PATH"),
|
...missingEnv("ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_KEY_PATH"),
|
||||||
|
|
@ -17,6 +26,37 @@ export default {
|
||||||
"the repo, and set ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_PATH in .env.local.",
|
"the repo, and set ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_PATH in .env.local.",
|
||||||
],
|
],
|
||||||
run: async () => {
|
run: async () => {
|
||||||
|
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
|
||||||
|
|
||||||
|
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})
|
await run("npx", ["cap", "build", "ios"], {cwd: root})
|
||||||
|
|
||||||
const directory = join(root, "ios/App/output")
|
const directory = join(root, "ios/App/output")
|
||||||
|
|
@ -26,15 +66,89 @@ export default {
|
||||||
throw new Error(`No ipa was exported to ${directory}`)
|
throw new Error(`No ipa was exported to ${directory}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
await uploadToAppStore({
|
await store.upload(join(directory, ipa))
|
||||||
ipa: join(directory, ipa),
|
}
|
||||||
keyId: process.env.ASC_KEY_ID,
|
|
||||||
issuerId: process.env.ASC_ISSUER_ID,
|
const started = now()
|
||||||
keyPath: resolve(root, process.env.ASC_KEY_PATH),
|
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(
|
followUps.push(
|
||||||
"App Store Connect: once the build finishes processing, add it to a version and submit for review at https://appstoreconnect.apple.com",
|
`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}`)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue