Publish signed Android APKs as gitea releases for Obtainium

This commit is contained in:
Coracle-Bot 2026-09-15 08:37:42 +00:00
parent 05cb5c29f7
commit 0922a98eb6
3 changed files with 118 additions and 1 deletions

View file

@ -8,6 +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)
- **iOS** — [App Store](https://apps.apple.com/us/app/flotilla-chat/id6741344107)
- **Your own server** — see [Deployment](#deployment)
@ -153,6 +154,34 @@ 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
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.
Bump the version, write its `CHANGELOG.md` section and push the matching tag, then:
```sh
pnpm run release:android
GITEA_TOKEN=... pnpm run publish:android
```
`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.
### Obtainium
[Obtainium](https://obtainium.imranr.dev/) installs and updates Android apps from their release
pages. Gitea and Forgejo share a release API, so it works against this repository:
- App source URL: `https://gitea.coracle.social/coracle/flotilla`
- Override source: `Forgejo (Codeberg)`
Obtainium reports the git tag as the version.
## Deployment
To run your own Flotilla, it's as simple as:

View file

@ -14,10 +14,11 @@
"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",
"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 playwright.config.ts capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts/dev-desktop.mjs scripts/package-desktop.mjs && eslint src e2e packages capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts/dev-desktop.mjs scripts/package-desktop.mjs",
"lint": "prettier --check src e2e packages 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 capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts/dev-desktop.mjs scripts/package-desktop.mjs scripts/publish-android-release.mjs",
"test": "playwright test",
"test:desktop": "playwright test --config e2e/desktop/playwright.config.ts",
"test:ui": "playwright test --ui",

View file

@ -0,0 +1,87 @@
#!/usr/bin/env node
import {readFile} from "node:fs/promises"
const read = path => readFile(new URL(path, import.meta.url), "utf-8")
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 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)