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 |
|
||||
| `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 |
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue