flotilla/scripts/release.mjs

466 lines
15 KiB
JavaScript
Raw Normal View History

2026-09-18 17:44:35 +00:00
#!/usr/bin/env node
import {existsSync} from "node:fs"
2026-09-18 18:57:25 +00:00
import {mkdtemp, readFile, readdir, rename, rm} from "node:fs/promises"
import {tmpdir} from "node:os"
2026-09-18 17:44:35 +00:00
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)
},
},
2026-09-18 18:57:25 +00:00
{
name: "fdroid",
title: "Rebuild the tag the way F-Droid will",
missing: () =>
git("cat-file", "-e", `${version}:scripts/prepare-fdroid-source.sh`) === undefined
? [`F-Droid support in the ${version} tag`]
: [],
setup: [
`The ${version} tag is older than fdroid/, so there is nothing for F-Droid to build from it.`,
"Name the steps you do want, or release a tag that has it.",
],
run: async () => {
const parent = await mkdtemp(join(tmpdir(), "flotilla-fdroid-"))
const checkout = join(parent, "flotilla")
// Preparation rewrites source and dependencies in place, so it only runs against a checkout
// that can be thrown away
try {
await run("git", ["worktree", "add", "--detach", checkout, version], {cwd: root})
await run("./scripts/prepare-fdroid-source.sh", [], {cwd: checkout})
await run("./scripts/build-fdroid-assets.sh", [], {cwd: checkout})
// F-Droid signs its own builds, so keep the distribution key out of gradle's environment
const env = {...process.env}
delete env.ANDROID_KEYSTORE_PATH
await run("./gradlew", ["--no-daemon", "assembleFdroidRelease"], {
cwd: join(checkout, "android"),
env,
})
const built = join(
checkout,
"android/app/build/outputs/apk/fdroid/release/app-fdroid-release-unsigned.apk",
)
if (!existsSync(built)) {
throw new Error(`the F-Droid build produced no apk at ${built}`)
}
} finally {
await rm(parent, {recursive: true, force: true})
await run("git", ["worktree", "prune"], {cwd: root})
}
},
},
2026-09-18 17:44:35 +00:00
{
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}),
},
]
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}`)
}
}