From 0a606e7a8a5a02e75715b43e83d5e412b3500fc6 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Thu, 24 Sep 2026 16:09:11 -0700 Subject: [PATCH] Publish a signed F-Droid build from the release so F-Droid can reproduce it and ship it with our key --- .gitea/workflows/release.yml | 12 +-- README.md | 17 ++-- fdroid/README.md | 25 ++++-- fdroid/metadata/social.flotilla.fdroid.yml | 8 ++ scripts/fdroid/reproduce.sh | 58 ++++++++++++++ scripts/release/lib/fdroid.mjs | 12 +++ scripts/release/lib/gitea.mjs | 45 +++++++++++ scripts/release/local.mjs | 3 +- scripts/release/steps/fdroid-sign.mjs | 93 ++++++++++++++++++++++ scripts/release/steps/fdroid.mjs | 82 +++++++++++-------- static/.well-known/assetlinks.json | 12 +++ 11 files changed, 305 insertions(+), 62 deletions(-) create mode 100755 scripts/fdroid/reproduce.sh create mode 100644 scripts/release/lib/fdroid.mjs create mode 100644 scripts/release/steps/fdroid-sign.mjs diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index f390ea5c..20d5032e 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -67,17 +67,6 @@ jobs: 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 - with: - packages: platform-tools - - name: Install dependencies run: | corepack enable @@ -87,4 +76,5 @@ jobs: - name: Release env: GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITEA_PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} run: pnpm release:ci --yes diff --git a/README.md b/README.md index 783faa2f..587fe087 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,8 @@ step that fails stops the run and prints the command to pick up from there. | `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 (or `PLAY_STATUS`) | | `ios` | local | `cap build ios` to an archive and IPA, uploaded with `altool`, then attached to the App Store version with its release notes once processed | -| `fdroid` | ci | reruns F-Droid's own preparation and build against the tag in a throwaway worktree | +| `fdroid` | ci | builds the tag with F-Droid's recipe in their buildserver image and uploads the unsigned apk to the `flotilla-fdroid` package | +| `fdroid-sign` | local | waits for that apk, signs it with the distribution key, and uploads it beside it for F-Droid to verify its own build against | | `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 --skip-preview --quiet zapstore.yaml` | @@ -227,12 +228,12 @@ Release notes come from the `CHANGELOG.md` section matching `package.json`'s ver 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 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. +F-Droid builds from the tag on its own servers and ships our apk instead of its own when the two +match, so it keeps our signature ([reproducible builds](fdroid/README.md)). The `fdroid` step makes +that apk the way F-Droid will, and if the 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 @@ -241,7 +242,7 @@ along with how to get them. | variable | what it is | | --- | --- | -| `GITEA_TOKEN` | gitea access token with `write:repository`, from Settings → Applications | +| `GITEA_TOKEN` | gitea access token with `write:repository` and `write:package`, 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 | diff --git a/fdroid/README.md b/fdroid/README.md index 32bc3af5..0165b396 100644 --- a/fdroid/README.md +++ b/fdroid/README.md @@ -43,11 +43,23 @@ and use its configured Gradle runner for `assembleFdroidRelease`. [`metadata/social.flotilla.fdroid.yml`](metadata/social.flotilla.fdroid.yml) is the recipe to submit to `fdroiddata`. The listing's text, icon, feature graphic and per-version changelogs come from -`fastlane/metadata/android/en-US/` at the tag F-Droid builds. Preparation installs dependencies before F-Droid's source scan, so the recipe -scan-ignores `node_modules`, which holds FLOSS build tools such as esbuild and sharp. The build -server's JDK is older than the 21 Capacitor needs, so the recipe installs it from Debian trixie, -along with Node from nodejs.org at a pinned checksum. None of that has been through `fdroid build` -yet. +`fastlane/metadata/android/en-US/` at the tag F-Droid builds. Preparation installs dependencies +before F-Droid's source scan, so the recipe scan-ignores `node_modules`, which holds FLOSS build +tools such as esbuild and sharp. The build server's JDK is older than the 21 Capacitor needs, so +the recipe installs it from Debian trixie, along with Node from nodejs.org at a pinned checksum. + +## Reproducible builds + +F-Droid rebuilds each tag, downloads the apk at the recipe's `Binaries` url, and ships that apk +instead of its own when copying its signature onto F-Droid's build verifies. So the F-Droid app +carries the distribution key, the one `AllowedAPKSigningKeys` names, and can never switch to +F-Droid's key. + +The release workflow's `fdroid` step runs `scripts/fdroid/reproduce.sh` in F-Droid's +`buildserver-trixie` image, the way fdroiddata's own CI builds a recipe, against the recipe in +`metadata/` pointed at the tag. It uploads the unsigned apk to the `flotilla-fdroid` generic +package on gitea, and `pnpm release:local fdroid-sign` signs it and uploads the result beside it. +The recipe in `metadata/` is the one both builds read, so a change to it goes to `fdroiddata` too. ## Updates @@ -56,5 +68,4 @@ Stable releases use bare version tags such as `1.9.1`, and the recipe checks tag `android/app/build.gradle` at that tag. The initial `fdroiddata` submission must still review scanner exceptions for FLOSS -build tools, optional OpenRouter use for a possible `NonFreeNet` declaration, and -the final F-Droid signing certificate for Android App Links. +build tools and optional OpenRouter use for a possible `NonFreeNet` declaration. diff --git a/fdroid/metadata/social.flotilla.fdroid.yml b/fdroid/metadata/social.flotilla.fdroid.yml index 64c8151d..13618b16 100644 --- a/fdroid/metadata/social.flotilla.fdroid.yml +++ b/fdroid/metadata/social.flotilla.fdroid.yml @@ -2,6 +2,7 @@ Categories: - Internet License: MIT AuthorName: Jon Staab +AuthorWebSite: https://flotilla.social WebSite: https://app.flotilla.social SourceCode: https://gitea.coracle.social/coracle/flotilla IssueTracker: https://gitea.coracle.social/coracle/flotilla/issues @@ -11,6 +12,7 @@ AutoName: Flotilla RepoType: git Repo: https://gitea.coracle.social/coracle/flotilla.git +Binaries: https://gitea.coracle.social/api/packages/coracle/generic/flotilla-fdroid/%v/flotilla-fdroid-%v.apk Builds: - versionName: 1.11.1 @@ -35,6 +37,8 @@ Builds: - node_modules build: ../../scripts/fdroid/build.sh +AllowedAPKSigningKeys: 6daf683e1ca83a4cd88573e9739e2aa944c85d56154e344230557cffed4ad78c + MaintainerNotes: |- scripts/fdroid/prepare.sh removes Firebase, Google Services, Plausible and the ACINQ native secp256k1 library, then installs dependencies, so it runs as prebuild ahead of @@ -42,6 +46,10 @@ MaintainerNotes: |- Capacitor needs JDK 21, installed from trixie, and Node comes from nodejs.org at a pinned checksum to match the lts/jod in .nvmrc. + Builds are reproducible: upstream's release workflow runs this recipe in the + buildserver-trixie image (scripts/fdroid/reproduce.sh) and publishes that apk, signed + with the distribution key, at Binaries. + AutoUpdateMode: Version UpdateCheckMode: Tags ^[0-9]+\.[0-9]+\.[0-9]+$ UpdateCheckData: android/app/build.gradle|(?m)^\s*versionCode\s+(\d+)\s*$|.|(?m)^\s*versionName\s+"([^"]+)"\s*$ diff --git a/scripts/fdroid/reproduce.sh b/scripts/fdroid/reproduce.sh new file mode 100755 index 00000000..f0db3013 --- /dev/null +++ b/scripts/fdroid/reproduce.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Builds the tag the way fdroiddata's "fdroid build" CI job does, in the same buildserver image, so +# the apk is the one F-Droid will rebuild and compare against. Runs as root inside +# registry.gitlab.com/fdroid/fdroidserver:buildserver-trixie, with the recipe at /work/recipe.yml, +# and leaves the unsigned apk at /work/unsigned.apk. +set -euo pipefail + +source /etc/profile.d/bsenv.sh +export ANDROID_HOME=/opt/android-sdk + +apt-get update +apt-get -y dist-upgrade +sdkmanager "platform-tools" "build-tools;31.0.0" + +rm -rf "$fdroidserver" +mkdir "$fdroidserver" +curl --silent https://gitlab.com/fdroid/fdroidserver/-/archive/master/fdroidserver-master.tar.gz | + tar -xz --directory="$fdroidserver" --strip-components=1 +git -C "$home_vagrant/gradlew-fdroid" pull + +apt-get install -y sudo openjdk-21-jdk-headless +update-alternatives --set java /usr/lib/jvm/java-21-openjdk-amd64/bin/java + +# The recipe with its one build pointed at this tag, and without the published apk it would +# otherwise try to compare against, since this is the build that becomes it +mkdir -p "$home_vagrant/metadata" "$home_vagrant/build" "$home_vagrant/tmp" "$home_vagrant/unsigned" +python3 - <<'EOF' +import os +import yaml + +recipe = yaml.safe_load(open("/work/recipe.yml")) +recipe.pop("Binaries", None) +recipe.pop("AllowedAPKSigningKeys", None) +build = recipe["Builds"][-1] +build.update( + versionName=os.environ["VERSION"], + versionCode=int(os.environ["VERSION_CODE"]), + commit=os.environ["COMMIT"], +) +recipe["Builds"] = [build] +recipe["CurrentVersion"] = os.environ["VERSION"] +recipe["CurrentVersionCode"] = int(os.environ["VERSION_CODE"]) + +with open(os.path.join(os.environ["home_vagrant"], "metadata/social.flotilla.fdroid.yml"), "w") as file: + yaml.safe_dump(recipe, file, sort_keys=False) +EOF +chown -R vagrant "$home_vagrant" + +cd "$home_vagrant" +sudo --preserve-env --user vagrant \ + env PATH="$fdroidserver:$PATH" \ + env PYTHONPATH="$fdroidserver:$fdroidserver/examples" \ + env PYTHONUNBUFFERED=true \ + env HOME="$home_vagrant" \ + fdroid build --verbose --test --refresh-scanner --on-server --no-tarball \ + "social.flotilla.fdroid:$VERSION_CODE" + +cp "$home_vagrant/tmp/social.flotilla.fdroid_$VERSION_CODE.apk" /work/unsigned.apk diff --git a/scripts/release/lib/fdroid.mjs b/scripts/release/lib/fdroid.mjs new file mode 100644 index 00000000..d98dc2e1 --- /dev/null +++ b/scripts/release/lib/fdroid.mjs @@ -0,0 +1,12 @@ +import {name, repository, version} from "./context.mjs" +import {giteaPackage} from "./gitea.mjs" + +// F-Droid rebuilds each tag and ships this apk, signed with our key, only if its own build matches +export const fdroidPackage = token => + giteaPackage({repository, token, name: `${name}-fdroid`, version}) + +export const unsignedApk = `${name}-fdroid-${version}-unsigned.apk` +export const signedApk = `${name}-fdroid-${version}.apk` + +// CI publishes packages with its own token, since the job's gitea token can't +export const packageToken = process.env.GITEA_PACKAGE_TOKEN || process.env.GITEA_TOKEN diff --git a/scripts/release/lib/gitea.mjs b/scripts/release/lib/gitea.mjs index 3f60ecbb..9e5fd840 100644 --- a/scripts/release/lib/gitea.mjs +++ b/scripts/release/lib/gitea.mjs @@ -61,3 +61,48 @@ export const gitea = ({repository, token}) => { }, } } + +// Gitea's generic package registry, for files that shouldn't sit on the release itself +export const giteaPackage = ({repository, token, name, version}) => { + const [, owner] = repository.pathname.split("/") + const url = file => + `${repository.origin}/api/packages/${owner}/generic/${name}/${version}/${encodeURIComponent(file)}` + + const request = async (method, file, body) => { + const response = await fetch(url(file), { + method, + headers: {Authorization: `token ${token}`}, + body, + }) + + if (response.status === 404 && method !== "PUT") { + return undefined + } + + if (!response.ok) { + throw new Error( + `${method} ${url(file)} responded ${response.status}: ${await response.text()}`, + ) + } + + return response + } + + return { + url, + + download: async file => { + const response = await request("GET", file) + + return response && new Uint8Array(await response.arrayBuffer()) + }, + + // A package file can't be overwritten, so a rebuild of the same version replaces it + upload: async (file, data) => { + await request("DELETE", file) + await request("PUT", file, data) + + return url(file) + }, + } +} diff --git a/scripts/release/local.mjs b/scripts/release/local.mjs index e13c59a4..275f1bb3 100644 --- a/scripts/release/local.mjs +++ b/scripts/release/local.mjs @@ -3,10 +3,11 @@ import {release} from "./lib/pipeline.mjs" import apk from "./steps/apk.mjs" import desktop from "./steps/desktop.mjs" +import fdroidSign from "./steps/fdroid-sign.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]) +await release("pnpm release:local", [web, apk, play, ios, desktop, fdroidSign, gitea, zapstore]) diff --git a/scripts/release/steps/fdroid-sign.mjs b/scripts/release/steps/fdroid-sign.mjs new file mode 100644 index 00000000..7896609c --- /dev/null +++ b/scripts/release/steps/fdroid-sign.mjs @@ -0,0 +1,93 @@ +import {existsSync, readFileSync, readdirSync} from "node:fs" +import {mkdtemp, readFile, rm, writeFile} from "node:fs/promises" +import {tmpdir} from "node:os" +import {join} from "node:path" +import {HOUR, MINUTE, ago, ms, now, sleep} from "@welshman/lib" +import {keystoreEnv} from "../lib/android.mjs" +import {missingEnv, root} from "../lib/context.mjs" +import {fdroidPackage, packageToken, signedApk, unsignedApk} from "../lib/fdroid.mjs" +import {output, run} from "../lib/shell.mjs" + +const sdk = + process.env.ANDROID_HOME ?? + readFileSync(join(root, "android/local.properties"), "utf-8").match(/^sdk\.dir=(.+)$/m)?.[1] + +const apksigner = () => { + const tools = join(sdk, "build-tools") + const [latest] = readdirSync(tools).sort((a, b) => b.localeCompare(a, "en", {numeric: true})) + + return join(tools, latest, "apksigner") +} + +export default { + name: "fdroid-sign", + title: "Sign the release workflow's F-Droid build with the distribution key", + missing: () => [ + ...missingEnv("ANDROID_KEYSTORE_PATH", "ANDROID_KEYSTORE_PASSWORD", "ANDROID_KEYSTORE_ALIAS"), + ...(packageToken ? [] : ["GITEA_TOKEN"]), + ...(sdk && existsSync(join(sdk, "build-tools")) ? [] : ["the Android SDK's build-tools"]), + ], + setup: [ + "Signs with the same ANDROID_KEYSTORE_* key as the apk step. GITEA_TOKEN needs the", + "write:package scope. apksigner comes from the Android SDK in ANDROID_HOME, or the sdk.dir", + "Android Studio writes to android/local.properties.", + ], + run: async () => { + const api = fdroidPackage(packageToken) + const started = now() + let unsigned = await api.download(unsignedApk) + + while (!unsigned) { + if (started < ago(2 * HOUR)) { + throw new Error( + `The release workflow hasn't uploaded ${api.url(unsignedApk)} after two hours`, + ) + } + + console.log(`Waiting for the release workflow to upload ${unsignedApk}`) + await sleep(ms(MINUTE)) + unsigned = await api.download(unsignedApk) + } + + const work = await mkdtemp(join(tmpdir(), "flotilla-fdroid-sign-")) + + try { + const signing = keystoreEnv("ANDROID") + + await writeFile(join(work, unsignedApk), unsigned) + + // F-Droid copies this signature onto its own build, so nothing but the signature may change + await run( + apksigner(), + [ + "sign", + "--ks", + signing.ANDROID_KEYSTORE_PATH, + "--ks-key-alias", + signing.ANDROID_KEYSTORE_ALIAS, + "--ks-pass", + "env:ANDROID_KEYSTORE_PASSWORD", + "--key-pass", + "env:ANDROID_KEYSTORE_ALIAS_PASSWORD", + "--alignment-preserved", + "--out", + join(work, signedApk), + join(work, unsignedApk), + ], + {env: {...process.env, ...signing}}, + ) + + const recipe = readFileSync(join(root, "fdroid/metadata/social.flotilla.fdroid.yml"), "utf-8") + const allowed = recipe.match(/^AllowedAPKSigningKeys: (\w+)$/m)[1] + const certificates = output(apksigner(), ["verify", "--print-certs", join(work, signedApk)]) + + if (!certificates.includes(`certificate SHA-256 digest: ${allowed}`)) { + throw new Error(`${signedApk} isn't signed with the key the recipe allows, ${allowed}`) + } + + console.log(await api.upload(signedApk, await readFile(join(work, signedApk)))) + } finally { + await rm(work, {recursive: true, force: true}) + } + }, +} diff --git a/scripts/release/steps/fdroid.mjs b/scripts/release/steps/fdroid.mjs index 75a1a24b..f17e6ba5 100644 --- a/scripts/release/steps/fdroid.mjs +++ b/scripts/release/steps/fdroid.mjs @@ -1,53 +1,65 @@ -import {existsSync} from "node:fs" -import {mkdtemp, rm} from "node:fs/promises" +import {cp, mkdtemp, readFile, 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" +import {git, root, version, versionCode} from "../lib/context.mjs" +import {fdroidPackage, packageToken, unsignedApk} from "../lib/fdroid.mjs" +import {installed, run} from "../lib/shell.mjs" + +const docker = process.env.DOCKER || "docker" export default { name: "fdroid", - title: "Rebuild the tag the way F-Droid will", - missing: () => - git("cat-file", "-e", `${version}:scripts/fdroid/prepare.sh`) === undefined + title: "Build the tag the way F-Droid will, and upload the unsigned apk", + missing: () => [ + ...(git("cat-file", "-e", `${version}:scripts/fdroid/reproduce.sh`) === undefined ? [`F-Droid support in the ${version} tag`] - : [], + : []), + ...(installed(docker) ? [] : [docker]), + ...(packageToken ? [] : ["GITEA_PACKAGE_TOKEN or GITEA_TOKEN"]), + ], 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.", + `The ${version} tag is older than scripts/fdroid/reproduce.sh, so it can't be built the way`, + "F-Droid builds it. The build runs in F-Droid's buildserver image, which needs docker. The", + "token needs the write:package scope.", ], run: async () => { - const parent = await mkdtemp(join(tmpdir(), "flotilla-fdroid-")) - const checkout = join(parent, "flotilla") + const work = await mkdtemp(join(tmpdir(), "flotilla-fdroid-")) + const container = `flotilla-fdroid-${process.pid}` - // Preparation rewrites source and dependencies in place, so it only runs against a checkout - // that can be thrown away + // Copied in and out rather than mounted, like the desktop packages, for runners that share + // the host's docker socket 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}) + await cp(join(root, "scripts/fdroid/reproduce.sh"), join(work, "reproduce.sh")) + await cp(join(root, "fdroid/metadata/social.flotilla.fdroid.yml"), join(work, "recipe.yml")) + await run(docker, [ + "create", + "--name", + container, + "--platform", + "linux/amd64", + "--env", + `VERSION=${version}`, + "--env", + `VERSION_CODE=${versionCode}`, + "--env", + `COMMIT=${git("rev-parse", `${version}^{commit}`)}`, + "registry.gitlab.com/fdroid/fdroidserver:buildserver-trixie", + "bash", + "/work/reproduce.sh", + ]) + await run(docker, ["cp", `${work}/.`, `${container}:/work`]) + await run(docker, ["start", "--attach", container]) + await run(docker, ["cp", `${container}:/work/unsigned.apk`, join(work, unsignedApk)]) - // 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", + const url = await fdroidPackage(packageToken).upload( + unsignedApk, + await readFile(join(work, unsignedApk)), ) - if (!existsSync(built)) { - throw new Error(`the F-Droid build produced no apk at ${built}`) - } + console.log(url) } finally { - await rm(parent, {recursive: true, force: true}) - await run("git", ["worktree", "prune"], {cwd: root}) + await run(docker, ["rm", "--force", container], {stdio: "ignore"}).catch(() => {}) + await rm(work, {recursive: true, force: true}) } }, } diff --git a/static/.well-known/assetlinks.json b/static/.well-known/assetlinks.json index 7c19963f..16cd26c1 100644 --- a/static/.well-known/assetlinks.json +++ b/static/.well-known/assetlinks.json @@ -12,5 +12,17 @@ "8C:EE:37:F9:8A:08:02:A7:BB:55:2B:64:E5:A5:93:D8:58:73:14:26:66:71:DD:B0:4F:AB:9D:D5:4C:DF:FB:F7" ] } + }, + { + "relation": [ + "delegate_permission/common.handle_all_urls" + ], + "target": { + "namespace": "android_app", + "package_name": "social.flotilla.fdroid", + "sha256_cert_fingerprints": [ + "6D:AF:68:3E:1C:A8:3A:4C:D8:85:73:E9:73:9E:2A:A9:44:C8:5D:56:15:4E:34:42:30:55:7C:FF:ED:4A:D7:8C" + ] + } } ]