Consolidate release scripts
This commit is contained in:
parent
e46f2d2054
commit
cf6c025bf2
12 changed files with 710 additions and 111 deletions
|
|
@ -8,7 +8,6 @@ build
|
|||
*.ttf
|
||||
gradlew*
|
||||
_app
|
||||
release
|
||||
ios/DerivedData/
|
||||
ios/App/Pods/
|
||||
android/capacitor-cordova-android-plugins
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@ See `.env.template` for all options.
|
|||
|
||||
**Capacitor Integration:**
|
||||
|
||||
- Android: Full support, APK builds via `pnpm run release:android`
|
||||
- Android: Full support, release builds via `pnpm release` (see README for the release flow)
|
||||
- iOS: Full support (zaps disabled due to App Store policy)
|
||||
- PWA: Progressive Web App with service worker
|
||||
|
||||
|
|
|
|||
66
README.md
66
README.md
|
|
@ -8,7 +8,7 @@ A discord-like nostr client based on the idea of "relays as groups". Supports NI
|
|||
|
||||
- **Web** — [app.flotilla.social](https://app.flotilla.social), installable as a PWA
|
||||
- **Android** — [Google Play](https://play.google.com/store/apps/details?id=social.flotilla)
|
||||
- **Android APK** — [releases](https://gitea.coracle.social/coracle/flotilla/releases), see [Android releases](#android-releases)
|
||||
- **Android APK** — [releases](https://gitea.coracle.social/coracle/flotilla/releases), see [Releasing](#releasing)
|
||||
- **iOS** — [App Store](https://apps.apple.com/us/app/flotilla-chat/id6741344107)
|
||||
- **Your own server** — see [Deployment](#deployment)
|
||||
|
||||
|
|
@ -154,25 +154,61 @@ Use the AppImage or installed executable rather than the installer. This checks
|
|||
local assets, navigation, workers, and CSP using a disposable profile. Installation, reboot, and
|
||||
uninstall still require target-OS testing.
|
||||
|
||||
## Android releases
|
||||
## Releasing
|
||||
|
||||
Signed APKs are attached to releases on the
|
||||
[releases page](https://gitea.coracle.social/coracle/flotilla/releases), so Android users can
|
||||
install and update outside an app store.
|
||||
|
||||
Publishing needs `GITEA_TOKEN` in `.env.local`, set to a gitea access token with `write:repository`
|
||||
(Settings → Applications → Access Tokens). Bump the version, write its `CHANGELOG.md` section and
|
||||
push the matching tag, then:
|
||||
`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 one of them is missing rather than getting halfway. What's
|
||||
left — rolling out on Play, submitting for review — comes back as a list when it finishes.
|
||||
|
||||
```sh
|
||||
pnpm run release:android
|
||||
pnpm run publish:android
|
||||
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
|
||||
```
|
||||
|
||||
`publish:android` creates the release for the tag, takes its notes from the changelog, and attaches
|
||||
the APK as `flotilla-<version>.apk`, replacing any existing asset of that name. It reads the
|
||||
repository and the APK path from `zapstore.yaml`, so a release and a zapstore publish ship the same
|
||||
file.
|
||||
`pnpm release --check` runs those checks and reports the plan without building anything. Naming
|
||||
steps runs a subset — `pnpm release ios`, or `pnpm release 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` |
|
||||
| `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 |
|
||||
| `gitea` | creates the release for the tag from the changelog, attaches the APK and any desktop packages |
|
||||
| `zapstore` | `zsp publish zapstore.yaml` |
|
||||
| `fdroid` | nothing to upload; F-Droid builds from the tag, see [fdroid/README.md](fdroid/README.md) |
|
||||
|
||||
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`.
|
||||
|
||||
### Credentials
|
||||
|
||||
These go in `.env.local`, which is gitignored. `pnpm release --check` lists whichever are missing
|
||||
along with how to get them.
|
||||
|
||||
| variable | what it is |
|
||||
| --- | --- |
|
||||
| `GITEA_TOKEN` | gitea access token with `write:repository`, from Settings → Applications |
|
||||
| `ANDROID_KEYSTORE_PATH`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEYSTORE_ALIAS` | the key APKs outside the app stores are signed with; it can never change without breaking updates |
|
||||
| `PLAY_KEYSTORE_PATH`, `PLAY_KEYSTORE_PASSWORD`, `PLAY_KEYSTORE_ALIAS` | the Play upload key |
|
||||
| `PLAY_SERVICE_ACCOUNT` | path to a service account json with the Release manager role, from Play Console → Setup → API access |
|
||||
| `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_KEY_PATH` | App Store Connect API key with the App Manager role, from Users and Access → Integrations |
|
||||
| `SIGN_WITH` | nostr key for zapstore: an nsec, a `bunker://` url, or `browser` |
|
||||
|
||||
Add `_ALIAS_PASSWORD` to either keystore prefix when the alias has its own password. `PLAY_TRACK`
|
||||
(default `production`) and `PLAY_STATUS` (default `draft`) choose where a Play upload lands.
|
||||
Keystores and API keys belong outside the repository; only their paths go in `.env.local`.
|
||||
|
||||
Gradle signs from those variables, so Android Studio still opens and builds the project without
|
||||
them — it just produces an unsigned release build.
|
||||
|
||||
### Obtainium
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
|
||||
// Release credentials come from the environment so they stay out of the repo and out of the
|
||||
// process list. Without them the release build is unsigned, which is what Android Studio gets.
|
||||
def releaseKeystore = System.getenv("ANDROID_KEYSTORE_PATH")
|
||||
|
||||
android {
|
||||
namespace = "social.flotilla"
|
||||
compileSdk = rootProject.ext.compileSdkVersion
|
||||
|
|
@ -17,8 +21,21 @@ android {
|
|||
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
signingConfigs {
|
||||
release {
|
||||
if (releaseKeystore) {
|
||||
storeFile file(releaseKeystore)
|
||||
storePassword System.getenv("ANDROID_KEYSTORE_PASSWORD")
|
||||
keyAlias System.getenv("ANDROID_KEYSTORE_ALIAS")
|
||||
keyPassword System.getenv("ANDROID_KEYSTORE_ALIAS_PASSWORD")
|
||||
}
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
if (releaseKeystore) {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 run release:android</code></td>
|
||||
<td><code>android/</code>, <code>@capacitor/assets</code>, <code>pnpm release</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Branded iOS app</td>
|
||||
|
|
|
|||
|
|
@ -13,12 +13,11 @@
|
|||
"start:desktop": "npm --prefix electron start",
|
||||
"build:server": "vite build --config vite.config.server.ts",
|
||||
"start": "node server.js",
|
||||
"release:android": "./scripts/build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
|
||||
"publish:android": "node scripts/publish-android-release.mjs",
|
||||
"release": "node scripts/release.mjs",
|
||||
"bump": "node scripts/bump-version.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/publish-android-release.mjs && eslint src e2e packages fdroid capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts/dev-desktop.mjs scripts/package-desktop.mjs scripts/publish-android-release.mjs",
|
||||
"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": "playwright test",
|
||||
"test:desktop": "playwright test --config e2e/desktop/playwright.config.ts",
|
||||
"test:ui": "playwright test --ui",
|
||||
|
|
|
|||
|
|
@ -1,90 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
import {readFile} from "node:fs/promises"
|
||||
import {config} from "dotenv"
|
||||
|
||||
const read = path => readFile(new URL(path, import.meta.url), "utf-8")
|
||||
|
||||
config({path: new URL("../.env.local", import.meta.url)})
|
||||
|
||||
const token = process.env.GITEA_TOKEN
|
||||
const {name, version} = JSON.parse(await read("../package.json"))
|
||||
const changelog = await read("../CHANGELOG.md")
|
||||
const zapstore = await read("../zapstore.yaml")
|
||||
|
||||
const zapstoreField = key => {
|
||||
const match = zapstore.match(new RegExp(`^${key}:\\s*(\\S+)\\s*$`, "m"))
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`zapstore.yaml is missing ${key}`)
|
||||
}
|
||||
|
||||
return match[1]
|
||||
}
|
||||
|
||||
const repository = new URL(zapstoreField("repository"))
|
||||
const base = `${repository.origin}/api/v1/repos${repository.pathname}`
|
||||
|
||||
const api = async (method, path, {body, allow404} = {}) => {
|
||||
const multipart = body instanceof FormData
|
||||
const response = await fetch(base + path, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
...(body && !multipart ? {"Content-Type": "application/json"} : {}),
|
||||
},
|
||||
body: multipart ? body : body && JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (response.status === 404 && allow404) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`${method} ${path} responded ${response.status}: ${await response.text()}`)
|
||||
}
|
||||
|
||||
return response.status === 204 ? undefined : response.json()
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
throw new Error("Set GITEA_TOKEN in .env.local to a token with write access to the repository")
|
||||
}
|
||||
|
||||
const lines = changelog.split("\n")
|
||||
const heading = lines.indexOf(`# ${version}`)
|
||||
|
||||
if (heading < 0) {
|
||||
throw new Error(`CHANGELOG.md has no "# ${version}" section`)
|
||||
}
|
||||
|
||||
const remainder = lines.slice(heading + 1)
|
||||
const nextHeading = remainder.findIndex(line => line.startsWith("# "))
|
||||
const notes = (nextHeading < 0 ? remainder : remainder.slice(0, nextHeading)).join("\n").trim()
|
||||
const apk = await readFile(new URL(`../${zapstoreField("release_source")}`, import.meta.url))
|
||||
|
||||
if (!(await api("GET", `/tags/${version}`, {allow404: true}))) {
|
||||
throw new Error(`${repository} has no ${version} tag; push it before publishing`)
|
||||
}
|
||||
|
||||
const release =
|
||||
(await api("GET", `/releases/tags/${version}`, {allow404: true})) ??
|
||||
(await api("POST", "/releases", {body: {tag_name: version, name: version, body: notes}}))
|
||||
|
||||
const filename = `${name}-${version}.apk`
|
||||
const existing = release.assets?.find(asset => asset.name === filename)
|
||||
|
||||
if (existing) {
|
||||
await api("DELETE", `/releases/${release.id}/assets/${existing.id}`)
|
||||
}
|
||||
|
||||
const form = new FormData()
|
||||
|
||||
form.append("attachment", new Blob([apk]), filename)
|
||||
|
||||
const asset = await api(
|
||||
"POST",
|
||||
`/releases/${release.id}/assets?name=${encodeURIComponent(filename)}`,
|
||||
{body: form},
|
||||
)
|
||||
|
||||
console.log(asset.browser_download_url)
|
||||
428
scripts/release.mjs
Normal file
428
scripts/release.mjs
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
#!/usr/bin/env node
|
||||
import {existsSync} from "node:fs"
|
||||
import {readFile, readdir, rename} from "node:fs/promises"
|
||||
import {dirname, join, resolve} from "node:path"
|
||||
import {fileURLToPath} from "node:url"
|
||||
import {parseArgs} from "node:util"
|
||||
import {config} from "dotenv"
|
||||
import {uploadToAppStore} from "./release/appstore.mjs"
|
||||
import {gitea} from "./release/gitea.mjs"
|
||||
import {uploadToPlay} from "./release/play.mjs"
|
||||
import {ask, bold, dim, green, installed, output, red, run, yellow} from "./release/shell.mjs"
|
||||
|
||||
const root = fileURLToPath(new URL("../", import.meta.url))
|
||||
|
||||
config({path: join(root, ".env.local")})
|
||||
|
||||
const fail = message => {
|
||||
console.error(red(message))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let args
|
||||
|
||||
try {
|
||||
args = parseArgs({
|
||||
options: {
|
||||
check: {type: "boolean", default: false},
|
||||
yes: {type: "boolean", short: "y", default: false},
|
||||
},
|
||||
allowPositionals: true,
|
||||
})
|
||||
} catch (error) {
|
||||
fail(`${error.message}\nUsage: pnpm release [--check] [--yes] [step...]`)
|
||||
}
|
||||
|
||||
const {values: options, positionals: chosen} = args
|
||||
|
||||
const {name, version} = JSON.parse(await readFile(join(root, "package.json"), "utf-8"))
|
||||
const changelog = await readFile(join(root, "CHANGELOG.md"), "utf-8")
|
||||
const zapstore = await readFile(join(root, "zapstore.yaml"), "utf-8")
|
||||
const gradleConfig = await readFile(join(root, "android/app/build.gradle"), "utf-8")
|
||||
|
||||
const zapstoreField = key => {
|
||||
const match = zapstore.match(new RegExp(`^${key}:\\s*(\\S+)\\s*$`, "m"))
|
||||
|
||||
if (!match) {
|
||||
fail(`zapstore.yaml is missing ${key}`)
|
||||
}
|
||||
|
||||
return match[1]
|
||||
}
|
||||
|
||||
const appId = gradleConfig.match(/applicationId "(.+)"/)[1]
|
||||
const repository = new URL(zapstoreField("repository"))
|
||||
const apk = join(root, zapstoreField("release_source"))
|
||||
const aab = join(root, "android/app/build/outputs/bundle/release/app-release.aab")
|
||||
const desktopDist = join(root, "electron/dist")
|
||||
|
||||
const lines = changelog.split("\n")
|
||||
const heading = lines.indexOf(`# ${version}`)
|
||||
const remainder = heading < 0 ? [] : lines.slice(heading + 1)
|
||||
const nextHeading = remainder.findIndex(line => line.startsWith("# "))
|
||||
const notes = (nextHeading < 0 ? remainder : remainder.slice(0, nextHeading)).join("\n").trim()
|
||||
|
||||
const git = (...gitArgs) => {
|
||||
try {
|
||||
return output("git", gitArgs, {cwd: root, stdio: ["ignore", "pipe", "ignore"]})
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const missingEnv = (...keys) => keys.filter(key => !process.env[key])
|
||||
|
||||
const keystoreEnv = prefix => ({
|
||||
ANDROID_KEYSTORE_PATH: resolve(root, process.env[`${prefix}_KEYSTORE_PATH`]),
|
||||
ANDROID_KEYSTORE_PASSWORD: process.env[`${prefix}_KEYSTORE_PASSWORD`],
|
||||
ANDROID_KEYSTORE_ALIAS: process.env[`${prefix}_KEYSTORE_ALIAS`],
|
||||
ANDROID_KEYSTORE_ALIAS_PASSWORD:
|
||||
process.env[`${prefix}_KEYSTORE_ALIAS_PASSWORD`] ?? process.env[`${prefix}_KEYSTORE_PASSWORD`],
|
||||
})
|
||||
|
||||
// A fresh jvm per build, so a reused daemon can't hand one gradle run the other's signing key
|
||||
const gradle = (task, signing) =>
|
||||
run("./gradlew", ["--no-daemon", task], {
|
||||
cwd: join(root, "android"),
|
||||
env: {...process.env, ...signing},
|
||||
})
|
||||
|
||||
// Gradle records what it actually built beside the apk, the only version stamp on an artifact
|
||||
// whose filename never changes
|
||||
const apkMetadata = async () => {
|
||||
const path = join(dirname(apk), "output-metadata.json")
|
||||
|
||||
if (!existsSync(path)) {
|
||||
throw new Error(`${path} is missing; run pnpm release apk`)
|
||||
}
|
||||
|
||||
const {elements} = JSON.parse(await readFile(path, "utf-8"))
|
||||
const [element] = elements
|
||||
|
||||
if (element.versionName !== version) {
|
||||
throw new Error(`the last android build was ${element.versionName}, not ${version}`)
|
||||
}
|
||||
|
||||
return element
|
||||
}
|
||||
|
||||
const desktopTargets = {darwin: ["macos"], linux: ["linux", "windows"], win32: []}
|
||||
const desktopTarget = desktopTargets[process.platform]?.[0]
|
||||
|
||||
const packagedDesktop = async () =>
|
||||
existsSync(desktopDist)
|
||||
? (await readdir(desktopDist)).filter(
|
||||
file => file.includes(version) && /\.(dmg|AppImage|exe)$/.test(file),
|
||||
)
|
||||
: []
|
||||
|
||||
const followUps = []
|
||||
|
||||
const steps = [
|
||||
{
|
||||
name: "web",
|
||||
title: "Build the web bundle and sync the native projects",
|
||||
run: () => run("bash", ["scripts/build.sh"], {cwd: root}),
|
||||
},
|
||||
{
|
||||
name: "apk",
|
||||
title: "Build the APK, signed with the distribution key",
|
||||
missing: () =>
|
||||
missingEnv("ANDROID_KEYSTORE_PATH", "ANDROID_KEYSTORE_PASSWORD", "ANDROID_KEYSTORE_ALIAS"),
|
||||
setup: [
|
||||
"Set ANDROID_KEYSTORE_PATH, ANDROID_KEYSTORE_PASSWORD and ANDROID_KEYSTORE_ALIAS in",
|
||||
".env.local (plus ANDROID_KEYSTORE_ALIAS_PASSWORD if the alias has its own password).",
|
||||
"This is the key gitea, zapstore and Obtainium updates are signed with, so it has to stay",
|
||||
"the same one forever.",
|
||||
],
|
||||
run: async () => {
|
||||
await gradle("assembleRelease", keystoreEnv("ANDROID"))
|
||||
|
||||
const {outputFile} = await apkMetadata()
|
||||
|
||||
await rename(join(dirname(apk), outputFile), apk)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "play",
|
||||
title: "Build the AAB and upload it to Google Play",
|
||||
missing: () =>
|
||||
missingEnv(
|
||||
"PLAY_KEYSTORE_PATH",
|
||||
"PLAY_KEYSTORE_PASSWORD",
|
||||
"PLAY_KEYSTORE_ALIAS",
|
||||
"PLAY_SERVICE_ACCOUNT",
|
||||
),
|
||||
setup: [
|
||||
"PLAY_KEYSTORE_PATH, PLAY_KEYSTORE_PASSWORD, PLAY_KEYSTORE_ALIAS (and",
|
||||
"PLAY_KEYSTORE_ALIAS_PASSWORD) are the upload key Android Studio has been signing with.",
|
||||
"PLAY_SERVICE_ACCOUNT is the path to a service account json:",
|
||||
" 1. Play Console -> Setup -> API access, link or create a Google Cloud project",
|
||||
" 2. Create a service account there, then grant it the Release manager role on this app",
|
||||
" 3. Google Cloud -> that service account -> Keys -> Add key -> JSON, save it outside the repo",
|
||||
"PLAY_TRACK (default production) and PLAY_STATUS (default draft) are optional.",
|
||||
],
|
||||
run: async () => {
|
||||
await gradle("bundleRelease", keystoreEnv("PLAY"))
|
||||
|
||||
const track = process.env.PLAY_TRACK ?? "production"
|
||||
const status = process.env.PLAY_STATUS ?? "draft"
|
||||
const versionCode = await uploadToPlay({
|
||||
credentials: JSON.parse(
|
||||
await readFile(resolve(root, process.env.PLAY_SERVICE_ACCOUNT), "utf-8"),
|
||||
),
|
||||
packageName: appId,
|
||||
bundle: await readFile(aab),
|
||||
track,
|
||||
status,
|
||||
// Play rejects release notes over 500 characters
|
||||
notes: notes.slice(0, 500),
|
||||
})
|
||||
|
||||
followUps.push(
|
||||
`Play Console: ${version} (${versionCode}) is a ${status} release on the ${track} track, review and roll it out at https://play.google.com/console`,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ios",
|
||||
title: "Archive the iOS app and upload it to App Store Connect",
|
||||
missing: () => [
|
||||
...(process.platform === "darwin" ? [] : ["macOS with Xcode"]),
|
||||
...missingEnv("ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_KEY_PATH"),
|
||||
],
|
||||
setup: [
|
||||
"App Store Connect -> Users and Access -> Integrations -> App Store Connect API, generate a",
|
||||
"team key with the App Manager role. Download the .p8 (only offered once), keep it outside",
|
||||
"the repo, and set ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_PATH in .env.local.",
|
||||
],
|
||||
run: async () => {
|
||||
await run("npx", ["cap", "build", "ios"], {cwd: root})
|
||||
|
||||
const directory = join(root, "ios/App/output")
|
||||
const ipa = (await readdir(directory)).find(file => file.endsWith(".ipa"))
|
||||
|
||||
if (!ipa) {
|
||||
throw new Error(`No ipa was exported to ${directory}`)
|
||||
}
|
||||
|
||||
await uploadToAppStore({
|
||||
ipa: join(directory, ipa),
|
||||
keyId: process.env.ASC_KEY_ID,
|
||||
issuerId: process.env.ASC_ISSUER_ID,
|
||||
keyPath: resolve(root, process.env.ASC_KEY_PATH),
|
||||
})
|
||||
|
||||
followUps.push(
|
||||
"App Store Connect: once the build finishes processing, add it to a version and submit for review at https://appstoreconnect.apple.com",
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "desktop",
|
||||
title: "Package the desktop app",
|
||||
missing: () => [
|
||||
...(desktopTarget ? [] : [`desktop packaging on ${process.platform}`]),
|
||||
...(existsSync(join(root, "electron/node_modules")) ? [] : ["electron dependencies"]),
|
||||
],
|
||||
setup: [
|
||||
"Run npm ci --prefix electron. Each platform's packages have to be built on that platform,",
|
||||
"so run pnpm release desktop gitea on the others to add theirs to the same release.",
|
||||
],
|
||||
run: async () => {
|
||||
await run("pnpm", ["run", `package:desktop:${desktopTarget}`], {cwd: root})
|
||||
|
||||
const elsewhere = Object.values(desktopTargets)
|
||||
.flat()
|
||||
.filter(target => !desktopTargets[process.platform].includes(target))
|
||||
|
||||
followUps.push(
|
||||
`Desktop: ${elsewhere.join(" and ")} packages have to be built on those platforms, then attached with pnpm release gitea`,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gitea",
|
||||
title: "Publish the gitea release and attach the artifacts",
|
||||
missing: () => missingEnv("GITEA_TOKEN"),
|
||||
setup: [
|
||||
`Generate an access token at ${repository.origin}/user/settings/applications with the`,
|
||||
"write:repository scope, and set GITEA_TOKEN in .env.local.",
|
||||
],
|
||||
run: async () => {
|
||||
const api = gitea({repository, token: process.env.GITEA_TOKEN})
|
||||
|
||||
if (!(await api.hasTag(version))) {
|
||||
throw new Error(`${repository} has no ${version} tag; push it before publishing`)
|
||||
}
|
||||
|
||||
if (existsSync(apk)) {
|
||||
await apkMetadata()
|
||||
}
|
||||
|
||||
const files = [
|
||||
...(existsSync(apk) ? [[apk, `${name}-${version}.apk`]] : []),
|
||||
...(await packagedDesktop()).map(file => [join(desktopDist, file), file]),
|
||||
]
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new Error("Nothing to attach; build the apk or the desktop packages first")
|
||||
}
|
||||
|
||||
const release = await api.upsertRelease(version, notes)
|
||||
|
||||
for (const [path, filename] of files) {
|
||||
console.log(dim(` ${await api.attach(release.id, filename, await readFile(path))}`))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "zapstore",
|
||||
title: "Publish the APK to zapstore",
|
||||
missing: () => [
|
||||
...(installed("zsp") ? [] : ["zsp (not installed)"]),
|
||||
...missingEnv("SIGN_WITH"),
|
||||
],
|
||||
setup: [
|
||||
"Install zsp from https://github.com/zapstore/zsp, then set SIGN_WITH in .env.local to an",
|
||||
"nsec, a bunker:// url, or `browser` to sign with a nostr extension.",
|
||||
],
|
||||
run: () => run("zsp", ["publish", "zapstore.yaml"], {cwd: root}),
|
||||
},
|
||||
{
|
||||
name: "fdroid",
|
||||
title: "F-Droid",
|
||||
optional: true,
|
||||
manual: [
|
||||
"F-Droid builds from source on their own servers, so a release has nothing to upload: the",
|
||||
"metadata tracks version tags, which makes pushing the tag the whole story. The fdroiddata",
|
||||
"submission is still open — see fdroid/README.md.",
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
34
scripts/release/appstore.mjs
Normal file
34
scripts/release/appstore.mjs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import {copyFile, mkdir, mkdtemp, rm} from "node:fs/promises"
|
||||
import {tmpdir} from "node:os"
|
||||
import {join} from "node:path"
|
||||
import {run} from "./shell.mjs"
|
||||
|
||||
export const uploadToAppStore = async ({ipa, keyId, issuerId, keyPath}) => {
|
||||
// altool only reads the api key from a `private_keys` directory beside its working directory or
|
||||
// under $HOME, so give it a private one rather than leaving the key in the repo or home dir.
|
||||
const directory = await mkdtemp(join(tmpdir(), "flotilla-appstore-"))
|
||||
|
||||
try {
|
||||
await mkdir(join(directory, "private_keys"))
|
||||
await copyFile(keyPath, join(directory, "private_keys", `AuthKey_${keyId}.p8`))
|
||||
|
||||
await run(
|
||||
"xcrun",
|
||||
[
|
||||
"altool",
|
||||
"--upload-app",
|
||||
"-f",
|
||||
ipa,
|
||||
"-t",
|
||||
"ios",
|
||||
"--apiKey",
|
||||
keyId,
|
||||
"--apiIssuer",
|
||||
issuerId,
|
||||
],
|
||||
{cwd: directory},
|
||||
)
|
||||
} finally {
|
||||
await rm(directory, {recursive: true, force: true})
|
||||
}
|
||||
}
|
||||
54
scripts/release/gitea.mjs
Normal file
54
scripts/release/gitea.mjs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
export const gitea = ({repository, token}) => {
|
||||
const base = `${repository.origin}/api/v1/repos${repository.pathname}`
|
||||
|
||||
const api = async (method, path, {body, allow404} = {}) => {
|
||||
const multipart = body instanceof FormData
|
||||
const response = await fetch(base + path, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
...(body && !multipart ? {"Content-Type": "application/json"} : {}),
|
||||
},
|
||||
body: multipart ? body : body && JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (response.status === 404 && allow404) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`${method} ${path} responded ${response.status}: ${await response.text()}`)
|
||||
}
|
||||
|
||||
return response.status === 204 ? undefined : response.json()
|
||||
}
|
||||
|
||||
return {
|
||||
hasTag: async tag => Boolean(await api("GET", `/tags/${tag}`, {allow404: true})),
|
||||
|
||||
upsertRelease: async (tag, notes) =>
|
||||
(await api("GET", `/releases/tags/${tag}`, {allow404: true})) ??
|
||||
(await api("POST", "/releases", {body: {tag_name: tag, name: tag, body: notes}})),
|
||||
|
||||
attach: async (releaseId, filename, data) => {
|
||||
const {assets} = await api("GET", `/releases/${releaseId}`)
|
||||
const existing = assets?.find(asset => asset.name === filename)
|
||||
|
||||
if (existing) {
|
||||
await api("DELETE", `/releases/${releaseId}/assets/${existing.id}`)
|
||||
}
|
||||
|
||||
const form = new FormData()
|
||||
|
||||
form.append("attachment", new Blob([data]), filename)
|
||||
|
||||
const asset = await api(
|
||||
"POST",
|
||||
`/releases/${releaseId}/assets?name=${encodeURIComponent(filename)}`,
|
||||
{body: form},
|
||||
)
|
||||
|
||||
return asset.browser_download_url
|
||||
},
|
||||
}
|
||||
}
|
||||
81
scripts/release/play.mjs
Normal file
81
scripts/release/play.mjs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import {createSign} from "node:crypto"
|
||||
|
||||
const encode = value => Buffer.from(JSON.stringify(value)).toString("base64url")
|
||||
|
||||
const getAccessToken = async ({client_email, private_key}) => {
|
||||
const issued = Math.floor(Date.now() / 1000)
|
||||
const claims = {
|
||||
iss: client_email,
|
||||
scope: "https://www.googleapis.com/auth/androidpublisher",
|
||||
aud: "https://oauth2.googleapis.com/token",
|
||||
iat: issued,
|
||||
exp: issued + 3600,
|
||||
}
|
||||
|
||||
const signed = `${encode({alg: "RS256", typ: "JWT"})}.${encode(claims)}`
|
||||
const signature = createSign("RSA-SHA256").update(signed).sign(private_key, "base64url")
|
||||
|
||||
const response = await fetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/x-www-form-urlencoded"},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||
assertion: `${signed}.${signature}`,
|
||||
}),
|
||||
})
|
||||
|
||||
const body = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Google rejected the service account: ${body.error_description ?? body.error}`)
|
||||
}
|
||||
|
||||
return body.access_token
|
||||
}
|
||||
|
||||
export const uploadToPlay = async ({credentials, packageName, bundle, track, status, notes}) => {
|
||||
const accessToken = await getAccessToken(credentials)
|
||||
const base = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${packageName}`
|
||||
|
||||
const api = async (method, url, body) => {
|
||||
const binary = body instanceof Uint8Array
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
...(body ? {"Content-Type": binary ? "application/octet-stream" : "application/json"} : {}),
|
||||
},
|
||||
body: binary ? body : body && JSON.stringify(body),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Play API: ${result.error?.message ?? JSON.stringify(result)}`)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const edit = await api("POST", `${base}/edits`)
|
||||
const {versionCode} = await api(
|
||||
"POST",
|
||||
`https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/${packageName}/edits/${edit.id}/bundles?uploadType=media`,
|
||||
bundle,
|
||||
)
|
||||
|
||||
await api("PUT", `${base}/edits/${edit.id}/tracks/${track}`, {
|
||||
track,
|
||||
releases: [
|
||||
{
|
||||
status,
|
||||
versionCodes: [String(versionCode)],
|
||||
releaseNotes: [{language: "en-US", text: notes}],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await api("POST", `${base}/edits/${edit.id}:commit`)
|
||||
|
||||
return versionCode
|
||||
}
|
||||
41
scripts/release/shell.mjs
Normal file
41
scripts/release/shell.mjs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import {execFileSync, spawn} from "node:child_process"
|
||||
import {createInterface} from "node:readline/promises"
|
||||
|
||||
const color = code => text => (process.stdout.isTTY ? `\x1b[${code}m${text}\x1b[0m` : text)
|
||||
|
||||
export const bold = color(1)
|
||||
export const dim = color(2)
|
||||
export const red = color(31)
|
||||
export const green = color(32)
|
||||
export const yellow = color(33)
|
||||
|
||||
export const run = (command, args, options = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {stdio: "inherit", ...options})
|
||||
|
||||
child.on("error", reject)
|
||||
child.on("exit", (code, signal) =>
|
||||
code === 0 ? resolve() : reject(new Error(`${command} failed (${signal || code})`)),
|
||||
)
|
||||
})
|
||||
|
||||
export const output = (command, args, options = {}) =>
|
||||
execFileSync(command, args, {encoding: "utf-8", ...options}).trim()
|
||||
|
||||
export const installed = command => {
|
||||
try {
|
||||
return Boolean(output("which", [command]))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const ask = async question => {
|
||||
const readline = createInterface({input: process.stdin, output: process.stdout})
|
||||
|
||||
try {
|
||||
return await readline.question(question)
|
||||
} finally {
|
||||
readline.close()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue