From cf6c025bf22ef85fcbda7134823ec1296104fc47 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Fri, 18 Sep 2026 10:44:35 -0700 Subject: [PATCH] Consolidate release scripts --- .fdignore | 1 - AGENTS.md | 2 +- README.md | 66 ++++- android/app/build.gradle | 17 ++ docs/feature_matrix.html | 2 +- package.json | 5 +- scripts/publish-android-release.mjs | 90 ------ scripts/release.mjs | 428 ++++++++++++++++++++++++++++ scripts/release/appstore.mjs | 34 +++ scripts/release/gitea.mjs | 54 ++++ scripts/release/play.mjs | 81 ++++++ scripts/release/shell.mjs | 41 +++ 12 files changed, 710 insertions(+), 111 deletions(-) delete mode 100644 scripts/publish-android-release.mjs create mode 100644 scripts/release.mjs create mode 100644 scripts/release/appstore.mjs create mode 100644 scripts/release/gitea.mjs create mode 100644 scripts/release/play.mjs create mode 100644 scripts/release/shell.mjs diff --git a/.fdignore b/.fdignore index ca6d6261..d69f36e7 100644 --- a/.fdignore +++ b/.fdignore @@ -8,7 +8,6 @@ build *.ttf gradlew* _app -release ios/DerivedData/ ios/App/Pods/ android/capacitor-cordova-android-plugins diff --git a/AGENTS.md b/AGENTS.md index 9012ef37..129a3c78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -231,7 +231,7 @@ See `.env.template` for all options. **Capacitor Integration:** -- Android: Full support, APK builds via `pnpm run release:android` +- Android: Full support, release builds via `pnpm release` (see README for the release flow) - iOS: Full support (zaps disabled due to App Store policy) - PWA: Progressive Web App with service worker diff --git a/README.md b/README.md index fced0ce1..18df84ee 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A discord-like nostr client based on the idea of "relays as groups". Supports NI - **Web** — [app.flotilla.social](https://app.flotilla.social), installable as a PWA - **Android** — [Google Play](https://play.google.com/store/apps/details?id=social.flotilla) -- **Android APK** — [releases](https://gitea.coracle.social/coracle/flotilla/releases), see [Android releases](#android-releases) +- **Android APK** — [releases](https://gitea.coracle.social/coracle/flotilla/releases), see [Releasing](#releasing) - **iOS** — [App Store](https://apps.apple.com/us/app/flotilla-chat/id6741344107) - **Your own server** — see [Deployment](#deployment) @@ -154,25 +154,61 @@ Use the AppImage or installed executable rather than the installer. This checks local assets, navigation, workers, and CSP using a disposable profile. Installation, reboot, and uninstall still require target-OS testing. -## Android releases +## Releasing -Signed APKs are attached to releases on the -[releases page](https://gitea.coracle.social/coracle/flotilla/releases), so Android users can -install and update outside an app store. - -Publishing needs `GITEA_TOKEN` in `.env.local`, set to a gitea access token with `write:repository` -(Settings → Applications → Access Tokens). Bump the version, write its `CHANGELOG.md` section and -push the matching tag, then: +`pnpm release` takes a tagged commit and ships it everywhere: the web bundle and native projects, +the signed APK on gitea and zapstore, the AAB on Google Play, the iOS build on App Store Connect, +and the desktop packages. It checks the tag, the changelog section, every credential and every +tool up front, and refuses to start if one of them is missing rather than getting halfway. What's +left — rolling out on Play, submitting for review — comes back as a list when it finishes. ```sh -pnpm run release:android -pnpm run publish:android +pnpm bump minor # or patch, major, or an explicit x.y.z +# write the CHANGELOG.md section for the new version +git commit -am "Bump version" +git tag 1.12.0 && git push origin dev 1.12.0 +pnpm release ``` -`publish:android` creates the release for the tag, takes its notes from the changelog, and attaches -the APK as `flotilla-.apk`, replacing any existing asset of that name. It reads the -repository and the APK path from `zapstore.yaml`, so a release and a zapstore publish ship the same -file. +`pnpm release --check` runs those checks and reports the plan without building anything. Naming +steps runs a subset — `pnpm release ios`, or `pnpm release apk gitea`. A step that fails stops the +run and prints the command to pick up from there. + +| step | what it does | +| --- | --- | +| `web` | `scripts/build.sh`: web bundle, `cap sync`, generated icons and splash screens | +| `apk` | `assembleRelease` signed with the distribution key, renamed to the path in `zapstore.yaml` | +| `play` | `bundleRelease` signed with the upload key, uploaded to a Play track as a draft | +| `ios` | `cap build ios` to an archive and IPA, uploaded with `altool` | +| `desktop` | `package:desktop:*` for this OS | +| `gitea` | creates the release for the tag from the changelog, attaches the APK and any desktop packages | +| `zapstore` | `zsp publish zapstore.yaml` | +| `fdroid` | nothing to upload; F-Droid builds from the tag, see [fdroid/README.md](fdroid/README.md) | + +Release notes come from the `CHANGELOG.md` section matching `package.json`'s version, so every +store shows the same text. The APK and zapstore share one artifact, whose path lives in +`zapstore.yaml`. + +### Credentials + +These go in `.env.local`, which is gitignored. `pnpm release --check` lists whichever are missing +along with how to get them. + +| variable | what it is | +| --- | --- | +| `GITEA_TOKEN` | gitea access token with `write:repository`, from Settings → Applications | +| `ANDROID_KEYSTORE_PATH`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEYSTORE_ALIAS` | the key APKs outside the app stores are signed with; it can never change without breaking updates | +| `PLAY_KEYSTORE_PATH`, `PLAY_KEYSTORE_PASSWORD`, `PLAY_KEYSTORE_ALIAS` | the Play upload key | +| `PLAY_SERVICE_ACCOUNT` | path to a service account json with the Release manager role, from Play Console → Setup → API access | +| `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_KEY_PATH` | App Store Connect API key with the App Manager role, from Users and Access → Integrations | +| `SIGN_WITH` | nostr key for zapstore: an nsec, a `bunker://` url, or `browser` | + +Add `_ALIAS_PASSWORD` to either keystore prefix when the alias has its own password. `PLAY_TRACK` +(default `production`) and `PLAY_STATUS` (default `draft`) choose where a Play upload lands. +Keystores and API keys belong outside the repository; only their paths go in `.env.local`. + +Gradle signs from those variables, so Android Studio still opens and builds the project without +them — it just produces an unsigned release build. ### Obtainium diff --git a/android/app/build.gradle b/android/app/build.gradle index 593d1662..ebbb9acd 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,6 +1,10 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' +// Release credentials come from the environment so they stay out of the repo and out of the +// process list. Without them the release build is unsigned, which is what Android Studio gets. +def releaseKeystore = System.getenv("ANDROID_KEYSTORE_PATH") + android { namespace = "social.flotilla" compileSdk = rootProject.ext.compileSdkVersion @@ -17,8 +21,21 @@ android { ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' } } + signingConfigs { + release { + if (releaseKeystore) { + storeFile file(releaseKeystore) + storePassword System.getenv("ANDROID_KEYSTORE_PASSWORD") + keyAlias System.getenv("ANDROID_KEYSTORE_ALIAS") + keyPassword System.getenv("ANDROID_KEYSTORE_ALIAS_PASSWORD") + } + } + } buildTypes { release { + if (releaseKeystore) { + signingConfig signingConfigs.release + } minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } diff --git a/docs/feature_matrix.html b/docs/feature_matrix.html index 5e93aa35..64fe8fed 100644 --- a/docs/feature_matrix.html +++ b/docs/feature_matrix.html @@ -461,7 +461,7 @@ the story catalog in e2e/USER_STORIES.md.

Branded Android app Implemented -android/, @capacitor/assets, pnpm run release:android +android/, @capacitor/assets, pnpm release Branded iOS app diff --git a/package.json b/package.json index 3082ecd0..54f39b6c 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,11 @@ "start:desktop": "npm --prefix electron start", "build:server": "vite build --config vite.config.server.ts", "start": "node server.js", - "release:android": "./scripts/build.sh && cap build android --androidreleasetype APK --signing-type apksigner", - "publish:android": "node scripts/publish-android-release.mjs", + "release": "node scripts/release.mjs", "bump": "node scripts/bump-version.mjs", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "lint": "prettier --check src e2e packages fdroid playwright.config.ts capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts/dev-desktop.mjs scripts/package-desktop.mjs scripts/publish-android-release.mjs && eslint src e2e packages fdroid capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts/dev-desktop.mjs scripts/package-desktop.mjs scripts/publish-android-release.mjs", + "lint": "prettier --check src e2e packages fdroid playwright.config.ts capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts/dev-desktop.mjs scripts/package-desktop.mjs scripts/release.mjs scripts/release/*.mjs && eslint src e2e packages fdroid capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts", "test": "playwright test", "test:desktop": "playwright test --config e2e/desktop/playwright.config.ts", "test:ui": "playwright test --ui", diff --git a/scripts/publish-android-release.mjs b/scripts/publish-android-release.mjs deleted file mode 100644 index 79ffa851..00000000 --- a/scripts/publish-android-release.mjs +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env node -import {readFile} from "node:fs/promises" -import {config} from "dotenv" - -const read = path => readFile(new URL(path, import.meta.url), "utf-8") - -config({path: new URL("../.env.local", import.meta.url)}) - -const token = process.env.GITEA_TOKEN -const {name, version} = JSON.parse(await read("../package.json")) -const changelog = await read("../CHANGELOG.md") -const zapstore = await read("../zapstore.yaml") - -const zapstoreField = key => { - const match = zapstore.match(new RegExp(`^${key}:\\s*(\\S+)\\s*$`, "m")) - - if (!match) { - throw new Error(`zapstore.yaml is missing ${key}`) - } - - return match[1] -} - -const repository = new URL(zapstoreField("repository")) -const base = `${repository.origin}/api/v1/repos${repository.pathname}` - -const api = async (method, path, {body, allow404} = {}) => { - const multipart = body instanceof FormData - const response = await fetch(base + path, { - method, - headers: { - Authorization: `token ${token}`, - ...(body && !multipart ? {"Content-Type": "application/json"} : {}), - }, - body: multipart ? body : body && JSON.stringify(body), - }) - - if (response.status === 404 && allow404) { - return undefined - } - - if (!response.ok) { - throw new Error(`${method} ${path} responded ${response.status}: ${await response.text()}`) - } - - return response.status === 204 ? undefined : response.json() -} - -if (!token) { - throw new Error("Set GITEA_TOKEN in .env.local to a token with write access to the repository") -} - -const lines = changelog.split("\n") -const heading = lines.indexOf(`# ${version}`) - -if (heading < 0) { - throw new Error(`CHANGELOG.md has no "# ${version}" section`) -} - -const remainder = lines.slice(heading + 1) -const nextHeading = remainder.findIndex(line => line.startsWith("# ")) -const notes = (nextHeading < 0 ? remainder : remainder.slice(0, nextHeading)).join("\n").trim() -const apk = await readFile(new URL(`../${zapstoreField("release_source")}`, import.meta.url)) - -if (!(await api("GET", `/tags/${version}`, {allow404: true}))) { - throw new Error(`${repository} has no ${version} tag; push it before publishing`) -} - -const release = - (await api("GET", `/releases/tags/${version}`, {allow404: true})) ?? - (await api("POST", "/releases", {body: {tag_name: version, name: version, body: notes}})) - -const filename = `${name}-${version}.apk` -const existing = release.assets?.find(asset => asset.name === filename) - -if (existing) { - await api("DELETE", `/releases/${release.id}/assets/${existing.id}`) -} - -const form = new FormData() - -form.append("attachment", new Blob([apk]), filename) - -const asset = await api( - "POST", - `/releases/${release.id}/assets?name=${encodeURIComponent(filename)}`, - {body: form}, -) - -console.log(asset.browser_download_url) diff --git a/scripts/release.mjs b/scripts/release.mjs new file mode 100644 index 00000000..358ecc8c --- /dev/null +++ b/scripts/release.mjs @@ -0,0 +1,428 @@ +#!/usr/bin/env node +import {existsSync} from "node:fs" +import {readFile, readdir, rename} from "node:fs/promises" +import {dirname, join, resolve} from "node:path" +import {fileURLToPath} from "node:url" +import {parseArgs} from "node:util" +import {config} from "dotenv" +import {uploadToAppStore} from "./release/appstore.mjs" +import {gitea} from "./release/gitea.mjs" +import {uploadToPlay} from "./release/play.mjs" +import {ask, bold, dim, green, installed, output, red, run, yellow} from "./release/shell.mjs" + +const root = fileURLToPath(new URL("../", import.meta.url)) + +config({path: join(root, ".env.local")}) + +const fail = message => { + console.error(red(message)) + process.exit(1) +} + +let args + +try { + args = parseArgs({ + options: { + check: {type: "boolean", default: false}, + yes: {type: "boolean", short: "y", default: false}, + }, + allowPositionals: true, + }) +} catch (error) { + fail(`${error.message}\nUsage: pnpm release [--check] [--yes] [step...]`) +} + +const {values: options, positionals: chosen} = args + +const {name, version} = JSON.parse(await readFile(join(root, "package.json"), "utf-8")) +const changelog = await readFile(join(root, "CHANGELOG.md"), "utf-8") +const zapstore = await readFile(join(root, "zapstore.yaml"), "utf-8") +const gradleConfig = await readFile(join(root, "android/app/build.gradle"), "utf-8") + +const zapstoreField = key => { + const match = zapstore.match(new RegExp(`^${key}:\\s*(\\S+)\\s*$`, "m")) + + if (!match) { + fail(`zapstore.yaml is missing ${key}`) + } + + return match[1] +} + +const appId = gradleConfig.match(/applicationId "(.+)"/)[1] +const repository = new URL(zapstoreField("repository")) +const apk = join(root, zapstoreField("release_source")) +const aab = join(root, "android/app/build/outputs/bundle/release/app-release.aab") +const desktopDist = join(root, "electron/dist") + +const lines = changelog.split("\n") +const heading = lines.indexOf(`# ${version}`) +const remainder = heading < 0 ? [] : lines.slice(heading + 1) +const nextHeading = remainder.findIndex(line => line.startsWith("# ")) +const notes = (nextHeading < 0 ? remainder : remainder.slice(0, nextHeading)).join("\n").trim() + +const git = (...gitArgs) => { + try { + return output("git", gitArgs, {cwd: root, stdio: ["ignore", "pipe", "ignore"]}) + } catch { + return undefined + } +} + +const missingEnv = (...keys) => keys.filter(key => !process.env[key]) + +const keystoreEnv = prefix => ({ + ANDROID_KEYSTORE_PATH: resolve(root, process.env[`${prefix}_KEYSTORE_PATH`]), + ANDROID_KEYSTORE_PASSWORD: process.env[`${prefix}_KEYSTORE_PASSWORD`], + ANDROID_KEYSTORE_ALIAS: process.env[`${prefix}_KEYSTORE_ALIAS`], + ANDROID_KEYSTORE_ALIAS_PASSWORD: + process.env[`${prefix}_KEYSTORE_ALIAS_PASSWORD`] ?? process.env[`${prefix}_KEYSTORE_PASSWORD`], +}) + +// A fresh jvm per build, so a reused daemon can't hand one gradle run the other's signing key +const gradle = (task, signing) => + run("./gradlew", ["--no-daemon", task], { + cwd: join(root, "android"), + env: {...process.env, ...signing}, + }) + +// Gradle records what it actually built beside the apk, the only version stamp on an artifact +// whose filename never changes +const apkMetadata = async () => { + const path = join(dirname(apk), "output-metadata.json") + + if (!existsSync(path)) { + throw new Error(`${path} is missing; run pnpm release apk`) + } + + const {elements} = JSON.parse(await readFile(path, "utf-8")) + const [element] = elements + + if (element.versionName !== version) { + throw new Error(`the last android build was ${element.versionName}, not ${version}`) + } + + return element +} + +const desktopTargets = {darwin: ["macos"], linux: ["linux", "windows"], win32: []} +const desktopTarget = desktopTargets[process.platform]?.[0] + +const packagedDesktop = async () => + existsSync(desktopDist) + ? (await readdir(desktopDist)).filter( + file => file.includes(version) && /\.(dmg|AppImage|exe)$/.test(file), + ) + : [] + +const followUps = [] + +const steps = [ + { + name: "web", + title: "Build the web bundle and sync the native projects", + run: () => run("bash", ["scripts/build.sh"], {cwd: root}), + }, + { + name: "apk", + title: "Build the APK, signed with the distribution key", + missing: () => + missingEnv("ANDROID_KEYSTORE_PATH", "ANDROID_KEYSTORE_PASSWORD", "ANDROID_KEYSTORE_ALIAS"), + setup: [ + "Set ANDROID_KEYSTORE_PATH, ANDROID_KEYSTORE_PASSWORD and ANDROID_KEYSTORE_ALIAS in", + ".env.local (plus ANDROID_KEYSTORE_ALIAS_PASSWORD if the alias has its own password).", + "This is the key gitea, zapstore and Obtainium updates are signed with, so it has to stay", + "the same one forever.", + ], + run: async () => { + await gradle("assembleRelease", keystoreEnv("ANDROID")) + + const {outputFile} = await apkMetadata() + + await rename(join(dirname(apk), outputFile), apk) + }, + }, + { + name: "play", + title: "Build the AAB and upload it to Google Play", + missing: () => + missingEnv( + "PLAY_KEYSTORE_PATH", + "PLAY_KEYSTORE_PASSWORD", + "PLAY_KEYSTORE_ALIAS", + "PLAY_SERVICE_ACCOUNT", + ), + setup: [ + "PLAY_KEYSTORE_PATH, PLAY_KEYSTORE_PASSWORD, PLAY_KEYSTORE_ALIAS (and", + "PLAY_KEYSTORE_ALIAS_PASSWORD) are the upload key Android Studio has been signing with.", + "PLAY_SERVICE_ACCOUNT is the path to a service account json:", + " 1. Play Console -> Setup -> API access, link or create a Google Cloud project", + " 2. Create a service account there, then grant it the Release manager role on this app", + " 3. Google Cloud -> that service account -> Keys -> Add key -> JSON, save it outside the repo", + "PLAY_TRACK (default production) and PLAY_STATUS (default draft) are optional.", + ], + run: async () => { + await gradle("bundleRelease", keystoreEnv("PLAY")) + + const track = process.env.PLAY_TRACK ?? "production" + const status = process.env.PLAY_STATUS ?? "draft" + const versionCode = await uploadToPlay({ + credentials: JSON.parse( + await readFile(resolve(root, process.env.PLAY_SERVICE_ACCOUNT), "utf-8"), + ), + packageName: appId, + bundle: await readFile(aab), + track, + status, + // Play rejects release notes over 500 characters + notes: notes.slice(0, 500), + }) + + followUps.push( + `Play Console: ${version} (${versionCode}) is a ${status} release on the ${track} track, review and roll it out at https://play.google.com/console`, + ) + }, + }, + { + name: "ios", + title: "Archive the iOS app and upload it to App Store Connect", + 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 () => { + 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), + keyId: process.env.ASC_KEY_ID, + issuerId: process.env.ASC_ISSUER_ID, + keyPath: resolve(root, process.env.ASC_KEY_PATH), + }) + + followUps.push( + "App Store Connect: once the build finishes processing, add it to a version and submit for review at https://appstoreconnect.apple.com", + ) + }, + }, + { + name: "desktop", + title: "Package the desktop app", + missing: () => [ + ...(desktopTarget ? [] : [`desktop packaging on ${process.platform}`]), + ...(existsSync(join(root, "electron/node_modules")) ? [] : ["electron dependencies"]), + ], + setup: [ + "Run npm ci --prefix electron. Each platform's packages have to be built on that platform,", + "so run pnpm release desktop gitea on the others to add theirs to the same release.", + ], + run: async () => { + await run("pnpm", ["run", `package:desktop:${desktopTarget}`], {cwd: root}) + + const elsewhere = Object.values(desktopTargets) + .flat() + .filter(target => !desktopTargets[process.platform].includes(target)) + + followUps.push( + `Desktop: ${elsewhere.join(" and ")} packages have to be built on those platforms, then attached with pnpm release gitea`, + ) + }, + }, + { + name: "gitea", + title: "Publish the gitea release and attach the artifacts", + missing: () => missingEnv("GITEA_TOKEN"), + setup: [ + `Generate an access token at ${repository.origin}/user/settings/applications with the`, + "write:repository scope, and set GITEA_TOKEN in .env.local.", + ], + run: async () => { + const api = gitea({repository, token: process.env.GITEA_TOKEN}) + + if (!(await api.hasTag(version))) { + throw new Error(`${repository} has no ${version} tag; push it before publishing`) + } + + if (existsSync(apk)) { + await apkMetadata() + } + + const files = [ + ...(existsSync(apk) ? [[apk, `${name}-${version}.apk`]] : []), + ...(await packagedDesktop()).map(file => [join(desktopDist, file), file]), + ] + + if (files.length === 0) { + throw new Error("Nothing to attach; build the apk or the desktop packages first") + } + + const release = await api.upsertRelease(version, notes) + + for (const [path, filename] of files) { + console.log(dim(` ${await api.attach(release.id, filename, await readFile(path))}`)) + } + }, + }, + { + name: "zapstore", + title: "Publish the APK to zapstore", + missing: () => [ + ...(installed("zsp") ? [] : ["zsp (not installed)"]), + ...missingEnv("SIGN_WITH"), + ], + setup: [ + "Install zsp from https://github.com/zapstore/zsp, then set SIGN_WITH in .env.local to an", + "nsec, a bunker:// url, or `browser` to sign with a nostr extension.", + ], + run: () => run("zsp", ["publish", "zapstore.yaml"], {cwd: root}), + }, + { + name: "fdroid", + title: "F-Droid", + optional: true, + manual: [ + "F-Droid builds from source on their own servers, so a release has nothing to upload: the", + "metadata tracks version tags, which makes pushing the tag the whole story. The fdroiddata", + "submission is still open — see fdroid/README.md.", + ], + }, +] + +const unknownStep = chosen.find(step => !steps.some(({name}) => name === step)) + +if (unknownStep) { + fail(`Unknown step ${unknownStep}. Steps: ${steps.map(step => step.name).join(", ")}`) +} + +const selected = steps.filter(step => + chosen.length > 0 ? chosen.includes(step.name) : !step.optional, +) + +const width = Math.max(...selected.map(step => step.name.length)) +const problems = selected.map(step => ({step, missing: step.missing?.() ?? []})) +const warnings = [] + +if (!notes) { + problems.push({missing: [`CHANGELOG.md has no "# ${version}" section`]}) +} + +if (git("rev-parse", `refs/tags/${version}`)) { + const pushed = git("ls-remote", "--tags", "origin", `refs/tags/${version}`) + + if (pushed === undefined) { + warnings.push("couldn't reach origin to check whether the tag is pushed") + } else if (!pushed) { + problems.push({missing: [`the ${version} tag is not on origin: git push origin ${version}`]}) + } + + if (git("rev-parse", "HEAD") !== git("rev-parse", `refs/tags/${version}^{commit}`)) { + warnings.push(`HEAD is not the ${version} tag, so the build won't match what you tagged`) + } +} else { + problems.push({ + missing: [`there is no ${version} tag: git tag ${version} && git push origin ${version}`], + }) +} + +if (git("status", "--porcelain")) { + warnings.push("the working tree has uncommitted changes") +} + +console.log(bold(`\n${name} ${version} -> ${repository.host}${repository.pathname}\n`)) + +for (const step of selected) { + console.log(` ${step.name.padEnd(width)} ${step.manual ? dim(step.title) : step.title}`) +} + +if (warnings.length > 0) { + console.log("") + + for (const warning of warnings) { + console.log(yellow(` ! ${warning}`)) + } +} + +const blocked = problems.filter(({missing}) => missing.length > 0) + +if (blocked.length > 0) { + console.log("") + + for (const {step, missing} of blocked) { + console.log(red(` x ${step ? `${step.name}: missing ${missing.join(", ")}` : missing[0]}`)) + + for (const line of step?.setup ?? []) { + console.log(dim(` ${line}`)) + } + } + + fail("\nNothing ran.") +} + +if (options.check) { + console.log(green("\nReady to go.")) + process.exit(0) +} + +if (!options.yes) { + if (!process.stdin.isTTY) { + fail("Not a terminal; pass --yes to run unattended") + } + + const answer = await ask(`\nRelease ${version}? [y/N] `) + + if (!["y", "yes"].includes(answer.trim().toLowerCase())) { + fail("Aborted.") + } +} + +const done = [] + +for (const [index, step] of selected.entries()) { + if (step.manual) { + followUps.push(step.manual.join(" ")) + continue + } + + console.log(bold(`\n> ${step.title}`)) + + const started = Date.now() + + try { + await step.run() + } catch (error) { + console.error(red(`\n${step.name} failed: ${error.message}`)) + + const remaining = selected.slice(index).map(remainingStep => remainingStep.name) + + fail(`Pick up where this left off with: pnpm release ${remaining.join(" ")}`) + } + + done.push(`${step.name.padEnd(width)} ${Math.round((Date.now() - started) / 1000)}s`) +} + +console.log(bold(`\n${name} ${version}\n`)) + +for (const line of done) { + console.log(` ${green("done")} ${line}`) +} + +if (followUps.length > 0) { + console.log(bold("\nLeft to do by hand")) + + for (const followUp of followUps) { + console.log(` - ${followUp}`) + } +} diff --git a/scripts/release/appstore.mjs b/scripts/release/appstore.mjs new file mode 100644 index 00000000..da6fa231 --- /dev/null +++ b/scripts/release/appstore.mjs @@ -0,0 +1,34 @@ +import {copyFile, mkdir, mkdtemp, rm} from "node:fs/promises" +import {tmpdir} from "node:os" +import {join} from "node:path" +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-")) + + 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/gitea.mjs b/scripts/release/gitea.mjs new file mode 100644 index 00000000..845fb456 --- /dev/null +++ b/scripts/release/gitea.mjs @@ -0,0 +1,54 @@ +export const gitea = ({repository, token}) => { + const base = `${repository.origin}/api/v1/repos${repository.pathname}` + + const api = async (method, path, {body, allow404} = {}) => { + const multipart = body instanceof FormData + const response = await fetch(base + path, { + method, + headers: { + Authorization: `token ${token}`, + ...(body && !multipart ? {"Content-Type": "application/json"} : {}), + }, + body: multipart ? body : body && JSON.stringify(body), + }) + + if (response.status === 404 && allow404) { + return undefined + } + + if (!response.ok) { + throw new Error(`${method} ${path} responded ${response.status}: ${await response.text()}`) + } + + return response.status === 204 ? undefined : response.json() + } + + return { + hasTag: async tag => Boolean(await api("GET", `/tags/${tag}`, {allow404: true})), + + upsertRelease: async (tag, notes) => + (await api("GET", `/releases/tags/${tag}`, {allow404: true})) ?? + (await api("POST", "/releases", {body: {tag_name: tag, name: tag, body: notes}})), + + attach: async (releaseId, filename, data) => { + const {assets} = await api("GET", `/releases/${releaseId}`) + const existing = assets?.find(asset => asset.name === filename) + + if (existing) { + await api("DELETE", `/releases/${releaseId}/assets/${existing.id}`) + } + + const form = new FormData() + + form.append("attachment", new Blob([data]), filename) + + const asset = await api( + "POST", + `/releases/${releaseId}/assets?name=${encodeURIComponent(filename)}`, + {body: form}, + ) + + return asset.browser_download_url + }, + } +} diff --git a/scripts/release/play.mjs b/scripts/release/play.mjs new file mode 100644 index 00000000..b30e79b0 --- /dev/null +++ b/scripts/release/play.mjs @@ -0,0 +1,81 @@ +import {createSign} from "node:crypto" + +const encode = value => Buffer.from(JSON.stringify(value)).toString("base64url") + +const getAccessToken = async ({client_email, private_key}) => { + const issued = Math.floor(Date.now() / 1000) + const claims = { + iss: client_email, + scope: "https://www.googleapis.com/auth/androidpublisher", + aud: "https://oauth2.googleapis.com/token", + iat: issued, + exp: issued + 3600, + } + + const signed = `${encode({alg: "RS256", typ: "JWT"})}.${encode(claims)}` + const signature = createSign("RSA-SHA256").update(signed).sign(private_key, "base64url") + + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: {"Content-Type": "application/x-www-form-urlencoded"}, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion: `${signed}.${signature}`, + }), + }) + + const body = await response.json() + + if (!response.ok) { + throw new Error(`Google rejected the service account: ${body.error_description ?? body.error}`) + } + + return body.access_token +} + +export const uploadToPlay = async ({credentials, packageName, bundle, track, status, notes}) => { + const accessToken = await getAccessToken(credentials) + const base = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${packageName}` + + const api = async (method, url, body) => { + const binary = body instanceof Uint8Array + const response = await fetch(url, { + method, + headers: { + Authorization: `Bearer ${accessToken}`, + ...(body ? {"Content-Type": binary ? "application/octet-stream" : "application/json"} : {}), + }, + body: binary ? body : body && JSON.stringify(body), + }) + + const result = await response.json() + + if (!response.ok) { + throw new Error(`Play API: ${result.error?.message ?? JSON.stringify(result)}`) + } + + return result + } + + const edit = await api("POST", `${base}/edits`) + const {versionCode} = await api( + "POST", + `https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/${packageName}/edits/${edit.id}/bundles?uploadType=media`, + bundle, + ) + + await api("PUT", `${base}/edits/${edit.id}/tracks/${track}`, { + track, + releases: [ + { + status, + versionCodes: [String(versionCode)], + releaseNotes: [{language: "en-US", text: notes}], + }, + ], + }) + + await api("POST", `${base}/edits/${edit.id}:commit`) + + return versionCode +} diff --git a/scripts/release/shell.mjs b/scripts/release/shell.mjs new file mode 100644 index 00000000..77c45e3e --- /dev/null +++ b/scripts/release/shell.mjs @@ -0,0 +1,41 @@ +import {execFileSync, spawn} from "node:child_process" +import {createInterface} from "node:readline/promises" + +const color = code => text => (process.stdout.isTTY ? `\x1b[${code}m${text}\x1b[0m` : text) + +export const bold = color(1) +export const dim = color(2) +export const red = color(31) +export const green = color(32) +export const yellow = color(33) + +export const run = (command, args, options = {}) => + new Promise((resolve, reject) => { + const child = spawn(command, args, {stdio: "inherit", ...options}) + + child.on("error", reject) + child.on("exit", (code, signal) => + code === 0 ? resolve() : reject(new Error(`${command} failed (${signal || code})`)), + ) + }) + +export const output = (command, args, options = {}) => + execFileSync(command, args, {encoding: "utf-8", ...options}).trim() + +export const installed = command => { + try { + return Boolean(output("which", [command])) + } catch { + return false + } +} + +export const ask = async question => { + const readline = createInterface({input: process.stdin, output: process.stdout}) + + try { + return await readline.question(question) + } finally { + readline.close() + } +}