diff --git a/AGENTS.md b/AGENTS.md index 492739f..78524f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,9 @@ - `stacks/typescript`: framework-neutral Bun/TypeScript conventions. - `stacks/python`: optional Python API/shared-package workspace. - `stacks/ruby`: optional Ruby/Rails/Rack workspace conventions. +- `stacks/android`: optional native Android (Kotlin/Compose) release and + publishing convention pack — no app scaffold, since app structure is too + product-specific to template. - `.context`: gitignored workspace-local scratch for Conductor and agents. ## Development Workflow @@ -72,6 +75,10 @@ `cooldown:` source option requires Bundler `4.0.13` or newer. If Bundler is older, ask before upgrading it; do not add cooldown syntax that the target repo's Bundler cannot parse. +- When selecting the Android stack, do not assume F-Droid distribution or + hand-edit `versionCode`/`versionName` — see `stacks/android/README.md` for + the versioning model and the GPL-3/free-software-dependency constraint, + which only applies when the target app actually targets F-Droid. - The TypeScript stack includes Drizzle examples for database access. Keep Drizzle when it fits; replace it when the target repo already uses another data-access layer. diff --git a/MANIFEST.md b/MANIFEST.md index a2d9c0d..b54efa9 100644 --- a/MANIFEST.md +++ b/MANIFEST.md @@ -77,6 +77,7 @@ Disposition tags: | `stacks/typescript/pnpm/` | `select-per-stack` | pnpm root files and CI fragment for pnpm workspaces. | | `stacks/python/` | `select-per-stack` | Optional uv Python API/shared-package workspace with Ruff, MyPy, Pytest, Pydantic, and Alembic examples. | | `stacks/ruby/` | `select-per-stack` | Optional Ruby/Rails/Rack conventions with Bundler cooldown guidance. | +| `stacks/android/` | `select-per-stack` | Native Android (Kotlin/Compose) convention pack: release-please versioning, GitHub Actions release pipeline, Google Play and F-Droid (self-hosted + f-droid.org) publishing, and recurring architecture decisions. | ## Extras Inventory diff --git a/README.md b/README.md index 4648d28..c5d7b4a 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ Point an agent at this repo when starting or normalizing a project. The agent sh shown first. - Optional `uv` Python workspace conventions. - Optional Ruby/Rails/Rack conventions. +- Optional native Android (Kotlin/Compose) release and publishing + conventions: release-please versioning, a GitHub Actions release pipeline, + and Google Play/F-Droid publishing. - Dependency cooldowns for Bun, pnpm, uv, and Bundler. - Host-run development services. - Docker Compose examples for local infrastructure such as databases and @@ -170,6 +173,10 @@ is a template source, not an installable preset or final generated app. - `stacks/python/`: optional Python API/shared-package workspace. - `stacks/ruby/`: optional Ruby/Rails/Rack conventions with Bundler cooldown guidance. +- `stacks/android/`: optional native Android (Kotlin/Compose) convention + pack — release-please versioning, a GitHub Actions release pipeline, and + Google Play/F-Droid publishing. No app scaffold; app structure is too + product-specific to template. - `stacks/typescript/pnpm/`: pnpm root files and CI fragment for larger TypeScript workspaces. - `extras/dev-scripts/`: JS-first script variants and JS implementations of helper scripts. - `extras/dockerfiles/`: opt-in Dockerfile examples for deployment parity. @@ -196,8 +203,8 @@ selection and pruning before feature work. - Start with root hygiene files, then select only the stacks and extras that match the target repo. - Treat `stacks/` as peer language/runtime convention packs. TypeScript and - Python are examples, not universal defaults. Ruby is available as an - opt-in convention pack when the target repo actually uses Ruby. + Python are examples, not universal defaults. Ruby and Android are available + as opt-in convention packs when the target repo actually uses them. - Treat `extras/` as opt-in workflows or deployment helpers that may require repo settings, real owners, or team process. - Keep `.context/` gitignored. Promote durable learnings into tracked docs diff --git a/docs/deployment.md b/docs/deployment.md index d212535..8221954 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -25,6 +25,22 @@ When applying this devkit to a project, record the deployment decision here: | Coolify | Self-hosted platform-style deploys. | Adds a platform to operate and upgrade. | | Kubernetes | Larger teams with existing cluster operations. | Too much machinery for most new projects. | +## Mobile Store Publishing + +Native mobile apps aren't a deployment-platform decision at all — there's no +server to host. If the target project selects `stacks/android`, use its +release pipeline instead of anything in this file: `version.txt` + +release-please decide the version, a release-PR merge is what ships it, and +CI publishes signed builds to GitHub Releases, Google Play (internal track), +and F-Droid (a self-hosted repo plus an optional f-droid.org submission). See +`stacks/android/README.md` for the full model, including why the two jobs +live in one workflow run and how signing keys are handled. + +The target repo's own `docs/deployment.md` should be rewritten to describe +that concrete pipeline rather than keeping this file's generic +platform-decision-record shape — see `stacks/android/README.md` → "Docs To +Write In The Target Repo". + ## Workflow Guidance Keep deployment workflows platform-specific and explicit. A project should add deploy CI only after the platform is chosen and secrets are configured. diff --git a/docs/supply-chain.md b/docs/supply-chain.md index e786d5d..6dc7b51 100644 --- a/docs/supply-chain.md +++ b/docs/supply-chain.md @@ -108,6 +108,22 @@ Use `cooldown: 0`, `bundle install --cooldown 0`, or `BUNDLE_COOLDOWN=0` only for intentional exceptions such as emergency security fixes. Document the exception in the PR or release note. +## Gradle (Android) + +Use this when a project selects the Android stack (`stacks/android`). + +Gradle has no package-manager-level cooldown flag comparable to Bun/uv/pnpm/ +Bundler. Renovate is the whole mechanism: it natively understands Gradle +version catalogs and opens PRs against `gradle/libs.versions.toml` on the +repo's normal `minimumReleaseAge`, no extra Renovate config required. Don't +hand-edit dependency versions in that file opportunistically outside of a +Renovate PR unless fixing something broken. + +Verify Gradle wrapper integrity via `distributionSha256Sum` in +`gradle/wrapper/gradle-wrapper.properties`, and update both together when +bumping the wrapper version. CI should build against the committed wrapper +only — never a floating Gradle version. + ## CI Use frozen or locked installs: diff --git a/docs/tooling.md b/docs/tooling.md index 521da8c..4bb580b 100644 --- a/docs/tooling.md +++ b/docs/tooling.md @@ -84,6 +84,42 @@ adapt to `bin/dev`, Rails, Rack, RSpec, or Minitest-style test directories. When adding Bundler cooldowns, require Bundler `4.0.13` or newer and pin that Bundler version in `Gemfile.lock` before handing off the target repo. +## Android Stack + +Use `stacks/android` for native Android (Kotlin + Jetpack Compose) projects. +Unlike the other stacks, its reusable part is the release/publishing pipeline +and a set of recurring architecture decisions, not a local-dev script set — +see `stacks/android/README.md` for the full rationale. + +Required checks for `stacks/android`: + +```bash +./gradlew ktlintCheck +./gradlew testDebugUnitTest +./gradlew lintDebug +./gradlew assembleDebug +``` + +Track Gradle/Kotlin/Compose/library versions in `gradle/libs.versions.toml` +in the target repo, not in this devkit — Renovate natively understands Gradle +version catalogs and opens PRs against it on the repo's normal cooldown, +which is the expected update path. Don't hand-edit versions opportunistically +outside of that unless fixing something broken. + +**AGP 9+ has built-in Kotlin support.** Do not apply +`org.jetbrains.kotlin.android` (`kotlin-android`) alongside it — AGP 9.0+ +compiles Kotlin itself, and applying that plugin on top is a hard build +error, not a warning. See `stacks/android/README.md` for the rest of the +recurring Android decisions (Compose over Views, no DI framework by default, +Room vs. a flatter store, SDK level defaults, and the reverse-DNS +application-ID gotcha). + +The GPL-3/free-software-dependency constraint (AndroidX and +Kotlin-stdlib-class libraries only, no Google Play Services, no Firebase) is +specific to apps that target F-Droid distribution. Confirm that goal with the +user before applying it or the F-Droid publishing pipeline — not every +Android app in this devkit's scope needs either. + ## Dependency Safety Use dependency cooldowns and frozen installs: diff --git a/llms.txt b/llms.txt index 97f0676..847cb02 100644 --- a/llms.txt +++ b/llms.txt @@ -27,6 +27,10 @@ - Use deterministic worktree ports from `scripts/worktree-ports.sh`. - Keep `.context/` gitignored and workspace-local; durable knowledge belongs in tracked docs. - Put language/runtime conventions in `stacks/` and opt-in add-ons in `extras/`. +- `stacks/android` covers native Android release/versioning/publishing + conventions (release-please, GitHub Actions, Play, F-Droid) — no app + scaffold. Never hand-edit `versionCode`/`versionName`; derive them with + `scripts/sync-version.sh` from `version.txt`. - Before applying the devkit, use `MANIFEST.md` to produce a selection report covering every top-level path in the devkit and target repo. diff --git a/skills/508-devkit/SKILL.md b/skills/508-devkit/SKILL.md index a80149e..4ed5d39 100644 --- a/skills/508-devkit/SKILL.md +++ b/skills/508-devkit/SKILL.md @@ -15,8 +15,8 @@ Canonical repo: https://github.com/508-dev/508-devkit - Root files provide broadly useful hygiene: agent instructions, shell entrypoints, worktree-safe ports, Docker Compose examples, GitHub templates, dependency cooldowns, and docs. -- `stacks/` contains language/runtime convention packs. TypeScript, Python, and - Ruby are examples, not universal defaults. +- `stacks/` contains language/runtime convention packs. TypeScript, Python, + Ruby, and Android are examples, not universal defaults. - `extras/` contains opt-in add-ons such as Dockerfiles, dev containers, object storage, CODEOWNERS, Gitleaks, Dependency Review, and TODO-to-issue. - `.context/` is workspace-local agent scratch and must not be committed. @@ -40,6 +40,8 @@ example app, or design-history document. - `pyproject.toml`, `uv.lock` when Python is present. - `Gemfile`, `Gemfile.lock`, `.ruby-version`, `gems.rb`, and `gems.locked` when Ruby is present. + - `build.gradle.kts`, `settings.gradle.kts`, `gradle/libs.versions.toml` + when native Android is present. - `package.json`, `bun.lock`, `pnpm-lock.yaml`, `bunfig.toml`, `pnpm-workspace.yaml`. - Compose files. - `.github/workflows`. @@ -64,6 +66,13 @@ example app, or design-history document. - Use the Ruby stack only when the target repo is Ruby or the user asks for Ruby conventions. Treat Rails and Rack paths as adaptive examples, not requirements. + - Use the Android stack only when the target repo is a native Android app. + It has no app scaffold — only release/versioning/publishing conventions + (release-please, GitHub Actions release pipeline, Google Play, F-Droid) + and a list of recurring architecture decisions to open a `DECISIONS.md` + entry against. Confirm F-Droid distribution is an actual goal before + applying its GPL-3/free-software-dependency constraint or F-Droid + publishing pipeline. - Use Drizzle examples only when TypeScript-side database access is wanted and Drizzle fits the repo. Keep an existing data-access layer when one is already established. @@ -162,3 +171,15 @@ Before writing Bundler dependency cooldowns, run `bundle --version`. newer. If the target machine has an older Bundler, ask before upgrading it. Once cooldown is added, pin the compatible Bundler version in `Gemfile.lock` with `bundle lock --bundler=4.0.13` or newer. + +## Android Release Versioning + +Never hand-edit `versionCode`, `versionName`, or `CHANGELOG.md` in a repo +using `stacks/android`. `version.txt` is the source of truth; change that and +run `scripts/sync-version.sh`, which derives `versionCode` as +`major * 1000000 + minor * 1000 + patch` and keeps both values as plain +literals in `app/build.gradle.kts` — F-Droid's update bot regex-parses them +textually, so turning them into an expression silently ends automatic +F-Droid releases. See `stacks/android/README.md` for the full release model +(release-please, the two-jobs-in-one-workflow-run gotcha, and Play/F-Droid +publishing). diff --git a/stacks/android/README.md b/stacks/android/README.md new file mode 100644 index 0000000..b600ec8 --- /dev/null +++ b/stacks/android/README.md @@ -0,0 +1,400 @@ +# Android Stack + +Use this stack when the target project is a native Android app (Kotlin + +Jetpack Compose), especially one aimed at F-Droid, Google Play, or both. + +Unlike `stacks/typescript` or `stacks/python`, an Android app has no +server-side "run the dev server" shape and no package-manager cooldown +mechanism — the reusable part isn't local dev tooling, it's the release and +publishing pipeline. This stack is a convention pack for that: how versioning +works, how CI ships signed builds to GitHub Releases / Google Play / F-Droid, +and the architecture decisions that keep coming up across 508.dev's Android +apps. It was distilled from two sibling apps built on this pattern — +`soundboard` (the more complete one; it has the full release pipeline) and +`emotion-tracker` (the first one; it set the baseline Gradle/Compose +conventions). Where they differ, the reasons are called out below. + +## Contains + +- `README.md`: this file. +- `scripts/sync-version.sh`: derives `versionCode` and store release notes + from `version.txt`. +- `scripts/fdroid-publish.sh`: publishes a signed APK to a self-hosted F-Droid + repository on `gh-pages`. +- `github/release.yml.example`, `github/pr-title.yml.example`: the release + pipeline and the PR-title gate it depends on. +- `release-please-config.json.example`: release-please config for a + single-package Android repo. +- `version.txt.example`: seed version file. +- `keystore.properties.example`: local release-signing config, mirrored by + the CI environment variables in `release.yml`. +- `fastlane/metadata/android/en-US/`: the one place store copy is written — + Play, the self-hosted F-Droid repo, and the f-droid.org submission all read + from it. +- `fdroid/config.yml.example`, `fdroid/metadata/*.yml.example`: self-hosted + F-Droid repo config. +- `fdroid/fdroiddata/`: the one-time f-droid.org submission payload plus a + README explaining its unusual constraints. + +This stack does not include an app scaffold (no `MainActivity`, no +`build.gradle.kts`). Android app structure is too product-specific to +template usefully; what's reusable is the release machinery and the decisions +below. + +## Apply + +Every example file below uses the package `dev.co508.example`, app slug +`example`, and env var prefix `EXAMPLE_` as placeholders. Replace all three +consistently when copying files in: + +```bash +mkdir -p scripts fastlane/metadata/android/en-US/changelogs fdroid/metadata fdroid/fdroiddata .github/workflows +cp stacks/android/scripts/*.sh scripts/ +cp stacks/android/version.txt.example version.txt +cp stacks/android/release-please-config.json.example .release-please-config.json +cp stacks/android/keystore.properties.example . +cp stacks/android/github/release.yml.example .github/workflows/release.yml +cp stacks/android/github/pr-title.yml.example .github/workflows/pr-title.yml +cp -r stacks/android/fastlane . +cp stacks/android/fdroid/config.yml.example fdroid/config.yml +cp stacks/android/fdroid/metadata/dev.co508.example.yml.example \ + fdroid/metadata/.yml +cp -r stacks/android/fdroid/fdroiddata fdroid/ +``` + +Then, in every copied file: + +1. Replace `dev.co508.example` with the app's real application ID. See + "Application ID" below before picking one. +2. Replace the `example` app slug (artifact file names, F-Droid repo paths) + with the app's real short name. +3. Replace `EXAMPLE_` (the `SOUNDBOARD_KEYSTORE_*`-style env var prefix in + `release.yml` / `keystore.properties.example`) with the app's slug, + upper-cased. +4. Fill in `fastlane/metadata/android/en-US/{title,short_description, + full_description}.txt` with real store copy. +5. Rewrite `.release-please-config.json`'s `package-name` to match. + +Then read "Release & Versioning" below, set up the repository and secrets it +describes (`docs/deployment.md` in the target repo should carry a copy of +that section once adapted — see "Docs To Write" at the end of this file), and +bake the version: + +```bash +git tag v0.1.0 && git push origin v0.1.0 +``` + +## Recurring Architecture Decisions + +These are the calls that were open on the first Android app under this +pattern and stayed closed on the second. Treat them as defaults, and write a +`DECISIONS.md` entry in the target repo (not here) when the target app +deviates — that's what both source apps did. + +- **Jetpack Compose over Views.** Both apps use Compose (Material 3) + exclusively. It's the better fit for custom gesture-driven controls + (`Canvas` + `pointerInput`), and there's no reason to introduce the View + system alongside it. +- **No DI framework by default.** Two or three hand-constructed objects in + the `Application` subclass, reached through a `CreationExtras` helper, is + not a graph that earns Hilt's ceremony. Introduce Hilt (fully open source, + no Play Services dependency — it still qualifies for F-Droid) only once the + object graph actually outgrows manual wiring, and say so in `DECISIONS.md` + when it happens. +- **GPL-3, targeting F-Droid, by default.** If the product goal includes + F-Droid distribution, every shipped dependency — transitively — must itself + be free software: AndroidX and Kotlin-stdlib-class libraries only. No + Google Play Services, no Firebase, no closed-source SDKs. Compose's dynamic + color API is a pure AndroidX API despite the "Material You" branding and is + fine. `androidx.media3` is Apache-2.0 and qualifies. This constraint is + specific to the F-Droid goal, not to Android generally — drop it if the + target app has no F-Droid ambitions, but confirm that with the user first; + don't assume. +- **Local storage: Room vs. a flatter store.** Default to Room when the data + has real relations, queries, or history (journal entries, reminders). Reach + for `DataStore` with a `kotlinx.serialization` codec instead when the + entire persisted state is one small ordered list or document with no + queries — it avoids KSP, a schema directory, and migration tests for + something that's naturally a single JSON blob. Revisit if the data model + grows relations later. +- **SDK levels: `minSdk 26`, `targetSdk` one below `compileSdk`.** `minSdk + 26` (Android 8.0, Oreo) buys `java.time` without desugaring, native + notification channels, and `AudioFocusRequest` — real simplifications, not + just a floor pick. Keep `targetSdk` one below `compileSdk` until that API + level's behavior changes have been reviewed. Check `gradle/libs.versions.toml` + in the target repo (or a sibling app) for current values rather than + trusting a number written here — these move. +- **Application ID: reverse-DNS, and Android's package-name rule is + stricter than Kotlin's.** A leading-digit segment (`dev.508.app`) is + invalid twice over: Kotlin/Java identifiers can't start with a digit, and + even the underscore-prefixed workaround (`dev._508.app`) still fails, + because Android's manifest package-name rule requires every segment to + *start with a letter* — AAPT rejects `_508` outright. This is a hard build + error discovered by actually running `./gradlew assembleDebug`, not by + reading docs beforehand — verify a new namespace the same way, end to end, + before treating it as settled. 508.dev's own apps use `dev.co508.`. + Treat the chosen application ID as permanent once a store listing exists + under it (F-Droid and Play both key on it). +- **AGP 9+ has built-in Kotlin support — do not apply + `org.jetbrains.kotlin.android` alongside it.** AGP 9.0+ compiles Kotlin + itself; applying the `kotlin-android` plugin on top is a hard build error, + not a warning. The `org.jetbrains.kotlin.plugin.compose` and + `org.jetbrains.kotlin.plugin.serialization` sub-plugins are still applied + normally — built-in Kotlin only subsumes the base Kotlin-Android plugin. + JVM target comes from `android.compileOptions.targetCompatibility` + directly. See + . +- **The OSS-licenses list is hand-maintained, not generated.** Google's + OSS-licenses Gradle plugin is part of Play Services, which breaks the + F-Droid goal outright. Write the licenses screen by hand instead, grouped + by project rather than by Maven coordinate, and update it whenever + `gradle/libs.versions.toml` changes — a stale list is a compliance problem, + not a cosmetic one. +- **Renovate handles Gradle version bumps; don't hand-edit versions + opportunistically.** Renovate natively understands Gradle version catalogs + — no extra config needed for it to open PRs against + `gradle/libs.versions.toml` on the repo's normal cooldown. Android Studio + will flag newer AGP/Kotlin/Gradle releases as lint warnings before Renovate + gets to them; that's expected, not a signal to bump early. +- **Gradle daemon JVM pin.** Pin the Gradle daemon's JVM in + `gradle/gradle-daemon-jvm.properties` rather than assuming the system JDK + matches what AGP requires. Android Studio's bundled JBR matches the pin + automatically; from the CLI on a machine whose system JDK differs, invoke + as `JAVA_HOME=/path/to/android-studio/jbr ./gradlew `. +- **Deferred `androidTest` coverage is a legitimate default, not neglect.** + Both apps ship JVM unit tests only (pure logic: persistence round-trips, + domain math) and defer instrumented (`androidTest`) coverage for anything + needing a real device or emulator (playback, foreground services, SAF + permissions). That's fine for a first pass; add `androidTest` when the + deferred surface next changes shape, not preemptively. + +## Release & Versioning + +The release model is: **merging to `main` only grooms a standing release PR; +merging *that* PR is what tags a version and publishes it.** + +``` +merge a PR to main + -> release-please opens/updates "chore(main): release X.Y.Z" + (version.txt + CHANGELOG.md, from Conventional Commit PR titles) + -> CI tops the PR up with files derived from those + (versionCode, versionName, store release notes) + -> you merge the release PR when you want to ship + -> release-please tags vX.Y.Z and creates the GitHub Release + -> publish job: signed APK + AAB -> GitHub Release, Play, F-Droid +``` + +Why not release on every merge to `main`: that makes every merge a public +release with no way to batch commits into one version, and it needs CI to +push a version-bump commit directly to `main` — which means either leaving +`main` unprotected or granting a bot a branch-protection bypass. The +release-PR model needs neither: the bot only ever opens a PR, and the human +merge is the deliberate act. + +**`release-please` and `publish` run as two jobs in one workflow run, on +purpose.** A tag or PR created with `GITHUB_TOKEN` does not trigger another +workflow run. A separate tag-triggered publish workflow is the obvious +design and will silently never fire. `github/release.yml.example` keeps both +jobs in one `on: push: branches: [main]` workflow for this reason. + +PR titles are squash-merged into `main`'s commit subjects, and release-please +reads those subjects to decide the next version: + +| PR title prefix | Effect | +| --- | --- | +| `feat: ...` | minor bump | +| `fix: ...`, `perf: ...` | patch bump | +| `feat!: ...`, or a `BREAKING CHANGE:` footer | minor bump pre-1.0 (`bump-minor-pre-major`), major after | +| `docs:`, `chore:`, `ci:`, `refactor:`, `test:`, `build:`, `style:` | no release on their own | + +`github/pr-title.yml.example` enforces this shape on open PRs with one regex +— deliberately no third-party action, since a release-gating check is a bad +place to add a supply-chain dependency. + +### `versionCode` is derived, and stays a literal + +`version.txt` is the only source of truth for the version. Everything else — +`versionCode`, `versionName` in `app/build.gradle.kts`, and the store release +notes in `fastlane/metadata/android/*/changelogs/` — is derived from it by +`scripts/sync-version.sh`. Never hand-edit `versionCode`/`versionName`; run +the script after changing `version.txt` and commit what it writes. CI runs +`sync-version.sh --check` and fails the build on drift. + +`versionCode` is computed as `major * 1000000 + minor * 1000 + patch`, never +chosen by hand. That keeps it strictly increasing across any semver bump — +which matters because **Play permanently rejects a `versionCode` it has +already seen for an app, including one from a deleted release.** + +Both `versionCode` and `versionName` must stay plain literals on their own +line in `app/build.gradle.kts`, never an expression, however tidy. F-Droid's +own update bot regex-parses them straight out of that file to notice a new +version; an expression there silently ends automatic F-Droid releases with +no error anywhere. + +## GitHub Actions + +`github/release.yml.example` covers both jobs described above: +`release-please` (grooms/tags the release) and `publish` (builds signed +artifacts and ships them). Both declare a GitHub **Environment** (`Prod` in +the example) for their secrets rather than repo-level secrets — see +"Secrets" below for why that distinction matters. + +`github/pr-title.yml.example` is a required check on PRs, gating the +Conventional Commit shape release-please depends on. + +Adapt, don't skip, the following before relying on either: + +- `concurrency: { group: release, cancel-in-progress: false }` — never let + two releases interleave; they push to the same branches and the same + F-Droid index. Queue, don't cancel — a cancelled publish leaves a tagged + release with no artifacts. +- The `release-please` job checks out the release PR's branch (not `main`) + with a token that has push access, purely to run `sync-version.sh` and + commit the derived files back onto the PR before a human reviews it. +- The `publish` job builds from the **tag**, not whatever `main` has drifted + to since the release PR was opened. +- "Work out what is configured" degrades gracefully: no signing key means + the release is tagged with notes but nothing is published; no Play + credentials means GitHub Releases and F-Droid still ship. Useful while + store accounts are still being set up — don't turn this into a hard + failure. + +## Store Publishing + +### Signing + +There are up to three separate signatures in play, and they are not +interchangeable — Android refuses to update an installed app with a +differently-signed APK: + +| Channel | Signed by | +| --- | --- | +| GitHub Release APK / self-hosted F-Droid repo | the app's own release key | +| Google Play | Play App Signing (unless the same key above was uploaded to it at app creation) | +| f-droid.org | F-Droid's own key, always — nothing else is interchangeable with it | + +Upload the app's own key to **Play App Signing at app-creation time**, or +Google generates one instead and Play builds stop being interchangeable with +everything else. This choice is permanent. f-droid.org builds can never +match by design; F-Droid's +[reproducible builds](https://f-droid.org/docs/Reproducible_Builds/) process +is the eventual (optional) fix, not a prerequisite. + +CI reads the keystore from `EXAMPLE_KEYSTORE_FILE` / +`EXAMPLE_KEYSTORE_PASSWORD` / `EXAMPLE_KEY_ALIAS` / `EXAMPLE_KEY_PASSWORD` +environment variables (see `keystore.properties.example` for the local +equivalent, read by `app/build.gradle.kts` when no env vars are set). With +neither source configured, a release build is simply unsigned — which is +exactly what f-droid.org's own build server wants, since it signs with its +own key regardless. + +### Google Play + +CI uploads the AAB to the **internal** track only and stops. Promotion to +production stays a manual Play Console step on purpose — a bad build should +never reach users automatically, and it avoids hitting Google's review queue +on every merge. Store graphics (icon, feature graphic, screenshots) aren't +uploaded by this pipeline; set them once in the Play Console UI, pasting text +from `fastlane/metadata/android/en-US/` so listings stay in step. Upload the +R8 mapping file too, or every Play crash report comes back as an unreadable +obfuscated stack trace. + +The first Play release is unavoidably manual: the Play Developer API cannot +create an app or publish to a track that has never received one manual +upload. + +### F-Droid: two independent paths + +Run both — they solve different halves of the problem, not the same one. + +- **Self-hosted repository (`scripts/fdroid-publish.sh`, automated).** + Publishes to a git branch (typically `gh-pages`) served by GitHub Pages. + Live within minutes of a release, but users must add the repo URL by hand. + An F-Droid repository is cumulative — every version ever published stays + in the index — so the script clones the existing branch and adds to it + rather than deploying a fresh directory. The index is signed with the + app's own release key; publish the resulting fingerprint next to the URL + (see the generated `index.html`), since adding a repo without one is + trust-on-first-use. +- **f-droid.org (one-time submission, then automatic).** Gives discovery — + users find the app through search in the client they already have — but + nothing can ever be pushed to it from CI. It builds and signs on its own + infrastructure, on its own schedule, from a single merge request against + [gitlab.com/fdroid/fdroiddata](https://gitlab.com/fdroid/fdroiddata). See + `fdroid/fdroiddata/README.md` for the submission's unusual constraints — + most importantly, **that file must carry no comments**, because + fdroiddata's CI runs `fdroid rewritemeta` on any changed metadata file and + fails the job on any diff it produces, and `rewritemeta` unconditionally + strips every leading comment. + +Both `fastlane/metadata/android//` and the F-Droid-specific YAML +files read from the same store copy so Play, the self-hosted repo, and +f-droid.org never drift out of sync — keep it that way rather than +duplicating copy per store. + +Two things worth checking before an f-droid.org submission, because both +fail only on their builder, never on CI: + +- A pinned Gradle daemon JVM (`gradle/gradle-daemon-jvm.properties`) with a + JDK F-Droid's builder can't fetch fails the build there while passing + everywhere else — drop the pin before submitting if unsure. +- `./gradlew assembleRelease` must succeed from a clean checkout with no + `keystore.properties` and no network beyond declared Gradle dependencies. + +## Secrets + +| Secret | Used for | Without it | +| --- | --- | --- | +| `RELEASE_KEYSTORE_BASE64` | app signing key, `base64 -w0` of the keystore | nothing is published; the release is still tagged | +| `RELEASE_KEYSTORE_PASSWORD` | keystore password | as above | +| `RELEASE_KEY_ALIAS` | key alias within the keystore | as above | +| `RELEASE_KEY_PASSWORD` | same value as the keystore password — PKCS12 keystores have no separate key password | as above | +| `PLAY_SERVICE_ACCOUNT_JSON` | Play Developer API service account, whole JSON file | Play upload is skipped; GitHub Releases and F-Droid still publish | +| `RELEASE_PLEASE_TOKEN` | fine-grained PAT so the release PR itself triggers CI | falls back to `GITHUB_TOKEN`; the release PR arrives unchecked | +| `FDROID_KEYSTORE`, `FDROID_KEY_ALIAS`, `FDROID_KEYSTORE_PASS`, `FDROID_KEY_PASS` | signs the self-hosted F-Droid index — same key as `RELEASE_KEYSTORE_*` | self-hosted F-Droid publish is skipped | + +Add these under a GitHub **Environment** (e.g. `Prod`), not repo-level +Secrets, if the workflow declares `environment: Prod` on the jobs that need +them (the example does). A secret added to the wrong one of those two places +doesn't error — the job just runs with `secrets.WHATEVER` silently empty. + +**The signing key cannot be rotated in practice.** Android refuses to update +an installed app with a differently-signed APK, so losing the keystore or its +password means every existing user has to uninstall and reinstall, losing +their local data. Back it up offline and off any development machine. + +## Docs To Write In The Target Repo + +This stack intentionally does not ship `docs/deployment.md`, +`docs/tooling.md`, or `docs/secrets.md` content — the target repo's copies of +those (from the devkit root) are meant to be *rewritten* for the app, not +appended to. Both source apps replaced the generic devkit versions outright: +`docs/deployment.md` became the pipeline diagram + versioning + signing + +Play + F-Droid walkthrough (essentially this section, made concrete with the +app's real name/package/secrets), `docs/tooling.md` became the Gradle +version-pin table plus the AGP-built-in-Kotlin gotcha, and `docs/secrets.md` +became just the signing-key table above (an Android app with no backend has +no `.env` contract to document). Do the same rewrite rather than leaving the +generic web-service-shaped versions in place. + +## Agent Notes + +- Do not copy this stack just because a repo happens to contain Kotlin. + Select it only when the target is a native Android app. +- Treat the architecture decisions above as defaults to open a + `DECISIONS.md` entry against, not silent requirements — write down *why* + when the target app deviates, the same way both source apps did. +- Don't assume F-Droid targeting. Confirm the product's distribution goal + before applying the GPL-3/free-software-dependency constraint or the + F-Droid publishing pipeline; a Play-only app doesn't need either. +- Never hand-edit `versionCode`, `versionName`, or `CHANGELOG.md`. Both are + generated from `version.txt` — change that, then run + `scripts/sync-version.sh`. +- Do not turn the version literals in `app/build.gradle.kts` into an + expression, or move them onto a shared line — F-Droid's bot parses them + textually. +- Do not add publishing to a new store, or promote a Play upload past the + internal track, without the maintainer asking. Release signing material + and store submissions are deliberate human decisions. +- Never print keystore contents or echo a `EXAMPLE_KEYSTORE_*` / + `FDROID_*` secret value in a workflow step. diff --git a/stacks/android/fastlane/metadata/android/en-US/changelogs/.gitkeep b/stacks/android/fastlane/metadata/android/en-US/changelogs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/stacks/android/fastlane/metadata/android/en-US/full_description.txt.example b/stacks/android/fastlane/metadata/android/en-US/full_description.txt.example new file mode 100644 index 0000000..2bd823f --- /dev/null +++ b/stacks/android/fastlane/metadata/android/en-US/full_description.txt.example @@ -0,0 +1,9 @@ +The full store listing description, shown on the app's Play Store and F-Droid +pages. Plain text (F-Droid) / limited HTML (Play) — check each store's current +formatting rules before relying on markup. + +This is the single source of store copy: scripts/fdroid-publish.sh copies this +whole fastlane/metadata/android/ tree into the self-hosted F-Droid repo, the +release workflow renames the per-release changelog into the layout Play wants, +and f-droid.org reads it from the repo directly for the one-time submission. +Edit it here, not per-store. diff --git a/stacks/android/fastlane/metadata/android/en-US/short_description.txt.example b/stacks/android/fastlane/metadata/android/en-US/short_description.txt.example new file mode 100644 index 0000000..9a30afc --- /dev/null +++ b/stacks/android/fastlane/metadata/android/en-US/short_description.txt.example @@ -0,0 +1 @@ +One sentence, <=80 characters, shown under the app name in store search results. diff --git a/stacks/android/fastlane/metadata/android/en-US/title.txt.example b/stacks/android/fastlane/metadata/android/en-US/title.txt.example new file mode 100644 index 0000000..48df79b --- /dev/null +++ b/stacks/android/fastlane/metadata/android/en-US/title.txt.example @@ -0,0 +1 @@ +Example App diff --git a/stacks/android/fdroid/config.yml.example b/stacks/android/fdroid/config.yml.example new file mode 100644 index 0000000..b090fa6 --- /dev/null +++ b/stacks/android/fdroid/config.yml.example @@ -0,0 +1,36 @@ +# Configuration for the self-hosted F-Droid repository published from this +# repo's `gh-pages` branch and served by GitHub Pages. +# +# `scripts/fdroid-publish.sh` copies this file into the gh-pages checkout at +# publish time and never commits it there, so the signing material below is +# only ever resolved on the runner. `{env: NAME}` is fdroidserver's own +# indirection for exactly this — nothing secret is stored in this file. +# +# This is NOT the f-droid.org submission. That is a separate metadata file, +# `fdroid/fdroiddata/dev.co508.example.yml.example`; see +# stacks/android/README.md for how the two differ and why both exist. +# +# TEMPLATE NOTE: replace the repo_url, repo_name, and repo_description below +# with the real app's values. + +repo_url: https://508-dev.github.io/example/fdroid/repo +repo_name: Example App +repo_description: >- + Release builds of Example App, signed with the same key as the APKs + attached to its GitHub Releases — so you can move between the two without + uninstalling. Built and published from CI on every tagged release. +repo_icon: icon.png + +# Keep every published version in the main index rather than rolling older ones +# into an archive repo users would have to subscribe to separately. This is one +# small app; the index stays tiny either way. +archive_older: 0 + +# The index is signed with the app's own release key (see the target repo's +# docs/secrets.md for the tradeoff that accepts). Clients pin this key as the +# repo's fingerprint, so rotating it forces every user to re-add the +# repository. +keystore: {env: FDROID_KEYSTORE} +repo_keyalias: {env: FDROID_KEY_ALIAS} +keystorepass: {env: FDROID_KEYSTORE_PASS} +keypass: {env: FDROID_KEY_PASS} diff --git a/stacks/android/fdroid/fdroiddata/README.md b/stacks/android/fdroid/fdroiddata/README.md new file mode 100644 index 0000000..1b87af0 --- /dev/null +++ b/stacks/android/fdroid/fdroiddata/README.md @@ -0,0 +1,80 @@ +# f-droid.org Submission + +`dev.co508.example.yml.example` in this directory is the payload for a +one-time merge request to +[gitlab.com/fdroid/fdroiddata](https://gitlab.com/fdroid/fdroiddata), copied +there (after renaming to the real application ID, with the `.example` suffix +dropped) as `metadata/.yml`. See `stacks/android/README.md` → +"F-Droid" for the submission steps. + +**The metadata file itself must carry no comments.** fdroiddata's CI runs +`fdroid rewritemeta` on any changed file and fails the job if that produces a +diff — and `rewritemeta` unconditionally strips every leading comment. A +header explaining the file lasts exactly one rewrite pass before their own +lint deletes it and breaks CI on the next unrelated change. Keep explanatory +context here instead, in the target repo's copy of this README. + +## Why it's shaped this way + +- **Nothing in the target repo pushes to f-droid.org.** It builds and signs on + its own infrastructure, on its own schedule, with its own key. This file is + read once at merge time and from then on only by their bot. +- **`UpdateCheckMode: Tags`** makes that bot watch the repo for new tags. + Deliberately no tag pattern after it if release-please's `package-name` puts + a component prefix on every tag it cuts (e.g. `example-v0.1.2`, not + `v0.1.2`) — a pattern like `v*` would match a hand-cut baseline tag and miss + every real release since. Only the very first, hand-tagged baseline release + (used to seed release-please before it had ever run) is bare. +- **`AutoUpdateMode: Version`** (no tag pattern after it) makes it read + `versionName`/`versionCode` out of `app/build.gradle.kts` at that tag and + append a new `Builds:` entry itself — the reason those two values have to + stay plain literals in that file, not an expression. An older syntax, + `Version v%v`, supplied a commit-tag pattern; it's not valid on current + fdroiddata schemas, and it was always redundant under `UpdateCheckMode: + Tags` anyway — `checkupdates` already knows the tag, and it resolves that tag + to a commit rather than writing the tag name. +- **`commit:` is always a full 40-character hash, never a tag or branch.** + fdroiddata maintainers ask for this on review, and their own bot agrees: + `fdroid checkupdates --auto` writes `commit: ce7698e5…` for a tag it just + found. A tag is mutable — it can be moved or deleted after review, which + would silently change what F-Droid builds and signs. A hash cannot. Resolve + it with `git rev-list -n1 `; don't read it off `git log`, which tracks + the branch tip rather than the release. +- **No `Summary`/`Description` fields.** F-Droid reads + `fastlane/metadata/android/` from the repo instead, so the store copy has + one source rather than three. +- **`AutoName:` is required, not optional**, and must match the app's + `app_name` string resource exactly. Any app with `RepoType` set and + `UpdateCheckMode` other than `None`/`Static` — which is every app here — gets + its display name auto-derived by `checkupdates` from the built manifest on + every CI run, and their `checkupdates` job fails if that produces any diff + against what's committed. Leaving `AutoName` out doesn't skip the check, it + just guarantees the first run adds it and fails the diff. + +## Before submitting + +Run against a real fdroiddata checkout, not just schema validation — CI runs +both `fdroid rewritemeta` and `fdroid checkupdates` and fails on any diff +either produces, neither of which schema validation alone catches: + +```bash +python3 -m venv /tmp/fdroid-venv && /tmp/fdroid-venv/bin/pip install fdroidserver +mkdir -p /tmp/fdroiddata-check/metadata +cp fdroid/fdroiddata/.yml /tmp/fdroiddata-check/metadata/ +printf 'repo_url: https://example.org/fdroid/repo\nrepo_name: check\n' > /tmp/fdroiddata-check/config.yml +chmod 600 /tmp/fdroiddata-check/config.yml +cd /tmp/fdroiddata-check +git init --quiet && git add -A && git -c user.email=t@t -c user.name=t commit --quiet -m init + +/tmp/fdroid-venv/bin/fdroid rewritemeta +git --no-pager diff --exit-code || echo "rewritemeta changed the file — fix before submitting" + +mkdir -p build # checkupdates clones the real repo under here +/tmp/fdroid-venv/bin/fdroid checkupdates +git --no-pager diff --exit-code || echo "checkupdates changed the file — fix before submitting" +``` + +`checkupdates` needs a real, already-pushed tag to check out (it clones the +repo from the `Repo:` URL in the metadata), so run it after tagging a release, +not before. Two clean `git diff --exit-code`s means the file is already in +canonical form for both of fdroiddata's checks. diff --git a/stacks/android/fdroid/fdroiddata/dev.co508.example.yml.example b/stacks/android/fdroid/fdroiddata/dev.co508.example.yml.example new file mode 100644 index 0000000..f3611bc --- /dev/null +++ b/stacks/android/fdroid/fdroiddata/dev.co508.example.yml.example @@ -0,0 +1,21 @@ +Categories: + - Multimedia +License: GPL-3.0-only +AuthorName: 508.dev +AuthorEmail: caleb@508.dev +AuthorWebSite: https://508.dev +WebSite: https://508.dev +SourceCode: https://github.com/508-dev/example +IssueTracker: https://github.com/508-dev/example/issues +Changelog: https://github.com/508-dev/example/blob/HEAD/CHANGELOG.md +AutoName: Example App + +RepoType: git +Repo: https://github.com/508-dev/example.git + +Builds: [] + +AutoUpdateMode: Version +UpdateCheckMode: Tags +CurrentVersion: 0.1.0 +CurrentVersionCode: 1000 diff --git a/stacks/android/fdroid/metadata/dev.co508.example.yml.example b/stacks/android/fdroid/metadata/dev.co508.example.yml.example new file mode 100644 index 0000000..60a6b44 --- /dev/null +++ b/stacks/android/fdroid/metadata/dev.co508.example.yml.example @@ -0,0 +1,29 @@ +# App metadata for the SELF-HOSTED F-Droid repository (gh-pages / GitHub +# Pages). +# +# This repo distributes APKs we build and sign ourselves, so there is no +# `Builds:` section — fdroidserver reads the version straight out of each APK it +# finds in `repo/`. The name, summary, description, changelogs, and screenshots +# all come from `fastlane/metadata/android/`, which +# `scripts/fdroid-publish.sh` copies into `metadata/dev.co508.example/`. +# Keep store copy there, not here, so Play and both F-Droid paths stay in sync. +# +# The f-droid.org submission is a different file with different requirements: +# fdroid/fdroiddata/dev.co508.example.yml. See stacks/android/README.md. +# +# TEMPLATE NOTE: rename this file to match the real application ID, and +# replace every field below with the real app's values. + +Categories: + - Multimedia +License: GPL-3.0-only +AuthorName: 508.dev +AuthorEmail: caleb@508.dev +AuthorWebSite: https://508.dev +WebSite: https://508.dev +SourceCode: https://github.com/508-dev/example +IssueTracker: https://github.com/508-dev/example/issues +Changelog: https://github.com/508-dev/example/blob/HEAD/CHANGELOG.md + +RepoType: git +Repo: https://github.com/508-dev/example.git diff --git a/stacks/android/github/pr-title.yml.example b/stacks/android/github/pr-title.yml.example new file mode 100644 index 0000000..5694786 --- /dev/null +++ b/stacks/android/github/pr-title.yml.example @@ -0,0 +1,53 @@ +# release-please decides the next version, and writes the changelog, from +# Conventional Commit subjects on main. With squash merging the PR title +# *becomes* that subject, so an unparseable title silently costs a release +# entry — this check catches it while the PR is still open. +# +# No third-party action: this is one regex, and a release-gating check is a bad +# place to add a supply-chain dependency. +name: PR title + +on: + pull_request: + types: [opened, edited, reopened, synchronize] + +permissions: + contents: read + +jobs: + conventional-commit: + runs-on: ubuntu-latest + steps: + - name: Check the PR title is a Conventional Commit + env: + # Via env, never inlined into the script: a PR title is untrusted + # input and would otherwise be a shell injection. + TITLE: ${{ github.event.pull_request.title }} + run: | + pattern='^(feat|fix|perf|revert|refactor|docs|test|build|ci|chore|style)(\([a-z0-9._/-]+\))?!?: .+' + if [[ $TITLE =~ $pattern ]]; then + echo "OK: $TITLE" + exit 0 + fi + cat >&2 <[optional (scope)][optional !]: + + feat: a user-visible feature -> minor version bump + fix: a user-visible bug fix -> patch version bump + ! a breaking change -> see stacks/android/README.md + perf, refactor, docs, test, build, ci, chore, style, revert + -> no release on their own + + Examples: + feat: add a dark mode toggle + fix(playback): keep focus when a call interrupts playback + chore(deps): bump media3 to 1.11.0 + EOF + exit 1 diff --git a/stacks/android/github/release.yml.example b/stacks/android/github/release.yml.example new file mode 100644 index 0000000..6fef6dd --- /dev/null +++ b/stacks/android/github/release.yml.example @@ -0,0 +1,240 @@ +# Versioning and publishing. Merges to main only groom a release PR; merging +# that PR is what tags a version and ships it. See stacks/android/README.md. +# +# merge a PR to main +# -> release-please opens/updates "chore(main): release X.Y.Z" +# (version.txt + CHANGELOG.md, topped up with the files derived from them) +# -> you merge that PR when you want to ship +# -> release-please tags vX.Y.Z and creates the GitHub Release +# -> publish: signed APK + AAB -> Release assets, Play, F-Droid +# +# Both jobs run in the same workflow run on purpose. A tag or PR created with +# GITHUB_TOKEN does not start a new workflow, so a separate tag-triggered +# publish workflow would silently never fire. +# +# TEMPLATE NOTE: replace every `example` app slug, `dev.co508.example` +# package name, and `EXAMPLE_` env var prefix below with the real app's +# values before enabling this workflow. +name: Release + +on: + push: + branches: + - main + workflow_dispatch: + +# Never let two releases interleave: they push to the same branches and the +# same F-Droid index. Queue instead of cancelling — a cancelled publish leaves +# a tagged release with no artifacts. +concurrency: + group: release + cancel-in-progress: false + +permissions: + contents: read + +jobs: + release-please: + runs-on: ubuntu-latest + # RELEASE_PLEASE_TOKEN lives in the Prod environment, not as a repo-level + # secret — GitHub only injects environment secrets into a job that + # declares that environment. + environment: Prod + permissions: + contents: write + pull-requests: write + outputs: + released: ${{ steps.release.outputs.releases_created }} + tag: ${{ steps.release.outputs.tag_name }} + version: ${{ steps.release.outputs.version }} + steps: + - uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2 + with: + egress-policy: audit + + - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + id: release + with: + # A PAT is strongly preferred here: a PR opened with GITHUB_TOKEN does + # not trigger CI, so the release PR would merge unchecked. Falls back + # to GITHUB_TOKEN so the workflow still works before one is set up. + token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} + config-file: .release-please-config.json + manifest-file: .release-please-manifest.json + + # release-please only knows how to bump version.txt and CHANGELOG.md. + # The Gradle version literals and the store release notes are derived from + # those two, so top the PR up before anyone merges it. `publish` re-checks + # this and refuses to ship if they ever drift apart. + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + if: ${{ steps.release.outputs.pr }} + with: + ref: ${{ fromJSON(steps.release.outputs.pr).headBranchName }} + # Credentials are kept here, unlike every other checkout in this repo: + # this step exists to push back to the release branch. + token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} + + - name: Sync derived version files into the release PR + if: ${{ steps.release.outputs.pr }} + run: | + ./scripts/sync-version.sh + # `git diff` alone misses a brand-new changelog file — it only shows + # tracked changes, and a new versionCode means a changelog that has + # never been committed before. `status --porcelain` catches both. + if [ -z "$(git status --porcelain)" ]; then + echo "derived files already up to date" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + # Explicit paths, not `-A`: these two are exactly what + # sync-version.sh writes, and only that. `commit -am` was the + # original bug here — `-a` stages modifications to already-tracked + # files only, so it silently dropped the new changelog file every + # time. + git add app/build.gradle.kts fastlane/metadata/android/*/changelogs/ + git commit -m "chore: sync versionCode and store release notes" + git push + + publish: + needs: release-please + if: needs.release-please.outputs.released == 'true' + runs-on: ubuntu-latest + # The RELEASE_KEYSTORE_*/PLAY_SERVICE_ACCOUNT_JSON secrets live in the Prod + # environment, not as repo-level secrets — without this, every secrets.* + # reference below resolves empty and "Work out what is configured" quietly + # skips all publishing rather than erroring. + environment: Prod + permissions: + # Attach assets to the release, and push the F-Droid repo to gh-pages. + contents: write + steps: + - uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2 + with: + egress-policy: audit + + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + # Build the tag, not whatever main has drifted to since. + ref: ${{ needs.release-please.outputs.tag }} + persist-credentials: false + + - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: "17" + + - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 + + - name: Accept Android SDK licenses + run: yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null || true + + # The APK, the Play listing, and the F-Droid index all key off these + # numbers. Getting them wrong is not recoverable after publication: + # Play permanently rejects a versionCode it has already seen. + - name: Check the derived version files + run: ./scripts/sync-version.sh --check + + - name: Work out what is configured + id: targets + env: + KEYSTORE: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + PLAY: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + run: | + # Publishing degrades gracefully while the store accounts are still + # being set up: no signing key means source-only release notes, and + # no Play credentials means F-Droid and GitHub still get the build. + signing=$([ -n "$KEYSTORE" ] && echo true || echo false) + play=$([ -n "$PLAY" ] && echo true || echo false) + echo "signing=$signing" >> "$GITHUB_OUTPUT" + echo "play=$play" >> "$GITHUB_OUTPUT" + echo "signing=$signing, play=$play" >> "$GITHUB_STEP_SUMMARY" + [ "$signing" = true ] || echo "::warning::RELEASE_KEYSTORE_BASE64 is not set — skipping all publishing." + + - name: Restore the signing keystore + if: steps.targets.outputs.signing == 'true' + env: + KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} + run: | + # Strip whitespace first: a secret pasted with a trailing newline is + # the single most common way this step fails. + printf '%s' "$KEYSTORE_BASE64" | tr -d '[:space:]' | base64 -d \ + > "$RUNNER_TEMP/release.keystore" + # Prove the keystore and password actually work. Otherwise a truncated + # or mis-pasted secret surfaces much later, during signing, as a far + # less obvious error. + keytool -list -keystore "$RUNNER_TEMP/release.keystore" \ + -storepass "$KEYSTORE_PASSWORD" > /dev/null + + - name: Build the signed APK and AAB + if: steps.targets.outputs.signing == 'true' + env: + EXAMPLE_KEYSTORE_FILE: ${{ runner.temp }}/release.keystore + EXAMPLE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} + EXAMPLE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} + EXAMPLE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} + run: ./gradlew assembleRelease bundleRelease + + - name: Collect the artifacts + if: steps.targets.outputs.signing == 'true' + id: artifacts + run: | + eval "$(./scripts/sync-version.sh --print)" + mkdir -p dist + cp app/build/outputs/apk/release/app-release.apk "dist/example-${version}.apk" + cp app/build/outputs/bundle/release/app-release.aab "dist/example-${version}.aab" + # Publish the APK's hash so a side-loader can check what they got. + (cd dist && sha256sum "example-${version}.apk" > "example-${version}.apk.sha256") + # Same release notes as F-Droid gets, renamed into the layout Play + # wants (whatsnew-, not .txt). + mkdir -p dist/whatsnew + for notes in fastlane/metadata/android/*/changelogs/"${version_code}.txt"; do + locale=$(basename "$(dirname "$(dirname "$notes")")") + cp "$notes" "dist/whatsnew/whatsnew-${locale}" + done + echo "apk=dist/example-${version}.apk" >> "$GITHUB_OUTPUT" + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "version_code=${version_code}" >> "$GITHUB_OUTPUT" + + - name: Attach the APK to the GitHub Release + if: steps.targets.outputs.signing == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.release-please.outputs.tag }} + APK: ${{ steps.artifacts.outputs.apk }} + run: | + # The AAB is deliberately not attached: it is only useful to Play, and + # publishing it invites people to try to install a file they cannot. + gh release upload "$TAG" "$APK" "$APK.sha256" --clobber + + - name: Upload to Google Play (internal track) + if: steps.targets.outputs.signing == 'true' && steps.targets.outputs.play == 'true' + uses: r0adkll/upload-google-play@e738b9dd8f2476ea806d921b64aacd24f34515a5 # v1.1.5 + with: + serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + packageName: dev.co508.example + releaseFiles: dist/example-${{ steps.artifacts.outputs.version }}.aab + track: internal + status: completed + # R8 is on for release builds, so without this every Play crash report + # comes back as unreadable obfuscated stack traces. + mappingFile: app/build/outputs/mapping/release/mapping.txt + whatsNewDirectory: dist/whatsnew + + - name: Publish to the self-hosted F-Droid repository + if: steps.targets.outputs.signing == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + FDROID_KEYSTORE: ${{ runner.temp }}/release.keystore + FDROID_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} + FDROID_KEYSTORE_PASS: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} + FDROID_KEY_PASS: ${{ secrets.RELEASE_KEY_PASSWORD }} + APK: ${{ steps.artifacts.outputs.apk }} + run: | + # Pinned: fdroidserver decides the on-disk layout and index format of + # a published repository, so an unattended upgrade is not something to + # find out about from a broken index. + python3 -m venv "$RUNNER_TEMP/fdroid-venv" + "$RUNNER_TEMP/fdroid-venv/bin/pip" install --quiet fdroidserver==2.4.5 + PATH="$RUNNER_TEMP/fdroid-venv/bin:$PATH" ./scripts/fdroid-publish.sh "$APK" diff --git a/stacks/android/keystore.properties.example b/stacks/android/keystore.properties.example new file mode 100644 index 0000000..e8312d6 --- /dev/null +++ b/stacks/android/keystore.properties.example @@ -0,0 +1,15 @@ +# Local release signing config, read by app/build.gradle.kts when present. +# Copy to keystore.properties (gitignored) for local release builds; CI never +# writes this file — it passes the same four values as EXAMPLE_KEYSTORE_FILE / +# EXAMPLE_KEYSTORE_PASSWORD / EXAMPLE_KEY_ALIAS / EXAMPLE_KEY_PASSWORD +# environment variables instead. With neither source configured, a release +# build is simply unsigned, which is what f-droid.org's build server wants. +# +# See stacks/android/README.md -> "Signing" for how this key is used and why +# it cannot be rotated in practice. +storeFile=/absolute/path/to/release.keystore +storePassword=changeme +keyAlias=example +# Same value as storePassword — PKCS12 keystores (keytool's default) have no +# separate per-entry key password. +keyPassword=changeme diff --git a/stacks/android/release-please-config.json.example b/stacks/android/release-please-config.json.example new file mode 100644 index 0000000..61fc370 --- /dev/null +++ b/stacks/android/release-please-config.json.example @@ -0,0 +1,24 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "simple", + "include-v-in-tag": true, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": false, + "packages": { + ".": { + "package-name": "example" + } + }, + "changelog-sections": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug Fixes" }, + { "type": "perf", "section": "Performance" }, + { "type": "revert", "section": "Reverts" }, + { "type": "refactor", "section": "Refactoring", "hidden": true }, + { "type": "docs", "section": "Documentation", "hidden": true }, + { "type": "test", "section": "Tests", "hidden": true }, + { "type": "build", "section": "Build", "hidden": true }, + { "type": "ci", "section": "CI", "hidden": true }, + { "type": "chore", "section": "Chores", "hidden": true } + ] +} diff --git a/stacks/android/scripts/fdroid-publish.sh b/stacks/android/scripts/fdroid-publish.sh new file mode 100755 index 0000000..03d02b0 --- /dev/null +++ b/stacks/android/scripts/fdroid-publish.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# Publish a signed release APK into the self-hosted F-Droid repository that +# lives on this repo's `gh-pages` branch and is served by GitHub Pages. +# +# gh-pages/ +# index.html landing page: repo URL + fingerprint +# fdroid/repo/*.apk every release ever published +# fdroid/repo/index-v*.{jar,json} the signed index clients fetch +# fdroid/metadata/ app metadata + fastlane store copy +# +# The branch is the store: an F-Droid repo is cumulative, so old APKs have to +# survive each run. That's why this clones the branch and adds to it rather +# than deploying a freshly built directory. +# +# Usage: scripts/fdroid-publish.sh +# +# Requires `fdroid` (fdroidserver) on PATH, a JDK for jarsigner, ANDROID_HOME +# for apksigner, and: +# GH_TOKEN, GITHUB_REPOSITORY push access to gh-pages +# FDROID_KEYSTORE path to the index-signing keystore +# FDROID_KEY_ALIAS +# FDROID_KEYSTORE_PASS, FDROID_KEY_PASS +# +# TEMPLATE NOTE: replace `dev.co508.example` (package_name), `example` +# (display name / landing-page copy), and the GitHub repo URLs below with the +# real app's values. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." +repo_root=$PWD + +apk=${1:?usage: scripts/fdroid-publish.sh } +[[ -f $apk ]] || { echo "no such APK: $apk" >&2; exit 1; } +apk=$(realpath "$apk") + +: "${GH_TOKEN:?}" "${GITHUB_REPOSITORY:?}" "${FDROID_KEYSTORE:?}" +: "${FDROID_KEY_ALIAS:?}" "${FDROID_KEYSTORE_PASS:?}" "${FDROID_KEY_PASS:?}" + +package_name=dev.co508.example +# Single source for the version numbers — never recompute the versionCode here. +eval "$(scripts/sync-version.sh --print)" +pages_url="https://$(cut -d/ -f1 <<<"$GITHUB_REPOSITORY").github.io/$(cut -d/ -f2 <<<"$GITHUB_REPOSITORY")" +remote="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + +workdir=$(mktemp -d) +trap 'rm -rf "$workdir"' EXIT +pages=$workdir/pages + +# --- get the branch, or start it --------------------------------------------- +if git clone --quiet --depth 1 --branch gh-pages "$remote" "$pages" 2>/dev/null; then + echo "==> cloned existing gh-pages" +else + echo "==> gh-pages does not exist yet, starting it" + git init --quiet --initial-branch=gh-pages "$pages" + git -C "$pages" remote add origin "$remote" +fi +git -C "$pages" config user.name "github-actions[bot]" +git -C "$pages" config user.email "41898282+github-actions[bot]@users.noreply.github.com" + +mkdir -p "$pages/fdroid/repo" "$pages/fdroid/metadata/$package_name" + +# --- stage config, metadata, and the new APK --------------------------------- +# config.yml carries the signing material (indirected through {env:}), so it is +# staged but never committed — see the .gitignore written below. +cp "$repo_root/fdroid/config.yml" "$pages/fdroid/config.yml" +cp "$repo_root/fdroid/metadata/$package_name.yml" "$pages/fdroid/metadata/" +# fdroidserver attaches metadata///changelogs/.txt to a +# release only by matching against the app's current version, and +# index-v2 additionally requires the newest Builds: entry to carry that same +# versionCode. Neither is inferred from the APK for a binary repo like this +# one. Pin both here rather than in the tracked metadata file, which would +# otherwise need editing on every release. Nothing builds from this Builds: +# entry — `fdroid update` only reads it. +# shellcheck disable=SC2154 # version/version_code come from the eval above +cat >> "$pages/fdroid/metadata/$package_name.yml" <//. +cp -r "$repo_root/fastlane/metadata/android/." "$pages/fdroid/metadata/$package_name/" +cp "$apk" "$pages/fdroid/repo/" + +# Without this fdroidserver generates a placeholder repo icon every run. +if [[ -f $repo_root/fastlane/metadata/android/en-US/images/icon.png ]]; then + mkdir -p "$pages/fdroid/repo/icons" + cp "$repo_root/fastlane/metadata/android/en-US/images/icon.png" \ + "$pages/fdroid/repo/icons/icon.png" +fi + +# repo/status/ is fdroidserver's own run bookkeeping: it changes every run and +# records absolute runner paths. Nothing serves it to clients. +printf 'config.yml\n*.keystore\n*.jks\nrepo/status/\n' > "$pages/fdroid/.gitignore" +# Staged config.yml resolves the signing passwords; fdroidserver refuses to be +# quiet about group/world-readable permissions on it, and it is right to. +chmod 600 "$pages/fdroid/config.yml" + +# --- build and sign the index ------------------------------------------------- +# --use-date-from-apk keeps "Last updated" tied to the build rather than to +# whenever this script happened to run, so re-running it is a no-op. +echo "==> fdroid update" +(cd "$pages/fdroid" && fdroid update --create-metadata --pretty --use-date-from-apk) + +# --- landing page -------------------------------------------------------------- +# Clients pin the repo by fingerprint, so publish it next to the URL: adding the +# repo without one is trust-on-first-use. +# The fingerprint is the SHA-256 of the certificate the index is signed with. +# It is not written into the index itself, so read it back off the signed jar. +fingerprint=$(keytool -printcert -jarfile "$pages/fdroid/repo/index.jar" 2>/dev/null | + sed -nE 's/^[[:space:]]*SHA256:[[:space:]]*([0-9A-Fa-f:]+)$/\1/p' | + head -1 | tr -d ':' | tr '[:upper:]' '[:lower:]') +[[ -n $fingerprint ]] || echo "warning: could not read the index signing fingerprint" >&2 + +repo_url="$pages_url/fdroid/repo" +add_url=$repo_url${fingerprint:+?fingerprint=$fingerprint} + +python3 - "$pages/index.html" "$repo_url" "$add_url" "$fingerprint" <<'PY' +import html, sys +out, repo_url, add_url, fingerprint = sys.argv[1:5] +fp = html.escape(fingerprint) or "(unavailable)" +with open(out, "w") as f: + f.write(f""" + + + +Example App — F-Droid repository + +

