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}), +}