diff --git a/.github/scripts/changelog-section.py b/.github/scripts/changelog-section.py new file mode 100755 index 000000000000..85c55e6cf677 --- /dev/null +++ b/.github/scripts/changelog-section.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +# +# Prints the CHANGELOG.md section for one version, and fails when there is none. +# +# The release run reads it twice: once before building, so a version dispatched +# without release copy fails in seconds rather than once both flavors are on the +# store, and once in the record job, which makes that section the body of the +# drafted github release. Being read by the release it describes is what stops it +# 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 say 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..a6e3f412fd12 100755 --- a/.github/scripts/resolve-version.py +++ b/.github/scripts/resolve-version.py @@ -3,22 +3,21 @@ # 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). # -# 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 +# a version input that version +# no input 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 +# +# It is 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 the +# tags afterwards instead. +# +# OpenDocument.ios has the same script and the same two arguments, differing only +# in the version 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 +49,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 +84,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..cd0b6a69151a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,75 +1,46 @@ 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, because that is what makes a half uploaded +# release repairable: if lite's upload fails alone, "Re-run failed jobs" retries just +# that job against the bundle already built, signed and checked. # -# 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. +# Both flavors always go out together and nothing chooses one - they are the same app +# with ads and tracking switched off, so a version's build tag names a single commit. # -# 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. +# No tag triggers anything and none is written before an upload. build/ is +# written afterwards; the plain v tag appears only when the drafted release is +# published, once the release is really live. That wait is not cosmetic: f-droid tracks +# this repository with UpdateCheckMode: Tags, so a v* tag is what makes it ship. # -# 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. +# The version is the input and nothing else - gradle turns v4.8.0 into version code +# 40800. Secrets are listed in the README's "Release signing" section; the two that are +# more than a password are opened and checked before the build rather than after it. 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 +52,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 + # does not build first + if [ "$dry_run" != "true" ]; then [ -n "$service_account" ] || missing="$missing GOOGLE_PLAY_SERVICE_ACCOUNT" fi if [ -n "$missing" ]; then @@ -92,19 +65,34 @@ 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" + + # seconds, against finding out once a bundle is on the store - where the only fix + # left is a new version, since play refuses a version code twice + - 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 + + # whenever there is a version, dry run or not: the record job publishes this + # section as the release body, and a dry run is the rehearsal for that + - 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 +103,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 +115,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 +126,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 ./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 rest of the run 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 +149,8 @@ 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. the record job attaches the pro + # one to the draft, so the release page gets the apk that really went up - name: Artifact apks uses: actions/upload-artifact@v7 with: @@ -205,57 +159,131 @@ 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 "Re-run failed jobs" can retry one half. + # 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 build outputs come from the artifact + - name: checkout + uses: actions/checkout@v7 + + # back where gradle put them: upload-artifact roots an artifact at the least + # common ancestor it matched, and uploadBundle 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 in this job - the bundle it signed + # was signed in the build job + - 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 anything wider is a + # promotion in the play console + - 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 may not repeat the + # job that produced it, while the dispatch 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}" + + # this job 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..186fb78e0b7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ 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. +The heading is cut when the version is dispatched to the release workflow, not +when it is tagged: a version code can only be uploaded once, so from that point +no later commit can ever ship under that version. + +The release run reads that section: it refuses a version without one, and makes +it the body of the GitHub release it drafts. It is still not the store copy - +what Play shows under "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..c94dcfccefed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,15 +127,19 @@ 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 also what lets F-Droid ship it (`UpdateCheckMode: Tags`). See + the README's "Tags" section ### Package names diff --git a/README.md b/README.md index bc473dae0ac5..4ced07834370 100644 --- a/README.md +++ b/README.md @@ -44,13 +44,27 @@ 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 - the flavor switches ads and tracking off, nothing else - so anything worth +rebuilding one for is worth rebuilding the other for. That is also what keeps a version's +build tag on a single commit, which is the commit the `v*` tag then names and F-Droid then +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 +72,20 @@ 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 once it lands. +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 kind of +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. +`version` is the only place a version comes from. Before building, the run also refuses a +version that has already gone out and one with no `CHANGELOG.md` section - that section +becomes the release body, and finding it missing afterwards leaves nothing to fix but the +version number. `.github/scripts/resolve-version.py` and `changelog-section.py` are what +decide both; run either by hand to see what a dispatch would do. It needs these repository secrets: @@ -90,15 +107,54 @@ 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 + +Nothing that builds is triggered by a tag, and no tag is pushed before a build. A tag +written up front is a promise the run can fail to keep: it can die before the upload, +or get only one of the two flavors through, and what reaches the store is then built +from some other commit. `v4.9.0` is the case in point - its 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, so +there is only one commit to name. A half uploaded release gets no tag, which is the honest +answer - nothing yet could be published from it. A lane run from a laptop leaves none +either, so an upload made by hand is not recorded. + +**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. 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 stays human because internal is not released: the +promotion to production, and the review it waits on, happen in the Play Console days later. +The tag has to wait for that - F-Droid tracks this repository with `UpdateCheckMode: Tags`, +so a `v*` tag is what makes it build and ship, and one written when the bundle merely +reached the internal track would push a version to F-Droid users that Google may never +release. + +The release body is the version's `CHANGELOG.md` section with the generated list of pull +requests below it. + ## 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`