Example App

+

An F-Droid repository with signed release builds of +Example App.

+

Add this repository

+

In the F-Droid app: Settings → Repositories → +, then paste:

+
{html.escape(add_url)}
+

Repository URL: {html.escape(repo_url)}
+Signing fingerprint (SHA-256): {fp}

+

These builds are signed with the same key as the APKs attached to the +project's GitHub Releases, so you can switch between the two without +uninstalling. Builds from f-droid.org are signed by F-Droid instead and +are not interchangeable with these.

+

Source code · GPL-3.0

+ +""") +PY + +# --- commit ------------------------------------------------------------------ +# Explicit paths, never `git add -A` and never --force: config.yml holds +# resolved secrets on disk at this point, and the .gitignore written above is +# the second line of defence that keeps it and any stray keystore out. +git -C "$pages" add \ + index.html fdroid/.gitignore fdroid/repo fdroid/metadata +if git -C "$pages" diff --cached --quiet; then + echo "==> nothing changed, not pushing" + exit 0 +fi +git -C "$pages" commit --quiet -m "Publish $(basename "$apk")" +git -C "$pages" push --quiet origin gh-pages +echo "==> published to $repo_url" +echo "==> fingerprint ${fingerprint:-unknown}" diff --git a/stacks/android/scripts/sync-version.sh b/stacks/android/scripts/sync-version.sh new file mode 100755 index 0000000..567f4c8 --- /dev/null +++ b/stacks/android/scripts/sync-version.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Propagate version.txt into the files that must carry a literal version. +# +# version.txt <- release-please owns this +# -> app/build.gradle.kts versionName + versionCode +# -> fastlane/metadata/android/en-US/changelogs/.txt +# +# versionCode is derived, never chosen: major * 1000000 + minor * 1000 + patch. +# That keeps it strictly increasing across any semver bump (Play rejects a +# versionCode that does not increase, permanently), while staying far below +# Play's 2100000000 ceiling. Both values stay plain literals on their own lines +# because F-Droid's update bot regex-parses them out of build.gradle.kts to +# decide there is a new version to build — an expression it cannot parse means +# no automatic F-Droid releases. See stacks/android/README.md. +# +# Usage: +# scripts/sync-version.sh rewrite the derived files +# scripts/sync-version.sh --check fail if they are out of date (CI) +# scripts/sync-version.sh --print emit `version=`/`version_code=` for eval +# +# --print exists so nothing else has to reimplement the formula above. Callers +# do: eval "$(scripts/sync-version.sh --print)". +# +# TEMPLATE NOTE: replace the `en-US` locale below if the app ships copy in a +# different default locale. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +mode=${1:-write} +case $mode in + write | --check | --print) ;; + *) + echo "usage: ${BASH_SOURCE[0]##*/} [--check|--print]" >&2 + exit 2 + ;; +esac + +version=$(tr -d '[:space:]' < version.txt) +[[ $version =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] || { + echo "version.txt must hold a bare MAJOR.MINOR.PATCH version, got: '$version'" >&2 + exit 1 +} +major=${BASH_REMATCH[1]} minor=${BASH_REMATCH[2]} patch=${BASH_REMATCH[3]} + +if ((minor > 999 || patch > 999)); then + echo "minor/patch above 999 would collide in the versionCode formula" >&2 + exit 1 +fi +version_code=$((major * 1000000 + minor * 1000 + patch)) + +if [[ $mode == --print ]]; then + printf 'version=%s\nversion_code=%s\n' "$version" "$version_code" + exit 0 +fi + +gradle_file=app/build.gradle.kts +changelog_file="fastlane/metadata/android/en-US/changelogs/${version_code}.txt" + +# --- app/build.gradle.kts ---------------------------------------------------- +gradle_updated=$(sed -E \ + -e "s/^([[:space:]]*)versionCode = [0-9]+$/\1versionCode = ${version_code}/" \ + -e "s/^([[:space:]]*)versionName = \".*\"$/\1versionName = \"${version}\"/" \ + "$gradle_file") + +# A typo'd or reformatted literal would silently no-op the sed above, so prove +# the result actually says what we intended rather than trusting the rewrite. +if ! grep -qE "^[[:space:]]*versionCode = ${version_code}$" <<<"$gradle_updated" || + ! grep -qE "^[[:space:]]*versionName = \"${version}\"$" <<<"$gradle_updated"; then + echo "could not set the version literals in $gradle_file." >&2 + echo "They must each be a plain literal on its own line." >&2 + exit 1 +fi + +# --- release notes ----------------------------------------------------------- +# Play and F-Droid both read a plain-text, <=500-char changelog keyed by +# versionCode. Render it from the top section of the CHANGELOG release-please +# generates, stripping the Markdown they would show verbatim. +render_changelog() { + if [[ ! -f CHANGELOG.md ]]; then + echo "Version ${version}." + return + fi + awk ' + /^## / { if (seen++) exit; next } # start at section 1, stop at section 2 + seen # print the body in between + ' CHANGELOG.md | + sed -E \ + -e 's/^#+ +(.*)$/\1:/' \ + -e 's/^\* /- /' \ + -e 's/ ?\(\[[^]]*\]\([^)]*\)\)//g' \ + -e 's/\[([^]]*)\]\([^)]*\)/\1/g' \ + -e 's/[[:space:]]+$//' | + cat -s | + sed -e '/./,$!d' | + head -c 500 +} +changelog_body=$(render_changelog) +[[ -n ${changelog_body//[[:space:]]/} ]] || changelog_body="Version ${version}." + +# --- write or verify --------------------------------------------------------- +if [[ $mode == --check ]]; then + status=0 + diff -u "$gradle_file" - <<<"$gradle_updated" || status=1 + if [[ ! -f $changelog_file ]]; then + echo "missing release notes: $changelog_file" >&2 + status=1 + elif ! diff -u "$changelog_file" - <<<"$changelog_body"; then + status=1 + fi + if ((status != 0)); then + echo >&2 + echo "Derived version files are stale. Run: scripts/sync-version.sh" >&2 + fi + exit $status +fi + +printf '%s\n' "$gradle_updated" > "$gradle_file" +mkdir -p "$(dirname "$changelog_file")" +printf '%s\n' "$changelog_body" > "$changelog_file" +echo "version ${version} -> versionCode ${version_code}, ${changelog_file}" diff --git a/stacks/android/version.txt.example b/stacks/android/version.txt.example new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/stacks/android/version.txt.example @@ -0,0 +1 @@ +0.1.0