Split the release into a local run for signed builds and a tag-triggered CI run for the rest, and sort the scripts into folders

This commit is contained in:
Jon Staab 2026-09-23 19:47:46 -07:00
parent 2caf8be20f
commit e47957b4c2
35 changed files with 722 additions and 644 deletions

View file

@ -167,10 +167,10 @@ One web build runs in several shells:
- **Web/PWA.** `SvelteKitPWA` in `vite.config.ts` generates the service worker and manifest,
except when `FLOTILLA_DESKTOP=1`. `src/service-worker.js` only claims clients.
- **Android/iOS.** Capacitor wraps `build/` (`capacitor.config.ts`). `scripts/build.sh` runs the
- **Android/iOS.** Capacitor wraps `build/` (`capacitor.config.ts`). `scripts/build/app.sh` runs the
web build, `cap sync`, and native asset generation.
- **Desktop.** `electron/main.ts` starts the Capawesome Electron platform, driven by
`scripts/build-desktop.sh` and `scripts/dev-desktop.mjs`.
`scripts/desktop/build.sh` and `scripts/desktop/dev.mjs`.
- **`server.js`.** A Hono server that serves `build/`. For `/join` and `/spaces/...` URLs it
rewrites the OpenGraph tags from the relay's NIP-11 document, fetched through welshman's
`Relays`. `vite.config.server.ts` bundles it and the `Dockerfile` runs it. It is not an API, and
@ -210,7 +210,7 @@ guards.
- `.env` is committed and holds working defaults. `.env.local` (gitignored) overrides it. There is
no `.env.template`, though AGENTS.md and the README refer to one.
- Env is read at build time. `scripts/build-web.sh` sources `.env` without overwriting variables
- Env is read at build time. `scripts/build/web.sh` sources `.env` without overwriting variables
already set, then fills the `{NAME}`, `{URL}`, `{ACCENT}` and `{DESCRIPTION}` placeholders from
`src/app.html` in `build/index.html`. `server.js` reads `VITE_PLATFORM_NAME` and
`VITE_PLATFORM_DESCRIPTION` at runtime.

View file

@ -1,9 +1,8 @@
name: Container Image Build and Publish
name: Release
on:
push:
branches: [master]
workflow_dispatch:
tags: ["*.*.*"]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@ -14,30 +13,8 @@ env:
IMAGE_NAME: coracle/flotilla
jobs:
lint-check:
image:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Install dependencies
run: corepack enable && pnpm i --frozen-lockfile
- name: Lint
run: pnpm run lint
- name: Check
run: pnpm run check
build-and-push-image:
runs-on: ubuntu-latest
needs: lint-check
permissions:
contents: read
packages: write
@ -59,8 +36,8 @@ jobs:
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
type=semver,pattern={{version}}
type=raw,value=latest
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@ -68,7 +45,6 @@ jobs:
driver: docker-container
- name: Build and push Docker image
id: push
uses: docker/build-push-action@v5
with:
context: .
@ -77,3 +53,36 @@ jobs:
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# The signed builds come from pnpm release:local, so nothing here holds a signing key
release:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Install dependencies
run: |
corepack enable
pnpm i --frozen-lockfile
npm ci --prefix electron
- name: Release
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm release:ci --yes

View file

