#!/usr/bin/env node import {existsSync} from "node:fs" import {mkdtemp, readFile, readdir, rename, rm} from "node:fs/promises" import {tmpdir} from "node:os" 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 hostTargets = desktopTargets[process.platform] ?? [] // Gitea's latest release is the desktop update feed, so it only goes public once every platform's // manifest is on it const desktopManifests = ["latest.yml", "latest-linux.yml", "latest-mac.yml"] const packagedDesktop = async () => { const files = existsSync(desktopDist) ? await readdir(desktopDist) : [] const manifests = [] // Manifest names carry no version, so a leftover from an older build is told apart by its contents for (const file of files.filter(file => desktopManifests.includes(file))) { const text = await readFile(join(desktopDist, file), "utf-8") if (text.match(/^version: '?([^'\s]+)'?$/m)?.[1] === version) { manifests.push({ file, urls: [...text.matchAll(/^\s*- url: '?(.+?)'?$/gm)].map(match => match[1]), }) } } return { artifacts: files.filter( file => file.includes(version) && /\.(dmg|zip|AppImage|exe|blockmap)$/.test(file), ), manifests, } } 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: "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}) } }, }, { 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: () => [ ...(hostTargets.length > 0 ? [] : [`desktop packaging on ${process.platform}`]), ...(existsSync(join(root, "electron/node_modules")) ? [] : ["electron dependencies"]), ...(hostTargets.includes("windows") && !installed("docker") ? ["docker"] : []), ...(hostTargets.includes("macos") ? missingEnv("CSC_NAME", "ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_KEY_PATH") : []), ], 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.", "Linux also cross-builds the Windows installer, which needs docker.", "macOS only installs updates to a signed app, so macOS packages are signed and notarized:", "set CSC_NAME to the name of the Developer ID Application certificate in your keychain,", "without its prefix, and the ASC_* key the ios step uses notarizes them.", ], run: async () => { for (const target of hostTargets) { await run("pnpm", ["run", `package:desktop:${target}`], { cwd: root, env: { ...process.env, ...(target === "macos" && { APPLE_API_KEY: resolve(root, process.env.ASC_KEY_PATH), APPLE_API_KEY_ID: process.env.ASC_KEY_ID, APPLE_API_ISSUER: process.env.ASC_ISSUER_ID, }), }, }) } const elsewhere = Object.values(desktopTargets) .flat() .filter(target => !hostTargets.includes(target)) followUps.push( `Desktop: ${elsewhere.join(" and ")} packages have to be built on those platforms, then attached with pnpm release desktop 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 apkName = `${name}-${version}.apk` const {artifacts, manifests} = await packagedDesktop() const files = [ ...(existsSync(apk) ? [[apk, apkName]] : []), ...artifacts.map(file => [join(desktopDist, file), file]), ] if (files.length + manifests.length === 0) { throw new Error("Nothing to attach; build the apk or the desktop packages first") } const release = await api.upsertRelease(version, notes) const attach = async (path, filename) => console.log(dim(` ${await api.attach(release.id, filename, await readFile(path))}`)) for (const [path, filename] of files) { await attach(path, filename) } // An updater acts on a manifest the moment it can read one, so what it points to goes up first const attached = await api.assetNames(release.id) for (const {file, urls} of manifests) { const absent = urls.filter(url => !attached.includes(url)) if (absent.length > 0) { throw new Error(`${file} points to ${absent.join(", ")}, which the release doesn't have`) } await attach(join(desktopDist, file), file) } if (release.draft) { const names = await api.assetNames(release.id) const missing = [apkName, ...desktopManifests].filter(file => !names.includes(file)) if (missing.length > 0) { followUps.push( `Gitea: ${version} stays a draft until it has ${missing.join(", ")}; build them and run pnpm release gitea to publish it`, ) } else { await api.publish(release.id) } } }, }, { 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}`) } }