From e47957b4c259e6571f53347898da02213864bf9e Mon Sep 17 00:00:00 2001
From: Jon Staab
Date: Wed, 23 Sep 2026 19:47:46 -0700
Subject: [PATCH] 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
---
.agents/skills/flotilla-architecture/SKILL.md | 6 +-
.../{container-publish.yml => release.yml} | 67 ++-
AGENTS.md | 2 +-
Dockerfile | 2 +-
README.md | 65 ++-
docs/feature_matrix.html | 4 +-
fdroid/README.md | 8 +-
package.json | 23 +-
scripts/{build.sh => build/app.sh} | 4 +-
.../notification-icon.mjs} | 0
scripts/{build-web.sh => build/web.sh} | 0
.../{build-desktop.sh => desktop/build.sh} | 2 +-
scripts/{dev-desktop.mjs => desktop/dev.mjs} | 2 +-
.../package.mjs} | 32 +-
.../build.sh} | 4 +-
.../prepare.sh} | 2 +-
scripts/release.mjs | 544 ------------------
.../{bump-version.mjs => release/bump.mjs} | 0
scripts/release/ci.mjs | 8 +
scripts/release/lib/android.mjs | 39 ++
scripts/release/{ => lib}/appstore.mjs | 0
scripts/release/lib/context.mjs | 48 ++
scripts/release/{ => lib}/gitea.mjs | 0
scripts/release/lib/pipeline.mjs | 138 +++++
scripts/release/{ => lib}/play.mjs | 0
scripts/release/{ => lib}/shell.mjs | 5 +
scripts/release/local.mjs | 12 +
scripts/release/steps/apk.mjs | 24 +
scripts/release/steps/desktop.mjs | 49 ++
scripts/release/steps/fdroid.mjs | 53 ++
scripts/release/steps/gitea.mjs | 112 ++++
scripts/release/steps/ios.mjs | 40 ++
scripts/release/steps/play.mjs | 50 ++
scripts/release/steps/web.mjs | 8 +
scripts/release/steps/zapstore.mjs | 13 +
35 files changed, 722 insertions(+), 644 deletions(-)
rename .gitea/workflows/{container-publish.yml => release.yml} (68%)
rename scripts/{build.sh => build/app.sh} (79%)
rename scripts/{generate-notification-icon.mjs => build/notification-icon.mjs} (100%)
rename scripts/{build-web.sh => build/web.sh} (100%)
rename scripts/{build-desktop.sh => desktop/build.sh} (94%)
rename scripts/{dev-desktop.mjs => desktop/dev.mjs} (99%)
rename scripts/{package-desktop.mjs => desktop/package.mjs} (83%)
rename scripts/{build-fdroid-assets.sh => fdroid/build.sh} (79%)
rename scripts/{prepare-fdroid-source.sh => fdroid/prepare.sh} (96%)
delete mode 100644 scripts/release.mjs
rename scripts/{bump-version.mjs => release/bump.mjs} (100%)
create mode 100644 scripts/release/ci.mjs
create mode 100644 scripts/release/lib/android.mjs
rename scripts/release/{ => lib}/appstore.mjs (100%)
create mode 100644 scripts/release/lib/context.mjs
rename scripts/release/{ => lib}/gitea.mjs (100%)
create mode 100644 scripts/release/lib/pipeline.mjs
rename scripts/release/{ => lib}/play.mjs (100%)
rename scripts/release/{ => lib}/shell.mjs (93%)
create mode 100644 scripts/release/local.mjs
create mode 100644 scripts/release/steps/apk.mjs
create mode 100644 scripts/release/steps/desktop.mjs
create mode 100644 scripts/release/steps/fdroid.mjs
create mode 100644 scripts/release/steps/gitea.mjs
create mode 100644 scripts/release/steps/ios.mjs
create mode 100644 scripts/release/steps/play.mjs
create mode 100644 scripts/release/steps/web.mjs
create mode 100644 scripts/release/steps/zapstore.mjs
diff --git a/.agents/skills/flotilla-architecture/SKILL.md b/.agents/skills/flotilla-architecture/SKILL.md
index 5b7ec557..b5de1524 100644
--- a/.agents/skills/flotilla-architecture/SKILL.md
+++ b/.agents/skills/flotilla-architecture/SKILL.md
@@ -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.
diff --git a/.gitea/workflows/container-publish.yml b/.gitea/workflows/release.yml
similarity index 68%
rename from .gitea/workflows/container-publish.yml
rename to .gitea/workflows/release.yml
index 77699b76..f61483ea 100644
--- a/.gitea/workflows/container-publish.yml
+++ b/.gitea/workflows/release.yml
@@ -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
diff --git a/AGENTS.md b/AGENTS.md
index 5c3378a1..3424aa68 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/Dockerfile b/Dockerfile
index 79880306..3a257dea 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
diff --git a/README.md b/README.md
index 271ddf3f..3634523a 100644
--- a/README.md
+++ b/README.md
@@ -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 |
diff --git a/docs/feature_matrix.html b/docs/feature_matrix.html
index 64fe8fed..e18b063c 100644
--- a/docs/feature_matrix.html
+++ b/docs/feature_matrix.html
@@ -461,7 +461,7 @@ the story catalog in e2e/USER_STORIES.md.
| Branded Android app |
Implemented |
-android/, @capacitor/assets, pnpm release |
+android/, @capacitor/assets, pnpm release:local |
| Branded iOS app |
@@ -471,7 +471,7 @@ the story catalog in e2e/USER_STORIES.md.
| Branded desktop app |
Implemented |
-electron/, scripts/package-desktop.mjs; pnpm run package:desktop:linux / :windows / :macos |
+electron/, scripts/desktop/package.mjs; pnpm run package:desktop:linux / :windows / :macos |
| PWA / installable web app |
diff --git a/fdroid/README.md b/fdroid/README.md
index e52f152e..ef493597 100644
--- a/fdroid/README.md
+++ b/fdroid/README.md
@@ -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
diff --git a/package.json b/package.json
index a825472c..ea7132e8 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/scripts/build.sh b/scripts/build/app.sh
similarity index 79%
rename from scripts/build.sh
rename to scripts/build/app.sh
index 3a4696b1..33e388c1 100755
--- a/scripts/build.sh
+++ b/scripts/build/app.sh
@@ -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
diff --git a/scripts/generate-notification-icon.mjs b/scripts/build/notification-icon.mjs
similarity index 100%
rename from scripts/generate-notification-icon.mjs
rename to scripts/build/notification-icon.mjs
diff --git a/scripts/build-web.sh b/scripts/build/web.sh
similarity index 100%
rename from scripts/build-web.sh
rename to scripts/build/web.sh
diff --git a/scripts/build-desktop.sh b/scripts/desktop/build.sh
similarity index 94%
rename from scripts/build-desktop.sh
rename to scripts/desktop/build.sh
index 792e8db1..62df6201 100644
--- a/scripts/build-desktop.sh
+++ b/scripts/desktop/build.sh
@@ -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.
diff --git a/scripts/dev-desktop.mjs b/scripts/desktop/dev.mjs
similarity index 99%
rename from scripts/dev-desktop.mjs
rename to scripts/desktop/dev.mjs
index 2acdffed..7f11ddb2 100644
--- a/scripts/dev-desktop.mjs
+++ b/scripts/desktop/dev.mjs
@@ -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
diff --git a/scripts/package-desktop.mjs b/scripts/desktop/package.mjs
similarity index 83%
rename from scripts/package-desktop.mjs
rename to scripts/desktop/package.mjs
index 030aa5ac..6f1eb186 100644
--- a/scripts/package-desktop.mjs
+++ b/scripts/desktop/package.mjs
@@ -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 {
diff --git a/scripts/build-fdroid-assets.sh b/scripts/fdroid/build.sh
similarity index 79%
rename from scripts/build-fdroid-assets.sh
rename to scripts/fdroid/build.sh
index b76c2918..b7aa676f 100755
--- a/scripts/build-fdroid-assets.sh
+++ b/scripts/fdroid/build.sh
@@ -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
diff --git a/scripts/prepare-fdroid-source.sh b/scripts/fdroid/prepare.sh
similarity index 96%
rename from scripts/prepare-fdroid-source.sh
rename to scripts/fdroid/prepare.sh
index 105c2b9d..f0424b11 100755
--- a/scripts/prepare-fdroid-source.sh
+++ b/scripts/fdroid/prepare.sh
@@ -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
diff --git a/scripts/release.mjs b/scripts/release.mjs
deleted file mode 100644
index e93d2c99..00000000
--- a/scripts/release.mjs
+++ /dev/null
@@ -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}`)
- }
-}
diff --git a/scripts/bump-version.mjs b/scripts/release/bump.mjs
similarity index 100%
rename from scripts/bump-version.mjs
rename to scripts/release/bump.mjs
diff --git a/scripts/release/ci.mjs b/scripts/release/ci.mjs
new file mode 100644
index 00000000..027ee6bc
--- /dev/null
+++ b/scripts/release/ci.mjs
@@ -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])
diff --git a/scripts/release/lib/android.mjs b/scripts/release/lib/android.mjs
new file mode 100644
index 00000000..e96e01f4
--- /dev/null
+++ b/scripts/release/lib/android.mjs
@@ -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
+}
diff --git a/scripts/release/appstore.mjs b/scripts/release/lib/appstore.mjs
similarity index 100%
rename from scripts/release/appstore.mjs
rename to scripts/release/lib/appstore.mjs
diff --git a/scripts/release/lib/context.mjs b/scripts/release/lib/context.mjs
new file mode 100644
index 00000000..22760faf
--- /dev/null
+++ b/scripts/release/lib/context.mjs
@@ -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 = []
diff --git a/scripts/release/gitea.mjs b/scripts/release/lib/gitea.mjs
similarity index 100%
rename from scripts/release/gitea.mjs
rename to scripts/release/lib/gitea.mjs
diff --git a/scripts/release/lib/pipeline.mjs b/scripts/release/lib/pipeline.mjs
new file mode 100644
index 00000000..19eac14f
--- /dev/null
+++ b/scripts/release/lib/pipeline.mjs
@@ -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}`)
+ }
+ }
+}
diff --git a/scripts/release/play.mjs b/scripts/release/lib/play.mjs
similarity index 100%
rename from scripts/release/play.mjs
rename to scripts/release/lib/play.mjs
diff --git a/scripts/release/shell.mjs b/scripts/release/lib/shell.mjs
similarity index 93%
rename from scripts/release/shell.mjs
rename to scripts/release/lib/shell.mjs
index 77c45e3e..fc57cdc5 100644
--- a/scripts/release/shell.mjs
+++ b/scripts/release/lib/shell.mjs
@@ -39,3 +39,8 @@ export const ask = async question => {
readline.close()
}
}
+
+export const fail = message => {
+ console.error(red(message))
+ process.exit(1)
+}
diff --git a/scripts/release/local.mjs b/scripts/release/local.mjs
new file mode 100644
index 00000000..e13c59a4
--- /dev/null
+++ b/scripts/release/local.mjs
@@ -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])
diff --git a/scripts/release/steps/apk.mjs b/scripts/release/steps/apk.mjs
new file mode 100644
index 00000000..80322c32
--- /dev/null
+++ b/scripts/release/steps/apk.mjs
@@ -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)
+ },
+}
diff --git a/scripts/release/steps/desktop.mjs b/scripts/release/steps/desktop.mjs
new file mode 100644
index 00000000..4cc2cebc
--- /dev/null
+++ b/scripts/release/steps/desktop.mjs
@@ -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",
+ )
+ },
+}
diff --git a/scripts/release/steps/fdroid.mjs b/scripts/release/steps/fdroid.mjs
new file mode 100644
index 00000000..75a1a24b
--- /dev/null
+++ b/scripts/release/steps/fdroid.mjs
@@ -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})
+ }
+ },
+}
diff --git a/scripts/release/steps/gitea.mjs b/scripts/release/steps/gitea.mjs
new file mode 100644
index 00000000..29932b52
--- /dev/null
+++ b/scripts/release/steps/gitea.mjs
@@ -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)
+ }
+ }
+ },
+}
diff --git a/scripts/release/steps/ios.mjs b/scripts/release/steps/ios.mjs
new file mode 100644
index 00000000..80294c62
--- /dev/null
+++ b/scripts/release/steps/ios.mjs
@@ -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",
+ )
+ },
+}
diff --git a/scripts/release/steps/play.mjs b/scripts/release/steps/play.mjs
new file mode 100644
index 00000000..0697e827
--- /dev/null
+++ b/scripts/release/steps/play.mjs
@@ -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`,
+ )
+ },
+}
diff --git a/scripts/release/steps/web.mjs b/scripts/release/steps/web.mjs
new file mode 100644
index 00000000..f85b2f91
--- /dev/null
+++ b/scripts/release/steps/web.mjs
@@ -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}),
+}
diff --git a/scripts/release/steps/zapstore.mjs b/scripts/release/steps/zapstore.mjs
new file mode 100644
index 00000000..47e1c9f5
--- /dev/null
+++ b/scripts/release/steps/zapstore.mjs
@@ -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}),
+}