diff --git a/.github/scripts/changelog-section.py b/.github/scripts/changelog-section.py new file mode 100755 index 000000000000..496c14490a6a --- /dev/null +++ b/.github/scripts/changelog-section.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +# +# Prints the CHANGELOG.md section for one version, and fails when there is none. +# +# The release run reads it before building, so missing release copy costs seconds +# rather than a version, and again in the record job, where it becomes the body of +# the drafted github release. Being read is what stops the changelog rotting. +# +# OpenDocument.ios has the same script against `## [1.37] - 2026-08-02` headings. + +import argparse +import os +import re +import sys + +# `## 4.13.0`, not `###`, which belongs to whichever section it sits in +HEADING = re.compile(r"^## +(.+?)\s*$") + + +def section(text, version): + """The body under `## `. Raises ValueError if it is missing or empty.""" + # an optional v, so the workflow can hand its input straight over + wanted = version.strip().removeprefix("v") + + found = False + collecting = False + body = [] + for line in text.splitlines(): + heading = HEADING.match(line) + if heading: + if collecting: + break + if heading.group(1).removeprefix("v") == wanted: + found = collecting = True + continue + if collecting: + body.append(line) + + if not found: + raise ValueError( + f"CHANGELOG.md has no '## {wanted}' section. Cut the Unreleased heading " + f"to '## {wanted}' before releasing it - that copy is the release body." + ) + + body = "\n".join(body).strip("\n") + if not body.strip(): + raise ValueError( + f"the '## {wanted}' section of CHANGELOG.md is empty. A release with " + "nothing user facing in it should say so rather than nothing." + ) + return body + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Print the CHANGELOG.md section of one version." + ) + parser.add_argument("--version", required=True, help="version to look up, e.g. v4.8.0") + parser.add_argument("--file", default="CHANGELOG.md", help="changelog to read") + args = parser.parse_args(argv) + + try: + with open(args.file) as changelog: + print(section(changelog.read(), args.version)) + except (OSError, ValueError) as reason: + # as in resolve-version.py: the annotation form only counts on stdout + if os.environ.get("GITHUB_ACTIONS"): + print(f"::error::{reason}") + else: + print(reason, file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/resolve-version.py b/.github/scripts/resolve-version.py index fd03aff92cc4..348875ac5f5d 100755 --- a/.github/scripts/resolve-version.py +++ b/.github/scripts/resolve-version.py @@ -3,22 +3,15 @@ # Works out which version a release run is building, and refuses the runs that # cannot sensibly build one. # -# There is no version number in the repository: it is the git tag, which -# app/build.gradle turns into a version name and a version code (v4.8.0 -> 4.8.0 -# and 40800, two digits per part). What is left to decide is which string gradle -# is handed, and that is only interesting when the run has no tag to read: +# There is no version number in the repository: it comes in as the release run's +# `version` input, and app/build.gradle turns it into a version name and a version +# code (v4.8.0 -> 4.8.0 and 40800, two digits per part). An input rather than the +# tag the run was pushed on, because a tag written before the upload names a commit +# that may never ship - release.yml writes its tags afterwards. # -# tag push the tag, and the version input has to agree with -# it or stay empty - the apk of a run is attached to -# the release of the tag it ran on, so building -# anything else would file it there under the wrong -# version -# dispatched off a branch the version input, which is how a release whose -# upload half failed gets finished off the branch it -# was cut from -# neither only a dry run, on gradle's unversioned fallback. -# uploading that would mean uploading a version code -# the store refuses, six minutes into the run +# Only a dry run may go without one, on gradle's unversioned fallback: uploading +# that would mean a version code the store refuses. OpenDocument.ios has the same +# script and the same two arguments, differing only in the shape it accepts. # # The shape is checked here rather than left to gradle, which checks it again and # is the one that counts: a typo in a dispatched version should not cost the @@ -50,22 +43,24 @@ def fail(message): return 1 -def resolve(tag, given, uploads, log=print): - """The version to build, or "" for none. Raises ValueError with the reason.""" - tag, given = tag.strip(), given.strip() +def boolean(value): + """A workflow input as it reaches a shell: the string "true" or "false".""" + if value.strip().lower() in ("true", "1"): + return True + if value.strip().lower() in ("false", "0", ""): + return False + raise ValueError(f"'{value}' is not true or false") - if tag and given and given.removeprefix("v") != tag.removeprefix("v"): - raise ValueError( - f"the version input ({given}) is not the tag this ran on ({tag}). " - "leave it blank to build the tag." - ) - version = tag or given +def resolve(given, dry_run, log=print): + """The version to build, or "" for none. Raises ValueError with the reason.""" + version = given.strip() + if not version: - if uploads != "none": + if not dry_run: raise ValueError( - "nothing to take a version from. push this as a v* tag, dispatch " - "it on one, or fill in the version input." + "nothing to take a version from: fill in the version input, or " + "tick dry_run to build without uploading." ) log("no version given - building gradle's unversioned fallback") return "" @@ -83,17 +78,16 @@ def main(argv=None): parser = argparse.ArgumentParser( description="Resolve the version a release run builds." ) - parser.add_argument("--tag", default="", help="tag the run was triggered by, if any") - parser.add_argument("--input", default="", help="version input of a dispatched run") + parser.add_argument("--input", default="", help="version input of the run") parser.add_argument( - "--uploads", - default="none", - help="what the run publishes; only 'none' may go without a version", + "--dry-run", + default="false", + help="whether the run publishes nothing; only a dry run may go without a version", ) args = parser.parse_args(argv) try: - version = resolve(args.tag, args.input, args.uploads) + version = resolve(args.input, boolean(args.dry_run)) except ValueError as reason: return fail(str(reason)) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 61e78e4df2a1..1067e0a60830 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,75 +1,37 @@ name: release -# Releases used to be built and uploaded from a maintainer laptop, which was the -# only machine holding the keystore. This does the same steps in CI: -# 1. build the signed lite and pro bundles and apks with gradle -# 2. hand the bundles to fastlane, which uploads them to the play store's -# internal track, and only ever that one. a wider track is a promotion in -# the play console, which moves the very bundle that was tested rather than -# uploading a second one, and is where the release notes are written anyway -# 3. on a tag, attach the signed pro apk to that tag's github release, which -# every release up to v4.6 carried and the laptop build produced by hand. -# the release has to exist already. this is a second job, so that it can -# be re-run without re-running the upload in step 2 +# Builds both signed flavors once, uploads each to the play store's internal track, +# then records what went out. Three jobs so that a half uploaded release is repairable: +# "Re-run failed jobs" retries one upload against the bundle already built and signed. # -# Both flavors go out together, the way they always have: they share a version -# and release notes, and a release of one without the other is not a thing that -# has ever been wanted. -# -# The version is the tag and nothing else. The repository holds no version number -# at all any more - gradle takes it from the -Podr.version passed below, and turns -# v4.8.0 into version code 40800, two digits per part. A dispatched run has no tag -# to read, so it fills in the version input instead. -# -# Requires these repository secrets (see the "Release signing" section in the -# README for what they mean): -# ODR_KEYSTORE_BASE64 base64 of google_play.keystore -# ODR_KEYSTORE_PASSWORD keystore password -# ODR_KEY_PASSWORD_PRO key password for the reader-pro alias, optional: -# an unset one falls back to the store password -# ODR_KEY_PASSWORD_LITE key password for the reader alias, same fallback -# GOOGLE_PLAY_SERVICE_ACCOUNT json key of the play console service account -# -# Without them the workflow fails fast in the "check secrets" step instead of -# producing an unsigned bundle and trying to upload it, and the two that are more -# than a password - the keystore and the service account key - are opened and -# checked before the build rather than after it. +# No tag triggers anything and none is written before an upload; build/ is +# written afterwards, and the v tag only when the drafted release is published, +# which is what makes f-droid ship. See the README's "Tags" section. on: workflow_dispatch: inputs: - # both flavors are always built and archived - this is only about what - # leaves the run. none is the dry run: build everything, publish nothing. - # pro or lite finishes a half uploaded release, which is otherwise a dead - # end, since play refuses a version code it has already accepted and the - # run therefore cannot simply be repeated - uploads: - description: what to publish - none builds and archives only - type: choice - options: [both, pro, lite, none] - default: both - # a tag push needs nothing here: the tag is the version. this is for dispatched - # runs, which have no tag to read - finishing a half uploaded release off the - # branch it was cut from, or putting a real version on a dry run version: - description: version to build, e.g. v4.8.0 - defaults to the tag + description: version to build, e.g. v4.8.0 - required unless this is a dry run type: string - push: - tags: - - 'v*' + dry_run: + description: build and archive only, do not upload + type: boolean + default: false concurrency: - group: release-${{ github.ref }} + # two overlapping releases would race for the same tag and draft + group: ${{ github.workflow }} cancel-in-progress: false permissions: contents: read env: - uploads: ${{ inputs.uploads || 'both' }} + dry_run: ${{ inputs.dry_run }} jobs: - release: + build: runs-on: ubuntu-24.04 steps: - name: check secrets @@ -81,7 +43,9 @@ jobs: missing="" [ -n "$keystore" ] || missing="$missing ODR_KEYSTORE_BASE64" [ -n "$keystore_password" ] || missing="$missing ODR_KEYSTORE_PASSWORD" - if [ "${{ env.uploads }}" != "none" ]; then + # the upload job's secret, checked here so a release that cannot finish + # never builds + if [ "$dry_run" != "true" ]; then [ -n "$service_account" ] || missing="$missing GOOGLE_PLAY_SERVICE_ACCOUNT" fi if [ -n "$missing" ]; then @@ -92,19 +56,32 @@ jobs: - name: checkout uses: actions/checkout@v7 - # the version is not in the checkout - it is the tag, handed to gradle below as - # -Podr.version. The script says which runs can build which version, and why; - # it is here, right behind the checkout it needs, because a run that cannot name - # a version should end before the six minutes of setup and building, not after - name: resolve version id: version - # through the environment rather than interpolated into the command: a tag name - # and a dispatch input are both strings from outside the workflow, and a run: - # line is the one place where that would be a shell injection + # through the environment: a run: line is the one place where an outside + # string would be a shell injection env: - tag: ${{ github.ref_type == 'tag' && github.ref_name || '' }} given: ${{ inputs.version }} - run: .github/scripts/resolve-version.py --tag "$tag" --input "$given" --uploads "$uploads" + run: .github/scripts/resolve-version.py --input "$given" --dry-run "$dry_run" + + # play refuses a version code twice, so learning this later costs a version + - name: check the version has not gone out + if: ${{ env.dry_run != 'true' }} + env: + version: ${{ steps.version.outputs.version }} + run: | + tag="build/v${version#v}" + if git ls-remote --exit-code --tags origin "$tag" > /dev/null 2>&1; then + echo "::error::$tag exists, so $version has already been uploaded" + exit 1 + fi + + # dry runs included - they are the rehearsal for the release body + - name: check the changelog names this version + if: ${{ steps.version.outputs.version != '' }} + env: + version: ${{ steps.version.outputs.version }} + run: .github/scripts/changelog-section.py --version "$version" > /dev/null - name: install ninja run: sudo apt-get install -y ninja-build @@ -115,19 +92,11 @@ jobs: distribution: 'zulu' java-version: 21 - # the version has to be spelled out here: setup-ruby only infers one from a - # .ruby-version / .tool-versions file, neither of which this repo has, and it - # does not read the `ruby ">= 3.2"` constraint in the Gemfile. 3.4 is what - # Gemfile.lock was resolved with. - # the keystore is decoded and checked up front, before the gradle setup: - # signing is the very last thing the build does, so a bad password - # otherwise only surfaces as a signProReleaseBundle failure six minutes in + # up front: signing is the last thing the build does, so a bad password would + # otherwise surface six minutes in - name: decode keystore run: echo "${{ secrets.ODR_KEYSTORE_BASE64 }}" | base64 -d > "${RUNNER_TEMP}/google_play.keystore" - # the script says why keytool rather than gradle gets to be the one that reports a - # bad password. it takes the same ODR_* variables the build takes, so the same - # check can be run against a keystore by hand - name: verify keystore env: ODR_KEYSTORE_PASSWORD: ${{ secrets.ODR_KEYSTORE_PASSWORD }} @@ -135,31 +104,9 @@ jobs: ODR_KEY_PASSWORD_LITE: ${{ secrets.ODR_KEY_PASSWORD_LITE }} run: .github/scripts/verify-keystore.sh "${RUNNER_TEMP}/google_play.keystore" - # written here rather than next to the upload, for the same reason the - # keystore is decoded up front: a key the play store cannot be opened with - # should fail the run in seconds, not once the build is done. it lands in - # RUNNER_TEMP and is handed to fastlane by absolute path - a relative one is - # resolved against whatever directory the action happens to run in, and this - # keeps the credentials out of the checkout the build reads from - - name: play store credentials - if: ${{ env.uploads != 'none' }} - env: - GOOGLE_PLAY_SERVICE_ACCOUNT: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }} - run: .github/scripts/play-service-account-key.py "${RUNNER_TEMP}/fastlane_google_play.json" - - - name: setup ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: '3.4' - bundler-cache: true - - name: Gradle cache uses: gradle/actions/setup-gradle@v6 - # the apks are the sideloadable copies of what the bundles ship. both get - # archived on the run; only pro goes onto the github release, the way it - # always has - lite is the ad supported play build, and an apk of it - # outside the store has no audience - name: build bundles and apks env: ODR_KEYSTORE: ${{ runner.temp }}/google_play.keystore @@ -168,25 +115,22 @@ jobs: ODR_KEY_PASSWORD_LITE: ${{ secrets.ODR_KEY_PASSWORD_LITE }} version: ${{ steps.version.outputs.version }} run: | - # left out entirely when there is no version, rather than passed as 0.0.0: - # gradle's fallback is that name with a version code of 1, since AGP refuses - # the 0 the name itself would derive to + # left out rather than passed as 0.0.0: gradle's fallback pairs that name + # with version code 1, since AGP refuses the 0 it would derive from it ./gradlew bundleProRelease bundleLiteRelease \ assembleProRelease assembleLiteRelease \ ${version:+-Podr.version=$version} --stacktrace - # a release that silently produced an unsigned bundle would be rejected by - # the play store with a much less obvious error, and an unsigned apk on the - # release page would not install at all. the script checks the same outputs - # against a local build + # release variants build unsigned rather than failing, so this is worth asking - name: verify bundles and apks are signed run: .github/scripts/verify-signed.sh - # ndk.debugSymbolLevel puts the symbols inside the bundle itself, under - # BUNDLE-METADATA/com.android.tools.build.debugsymbols, so the play store - # gets them from the upload and anyone who needs them can unzip the aab - # archived here. build/outputs/native-debug-symbols is only written on the - # apk path, by mergeNativeDebugMetadata, which a bundle build never runs + - name: drop the keystore + if: always() + run: rm -f "${RUNNER_TEMP}/google_play.keystore" + + # what the upload job works from. debug symbols ride inside the aab, under + # BUNDLE-METADATA/com.android.tools.build.debugsymbols - name: Artifact bundles uses: actions/upload-artifact@v7 with: @@ -194,9 +138,7 @@ jobs: path: app/build/outputs/bundle/*/*.aab if-no-files-found: error - # both, and not only on the release: a dispatched run has no tag to attach - # anything to, and this is how a release gets test flown - including lite, - # which is otherwise only installable once it is live in the store + # how a release gets test flown, lite included; record puts the pro one on the draft - name: Artifact apks uses: actions/upload-artifact@v7 with: @@ -205,57 +147,128 @@ jobs: if-no-files-found: error compression-level: 0 - - name: upload to play store - if: ${{ env.uploads != 'none' }} + # a job per flavor rather than a loop, so one half can be re-run alone. fail-fast + # off for the same reason + upload: + needs: build + if: ${{ !inputs.dry_run }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + include: + - flavor: pro + lane: uploadPro + - flavor: lite + lane: uploadLite + steps: + # for the Gemfile and the lanes; the bundles come from the artifact + - name: checkout + uses: actions/checkout@v7 + + # back where gradle put them: the Fastfile reads a fixed path under here + - name: fetch the bundles + uses: actions/download-artifact@v8 + with: + name: bundles + path: app/build/outputs/bundle + + # absolute, outside the checkout. no keystore here - the bundle arrives signed + - name: play store credentials + env: + GOOGLE_PLAY_SERVICE_ACCOUNT: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }} + run: .github/scripts/play-service-account-key.py "${RUNNER_TEMP}/fastlane_google_play.json" + + # spelled out: setup-ruby reads no .ruby-version here and ignores the Gemfile's + # constraint. 3.4 is what Gemfile.lock was resolved with + - name: setup ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + bundler-cache: true + + # no track: the Fastfile's DEFAULT_TRACK is internal, and wider is a promotion + - name: upload ${{ matrix.flavor }} to play store env: ODR_PLAY_JSON_KEY: ${{ runner.temp }}/fastlane_google_play.json - run: | - case "${{ env.uploads }}" in - pro) lanes="uploadPro" ;; - lite) lanes="uploadLite" ;; - *) lanes="uploadPro uploadLite" ;; - esac - # no track: the Fastfile's DEFAULT_TRACK is internal, and this workflow - # has no way of naming another one. everything wider is a promotion in - # the play console - for lane in $lanes; do - bundle exec fastlane android "$lane" - done + run: bundle exec fastlane android ${{ matrix.lane }} - name: drop credentials if: always() - run: rm -f "${RUNNER_TEMP}/fastlane_google_play.json" "${RUNNER_TEMP}/google_play.keystore" - - # needs: release, so the release page never offers an apk for a version that - # never reached play - and a job of its own, so that a failure here can be - # re-run on its own. re-running the release job is not an option once - # fastlane has been through it: play rejects a second upload of a version - # code it has already seen, so the retry would die before ever getting here - attach: - needs: release - # inputs, not env: a job level if cannot see the env context, and an unset - # input on a tag push is not 'none' either way - if: ${{ github.ref_type == 'tag' && inputs.uploads != 'none' }} + run: rm -f "${RUNNER_TEMP}/fastlane_google_play.json" + + # only once both flavors are up, so a half uploaded release is not recorded at all + record: + needs: upload + if: ${{ !inputs.dry_run }} runs-on: ubuntu-24.04 permissions: contents: write steps: + - name: checkout + uses: actions/checkout@v7 + + # re-resolved rather than carried as a job output: a re-run need not repeat the + # job that produced it, while the input is the same on every attempt + - name: resolve version + id: version + env: + given: ${{ inputs.version }} + run: .github/scripts/resolve-version.py --input "$given" --dry-run "$dry_run" + + - name: tag the commit that went out + env: + version: ${{ steps.version.outputs.version }} + run: | + tag="build/v${version#v}" + + # re-running behind a repaired upload is expected, so an existing tag is + # only wrong when it names a different commit + if git ls-remote --exit-code --tags origin "$tag" > /dev/null 2>&1; then + git fetch --no-tags origin "refs/tags/$tag:refs/tags/$tag" + already=$(git rev-list -n1 "$tag") + if [ "$already" != "$GITHUB_SHA" ]; then + echo "::error::$tag already names $already, not $GITHUB_SHA" + exit 1 + fi + echo "$tag was already written by an earlier attempt" + else + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$tag" \ + -m "v${version#v} uploaded to the play store internal track" \ + -m "run: $GITHUB_RUN_ID" + git push origin "$tag" + fi + echo "\`$GITHUB_SHA\` is \`$tag\`" >> "$GITHUB_STEP_SUMMARY" + - name: fetch the apks uses: actions/download-artifact@v8 with: name: apks - # pro alone, the way the release page has always had it. the release itself - # stays a human decision - this only fills in its apk, and says so rather - # than inventing one - - name: attach the pro apk to the github release + # a draft creates no tag; publishing it does. --target takes the sha rather than + # a branch, which would resolve to whatever main had become by then + - name: draft the github release env: GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - tag: ${{ github.ref_name }} + version: ${{ steps.version.outputs.version }} run: | - if ! gh release view "$tag" > /dev/null 2>&1; then - echo "::error::$tag has no github release to attach the apk to. create it, then re-run this job." - exit 1 + .github/scripts/changelog-section.py --version "$version" > "${RUNNER_TEMP}/notes.md" + tag="v${version#v}" + + if gh release view "$tag" > /dev/null 2>&1; then + gh release edit "$tag" --target "$GITHUB_SHA" --notes-file "${RUNNER_TEMP}/notes.md" + gh release upload "$tag" pro/release/app-pro-release.apk --clobber + else + # --generate-notes appends the pull requests below the changelog section + gh release create "$tag" \ + --draft \ + --target "$GITHUB_SHA" \ + --title "$tag" \ + --notes-file "${RUNNER_TEMP}/notes.md" \ + --generate-notes \ + pro/release/app-pro-release.apk fi - gh release upload "$tag" pro/release/app-pro-release.apk --clobber + + echo "drafted \`$tag\`. publishing it writes the tag and lets f-droid pick it up - do that once it is live." >> "$GITHUB_STEP_SUMMARY" diff --git a/CHANGELOG.md b/CHANGELOG.md index f01b9468cc36..a143be3734ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ User-facing changes since 4.6. Rendering and format support come from the OpenDocument core engine the app is built on, so changes absorbed from it are listed here too. +Entries go under `Unreleased` as the change lands, in the same pull request. That +heading is cut to the version when the version is dispatched to the release +workflow, which refuses a version without a section and makes it the body of the +GitHub release. It is not the store copy - Play's "What's new" is written in the +Play Console when the release is promoted. + +## Unreleased + ## 4.13.0 - Saving is safer. A save that fails no longer leaves the original file damaged, diff --git a/CLAUDE.md b/CLAUDE.md index 875b97e70037..49d2c3f1b8c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,15 +127,18 @@ what it replaced, and the second round of tapping is always the expensive one. - Configuration cache enabled - Release signing credentials come from gradle properties or environment variables (see README); without them release variants build unsigned rather than failing -- The version is the git tag, not a number in the tree. `AndroidManifest.xml` carries no - `versionCode`/`versionName`; `app/build.gradle` derives both from `-Podr.version` - (`v4.8.0` -> name `4.8.0`, code `40800`, two digits per part, parts above 99 are an - error), and a build handed no version is `0.0.0`. All three parts are required: a - two-part `v4.7` was once padded to `4.7.0`, which let one build carry two names and is - why the tags before `v4.8.0` are in two formats. Do not put the attributes back in the - manifest: gradle's values win in the merged manifest, so a second copy can only ever - disagree with the tag. The release workflow passes the tag it ran on; a dispatched run - passes its `version` input +- The version is the release run's `version` input, not a number in the tree and not a + tag. `AndroidManifest.xml` carries no `versionCode`/`versionName`; `app/build.gradle` + derives both from `-Podr.version` (`v4.8.0` -> name `4.8.0`, code `40800`, two digits + per part, parts above 99 are an error), and a build handed no version is `0.0.0`. All + three parts are required: a two-part `v4.7` was once padded to `4.7.0`, which let one + build carry two names and is why the tags before `v4.8.0` are in two formats. Do not + put the attributes back in the manifest: gradle's values win in the merged manifest, + so a second copy can only ever disagree +- Tags are written after a release, never before it, and nothing is triggered by one. + `release.yml` is dispatch-only, builds both flavors once and tags what went out as + `build/`; the plain `v` tag appears only when the release it drafted + is published, which is what lets F-Droid ship it. See the README's "Tags" section ### Package names diff --git a/README.md b/README.md index bc473dae0ac5..f1625cbe5c9f 100644 --- a/README.md +++ b/README.md @@ -44,13 +44,25 @@ Without them `bundleProRelease` and friends still build, just unsigned. ## Releasing -Pushing a `v*` tag runs the `release` workflow, which builds both signed bundles and -uploads them to the Play Store internal track - the same thing the fastlane lanes did -from a laptop. It also builds the signed Pro APK and attaches it to the GitHub release -of that tag, which has to exist already - the workflow does not create one, it fails -instead. That APK is the sideloadable copy every release up to v4.6 carried, and both APKs are -archived on the run itself. Both flavors always go out together. Running the workflow -manually additionally allows picking what to publish. +The `release` workflow builds both signed bundles and uploads them to the Play Store +internal track - the same thing the fastlane lanes did from a laptop. It is dispatched +by hand, with the version it should build: + +```sh +gh workflow run release.yml -f version=v4.14.0 +``` + +Nothing triggers it on a tag. It runs as three jobs: + +| job | what it does | +|---|---| +| `build` | one gradle run producing both signed flavors, archived on the run | +| `upload` | one job per flavor, handing its bundle to fastlane | +| `record` | once both landed: tag the commit, draft the GitHub release | + +Both flavors always go out together, and nothing chooses one: Lite and Pro are the same +app with ads and tracking switched off. That is what keeps a version on a single commit - +the one the `v*` tag names and F-Droid builds. Internal is the only track it uploads to. Anything wider - closed, open, production - is a promotion in the Play Console, which moves the same bundle and version code that @@ -58,17 +70,19 @@ was tested onto the wider track instead of uploading a second one, and is where release notes get written. It is also where the review that a production release waits on actually happens, so the workflow finishing is not the same as the release being out. -That last one, `uploads`, defaults to `both`. `none` is a dry run: everything gets -built, signed and attached to the run, nothing leaves it. `pro` or `lite` finishes a -half uploaded release - if one of the two lanes fails on its own the run cannot simply -be repeated, since the Play Store refuses a version code it has already accepted, so -dispatch it again for the flavor that did not make it. +**If one flavor's upload fails, press "Re-run failed jobs".** Only that upload runs again, +against the bundle already built and signed, and `record` runs behind it. Re-running *all* +jobs is the wrong button: Play refuses a version code it has already accepted, so the half +that made it cannot go up twice. Past the roughly 30 days GitHub offers re-runs for, the +way out is a new patch version for both flavors. + +`dry_run` builds and signs both flavors without uploading either. It is the only run +allowed to go without a version, and the only one leaving neither tag nor draft. -A dispatched run has no tag to take the version from, so it either gets one in the -`version` input or is a dry run; see below. Dispatched on a tag it is the tag that -counts, and the input may only repeat it: the APK a run produces is attached to the -release of the tag it ran on, so a run that built some other version would file it -there under the wrong one. +In its first seconds the run also refuses a version that has already gone out and one +with no `CHANGELOG.md` section, which is what the release body is made of. +`.github/scripts/resolve-version.py` and `changelog-section.py` decide both; run either +by hand to see what a dispatch would do. It needs these repository secrets: @@ -90,15 +104,47 @@ and uploads, and takes an optional `track:` (`... track:beta`). The version can `ODR_VERSION` instead, but it cannot be left out - see below. That reads the key from `fastlane_google_play.json` in the repository root, as the `Appfile` says. +### Tags + +No tag triggers a build, and none is pushed before one. A tag written up front is a +promise the run can fail to keep: `v4.9.0`'s tag push run failed and the upload came +from a dispatched run - the same commit that time, which was luck. + +Tags are written afterwards instead, in two kinds: + +| tag | who writes it | what it means | +|---|---|---| +| `build/` | the release workflow, once both flavors are up | this commit went to the internal track | +| `v` | publishing the drafted release | this is what shipped | + +One build tag, not one per flavor: a single run builds both from a single checkout. A +half uploaded release gets no tag at all, which is the honest answer - nothing yet could +be published from it. A lane run from a laptop leaves none either. + +**The `v*` tag is written neither by hand nor by the workflow.** `record` drafts a GitHub +release named `v` at the built commit, carrying the Pro APK - the sideloadable +copy every release up to v4.6 has had - and the version's `CHANGELOG.md` section above +GitHub's generated list of pull requests. A draft creates no tag; publishing it does, at +exactly that commit: + +```sh +gh release edit v4.14.0 --draft=false +``` + +That is the whole manual step, and it waits because internal is not released: promotion to +production, and the review it needs, happen in the Play Console days later. F-Droid tracks +this repository with `UpdateCheckMode: Tags`, so a `v*` tag written any earlier would push +a version to F-Droid users that Google may never release. + ## Versioning -The version is the git tag, and no version number is checked in anywhere. The release -workflow hands the tag it was triggered by to gradle as `-Podr.version`, and -`app/build.gradle` derives both halves of it: `v4.8.0` becomes version name `4.8.0` and -version code `40800`, two digits per part. Every part therefore has to stay below 100, -which the build refuses rather than folding `4.100.0` onto the same code as `5.0.0`. -Nothing has to be raised by hand before tagging, and no number on `main` can describe a -release that already went out. +The version is the release run's `version` input, and no version number is checked in +anywhere. The workflow hands it to gradle as `-Podr.version`, and `app/build.gradle` +derives both halves of it: `v4.8.0` becomes version name `4.8.0` and version code +`40800`, two digits per part. Every part therefore has to stay below 100, which the +build refuses rather than folding `4.100.0` onto the same code as `5.0.0`. Nobody bumps +it anywhere: a commit on `main` is not a release, and no number on `main` can describe +one that already went out. All three parts have to be spelled out. A two-part `v4.7` used to be padded to `4.7.0`, which meant one build could be tagged under two names, and the tags older than `v4.8.0`