Split the release into a local run for signed builds and a tag-triggered CI run for the rest, and sort the scripts into folders
This commit is contained in:
parent
2caf8be20f
commit
e47957b4c2
35 changed files with 722 additions and 644 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
65
README.md
65
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 |
|
||||
|
|
|
|||
|
|
@ -461,7 +461,7 @@ the story catalog in <code>e2e/USER_STORIES.md</code>.</p>
|
|||
<tr>
|
||||
<td>Branded Android app</td>
|
||||
<td><span class="status s-ok">Implemented</span></td>
|
||||
<td><code>android/</code>, <code>@capacitor/assets</code>, <code>pnpm release</code></td>
|
||||
<td><code>android/</code>, <code>@capacitor/assets</code>, <code>pnpm release:local</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Branded iOS app</td>
|
||||
|
|
@ -471,7 +471,7 @@ the story catalog in <code>e2e/USER_STORIES.md</code>.</p>
|
|||
<tr>
|
||||
<td>Branded desktop app</td>
|
||||
<td><span class="status s-ok">Implemented</span></td>
|
||||
<td><code>electron/</code>, <code>scripts/package-desktop.mjs</code>; <code>pnpm run package:desktop:linux</code> / <code>:windows</code> / <code>:macos</code></td>
|
||||
<td><code>electron/</code>, <code>scripts/desktop/package.mjs</code>; <code>pnpm run package:desktop:linux</code> / <code>:windows</code> / <code>:macos</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>PWA / installable web app</td>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
23
package.json
23
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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -1,544 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
import {existsSync} from "node:fs"
|
||||
import {mkdtemp, readFile, readdir, rename, rm} from "node:fs/promises"
|
||||
import {tmpdir} from "node:os"
|
||||
import {dirname, join, resolve} from "node:path"
|
||||
import {fileURLToPath} from "node:url"
|
||||
import {parseArgs} from "node:util"
|
||||
import {config} from "dotenv"
|
||||
import {uploadToAppStore} from "./release/appstore.mjs"
|
||||
import {gitea} from "./release/gitea.mjs"
|
||||
import {uploadToPlay} from "./release/play.mjs"
|
||||
import {ask, bold, dim, green, installed, output, red, run, yellow} from "./release/shell.mjs"
|
||||
|
||||
const root = fileURLToPath(new URL("../", import.meta.url))
|
||||
|
||||
config({path: join(root, ".env.local")})
|
||||
|
||||
const fail = message => {
|
||||
console.error(red(message))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let args
|
||||
|
||||
try {
|
||||
args = parseArgs({
|
||||
options: {
|
||||
check: {type: "boolean", default: false},
|
||||
yes: {type: "boolean", short: "y", default: false},
|
||||
},
|
||||
allowPositionals: true,
|
||||
})
|
||||
} catch (error) {
|
||||
fail(`${error.message}\nUsage: pnpm release [--check] [--yes] [step...]`)
|
||||
}
|
||||
|
||||
const {values: options, positionals: chosen} = args
|
||||
|
||||
const {name, version} = JSON.parse(await readFile(join(root, "package.json"), "utf-8"))
|
||||
const changelog = await readFile(join(root, "CHANGELOG.md"), "utf-8")
|
||||
const zapstore = await readFile(join(root, "zapstore.yaml"), "utf-8")
|
||||
const gradleConfig = await readFile(join(root, "android/app/build.gradle"), "utf-8")
|
||||
|
||||
const zapstoreField = key => {
|
||||
const match = zapstore.match(new RegExp(`^${key}:\\s*(\\S+)\\s*$`, "m"))
|
||||
|
||||
if (!match) {
|
||||
fail(`zapstore.yaml is missing ${key}`)
|
||||
}
|
||||
|
||||
return match[1]
|
||||
}
|
||||
|
||||
const appId = gradleConfig.match(/applicationId "(.+)"/)[1]
|
||||
const repository = new URL(zapstoreField("repository"))
|
||||
const apk = join(root, zapstoreField("release_source"))
|
||||
const aab = join(root, "android/app/build/outputs/bundle/release/app-release.aab")
|
||||
const desktopDist = join(root, "electron/dist")
|
||||
|
||||
const lines = changelog.split("\n")
|
||||
const heading = lines.indexOf(`# ${version}`)
|
||||
const remainder = heading < 0 ? [] : lines.slice(heading + 1)
|
||||
const nextHeading = remainder.findIndex(line => line.startsWith("# "))
|
||||
const notes = (nextHeading < 0 ? remainder : remainder.slice(0, nextHeading)).join("\n").trim()
|
||||
|
||||
const git = (...gitArgs) => {
|
||||
try {
|
||||
return output("git", gitArgs, {cwd: root, stdio: ["ignore", "pipe", "ignore"]})
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const missingEnv = (...keys) => keys.filter(key => !process.env[key])
|
||||
|
||||
const keystoreEnv = prefix => ({
|
||||
ANDROID_KEYSTORE_PATH: resolve(root, process.env[`${prefix}_KEYSTORE_PATH`]),
|
||||
ANDROID_KEYSTORE_PASSWORD: process.env[`${prefix}_KEYSTORE_PASSWORD`],
|
||||
ANDROID_KEYSTORE_ALIAS: process.env[`${prefix}_KEYSTORE_ALIAS`],
|
||||
ANDROID_KEYSTORE_ALIAS_PASSWORD:
|
||||
process.env[`${prefix}_KEYSTORE_ALIAS_PASSWORD`] ?? process.env[`${prefix}_KEYSTORE_PASSWORD`],
|
||||
})
|
||||
|
||||
// A fresh jvm per build, so a reused daemon can't hand one gradle run the other's signing key
|
||||
const gradle = (task, signing) =>
|
||||
run("./gradlew", ["--no-daemon", task], {
|
||||
cwd: join(root, "android"),
|
||||
env: {...process.env, ...signing},
|
||||
})
|
||||
|
||||
// Gradle records what it actually built beside the apk, the only version stamp on an artifact
|
||||
// whose filename never changes
|
||||
const apkMetadata = async () => {
|
||||
const path = join(dirname(apk), "output-metadata.json")
|
||||
|
||||
if (!existsSync(path)) {
|
||||
throw new Error(`${path} is missing; run pnpm release apk`)
|
||||
}
|
||||
|
||||
const {elements} = JSON.parse(await readFile(path, "utf-8"))
|
||||
const [element] = elements
|
||||
|
||||
if (element.versionName !== version) {
|
||||
throw new Error(`the last android build was ${element.versionName}, not ${version}`)
|
||||
}
|
||||
|
||||
return element
|
||||
}
|
||||
|
||||
const desktopTargets = {
|
||||
darwin: ["macos", "linux", "windows"],
|
||||
linux: ["linux", "windows"],
|
||||
win32: [],
|
||||
}
|
||||
const hostTargets = desktopTargets[process.platform] ?? []
|
||||
const docker = process.env.DOCKER || "docker"
|
||||
|
||||
// Gitea's latest release is the desktop update feed, so it only goes public once every platform's
|
||||
// manifest is on it
|
||||
const desktopManifests = ["latest.yml", "latest-linux.yml", "latest-mac.yml"]
|
||||
|
||||
const packagedDesktop = async () => {
|
||||
const files = existsSync(desktopDist) ? await readdir(desktopDist) : []
|
||||
const manifests = []
|
||||
|
||||
// Manifest names carry no version, so a leftover from an older build is told apart by its contents
|
||||
for (const file of files.filter(file => desktopManifests.includes(file))) {
|
||||
const text = await readFile(join(desktopDist, file), "utf-8")
|
||||
|
||||
if (text.match(/^version: '?([^'\s]+)'?$/m)?.[1] === version) {
|
||||
manifests.push({
|
||||
file,
|
||||
urls: [...text.matchAll(/^\s*- url: '?(.+?)'?$/gm)].map(match => match[1]),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
artifacts: files.filter(
|
||||
file => file.includes(version) && /\.(dmg|zip|AppImage|exe|blockmap)$/.test(file),
|
||||
),
|
||||
manifests,
|
||||
}
|
||||
}
|
||||
|
||||
const followUps = []
|
||||
|
||||
const steps = [
|
||||
{
|
||||
name: "web",
|
||||
title: "Build the web bundle and sync the native projects",
|
||||
run: () => run("bash", ["scripts/build.sh"], {cwd: root}),
|
||||
},
|
||||
{
|
||||
name: "apk",
|
||||
title: "Build the APK, signed with the distribution key",
|
||||
missing: () =>
|
||||
missingEnv("ANDROID_KEYSTORE_PATH", "ANDROID_KEYSTORE_PASSWORD", "ANDROID_KEYSTORE_ALIAS"),
|
||||
setup: [
|
||||
"Set ANDROID_KEYSTORE_PATH, ANDROID_KEYSTORE_PASSWORD and ANDROID_KEYSTORE_ALIAS in",
|
||||
".env.local (plus ANDROID_KEYSTORE_ALIAS_PASSWORD if the alias has its own password).",
|
||||
"This is the key gitea, zapstore and Obtainium updates are signed with, so it has to stay",
|
||||
"the same one forever.",
|
||||
],
|
||||
run: async () => {
|
||||
await gradle("assembleRelease", keystoreEnv("ANDROID"))
|
||||
|
||||
const {outputFile} = await apkMetadata()
|
||||
|
||||
await rename(join(dirname(apk), outputFile), apk)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fdroid",
|
||||
title: "Rebuild the tag the way F-Droid will",
|
||||
missing: () =>
|
||||
git("cat-file", "-e", `${version}:scripts/prepare-fdroid-source.sh`) === undefined
|
||||
? [`F-Droid support in the ${version} tag`]
|
||||
: [],
|
||||
setup: [
|
||||
`The ${version} tag is older than fdroid/, so there is nothing for F-Droid to build from it.`,
|
||||
"Name the steps you do want, or release a tag that has it.",
|
||||
],
|
||||
run: async () => {
|
||||
const parent = await mkdtemp(join(tmpdir(), "flotilla-fdroid-"))
|
||||
const checkout = join(parent, "flotilla")
|
||||
|
||||
// Preparation rewrites source and dependencies in place, so it only runs against a checkout
|
||||
// that can be thrown away
|
||||
try {
|
||||
await run("git", ["worktree", "add", "--detach", checkout, version], {cwd: root})
|
||||
await run("./scripts/prepare-fdroid-source.sh", [], {cwd: checkout})
|
||||
await run("./scripts/build-fdroid-assets.sh", [], {cwd: checkout})
|
||||
|
||||
// F-Droid signs its own builds, so keep the distribution key out of gradle's environment
|
||||
const env = {...process.env}
|
||||
|
||||
delete env.ANDROID_KEYSTORE_PATH
|
||||
|
||||
await run("./gradlew", ["--no-daemon", "assembleFdroidRelease"], {
|
||||
cwd: join(checkout, "android"),
|
||||
env,
|
||||
})
|
||||
|
||||
const built = join(
|
||||
checkout,
|
||||
"android/app/build/outputs/apk/fdroid/release/app-fdroid-release-unsigned.apk",
|
||||
)
|
||||
|
||||
if (!existsSync(built)) {
|
||||
throw new Error(`the F-Droid build produced no apk at ${built}`)
|
||||
}
|
||||
} finally {
|
||||
await rm(parent, {recursive: true, force: true})
|
||||
await run("git", ["worktree", "prune"], {cwd: root})
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "play",
|
||||
title: "Build the AAB and upload it to Google Play",
|
||||
missing: () =>
|
||||
missingEnv(
|
||||
"PLAY_KEYSTORE_PATH",
|
||||
"PLAY_KEYSTORE_PASSWORD",
|
||||
"PLAY_KEYSTORE_ALIAS",
|
||||
"PLAY_SERVICE_ACCOUNT",
|
||||
),
|
||||
setup: [
|
||||
"PLAY_KEYSTORE_PATH, PLAY_KEYSTORE_PASSWORD, PLAY_KEYSTORE_ALIAS (and",
|
||||
"PLAY_KEYSTORE_ALIAS_PASSWORD) are the upload key Android Studio has been signing with.",
|
||||
"PLAY_SERVICE_ACCOUNT is the path to a service account json:",
|
||||
" 1. Play Console -> Setup -> API access, link or create a Google Cloud project",
|
||||
" 2. Create a service account there, then grant it the Release manager role on this app",
|
||||
" 3. Google Cloud -> that service account -> Keys -> Add key -> JSON, save it outside the repo",
|
||||
"PLAY_TRACK (default production) and PLAY_STATUS (default draft) are optional.",
|
||||
],
|
||||
run: async () => {
|
||||
await gradle("bundleRelease", keystoreEnv("PLAY"))
|
||||
|
||||
const track = process.env.PLAY_TRACK ?? "production"
|
||||
const status = process.env.PLAY_STATUS ?? "draft"
|
||||
const versionCode = await uploadToPlay({
|
||||
credentials: JSON.parse(
|
||||
await readFile(resolve(root, process.env.PLAY_SERVICE_ACCOUNT), "utf-8"),
|
||||
),
|
||||
packageName: appId,
|
||||
bundle: await readFile(aab),
|
||||
track,
|
||||
status,
|
||||
// Play rejects release notes over 500 characters
|
||||
notes: notes.slice(0, 500),
|
||||
})
|
||||
|
||||
followUps.push(
|
||||
`Play Console: ${version} (${versionCode}) is a ${status} release on the ${track} track, review and roll it out at https://play.google.com/console`,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ios",
|
||||
title: "Archive the iOS app and upload it to App Store Connect",
|
||||
missing: () => [
|
||||
...(process.platform === "darwin" ? [] : ["macOS with Xcode"]),
|
||||
...missingEnv("ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_KEY_PATH"),
|
||||
],
|
||||
setup: [
|
||||
"App Store Connect -> Users and Access -> Integrations -> App Store Connect API, generate a",
|
||||
"team key with the App Manager role. Download the .p8 (only offered once), keep it outside",
|
||||
"the repo, and set ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_PATH in .env.local.",
|
||||
],
|
||||
run: async () => {
|
||||
await run("npx", ["cap", "build", "ios"], {cwd: root})
|
||||
|
||||
const directory = join(root, "ios/App/output")
|
||||
const ipa = (await readdir(directory)).find(file => file.endsWith(".ipa"))
|
||||
|
||||
if (!ipa) {
|
||||
throw new Error(`No ipa was exported to ${directory}`)
|
||||
}
|
||||
|
||||
await uploadToAppStore({
|
||||
ipa: join(directory, ipa),
|
||||
keyId: process.env.ASC_KEY_ID,
|
||||
issuerId: process.env.ASC_ISSUER_ID,
|
||||
keyPath: resolve(root, process.env.ASC_KEY_PATH),
|
||||
})
|
||||
|
||||
followUps.push(
|
||||
"App Store Connect: once the build finishes processing, add it to a version and submit for review at https://appstoreconnect.apple.com",
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "desktop",
|
||||
title: "Package the desktop app",
|
||||
missing: () => [
|
||||
...(hostTargets.length > 0 ? [] : [`desktop packaging on ${process.platform}`]),
|
||||
...(existsSync(join(root, "electron/node_modules")) ? [] : ["electron dependencies"]),
|
||||
...(hostTargets.includes("windows") && !installed(docker) ? [docker] : []),
|
||||
...(hostTargets.includes("macos")
|
||||
? missingEnv("CSC_NAME", "ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_KEY_PATH")
|
||||
: []),
|
||||
],
|
||||
setup: [
|
||||
"Run npm ci --prefix electron. Each platform's packages have to be built on that platform,",
|
||||
"so on Linux run pnpm release desktop gitea on a Mac to add the macOS ones to the release.",
|
||||
"Linux and macOS build the other platforms' packages in a container, which needs docker,",
|
||||
"or set DOCKER=podman in .env.local.",
|
||||
"macOS only installs updates to a signed app, so macOS packages are signed and notarized:",
|
||||
"set CSC_NAME to the name of the Developer ID Application certificate in your keychain,",
|
||||
"without its prefix, and the ASC_* key the ios step uses notarizes them.",
|
||||
],
|
||||
run: async () => {
|
||||
for (const target of hostTargets) {
|
||||
await run("pnpm", ["run", `package:desktop:${target}`], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
...(target === "macos" && {
|
||||
APPLE_API_KEY: resolve(root, process.env.ASC_KEY_PATH),
|
||||
APPLE_API_KEY_ID: process.env.ASC_KEY_ID,
|
||||
APPLE_API_ISSUER: process.env.ASC_ISSUER_ID,
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const elsewhere = Object.values(desktopTargets)
|
||||
.flat()
|
||||
.filter(target => !hostTargets.includes(target))
|
||||
|
||||
if (elsewhere.length > 0) {
|
||||
followUps.push(
|
||||
`Desktop: ${elsewhere.join(" and ")} packages have to be built on those platforms, then attached with pnpm release desktop gitea`,
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gitea",
|
||||
title: "Publish the gitea release and attach the artifacts",
|
||||
missing: () => missingEnv("GITEA_TOKEN"),
|
||||
setup: [
|
||||
`Generate an access token at ${repository.origin}/user/settings/applications with the`,
|
||||
"write:repository scope, and set GITEA_TOKEN in .env.local.",
|
||||
],
|
||||
run: async () => {
|
||||
const api = gitea({repository, token: process.env.GITEA_TOKEN})
|
||||
|
||||
if (!(await api.hasTag(version))) {
|
||||
throw new Error(`${repository} has no ${version} tag; push it before publishing`)
|
||||
}
|
||||
|
||||
if (existsSync(apk)) {
|
||||
await apkMetadata()
|
||||
}
|
||||
|
||||
const apkName = `${name}-${version}.apk`
|
||||
const {artifacts, manifests} = await packagedDesktop()
|
||||
const files = [
|
||||
...(existsSync(apk) ? [[apk, apkName]] : []),
|
||||
...artifacts.map(file => [join(desktopDist, file), file]),
|
||||
]
|
||||
|
||||
if (files.length + manifests.length === 0) {
|
||||
throw new Error("Nothing to attach; build the apk or the desktop packages first")
|
||||
}
|
||||
|
||||
const release = await api.upsertRelease(version, notes)
|
||||
const attach = async (path, filename) =>
|
||||
console.log(dim(` ${await api.attach(release.id, filename, await readFile(path))}`))
|
||||
|
||||
for (const [path, filename] of files) {
|
||||
await attach(path, filename)
|
||||
}
|
||||
|
||||
// An updater acts on a manifest the moment it can read one, so what it points to goes up first
|
||||
const attached = await api.assetNames(release.id)
|
||||
|
||||
for (const {file, urls} of manifests) {
|
||||
const absent = urls.filter(url => !attached.includes(url))
|
||||
|
||||
if (absent.length > 0) {
|
||||
throw new Error(`${file} points to ${absent.join(", ")}, which the release doesn't have`)
|
||||
}
|
||||
|
||||
await attach(join(desktopDist, file), file)
|
||||
}
|
||||
|
||||
if (release.draft) {
|
||||
const names = await api.assetNames(release.id)
|
||||
const missing = [apkName, ...desktopManifests].filter(file => !names.includes(file))
|
||||
|
||||
if (missing.length > 0) {
|
||||
followUps.push(
|
||||
`Gitea: ${version} stays a draft until it has ${missing.join(", ")}; build them and run pnpm release gitea to publish it`,
|
||||
)
|
||||
} else {
|
||||
await api.publish(release.id)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "zapstore",
|
||||
title: "Publish the APK to zapstore",
|
||||
missing: () => [
|
||||
...(installed("zsp") ? [] : ["zsp (not installed)"]),
|
||||
...missingEnv("SIGN_WITH"),
|
||||
],
|
||||
setup: [
|
||||
"Install zsp from https://github.com/zapstore/zsp, then set SIGN_WITH in .env.local to an",
|
||||
"nsec, a bunker:// url, or `browser` to sign with a nostr extension.",
|
||||
],
|
||||
run: () => run("zsp", ["publish", "zapstore.yaml"], {cwd: root}),
|
||||
},
|
||||
]
|
||||
|
||||
const unknownStep = chosen.find(step => !steps.some(({name}) => name === step))
|
||||
|
||||
if (unknownStep) {
|
||||
fail(`Unknown step ${unknownStep}. Steps: ${steps.map(step => step.name).join(", ")}`)
|
||||
}
|
||||
|
||||
const selected = steps.filter(step =>
|
||||
chosen.length > 0 ? chosen.includes(step.name) : !step.optional,
|
||||
)
|
||||
|
||||
const width = Math.max(...selected.map(step => step.name.length))
|
||||
const problems = selected.map(step => ({step, missing: step.missing?.() ?? []}))
|
||||
const warnings = []
|
||||
|
||||
if (!notes) {
|
||||
problems.push({missing: [`CHANGELOG.md has no "# ${version}" section`]})
|
||||
}
|
||||
|
||||
if (git("rev-parse", `refs/tags/${version}`)) {
|
||||
const pushed = git("ls-remote", "--tags", "origin", `refs/tags/${version}`)
|
||||
|
||||
if (pushed === undefined) {
|
||||
warnings.push("couldn't reach origin to check whether the tag is pushed")
|
||||
} else if (!pushed) {
|
||||
problems.push({missing: [`the ${version} tag is not on origin: git push origin ${version}`]})
|
||||
}
|
||||
|
||||
if (git("rev-parse", "HEAD") !== git("rev-parse", `refs/tags/${version}^{commit}`)) {
|
||||
warnings.push(`HEAD is not the ${version} tag, so the build won't match what you tagged`)
|
||||
}
|
||||
} else {
|
||||
problems.push({
|
||||
missing: [`there is no ${version} tag: git tag ${version} && git push origin ${version}`],
|
||||
})
|
||||
}
|
||||
|
||||
if (git("status", "--porcelain")) {
|
||||
warnings.push("the working tree has uncommitted changes")
|
||||
}
|
||||
|
||||
console.log(bold(`\n${name} ${version} -> ${repository.host}${repository.pathname}\n`))
|
||||
|
||||
for (const step of selected) {
|
||||
console.log(` ${step.name.padEnd(width)} ${step.manual ? dim(step.title) : step.title}`)
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
console.log("")
|
||||
|
||||
for (const warning of warnings) {
|
||||
console.log(yellow(` ! ${warning}`))
|
||||
}
|
||||
}
|
||||
|
||||
const blocked = problems.filter(({missing}) => missing.length > 0)
|
||||
|
||||
if (blocked.length > 0) {
|
||||
console.log("")
|
||||
|
||||
for (const {step, missing} of blocked) {
|
||||
console.log(red(` x ${step ? `${step.name}: missing ${missing.join(", ")}` : missing[0]}`))
|
||||
|
||||
for (const line of step?.setup ?? []) {
|
||||
console.log(dim(` ${line}`))
|
||||
}
|
||||
}
|
||||
|
||||
fail("\nNothing ran.")
|
||||
}
|
||||
|
||||
if (options.check) {
|
||||
console.log(green("\nReady to go."))
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (!options.yes) {
|
||||
if (!process.stdin.isTTY) {
|
||||
fail("Not a terminal; pass --yes to run unattended")
|
||||
}
|
||||
|
||||
const answer = await ask(`\nRelease ${version}? [y/N] `)
|
||||
|
||||
if (!["y", "yes"].includes(answer.trim().toLowerCase())) {
|
||||
fail("Aborted.")
|
||||
}
|
||||
}
|
||||
|
||||
const done = []
|
||||
|
||||
for (const [index, step] of selected.entries()) {
|
||||
if (step.manual) {
|
||||
followUps.push(step.manual.join(" "))
|
||||
continue
|
||||
}
|
||||
|
||||
console.log(bold(`\n> ${step.title}`))
|
||||
|
||||
const started = Date.now()
|
||||
|
||||
try {
|
||||
await step.run()
|
||||
} catch (error) {
|
||||
console.error(red(`\n${step.name} failed: ${error.message}`))
|
||||
|
||||
const remaining = selected.slice(index).map(remainingStep => remainingStep.name)
|
||||
|
||||
fail(`Pick up where this left off with: pnpm release ${remaining.join(" ")}`)
|
||||
}
|
||||
|
||||
done.push(`${step.name.padEnd(width)} ${Math.round((Date.now() - started) / 1000)}s`)
|
||||
}
|
||||
|
||||
console.log(bold(`\n${name} ${version}\n`))
|
||||
|
||||
for (const line of done) {
|
||||
console.log(` ${green("done")} ${line}`)
|
||||
}
|
||||
|
||||
if (followUps.length > 0) {
|
||||
console.log(bold("\nLeft to do by hand"))
|
||||
|
||||
for (const followUp of followUps) {
|
||||
console.log(` - ${followUp}`)
|
||||
}
|
||||
}
|
||||
8
scripts/release/ci.mjs
Normal file
8
scripts/release/ci.mjs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#!/usr/bin/env node
|
||||
// Everything that needs no key but the job's own gitea token, run by .gitea/workflows/release.yml
|
||||
import {release} from "./lib/pipeline.mjs"
|
||||
import desktop from "./steps/desktop.mjs"
|
||||
import fdroid from "./steps/fdroid.mjs"
|
||||
import gitea from "./steps/gitea.mjs"
|
||||
|
||||
await release("pnpm release:ci", [fdroid, desktop, gitea])
|
||||
39
scripts/release/lib/android.mjs
Normal file
39
scripts/release/lib/android.mjs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import {existsSync} from "node:fs"
|
||||
import {readFile} from "node:fs/promises"
|
||||
import {dirname, join, resolve} from "node:path"
|
||||
import {apk, root, version} from "./context.mjs"
|
||||
import {run} from "./shell.mjs"
|
||||
|
||||
export const keystoreEnv = prefix => ({
|
||||
ANDROID_KEYSTORE_PATH: resolve(root, process.env[`${prefix}_KEYSTORE_PATH`]),
|
||||
ANDROID_KEYSTORE_PASSWORD: process.env[`${prefix}_KEYSTORE_PASSWORD`],
|
||||
ANDROID_KEYSTORE_ALIAS: process.env[`${prefix}_KEYSTORE_ALIAS`],
|
||||
ANDROID_KEYSTORE_ALIAS_PASSWORD:
|
||||
process.env[`${prefix}_KEYSTORE_ALIAS_PASSWORD`] ?? process.env[`${prefix}_KEYSTORE_PASSWORD`],
|
||||
})
|
||||
|
||||
// A fresh jvm per build, so a reused daemon can't hand one gradle run the other's signing key
|
||||
export const gradle = (task, signing) =>
|
||||
run("./gradlew", ["--no-daemon", task], {
|
||||
cwd: join(root, "android"),
|
||||
env: {...process.env, ...signing},
|
||||
})
|
||||
|
||||
// Gradle records what it actually built beside the apk, the only version stamp on an artifact
|
||||
// whose filename never changes
|
||||
export const apkMetadata = async () => {
|
||||
const path = join(dirname(apk), "output-metadata.json")
|
||||
|
||||
if (!existsSync(path)) {
|
||||
throw new Error(`${path} is missing; run pnpm release:local apk`)
|
||||
}
|
||||
|
||||
const {elements} = JSON.parse(await readFile(path, "utf-8"))
|
||||
const [element] = elements
|
||||
|
||||
if (element.versionName !== version) {
|
||||
throw new Error(`the last android build was ${element.versionName}, not ${version}`)
|
||||
}
|
||||
|
||||
return element
|
||||
}
|
||||
48
scripts/release/lib/context.mjs
Normal file
48
scripts/release/lib/context.mjs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import {readFile} from "node:fs/promises"
|
||||
import {join} from "node:path"
|
||||
import {fileURLToPath} from "node:url"
|
||||
import {config} from "dotenv"
|
||||
import {fail, output} from "./shell.mjs"
|
||||
|
||||
export const root = fileURLToPath(new URL("../../../", import.meta.url))
|
||||
|
||||
config({path: join(root, ".env.local")})
|
||||
|
||||
export const {name, version} = JSON.parse(await readFile(join(root, "package.json"), "utf-8"))
|
||||
|
||||
const changelog = await readFile(join(root, "CHANGELOG.md"), "utf-8")
|
||||
const zapstore = await readFile(join(root, "zapstore.yaml"), "utf-8")
|
||||
|
||||
const zapstoreField = key => {
|
||||
const match = zapstore.match(new RegExp(`^${key}:\\s*(\\S+)\\s*$`, "m"))
|
||||
|
||||
if (!match) {
|
||||
fail(`zapstore.yaml is missing ${key}`)
|
||||
}
|
||||
|
||||
return match[1]
|
||||
}
|
||||
|
||||
export const repository = new URL(zapstoreField("repository"))
|
||||
export const apk = join(root, zapstoreField("release_source"))
|
||||
|
||||
const lines = changelog.split("\n")
|
||||
const heading = lines.indexOf(`# ${version}`)
|
||||
const remainder = heading < 0 ? [] : lines.slice(heading + 1)
|
||||
const nextHeading = remainder.findIndex(line => line.startsWith("# "))
|
||||
|
||||
export const notes = (nextHeading < 0 ? remainder : remainder.slice(0, nextHeading))
|
||||
.join("\n")
|
||||
.trim()
|
||||
|
||||
export const git = (...gitArgs) => {
|
||||
try {
|
||||
return output("git", gitArgs, {cwd: root, stdio: ["ignore", "pipe", "ignore"]})
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const missingEnv = (...keys) => keys.filter(key => !process.env[key])
|
||||
|
||||
export const followUps = []
|
||||
138
scripts/release/lib/pipeline.mjs
Normal file
138
scripts/release/lib/pipeline.mjs
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import {parseArgs} from "node:util"
|
||||
import {followUps, git, name, notes, repository, version} from "./context.mjs"
|
||||
import {ask, bold, dim, fail, green, red, yellow} from "./shell.mjs"
|
||||
|
||||
export const release = async (command, steps) => {
|
||||
let args
|
||||
|
||||
try {
|
||||
args = parseArgs({
|
||||
options: {
|
||||
check: {type: "boolean", default: false},
|
||||
yes: {type: "boolean", short: "y", default: false},
|
||||
},
|
||||
allowPositionals: true,
|
||||
})
|
||||
} catch (error) {
|
||||
fail(`${error.message}\nUsage: ${command} [--check] [--yes] [step...]`)
|
||||
}
|
||||
|
||||
const {values: options, positionals: chosen} = args
|
||||
const unknownStep = chosen.find(step => !steps.some(({name}) => name === step))
|
||||
|
||||
if (unknownStep) {
|
||||
fail(`Unknown step ${unknownStep}. Steps: ${steps.map(step => step.name).join(", ")}`)
|
||||
}
|
||||
|
||||
const selected = chosen.length > 0 ? steps.filter(step => chosen.includes(step.name)) : steps
|
||||
const width = Math.max(...selected.map(step => step.name.length))
|
||||
const problems = selected.map(step => ({step, missing: step.missing?.() ?? []}))
|
||||
const warnings = []
|
||||
|
||||
if (!notes) {
|
||||
problems.push({missing: [`CHANGELOG.md has no "# ${version}" section`]})
|
||||
}
|
||||
|
||||
if (git("rev-parse", `refs/tags/${version}`)) {
|
||||
const pushed = git("ls-remote", "--tags", "origin", `refs/tags/${version}`)
|
||||
|
||||
if (pushed === undefined) {
|
||||
warnings.push("couldn't reach origin to check whether the tag is pushed")
|
||||
} else if (!pushed) {
|
||||
problems.push({missing: [`the ${version} tag is not on origin: git push origin ${version}`]})
|
||||
}
|
||||
|
||||
if (git("rev-parse", "HEAD") !== git("rev-parse", `refs/tags/${version}^{commit}`)) {
|
||||
warnings.push(`HEAD is not the ${version} tag, so the build won't match what you tagged`)
|
||||
}
|
||||
} else {
|
||||
problems.push({
|
||||
missing: [`there is no ${version} tag: git tag ${version} && git push origin ${version}`],
|
||||
})
|
||||
}
|
||||
|
||||
if (git("status", "--porcelain")) {
|
||||
warnings.push("the working tree has uncommitted changes")
|
||||
}
|
||||
|
||||
console.log(bold(`\n${name} ${version} -> ${repository.host}${repository.pathname}\n`))
|
||||
|
||||
for (const step of selected) {
|
||||
console.log(` ${step.name.padEnd(width)} ${step.title}`)
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
console.log("")
|
||||
|
||||
for (const warning of warnings) {
|
||||
console.log(yellow(` ! ${warning}`))
|
||||
}
|
||||
}
|
||||
|
||||
const blocked = problems.filter(({missing}) => missing.length > 0)
|
||||
|
||||
if (blocked.length > 0) {
|
||||
console.log("")
|
||||
|
||||
for (const {step, missing} of blocked) {
|
||||
console.log(red(` x ${step ? `${step.name}: missing ${missing.join(", ")}` : missing[0]}`))
|
||||
|
||||
for (const line of step?.setup ?? []) {
|
||||
console.log(dim(` ${line}`))
|
||||
}
|
||||
}
|
||||
|
||||
fail("\nNothing ran.")
|
||||
}
|
||||
|
||||
if (options.check) {
|
||||
console.log(green("\nReady to go."))
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (!options.yes) {
|
||||
if (!process.stdin.isTTY) {
|
||||
fail("Not a terminal; pass --yes to run unattended")
|
||||
}
|
||||
|
||||
const answer = await ask(`\nRelease ${version}? [y/N] `)
|
||||
|
||||
if (!["y", "yes"].includes(answer.trim().toLowerCase())) {
|
||||
fail("Aborted.")
|
||||
}
|
||||
}
|
||||
|
||||
const done = []
|
||||
|
||||
for (const [index, step] of selected.entries()) {
|
||||
console.log(bold(`\n> ${step.title}`))
|
||||
|
||||
const started = Date.now()
|
||||
|
||||
try {
|
||||
await step.run()
|
||||
} catch (error) {
|
||||
console.error(red(`\n${step.name} failed: ${error.message}`))
|
||||
|
||||
const remaining = selected.slice(index).map(remainingStep => remainingStep.name)
|
||||
|
||||
fail(`Pick up where this left off with: ${command} ${remaining.join(" ")}`)
|
||||
}
|
||||
|
||||
done.push(`${step.name.padEnd(width)} ${Math.round((Date.now() - started) / 1000)}s`)
|
||||
}
|
||||
|
||||
console.log(bold(`\n${name} ${version}\n`))
|
||||
|
||||
for (const line of done) {
|
||||
console.log(` ${green("done")} ${line}`)
|
||||
}
|
||||
|
||||
if (followUps.length > 0) {
|
||||
console.log(bold("\nLeft to do by hand"))
|
||||
|
||||
for (const followUp of followUps) {
|
||||
console.log(` - ${followUp}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -39,3 +39,8 @@ export const ask = async question => {
|
|||
readline.close()
|
||||
}
|
||||
}
|
||||
|
||||
export const fail = message => {
|
||||
console.error(red(message))
|
||||
process.exit(1)
|
||||
}
|
||||
12
scripts/release/local.mjs
Normal file
12
scripts/release/local.mjs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
#!/usr/bin/env node
|
||||
// Everything that needs a signing key, so those keys never leave this machine
|
||||
import {release} from "./lib/pipeline.mjs"
|
||||
import apk from "./steps/apk.mjs"
|
||||
import desktop from "./steps/desktop.mjs"
|
||||
import gitea from "./steps/gitea.mjs"
|
||||
import ios from "./steps/ios.mjs"
|
||||
import play from "./steps/play.mjs"
|
||||
import web from "./steps/web.mjs"
|
||||
import zapstore from "./steps/zapstore.mjs"
|
||||
|
||||
await release("pnpm release:local", [web, apk, play, ios, desktop, gitea, zapstore])
|
||||
24
scripts/release/steps/apk.mjs
Normal file
24
scripts/release/steps/apk.mjs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import {rename} from "node:fs/promises"
|
||||
import {dirname, join} from "node:path"
|
||||
import {apkMetadata, gradle, keystoreEnv} from "../lib/android.mjs"
|
||||
import {apk, missingEnv} from "../lib/context.mjs"
|
||||
|
||||
export default {
|
||||
name: "apk",
|
||||
title: "Build the APK, signed with the distribution key",
|
||||
missing: () =>
|
||||
missingEnv("ANDROID_KEYSTORE_PATH", "ANDROID_KEYSTORE_PASSWORD", "ANDROID_KEYSTORE_ALIAS"),
|
||||
setup: [
|
||||
"Set ANDROID_KEYSTORE_PATH, ANDROID_KEYSTORE_PASSWORD and ANDROID_KEYSTORE_ALIAS in",
|
||||
".env.local (plus ANDROID_KEYSTORE_ALIAS_PASSWORD if the alias has its own password).",
|
||||
"This is the key gitea, zapstore and Obtainium updates are signed with, so it has to stay",
|
||||
"the same one forever.",
|
||||
],
|
||||
run: async () => {
|
||||
await gradle("assembleRelease", keystoreEnv("ANDROID"))
|
||||
|
||||
const {outputFile} = await apkMetadata()
|
||||
|
||||
await rename(join(dirname(apk), outputFile), apk)
|
||||
},
|
||||
}
|
||||
49
scripts/release/steps/desktop.mjs
Normal file
49
scripts/release/steps/desktop.mjs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import {existsSync} from "node:fs"
|
||||
import {join, resolve} from "node:path"
|
||||
import {followUps, missingEnv, root} from "../lib/context.mjs"
|
||||
import {installed, run} from "../lib/shell.mjs"
|
||||
|
||||
const targets = {darwin: ["macos"], linux: ["linux", "windows"]}[process.platform] ?? []
|
||||
const docker = process.env.DOCKER || "docker"
|
||||
|
||||
export default {
|
||||
name: "desktop",
|
||||
title: "Package the desktop app",
|
||||
missing: () => [
|
||||
...(targets.length > 0 ? [] : [`desktop packaging on ${process.platform}`]),
|
||||
...(existsSync(join(root, "electron/node_modules")) ? [] : ["electron dependencies"]),
|
||||
...(targets.includes("windows") && !installed(docker) ? [docker] : []),
|
||||
...(targets.includes("macos")
|
||||
? missingEnv("CSC_NAME", "ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_KEY_PATH")
|
||||
: []),
|
||||
],
|
||||
setup: [
|
||||
"Run npm ci --prefix electron. A Mac packages the macOS app, and Linux the Linux and Windows",
|
||||
"ones, which the release workflow does for every tag. Linux builds the Windows installer in a",
|
||||
"container, which needs docker, or set DOCKER=podman in .env.local.",
|
||||
"macOS only installs updates to a signed app, so macOS packages are signed and notarized:",
|
||||
"set CSC_NAME to the name of the Developer ID Application certificate in your keychain,",
|
||||
"without its prefix, and the ASC_* key the ios step uses notarizes them.",
|
||||
],
|
||||
run: async () => {
|
||||
for (const target of targets) {
|
||||
await run("pnpm", ["run", `package:desktop:${target}`], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
...(target === "macos" && {
|
||||
APPLE_API_KEY: resolve(root, process.env.ASC_KEY_PATH),
|
||||
APPLE_API_KEY_ID: process.env.ASC_KEY_ID,
|
||||
APPLE_API_ISSUER: process.env.ASC_ISSUER_ID,
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
followUps.push(
|
||||
targets.includes("macos")
|
||||
? "Desktop: the release workflow packages Linux and Windows for this tag and attaches them"
|
||||
: "Desktop: macOS packages have to be built on a Mac with pnpm release:local desktop gitea",
|
||||
)
|
||||
},
|
||||
}
|
||||
53
scripts/release/steps/fdroid.mjs
Normal file
53
scripts/release/steps/fdroid.mjs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import {existsSync} from "node:fs"
|
||||
import {mkdtemp, rm} from "node:fs/promises"
|
||||
import {tmpdir} from "node:os"
|
||||
import {join} from "node:path"
|
||||
import {git, root, version} from "../lib/context.mjs"
|
||||
import {run} from "../lib/shell.mjs"
|
||||
|
||||
export default {
|
||||
name: "fdroid",
|
||||
title: "Rebuild the tag the way F-Droid will",
|
||||
missing: () =>
|
||||
git("cat-file", "-e", `${version}:scripts/fdroid/prepare.sh`) === undefined
|
||||
? [`F-Droid support in the ${version} tag`]
|
||||
: [],
|
||||
setup: [
|
||||
`The ${version} tag is older than scripts/fdroid/, so there is nothing for F-Droid to build`,
|
||||
"from it. Name the steps you do want, or release a tag that has it.",
|
||||
],
|
||||
run: async () => {
|
||||
const parent = await mkdtemp(join(tmpdir(), "flotilla-fdroid-"))
|
||||
const checkout = join(parent, "flotilla")
|
||||
|
||||
// Preparation rewrites source and dependencies in place, so it only runs against a checkout
|
||||
// that can be thrown away
|
||||
try {
|
||||
await run("git", ["worktree", "add", "--detach", checkout, version], {cwd: root})
|
||||
await run("./scripts/fdroid/prepare.sh", [], {cwd: checkout})
|
||||
await run("./scripts/fdroid/build.sh", [], {cwd: checkout})
|
||||
|
||||
// F-Droid signs its own builds, so keep the distribution key out of gradle's environment
|
||||
const env = {...process.env}
|
||||
|
||||
delete env.ANDROID_KEYSTORE_PATH
|
||||
|
||||
await run("./gradlew", ["--no-daemon", "assembleFdroidRelease"], {
|
||||
cwd: join(checkout, "android"),
|
||||
env,
|
||||
})
|
||||
|
||||
const built = join(
|
||||
checkout,
|
||||
"android/app/build/outputs/apk/fdroid/release/app-fdroid-release-unsigned.apk",
|
||||
)
|
||||
|
||||
if (!existsSync(built)) {
|
||||
throw new Error(`the F-Droid build produced no apk at ${built}`)
|
||||
}
|
||||
} finally {
|
||||
await rm(parent, {recursive: true, force: true})
|
||||
await run("git", ["worktree", "prune"], {cwd: root})
|
||||
}
|
||||
},
|
||||
}
|
||||
112
scripts/release/steps/gitea.mjs
Normal file
112
scripts/release/steps/gitea.mjs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import {existsSync} from "node:fs"
|
||||
import {readFile, readdir} from "node:fs/promises"
|
||||
import {join} from "node:path"
|
||||
import {apkMetadata} from "../lib/android.mjs"
|
||||
import {
|
||||
apk,
|
||||
followUps,
|
||||
missingEnv,
|
||||
name,
|
||||
notes,
|
||||
repository,
|
||||
root,
|
||||
version,
|
||||
} from "../lib/context.mjs"
|
||||
import {gitea} from "../lib/gitea.mjs"
|
||||
import {dim} from "../lib/shell.mjs"
|
||||
|
||||
const desktopDist = join(root, "electron/dist")
|
||||
|
||||
// Gitea's latest release is the desktop update feed, so it only goes public once every platform's
|
||||
// manifest is on it
|
||||
const desktopManifests = ["latest.yml", "latest-linux.yml", "latest-mac.yml"]
|
||||
|
||||
const packagedDesktop = async () => {
|
||||
const files = existsSync(desktopDist) ? await readdir(desktopDist) : []
|
||||
const manifests = []
|
||||
|
||||
// Manifest names carry no version, so a leftover from an older build is told apart by its contents
|
||||
for (const file of files.filter(file => desktopManifests.includes(file))) {
|
||||
const text = await readFile(join(desktopDist, file), "utf-8")
|
||||
|
||||
if (text.match(/^version: '?([^'\s]+)'?$/m)?.[1] === version) {
|
||||
manifests.push({
|
||||
file,
|
||||
urls: [...text.matchAll(/^\s*- url: '?(.+?)'?$/gm)].map(match => match[1]),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
artifacts: files.filter(
|
||||
file => file.includes(version) && /\.(dmg|zip|AppImage|exe|blockmap)$/.test(file),
|
||||
),
|
||||
manifests,
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: "gitea",
|
||||
title: "Publish the gitea release and attach the artifacts",
|
||||
missing: () => missingEnv("GITEA_TOKEN"),
|
||||
setup: [
|
||||
`Generate an access token at ${repository.origin}/user/settings/applications with the`,
|
||||
"write:repository scope, and set GITEA_TOKEN in .env.local.",
|
||||
],
|
||||
run: async () => {
|
||||
const api = gitea({repository, token: process.env.GITEA_TOKEN})
|
||||
|
||||
if (!(await api.hasTag(version))) {
|
||||
throw new Error(`${repository} has no ${version} tag; push it before publishing`)
|
||||
}
|
||||
|
||||
if (existsSync(apk)) {
|
||||
await apkMetadata()
|
||||
}
|
||||
|
||||
const apkName = `${name}-${version}.apk`
|
||||
const {artifacts, manifests} = await packagedDesktop()
|
||||
const files = [
|
||||
...(existsSync(apk) ? [[apk, apkName]] : []),
|
||||
...artifacts.map(file => [join(desktopDist, file), file]),
|
||||
]
|
||||
|
||||
if (files.length + manifests.length === 0) {
|
||||
throw new Error("Nothing to attach; build the apk or the desktop packages first")
|
||||
}
|
||||
|
||||
const release = await api.upsertRelease(version, notes)
|
||||
const attach = async (path, filename) =>
|
||||
console.log(dim(` ${await api.attach(release.id, filename, await readFile(path))}`))
|
||||
|
||||
for (const [path, filename] of files) {
|
||||
await attach(path, filename)
|
||||
}
|
||||
|
||||
// An updater acts on a manifest the moment it can read one, so what it points to goes up first
|
||||
const attached = await api.assetNames(release.id)
|
||||
|
||||
for (const {file, urls} of manifests) {
|
||||
const absent = urls.filter(url => !attached.includes(url))
|
||||
|
||||
if (absent.length > 0) {
|
||||
throw new Error(`${file} points to ${absent.join(", ")}, which the release doesn't have`)
|
||||
}
|
||||
|
||||
await attach(join(desktopDist, file), file)
|
||||
}
|
||||
|
||||
if (release.draft) {
|
||||
const names = await api.assetNames(release.id)
|
||||
const missing = [apkName, ...desktopManifests].filter(file => !names.includes(file))
|
||||
|
||||
if (missing.length > 0) {
|
||||
followUps.push(
|
||||
`Gitea: ${version} stays a draft until it has ${missing.join(", ")}, and whichever release run attaches the last of them publishes it`,
|
||||
)
|
||||
} else {
|
||||
await api.publish(release.id)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
40
scripts/release/steps/ios.mjs
Normal file
40
scripts/release/steps/ios.mjs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import {readdir} from "node:fs/promises"
|
||||
import {join, resolve} from "node:path"
|
||||
import {uploadToAppStore} from "../lib/appstore.mjs"
|
||||
import {followUps, missingEnv, root} from "../lib/context.mjs"
|
||||
import {run} from "../lib/shell.mjs"
|
||||
|
||||
export default {
|
||||
name: "ios",
|
||||
title: "Archive the iOS app and upload it to App Store Connect",
|
||||
missing: () => [
|
||||
...(process.platform === "darwin" ? [] : ["macOS with Xcode"]),
|
||||
...missingEnv("ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_KEY_PATH"),
|
||||
],
|
||||
setup: [
|
||||
"App Store Connect -> Users and Access -> Integrations -> App Store Connect API, generate a",
|
||||
"team key with the App Manager role. Download the .p8 (only offered once), keep it outside",
|
||||
"the repo, and set ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_PATH in .env.local.",
|
||||
],
|
||||
run: async () => {
|
||||
await run("npx", ["cap", "build", "ios"], {cwd: root})
|
||||
|
||||
const directory = join(root, "ios/App/output")
|
||||
const ipa = (await readdir(directory)).find(file => file.endsWith(".ipa"))
|
||||
|
||||
if (!ipa) {
|
||||
throw new Error(`No ipa was exported to ${directory}`)
|
||||
}
|
||||
|
||||
await uploadToAppStore({
|
||||
ipa: join(directory, ipa),
|
||||
keyId: process.env.ASC_KEY_ID,
|
||||
issuerId: process.env.ASC_ISSUER_ID,
|
||||
keyPath: resolve(root, process.env.ASC_KEY_PATH),
|
||||
})
|
||||
|
||||
followUps.push(
|
||||
"App Store Connect: once the build finishes processing, add it to a version and submit for review at https://appstoreconnect.apple.com",
|
||||
)
|
||||
},
|
||||
}
|
||||
50
scripts/release/steps/play.mjs
Normal file
50
scripts/release/steps/play.mjs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import {readFile} from "node:fs/promises"
|
||||
import {join, resolve} from "node:path"
|
||||
import {gradle, keystoreEnv} from "../lib/android.mjs"
|
||||
import {followUps, missingEnv, notes, root, version} from "../lib/context.mjs"
|
||||
import {uploadToPlay} from "../lib/play.mjs"
|
||||
|
||||
export default {
|
||||
name: "play",
|
||||
title: "Build the AAB and upload it to Google Play",
|
||||
missing: () =>
|
||||
missingEnv(
|
||||
"PLAY_KEYSTORE_PATH",
|
||||
"PLAY_KEYSTORE_PASSWORD",
|
||||
"PLAY_KEYSTORE_ALIAS",
|
||||
"PLAY_SERVICE_ACCOUNT",
|
||||
),
|
||||
setup: [
|
||||
"PLAY_KEYSTORE_PATH, PLAY_KEYSTORE_PASSWORD, PLAY_KEYSTORE_ALIAS (and",
|
||||
"PLAY_KEYSTORE_ALIAS_PASSWORD) are the upload key Android Studio has been signing with.",
|
||||
"PLAY_SERVICE_ACCOUNT is the path to a service account json:",
|
||||
" 1. Play Console -> Setup -> API access, link or create a Google Cloud project",
|
||||
" 2. Create a service account there, then grant it the Release manager role on this app",
|
||||
" 3. Google Cloud -> that service account -> Keys -> Add key -> JSON, save it outside the repo",
|
||||
"PLAY_TRACK (default production) and PLAY_STATUS (default draft) are optional.",
|
||||
],
|
||||
run: async () => {
|
||||
await gradle("bundleRelease", keystoreEnv("PLAY"))
|
||||
|
||||
const gradleConfig = await readFile(join(root, "android/app/build.gradle"), "utf-8")
|
||||
const track = process.env.PLAY_TRACK ?? "production"
|
||||
const status = process.env.PLAY_STATUS ?? "draft"
|
||||
const versionCode = await uploadToPlay({
|
||||
credentials: JSON.parse(
|
||||
await readFile(resolve(root, process.env.PLAY_SERVICE_ACCOUNT), "utf-8"),
|
||||
),
|
||||
packageName: gradleConfig.match(/applicationId "(.+)"/)[1],
|
||||
bundle: await readFile(
|
||||
join(root, "android/app/build/outputs/bundle/release/app-release.aab"),
|
||||
),
|
||||
track,
|
||||
status,
|
||||
// Play rejects release notes over 500 characters
|
||||
notes: notes.slice(0, 500),
|
||||
})
|
||||
|
||||
followUps.push(
|
||||
`Play Console: ${version} (${versionCode}) is a ${status} release on the ${track} track, review and roll it out at https://play.google.com/console`,
|
||||
)
|
||||
},
|
||||
}
|
||||
8
scripts/release/steps/web.mjs
Normal file
8
scripts/release/steps/web.mjs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import {root} from "../lib/context.mjs"
|
||||
import {run} from "../lib/shell.mjs"
|
||||
|
||||
export default {
|
||||
name: "web",
|
||||
title: "Build the web bundle and sync the native projects",
|
||||
run: () => run("bash", ["scripts/build/app.sh"], {cwd: root}),
|
||||
}
|
||||
13
scripts/release/steps/zapstore.mjs
Normal file
13
scripts/release/steps/zapstore.mjs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import {missingEnv, root} from "../lib/context.mjs"
|
||||
import {installed, run} from "../lib/shell.mjs"
|
||||
|
||||
export default {
|
||||
name: "zapstore",
|
||||
title: "Publish the APK to zapstore",
|
||||
missing: () => [...(installed("zsp") ? [] : ["zsp (not installed)"]), ...missingEnv("SIGN_WITH")],
|
||||
setup: [
|
||||
"Install zsp from https://github.com/zapstore/zsp, then set SIGN_WITH in .env.local to an",
|
||||
"nsec, a bunker:// url, or `browser` to sign with a nostr extension.",
|
||||
],
|
||||
run: () => run("zsp", ["publish", "zapstore.yaml"], {cwd: root}),
|
||||
}
|
||||
Loading…
Reference in a new issue