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()
+ }
+}