Consolidate release scripts

This commit is contained in:
Jon Staab 2026-09-18 10:44:35 -07:00
parent e46f2d2054
commit cf6c025bf2
12 changed files with 710 additions and 111 deletions

View file

@ -8,7 +8,6 @@ build
*.ttf
gradlew*
_app
release
ios/DerivedData/
ios/App/Pods/
android/capacitor-cordova-android-plugins

View file

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

View file

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

View file

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

View file

@ -461,7 +461,7 @@ the story catalog in <code>e2e/USER_STORIES.md</code>.</p>
<tr>
<td>Branded Android app</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>android/</code>, <code>@capacitor/assets</code>, <code>pnpm run release:android</code></td>
<td><code>android/</code>, <code>@capacitor/assets</code>, <code>pnpm release</code></td>
</tr>
<tr>
<td>Branded iOS app</td>

View file

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

View file

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

428
scripts/release.mjs Normal file
View file

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

View file

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

54
scripts/release/gitea.mjs Normal file
View file

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

81
scripts/release/play.mjs Normal file
View file

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

41
scripts/release/shell.mjs Normal file
View file

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