@ -235,7 +235,7 @@ See `.env.template` for all options.
**Capacitor Integration:**
- Android: Full support, release builds via `pnpm release` (see README for the release flow)
- Android: Full support, release builds via `pnpm release:local` (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

@ -4,7 +4,7 @@
# docker run -p 3000:3000 flotilla
#
# Pass --build-arg VITE_BUILD_HASH=$(git rev-parse --short HEAD) to stamp the build.
# A .env in the build context is picked up by scripts/build.sh for branding config.
# A .env in the build context is picked up by scripts/build/app.sh for branding config.
# https://pnpm.io/docker#example-3-build-on-cicd
FROM node:24-slim AS builder

View file

@ -62,7 +62,7 @@ Create an `.env.local` file to override any of the values in `.env`:
- `VITE_POMADE_SIGNERS` - A comma-separated list of Pomade signer server URLs (3+ required to enable email signup)
- `VITE_THUMBNAIL_URL` - URL of the image thumbnail service
These values **won't** be used for a built version. Instead, env variables should be provided to `scripts/build.sh` directly or to the built container.
These values **won't** be used for a built version. Instead, env variables should be provided to `scripts/build/app.sh` directly or to the built container.
If you're deploying a custom version of flotilla, be sure to remove the `plausible.coracle.social` script from `app.html`. This sends analytics to a server hosted by the developer.
@ -143,7 +143,7 @@ entry from a development run, and updates the entry when an update renames the A
uses the executable's icon resources.
Linux packaging from macOS and Windows packaging from Linux or macOS use the pinned official
`electronuserland/builder` Wine image through Docker, mounting only a temporary copy of the prepared
`electronuserland/builder` Wine image through Docker, copying in only a temporary copy of the prepared
Electron project. Set `DOCKER=podman` in `.env.local` to use Podman instead. Native addons need a
target-OS ABI rebuild and cannot use this cross-build path.
Native Windows preparation needs Bash on PATH, for example Git Bash. DMG creation requires macOS. On
@ -182,55 +182,60 @@ both ZIPs with matching hashes. Do not hand-create or merge updater manifests.
## Releasing
`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 any is missing. It finishes with a list of what's left to do
by hand, such as rolling out on Play and submitting for review.
A release is two runs against one tag. `pnpm release:local` does everything that needs a signing
key, so those keys never leave your machine: 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 signed and
notarized macOS packages. Pushing the tag starts the release workflow in
`.gitea/workflows/release.yml`, which needs nothing but its own gitea token and the registry's: it
builds the container image as `latest` and the version, checks the F-Droid build, and runs
`pnpm release:ci` to package the Linux and Windows apps.
Both runs check the tag, the changelog section, every credential and every tool up front, and
refuse to start if any is missing. Each finishes with a list of what's left to do by hand, such as
rolling out on Play and submitting for review.
```sh
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
pnpm release:local
```
`pnpm release --check` runs those checks and reports the plan without building anything. Naming
steps runs a subset, such as `pnpm release ios` or `pnpm release apk gitea`. A step that fails stops the
run and prints the command to pick up from there.
`pnpm release:local --check` runs those checks and reports the plan without building anything.
Naming steps runs a subset, such as `pnpm release:local ios` or `pnpm release:local 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` |
| `fdroid` | reruns F-Droid's own preparation and build against the tag in a throwaway worktree |
| `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: Linux and Windows from Linux, all three from a Mac, with macOS signed and notarized |
| `gitea` | creates a draft release from the changelog, attaches the APK, desktop packages and update manifests, and publishes it once every platform is there |
| `zapstore` | `zsp publish zapstore.yaml` |
| step | run by | what it does |
| --- | --- | --- |
| `web` | local | `scripts/build/app.sh`: web bundle, `cap sync`, generated icons and splash screens |
| `apk` | local | `assembleRelease` signed with the distribution key, renamed to the path in `zapstore.yaml` |
| `play` | local | `bundleRelease` signed with the upload key, uploaded to a Play track as a draft |
| `ios` | local | `cap build ios` to an archive and IPA, uploaded with `altool` |
| `fdroid` | ci | reruns F-Droid's own preparation and build against the tag in a throwaway worktree |
| `desktop` | both | `package:desktop:*` for this OS: signed and notarized macOS from a Mac, Linux and Windows from Linux |
| `gitea` | both | creates a draft release from the changelog, attaches what this run built, and publishes it once every platform is there |
| `zapstore` | local | `zsp publish zapstore.yaml` |
A Mac builds every desktop package, the Linux and Windows ones in a container. Linux can't build
the macOS ones, so a release run from Linux needs a `pnpm release desktop gitea` on a Mac as well.
Gitea's latest release is the desktop update feed, so the release stays a
draft, hidden from updaters and Obtainium, until it has the APK and all three `latest*.yml`
manifests. Each manifest is uploaded after the files it lists. A mobile-only release can't be
published, so package the desktop apps for every release.
Gitea's latest release is the desktop update feed, so the release stays a draft, hidden from
updaters and Obtainium, until it has the APK and all three `latest*.yml` manifests. Whichever run
attaches the last of them publishes it. Each manifest is uploaded after the files it lists. A
mobile-only release can't be published, so package the desktop apps for every release.
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`.
F-Droid builds from the tag on its own servers, so the `fdroid` step uploads nothing. It runs
[their preparation and build](fdroid/README.md) against the tag in a throwaway git worktree and
fails the release before anything is published if that build breaks. Preparation patches source
[their preparation and build](fdroid/README.md) against the tag in a throwaway git worktree, and if
that build breaks, the workflow stops before attaching the Linux and Windows packages, which keeps
the release a draft. Preparation patches source
with exact-match replacements, so it breaks quietly when the files it rewrites change. The step is
slow because it installs and builds from scratch.
### Credentials
These go in `.env.local`, which is gitignored. `pnpm release --check` lists whichever are missing
These go in `.env.local`, which is gitignored. `pnpm release:local --check` lists whichever are missing
along with how to get them.
| variable | what it is |

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 release</code></td>
<td><code>android/</code>, <code>@capacitor/assets</code>, <code>pnpm release:local</code></td>
</tr>
<tr>
<td>Branded iOS app</td>
@ -471,7 +471,7 @@ the story catalog in <code>e2e/USER_STORIES.md</code>.</p>
<tr>
<td>Branded desktop app</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>electron/</code>, <code>scripts/package-desktop.mjs</code>; <code>pnpm run package:desktop:linux</code> / <code>:windows</code> / <code>:macos</code></td>
<td><code>electron/</code>, <code>scripts/desktop/package.mjs</code>; <code>pnpm run package:desktop:linux</code> / <code>:windows</code> / <code>:macos</code></td>
</tr>
<tr>
<td>PWA / installable web app</td>

View file

@ -21,8 +21,8 @@ with the Node, pnpm, Java, Android SDK, and Gradle versions pinned by this repos
```sh
corepack enable
./scripts/prepare-fdroid-source.sh
./scripts/build-fdroid-assets.sh
./scripts/fdroid/prepare.sh
./scripts/fdroid/build.sh
cd android
./gradlew assembleFdroidRelease
```
@ -48,9 +48,9 @@ subdir: android/app
gradle:
- fdroid
prebuild:
- ../../scripts/prepare-fdroid-source.sh
- ../../scripts/fdroid/prepare.sh
build:
- ../../scripts/build-fdroid-assets.sh
- ../../scripts/fdroid/build.sh
```
## Updates

View file

@ -7,27 +7,28 @@
"private": true,
"scripts": {
"dev": "vite dev",
"build": "./scripts/build.sh",
"build:desktop": "bash scripts/build-desktop.sh",
"dev:desktop": "node scripts/dev-desktop.mjs",
"build": "./scripts/build/app.sh",
"build:desktop": "bash scripts/desktop/build.sh",
"dev:desktop": "node scripts/desktop/dev.mjs",
"start:desktop": "npm --prefix electron start",
"build:server": "vite build --config vite.config.server.ts",
"start": "node server.js",
"release": "node scripts/release.mjs",
"bump": "node scripts/bump-version.mjs",
"release:local": "node scripts/release/local.mjs",
"release:ci": "node scripts/release/ci.mjs",
"bump": "node scripts/release/bump.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/release.mjs scripts/release/*.mjs && eslint src e2e packages fdroid capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts",
"test:desktop-storage": "node --test packages/desktop-secure-storage/test/*.test.mjs",
"lint": "prettier --check src e2e packages fdroid playwright.config.ts capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts/desktop/*.mjs scripts/release && 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",
"test:desktop": "playwright test --config e2e/desktop/playwright.config.ts",
"test:desktop-storage": "node --test packages/desktop-secure-storage/test/*.test.mjs",
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write",
"format:all": "prettier --write src",
"prepare": "husky",
"package:desktop:linux": "node scripts/package-desktop.mjs linux",
"package:desktop:windows": "node scripts/package-desktop.mjs windows",
"package:desktop:macos": "node scripts/package-desktop.mjs macos"
"package:desktop:linux": "node scripts/desktop/package.mjs linux",
"package:desktop:windows": "node scripts/desktop/package.mjs windows",
"package:desktop:macos": "node scripts/desktop/package.mjs macos"
},
"devDependencies": {
"@capacitor/assets": "^3.0.5",

View file

@ -1,6 +1,6 @@
#!/usr/bin/env bash
source ./scripts/build-web.sh
source ./scripts/build/web.sh
npx cap sync
npx @capacitor/assets generate \
@ -10,4 +10,4 @@ npx @capacitor/assets generate \
--splashBackgroundColorDark '#191E24'
# @capacitor/assets doesn't generate Android notification icons
node scripts/generate-notification-icon.mjs
node scripts/build/notification-icon.mjs

View file

@ -4,7 +4,7 @@ set -e
export FLOTILLA_DESKTOP=1
export NODE_ENV=production
unset FLOTILLA_DESKTOP_DEV_URL CAPACITOR_ELECTRON_DEV_SERVER_URL
source ./scripts/build-web.sh
source ./scripts/build/web.sh
export VITE_PLATFORM_NAME
# Capacitor sync swallows copy failures; its rejection handler also leaves exit 0.

View file

@ -3,7 +3,7 @@ import {createRequire} from "node:module"
import {dirname, resolve} from "node:path"
import {fileURLToPath} from "node:url"
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..")
const root = resolve(dirname(fileURLToPath(import.meta.url)), "../..")
const require = createRequire(import.meta.url)
const windows = process.platform === "win32"
let server

View file

@ -5,7 +5,7 @@ import {fileURLToPath} from "node:url"
import sharp from "sharp"
import {loadEnv} from "vite"
const root = fileURLToPath(new URL("../", import.meta.url))
const root = fileURLToPath(new URL("../../", import.meta.url))
const [target, option, ...rest] = process.argv.slice(2)
const platforms = {linux: "--linux", windows: "--win", macos: "--mac"}
const env = {
@ -29,7 +29,7 @@ const run = (command, args, options = {}) =>
try {
if (!Object.hasOwn(platforms, target) || rest.length || (option && option !== "--dir")) {
throw new Error("Usage: node scripts/package-desktop.mjs linux|windows|macos [--dir]")
throw new Error("Usage: node scripts/desktop/package.mjs linux|windows|macos [--dir]")
}
delete env.FLOTILLA_DESKTOP_DEV_URL
delete env.CAPACITOR_ELECTRON_DEV_SERVER_URL
@ -48,7 +48,7 @@ try {
env.VITE_PLATFORM_LOGO = "static/desktop-logo.png"
await sharp(logo).resize(1024, 1024).png().toFile(join(root, env.VITE_PLATFORM_LOGO))
await run("bash", ["scripts/build-desktop.sh"])
await run("bash", ["scripts/desktop/build.sh"])
await cp(join(root, env.VITE_PLATFORM_LOGO), join(root, "electron/generated/icon.png"))
await cp(join(root, "static/favicon.ico"), join(root, "electron/generated/icon.ico"))
for (const [size, name] of [
@ -84,6 +84,8 @@ try {
}
await mkdir(join(root, "electron/dist"), {recursive: true})
const directory = await mkdtemp(join(root, "electron/dist/package-"))
const docker = env.DOCKER || "docker"
const container = `flotilla-package-${process.pid}`
try {
await cp(join(root, "package.json"), join(directory, "package.json"))
for (const file of [
@ -97,13 +99,14 @@ try {
]) {
await cp(join(root, "electron", file), join(directory, "electron", file), {recursive: true})
}
await run(env.DOCKER || "docker", [
"run",
"--rm",
// Copied in and out rather than mounted: a CI job that shares the host's docker socket
// would mount the host's path, not its own
await run(docker, [
"create",
"--name",
container,
"--platform",
"linux/amd64",
"--volume",
`${directory}:/project:Z`,
"--workdir",
"/project/electron",
"--env",
@ -113,14 +116,19 @@ try {
"electronuserland/builder@sha256:41ae540902461b6cbc988987db79547fcc10cda04d2a6c6367504f59d4b37c64",
"bash",
"-c",
'owner=$1; group=$2; shift 2; npm ci --ignore-scripts && npm run pack -- "$@"; result=$?; chown -R "$owner:$group" /project; exit "$result"',
'npm ci --ignore-scripts && npm run pack -- "$@"',
"--",
String(process.getuid()),
String(process.getgid()),
...args,
])
await cp(join(directory, "electron/dist"), join(root, "electron/dist"), {recursive: true})
await run(docker, ["cp", `${directory}/.`, `${container}:/project`])
await run(docker, ["start", "--attach", container])
await run(docker, [
"cp",
`${container}:/project/electron/dist/.`,
join(root, "electron/dist"),
])
} finally {
await run(docker, ["rm", "--force", container], {stdio: "ignore"}).catch(() => {})
await rm(directory, {recursive: true, force: true})
}
} else {

View file

@ -1,6 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
cd "$root"
[[ -f .fdroid/prepared ]]
rm -rf build .svelte-kit
@ -8,4 +8,4 @@ pnpm exec tsc --module esnext --moduleResolution bundler --target es2020 \
--declaration --skipLibCheck --outDir node_modules/nostr-signer-capacitor-plugin/dist/esm \
.fdroid/signer/src/index.ts
export VITE_BUILD_HASH=$(cat .fdroid/build-hash)
source scripts/build.sh
source scripts/build/app.sh

View file

@ -1,6 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.."
cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.."
if [[ -f .fdroid/prepared ]]; then
exit 0
fi

View file

@ -1,544 +0,0 @@
#!/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", "windows"],
linux: ["linux", "windows"],
win32: [],
}
const hostTargets = desktopTargets[process.platform] ?? []
const docker = process.env.DOCKER || "docker"
// 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 on Linux run pnpm release desktop gitea on a Mac to add the macOS ones to the release.",
"Linux and macOS build the other platforms' packages in a container, which needs docker,",
"or set DOCKER=podman in .env.local.",
"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))
if (elsewhere.length > 0) {
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}`)
}
}

8
scripts/release/ci.mjs Normal file
View file

@ -0,0 +1,8 @@
#!/usr/bin/env node
// Everything that needs no key but the job's own gitea token, run by .gitea/workflows/release.yml
import {release} from "./lib/pipeline.mjs"
import desktop from "./steps/desktop.mjs"
import fdroid from "./steps/fdroid.mjs"
import gitea from "./steps/gitea.mjs"
await release("pnpm release:ci", [fdroid, desktop, gitea])

View file

@ -0,0 +1,39 @@
import {existsSync} from "node:fs"
import {readFile} from "node:fs/promises"
import {dirname, join, resolve} from "node:path"
import {apk, root, version} from "./context.mjs"
import {run} from "./shell.mjs"
export 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
export 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
export const apkMetadata = async () => {
const path = join(dirname(apk), "output-metadata.json")
if (!existsSync(path)) {
throw new Error(`${path} is missing; run pnpm release:local 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
}

View file

@ -0,0 +1,48 @@
import {readFile} from "node:fs/promises"
import {join} from "node:path"
import {fileURLToPath} from "node:url"
import {config} from "dotenv"
import {fail, output} from "./shell.mjs"
export const root = fileURLToPath(new URL("../../../", import.meta.url))
config({path: join(root, ".env.local")})
export 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 zapstoreField = key => {
const match = zapstore.match(new RegExp(`^${key}:\\s*(\\S+)\\s*$`, "m"))
if (!match) {
fail(`zapstore.yaml is missing ${key}`)
}
return match[1]
}
export const repository = new URL(zapstoreField("repository"))
export const apk = join(root, zapstoreField("release_source"))
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("# "))
export const notes = (nextHeading < 0 ? remainder : remainder.slice(0, nextHeading))
.join("\n")
.trim()
export const git = (...gitArgs) => {
try {
return output("git", gitArgs, {cwd: root, stdio: ["ignore", "pipe", "ignore"]})
} catch {
return undefined
}
}
export const missingEnv = (...keys) => keys.filter(key => !process.env[key])
export const followUps = []

View file

@ -0,0 +1,138 @@
import {parseArgs} from "node:util"
import {followUps, git, name, notes, repository, version} from "./context.mjs"
import {ask, bold, dim, fail, green, red, yellow} from "./shell.mjs"
export const release = async (command, steps) => {
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: ${command} [--check] [--yes] [step...]`)
}
const {values: options, positionals: chosen} = args
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 = chosen.length > 0 ? steps.filter(step => chosen.includes(step.name)) : steps
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.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()) {
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: ${command} ${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

@ -39,3 +39,8 @@ export const ask = async question => {
readline.close()
}
}
export const fail = message => {
console.error(red(message))
process.exit(1)
}

12
scripts/release/local.mjs Normal file
View file

@ -0,0 +1,12 @@
#!/usr/bin/env node
// Everything that needs a signing key, so those keys never leave this machine
import {release} from "./lib/pipeline.mjs"
import apk from "./steps/apk.mjs"
import desktop from "./steps/desktop.mjs"
import gitea from "./steps/gitea.mjs"
import ios from "./steps/ios.mjs"
import play from "./steps/play.mjs"
import web from "./steps/web.mjs"
import zapstore from "./steps/zapstore.mjs"
await release("pnpm release:local", [web, apk, play, ios, desktop, gitea, zapstore])

View file

@ -0,0 +1,24 @@
import {rename} from "node:fs/promises"
import {dirname, join} from "node:path"
import {apkMetadata, gradle, keystoreEnv} from "../lib/android.mjs"
import {apk, missingEnv} from "../lib/context.mjs"
export default {
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)
},
}

View file

@ -0,0 +1,49 @@
import {existsSync} from "node:fs"
import {join, resolve} from "node:path"
import {followUps, missingEnv, root} from "../lib/context.mjs"
import {installed, run} from "../lib/shell.mjs"
const targets = {darwin: ["macos"], linux: ["linux", "windows"]}[process.platform] ?? []
const docker = process.env.DOCKER || "docker"
export default {
name: "desktop",
title: "Package the desktop app",
missing: () => [
...(targets.length > 0 ? [] : [`desktop packaging on ${process.platform}`]),
...(existsSync(join(root, "electron/node_modules")) ? [] : ["electron dependencies"]),
...(targets.includes("windows") && !installed(docker) ? [docker] : []),
...(targets.includes("macos")
? missingEnv("CSC_NAME", "ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_KEY_PATH")
: []),
],
setup: [
"Run npm ci --prefix electron. A Mac packages the macOS app, and Linux the Linux and Windows",
"ones, which the release workflow does for every tag. Linux builds the Windows installer in a",
"container, which needs docker, or set DOCKER=podman in .env.local.",
"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 targets) {
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,
}),
},
})
}
followUps.push(
targets.includes("macos")
? "Desktop: the release workflow packages Linux and Windows for this tag and attaches them"
: "Desktop: macOS packages have to be built on a Mac with pnpm release:local desktop gitea",
)
},
}

View file

@ -0,0 +1,53 @@
import {existsSync} from "node:fs"
import {mkdtemp, rm} from "node:fs/promises"
import {tmpdir} from "node:os"
import {join} from "node:path"
import {git, root, version} from "../lib/context.mjs"
import {run} from "../lib/shell.mjs"
export default {
name: "fdroid",
title: "Rebuild the tag the way F-Droid will",
missing: () =>
git("cat-file", "-e", `${version}:scripts/fdroid/prepare.sh`) === undefined
? [`F-Droid support in the ${version} tag`]
: [],
setup: [
`The ${version} tag is older than scripts/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/fdroid/prepare.sh", [], {cwd: checkout})
await run("./scripts/fdroid/build.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})
}
},
}

View file

@ -0,0 +1,112 @@
import {existsSync} from "node:fs"
import {readFile, readdir} from "node:fs/promises"
import {join} from "node:path"
import {apkMetadata} from "../lib/android.mjs"
import {
apk,
followUps,
missingEnv,
name,
notes,
repository,
root,
version,
} from "../lib/context.mjs"
import {gitea} from "../lib/gitea.mjs"
import {dim} from "../lib/shell.mjs"
const desktopDist = join(root, "electron/dist")
// 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,
}
}
export default {
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(", ")}, and whichever release run attaches the last of them publishes it`,
)
} else {
await api.publish(release.id)
}
}
},
}

View file

@ -0,0 +1,40 @@
import {readdir} from "node:fs/promises"
import {join, resolve} from "node:path"
import {uploadToAppStore} from "../lib/appstore.mjs"
import {followUps, missingEnv, root} from "../lib/context.mjs"
import {run} from "../lib/shell.mjs"
export default {
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",
)
},
}

View file

@ -0,0 +1,50 @@
import {readFile} from "node:fs/promises"
import {join, resolve} from "node:path"
import {gradle, keystoreEnv} from "../lib/android.mjs"
import {followUps, missingEnv, notes, root, version} from "../lib/context.mjs"
import {uploadToPlay} from "../lib/play.mjs"
export default {
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 gradleConfig = await readFile(join(root, "android/app/build.gradle"), "utf-8")
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: gradleConfig.match(/applicationId "(.+)"/)[1],
bundle: await readFile(
join(root, "android/app/build/outputs/bundle/release/app-release.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`,
)
},
}

View file

@ -0,0 +1,8 @@
import {root} from "../lib/context.mjs"
import {run} from "../lib/shell.mjs"
export default {
name: "web",
title: "Build the web bundle and sync the native projects",
run: () => run("bash", ["scripts/build/app.sh"], {cwd: root}),
}

View file

@ -0,0 +1,13 @@
import {missingEnv, root} from "../lib/context.mjs"
import {installed, run} from "../lib/shell.mjs"
export default {
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}),
}