465 lines
15 KiB
JavaScript
465 lines
15 KiB
JavaScript
#!/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 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: "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: () => [
|
|
...(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}`)
|
|
}
|
|
}
|