From 5d2728a29d49d4d066fa99893d2ae5ca4fad1d6c Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Tue, 4 Aug 2026 08:11:31 +0200 Subject: [PATCH 1/2] Tag what a release uploaded instead of what it was meant to upload A tag had to be pushed before the run it named, which is a promise the run can fail to keep. v4.9.0 is the case in point: its tag push run failed and the upload came from a dispatched run instead. The same commit that time, but nothing made it so - a dispatch off main builds whatever main is at, and a release uploaded from the fix-play-store-upload branch has no tag naming it anywhere. So the tag stops being the trigger and becomes the record. release.yml is dispatch-only, and writes build// at the commit it uploaded, as each lane lands rather than once at the end: the half uploaded release is the case the record exists for, and a lite failure must not take pro's tag down with it. Per flavor for the same reason - the two halves get finished from different commits. The plain v* tag stays a human decision, taken once the release is actually live, and no longer builds anything. What it does run is attach-apk.yml, which is the old attach job moved out: it fetches the pro apk from the run that uploaded it and puts it on the github release. Attaching had to move anyway, since it keyed off a trigger tag that no longer exists, and it is better placed here - it now attaches the apk that went to play rather than one built alongside it, and it refuses a version tag that does not sit on the commit build/pro/ names. resolve-version.py loses its tag arm; the input is the only source. CHANGELOG.md gets the matching rule: the heading is cut when the version is dispatched, not when it is tagged, because a version code can only be uploaded once and no later commit can ever ship under it. Untested end to end, since a real run uploads to the play store. Both workflows parse, the run blocks are shellcheck clean, the tag round trip and the flavor loop were exercised in a scratch repo, and resolve-version.py was run across all its cases. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RsdTta67FZ4KeNfPPw5BVL --- .github/scripts/resolve-version.py | 46 ++++------ .github/workflows/attach-apk.yml | 106 +++++++++++++++++++++++ .github/workflows/release.yml | 132 ++++++++++++++--------------- CHANGELOG.md | 11 +++ CLAUDE.md | 21 +++-- README.md | 84 ++++++++++++------ 6 files changed, 268 insertions(+), 132 deletions(-) create mode 100644 .github/workflows/attach-apk.yml diff --git a/.github/scripts/resolve-version.py b/.github/scripts/resolve-version.py index fd03aff92cc4..1c42cbc7e7d7 100755 --- a/.github/scripts/resolve-version.py +++ b/.github/scripts/resolve-version.py @@ -3,22 +3,18 @@ # 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. # # 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 +46,15 @@ def fail(message): return 1 -def resolve(tag, given, uploads, log=print): +def resolve(given, uploads, log=print): """The version to build, or "" for none. Raises ValueError with the reason.""" - tag, given = tag.strip(), given.strip() - - 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 = given.strip() - version = tag or given if not version: if uploads != "none": 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 " + "set uploads to none to build without publishing." ) log("no version given - building gradle's unversioned fallback") return "" @@ -83,8 +72,7 @@ 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", @@ -93,7 +81,7 @@ def main(argv=None): args = parser.parse_args(argv) try: - version = resolve(args.tag, args.input, args.uploads) + version = resolve(args.input, args.uploads) except ValueError as reason: return fail(str(reason)) diff --git a/.github/workflows/attach-apk.yml b/.github/workflows/attach-apk.yml new file mode 100644 index 000000000000..d58cdecb5d60 --- /dev/null +++ b/.github/workflows/attach-apk.yml @@ -0,0 +1,106 @@ +name: attach apk + +# Puts the signed pro apk on the github release of a v* tag - the sideloadable +# copy every release up to v4.6 carried. +# +# A workflow of its own, because the two things happen days apart: release.yml +# uploads to play's internal track, and the v* tag is written afterwards, once the +# release is actually live. See the "Tags" section in the README. +# +# It attaches the apk that was uploaded rather than building a second one: the +# release run archives both apks, and this fetches that artifact. + +on: + push: + tags: + - 'v*' + +permissions: + contents: write # the release asset + actions: read # the release run's artifact + +jobs: + attach: + runs-on: ubuntu-24.04 + steps: + # the build tags are what says which commit went out, so all of them + - name: checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + # a v* tag means "this shipped", so there has to be an upload it names, and it + # has to be the commit that was uploaded. this is the only thing that catches + # the two drifting apart + - name: find the upload this tag names + id: upload + env: + tag: ${{ github.ref_name }} + run: | + build_tag="build/pro/v${tag#v}" + if ! git rev-parse -q --verify "refs/tags/$build_tag" > /dev/null; then + echo "::error::no $build_tag, so $tag names no pro upload. dispatch release.yml first." + exit 1 + fi + + built=$(git rev-list -n1 "$build_tag") + if [ "$built" != "$GITHUB_SHA" ]; then + echo "::error::$tag is at $GITHUB_SHA but pro was built from $built. move the tag: git tag -f $tag $build_tag^{}" + exit 1 + fi + + # both flavors always go out together, so a missing lite means the release + # is half done. a lite built elsewhere is legitimate - that is what + # finishing a half uploaded release looks like - but worth saying out loud + lite_tag="build/lite/v${tag#v}" + if ! git rev-parse -q --verify "refs/tags/$lite_tag" > /dev/null; then + echo "::error::no $lite_tag, so lite never went out under $tag" + exit 1 + fi + lite=$(git rev-list -n1 "$lite_tag") + if [ "$lite" != "$GITHUB_SHA" ]; then + echo "::warning::lite $tag was built from $lite, not $GITHUB_SHA" + fi + + run=$(git for-each-ref --format='%(contents)' "refs/tags/$build_tag" | sed -n 's/^run: //p') + if [ -z "$run" ]; then + echo "::error::$build_tag names no run, so the apk it uploaded cannot be found" + exit 1 + fi + echo "run=$run" >> "$GITHUB_OUTPUT" + + # artifacts are kept 90 days; a version tag written long after the upload finds + # nothing, and download-artifact says so far less clearly + - name: check the apks are still there + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run_id: ${{ steps.upload.outputs.run }} + run: | + expired=$(gh api "repos/$GH_REPO/actions/runs/$run_id/artifacts" \ + --jq '.artifacts[] | select(.name == "apks") | .expired') + if [ "$expired" != "false" ]; then + echo "::error::run $run_id no longer has its apks, so there is nothing to attach" + exit 1 + fi + + - name: fetch the apks + uses: actions/download-artifact@v8 + with: + name: apks + run-id: ${{ steps.upload.outputs.run }} + github-token: ${{ github.token }} + + # pro alone, the way the release page has always had it. the release itself + # stays a human decision - this only fills in its apk + - name: attach the pro apk to the github release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + tag: ${{ github.ref_name }} + 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 + fi + gh release upload "$tag" pro/release/app-pro-release.apk --clobber diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 61e78e4df2a1..ba07d82284d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,19 +7,22 @@ name: release # 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 +# 3. tag the commit each upload was built from, as build// # # 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. +# Nothing here is triggered by a tag. A tag pushed before the build is a promise +# the run can fail to keep - it can die before the upload, or get only half of it +# through, and what reaches the store is then built from some other commit. So the +# version comes in as an input and the tags are written afterwards, naming what +# really went up. The v* tag stays a human decision, taken once the release is +# live; attach-apk.yml is what runs on it. +# +# The version is the input and nothing else. The repository holds no version +# number at all - gradle takes it from the -Podr.version passed below, and turns +# v4.8.0 into version code 40800, two digits per part. # # Requires these repository secrets (see the "Release signing" section in the # README for what they mean): @@ -48,25 +51,20 @@ on: 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 uploads is none type: string - push: - tags: - - 'v*' concurrency: group: release-${{ github.ref }} cancel-in-progress: false permissions: - contents: read + # to tag the commit an upload was built from + contents: write env: - uploads: ${{ inputs.uploads || 'both' }} + uploads: ${{ inputs.uploads }} jobs: release: @@ -92,19 +90,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 + # the version is not in the checkout - it comes in as an input, and reaches gradle + # below as -Podr.version. Here, right behind the checkout it needs, so that a run + # that cannot name a version ends before six minutes of setup and building - 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 rather than interpolated into the command: the input + # is a string from outside the workflow, and a run: line is the one place where + # that 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" --uploads "$uploads" + + # same reason: play refuses a version code it has already accepted, so a version + # that is already tagged as uploaded would only fail once the build was done + - name: check the version has not gone out + if: ${{ env.uploads != 'none' }} + env: + version: ${{ steps.version.outputs.version }} + run: | + for flavor in $([ "$uploads" = both ] && echo pro lite || echo "$uploads"); do + tag="build/$flavor/v${version#v}" + if git ls-remote --exit-code --tags origin "$tag" > /dev/null 2>&1; then + echo "::error::$tag exists, so $flavor $version has already been uploaded" + exit 1 + fi + done - name: install ninja run: sudo apt-get install -y ninja-build @@ -194,9 +205,10 @@ 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 - including lite, which is otherwise only + # installable once it is live in the store. attach-apk.yml fetches the pro one + # from here when the version tag is pushed, so this is also what ends up on the + # release page rather than a second build of the same commit - name: Artifact apks uses: actions/upload-artifact@v7 with: @@ -205,57 +217,37 @@ jobs: if-no-files-found: error compression-level: 0 + # each flavor is tagged as it lands rather than both at the end: the release + # that goes up half is the one the record exists for, and a lite failure must + # not take pro's tag with it. per flavor for the same reason - the two halves + # can be finished from different commits - name: upload to play store if: ${{ env.uploads != 'none' }} env: ODR_PLAY_JSON_KEY: ${{ runner.temp }}/fastlane_google_play.json + version: ${{ steps.version.outputs.version }} run: | - case "${{ env.uploads }}" in - pro) lanes="uploadPro" ;; - lite) lanes="uploadLite" ;; - *) lanes="uploadPro uploadLite" ;; - esac + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + # 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" + for flavor in $([ "$uploads" = both ] && echo pro lite || echo "$uploads"); do + case "$flavor" in + pro) bundle exec fastlane android uploadPro ;; + lite) bundle exec fastlane android uploadLite ;; + esac + + tag="build/$flavor/v${version#v}" + # attach-apk.yml reads the run back out of the message + git tag -a "$tag" \ + -m "$flavor v${version#v} uploaded to the play store internal track" \ + -m "run: $GITHUB_RUN_ID" + git push origin "$tag" + echo "tagged \`$GITHUB_SHA\` as \`$tag\`" >> "$GITHUB_STEP_SUMMARY" done - 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' }} - runs-on: ubuntu-24.04 - permissions: - contents: write - steps: - - 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 - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - tag: ${{ github.ref_name }} - 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 - fi - gh release upload "$tag" pro/release/app-pro-release.apk --clobber diff --git a/CHANGELOG.md b/CHANGELOG.md index f01b9468cc36..c5e485cce841 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ 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. + +Nothing in the build reads this file, and it is not the store copy either - +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..eb5e0f9e7dc0 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 that builds is + triggered by one. `release.yml` is dispatch-only and tags each commit it uploaded as + `build//`; the plain `v` tag is pushed by hand once the + release is live, and `attach-apk.yml` runs on it. See the README's "Tags" section ### Package names diff --git a/README.md b/README.md index bc473dae0ac5..aee8becba31b 100644 --- a/README.md +++ b/README.md @@ -44,13 +44,18 @@ 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 -f uploads=both +``` + +Nothing triggers it on a tag. Both APKs are archived on the run, and the Pro one - +the sideloadable copy every release up to v4.6 carried - goes onto the GitHub release +page later, when the `v*` tag is written; see [Tags](#tags). Both flavors always go +out together. 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 +63,15 @@ 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. +`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. -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, and a run without one has to be a +dry run. `.github/scripts/resolve-version.py` decides what a run builds and refuses +the runs that cannot name a version; run it by hand to see what a dispatch would do. It needs these repository secrets: @@ -90,15 +93,48 @@ 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, after each upload | this commit went to the internal track | +| `` | you, once the release is live | this is what shipped | + +Per flavor, because the two halves of a half uploaded release get finished from +different commits. A lane run from a laptop leaves no tag, so an upload made by hand is +not recorded. + +The version tag stays a human decision because internal is not released: the promotion +to production, and the review it waits on, happen in the Play Console days later. Tag +the build that made it rather than whatever is at the tip of `main`: + +```sh +git tag v4.14.0 build/pro/v4.14.0^{} && git push origin v4.14.0 +``` + +That runs `attach-apk`, which puts the Pro APK from that upload's own run onto the +GitHub release - which has to exist already, the way it always has. It refuses a +version tag that does not sit on the commit `build/pro/` names, which is the +one thing that catches the two drifting apart. Run artifacts are kept 90 days, so a +version tag written much later has no APK left to attach. + ## 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` From 44a783c5f6fb07a862d3e7fb31f9000bd8c9508d Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Wed, 5 Aug 2026 20:07:52 +0200 Subject: [PATCH 2/2] Build once, upload per flavor, and record what actually shipped Follows the same change in OpenDocument.ios. The release workflow was one job that built both flavors and uploaded them in a loop, with an `uploads` input to pick a flavor when one half failed. That input is gone, and so is the loop. Both flavors always go out together now. Lite and pro are the same app with ads and tracking switched off, so anything worth rebuilding one for is worth rebuilding the other for - and once one run builds both from one checkout, a version's build tag names a single commit. That is what the v* tag and f-droid build from, so it is worth more than the flexibility it costs. Build, upload and record are three jobs. A half uploaded release is repaired with "Re-run failed jobs", which retries only the failed upload against the bundle already built, signed and verified - no rebuild, so the retry cannot come out different from what its other half uploaded. The v* tag is no longer written by hand. `record` drafts a github release at the built commit carrying the pro apk; a draft creates no tag, and publishing it creates one there. That wait matters: 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. attach-apk.yml is deleted - every check it made is now structural. No cross-run artifact fetch, no 90 day expiry, no version-tag-vs-build-tag drift check. The release body is the version's CHANGELOG.md section with the generated pull request list below it, and a version with no section is refused before anything is built rather than after both flavors are on the store. resolve-version.py takes --dry-run instead of --uploads, so both repositories now run the same script with the same arguments. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Nhfz8qVwtcDrJ6wdkrrcJr --- .github/scripts/changelog-section.py | 78 +++++++ .github/scripts/resolve-version.py | 26 ++- .github/workflows/attach-apk.yml | 106 --------- .github/workflows/release.yml | 320 +++++++++++++++------------ CHANGELOG.md | 3 +- CLAUDE.md | 9 +- README.md | 74 ++++--- 7 files changed, 329 insertions(+), 287 deletions(-) create mode 100755 .github/scripts/changelog-section.py delete mode 100644 .github/workflows/attach-apk.yml 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 1c42cbc7e7d7..a6e3f412fd12 100755 --- a/.github/scripts/resolve-version.py +++ b/.github/scripts/resolve-version.py @@ -16,6 +16,9 @@ # 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 # gradle setup first. The version code is deliberately not computed here - one @@ -46,15 +49,24 @@ def fail(message): return 1 -def resolve(given, uploads, log=print): +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") + + +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: fill in the version input, or " - "set uploads to none to build without publishing." + "tick dry_run to build without uploading." ) log("no version given - building gradle's unversioned fallback") return "" @@ -74,14 +86,14 @@ def main(argv=None): ) 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.input, args.uploads) + version = resolve(args.input, boolean(args.dry_run)) except ValueError as reason: return fail(str(reason)) diff --git a/.github/workflows/attach-apk.yml b/.github/workflows/attach-apk.yml deleted file mode 100644 index d58cdecb5d60..000000000000 --- a/.github/workflows/attach-apk.yml +++ /dev/null @@ -1,106 +0,0 @@ -name: attach apk - -# Puts the signed pro apk on the github release of a v* tag - the sideloadable -# copy every release up to v4.6 carried. -# -# A workflow of its own, because the two things happen days apart: release.yml -# uploads to play's internal track, and the v* tag is written afterwards, once the -# release is actually live. See the "Tags" section in the README. -# -# It attaches the apk that was uploaded rather than building a second one: the -# release run archives both apks, and this fetches that artifact. - -on: - push: - tags: - - 'v*' - -permissions: - contents: write # the release asset - actions: read # the release run's artifact - -jobs: - attach: - runs-on: ubuntu-24.04 - steps: - # the build tags are what says which commit went out, so all of them - - name: checkout - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - # a v* tag means "this shipped", so there has to be an upload it names, and it - # has to be the commit that was uploaded. this is the only thing that catches - # the two drifting apart - - name: find the upload this tag names - id: upload - env: - tag: ${{ github.ref_name }} - run: | - build_tag="build/pro/v${tag#v}" - if ! git rev-parse -q --verify "refs/tags/$build_tag" > /dev/null; then - echo "::error::no $build_tag, so $tag names no pro upload. dispatch release.yml first." - exit 1 - fi - - built=$(git rev-list -n1 "$build_tag") - if [ "$built" != "$GITHUB_SHA" ]; then - echo "::error::$tag is at $GITHUB_SHA but pro was built from $built. move the tag: git tag -f $tag $build_tag^{}" - exit 1 - fi - - # both flavors always go out together, so a missing lite means the release - # is half done. a lite built elsewhere is legitimate - that is what - # finishing a half uploaded release looks like - but worth saying out loud - lite_tag="build/lite/v${tag#v}" - if ! git rev-parse -q --verify "refs/tags/$lite_tag" > /dev/null; then - echo "::error::no $lite_tag, so lite never went out under $tag" - exit 1 - fi - lite=$(git rev-list -n1 "$lite_tag") - if [ "$lite" != "$GITHUB_SHA" ]; then - echo "::warning::lite $tag was built from $lite, not $GITHUB_SHA" - fi - - run=$(git for-each-ref --format='%(contents)' "refs/tags/$build_tag" | sed -n 's/^run: //p') - if [ -z "$run" ]; then - echo "::error::$build_tag names no run, so the apk it uploaded cannot be found" - exit 1 - fi - echo "run=$run" >> "$GITHUB_OUTPUT" - - # artifacts are kept 90 days; a version tag written long after the upload finds - # nothing, and download-artifact says so far less clearly - - name: check the apks are still there - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - run_id: ${{ steps.upload.outputs.run }} - run: | - expired=$(gh api "repos/$GH_REPO/actions/runs/$run_id/artifacts" \ - --jq '.artifacts[] | select(.name == "apks") | .expired') - if [ "$expired" != "false" ]; then - echo "::error::run $run_id no longer has its apks, so there is nothing to attach" - exit 1 - fi - - - name: fetch the apks - uses: actions/download-artifact@v8 - with: - name: apks - run-id: ${{ steps.upload.outputs.run }} - github-token: ${{ github.token }} - - # pro alone, the way the release page has always had it. the release itself - # stays a human decision - this only fills in its apk - - name: attach the pro apk to the github release - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - tag: ${{ github.ref_name }} - 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 - fi - gh release upload "$tag" pro/release/app-pro-release.apk --clobber diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba07d82284d7..cd0b6a69151a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,73 +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. tag the commit each upload was built from, as build// +# 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. # -# Nothing here is triggered by a tag. A tag pushed before the build is a promise -# the run can fail to keep - it can die before the upload, or get only half of it -# through, and what reaches the store is then built from some other commit. So the -# version comes in as an input and the tags are written afterwards, naming what -# really went up. The v* tag stays a human decision, taken once the release is -# live; attach-apk.yml is what runs on it. +# 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. # -# The version is the input and nothing else. The repository holds no version -# number at all - gradle takes it from the -Podr.version passed below, and turns -# v4.8.0 into version code 40800, two digits per part. -# -# 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 version: - description: version to build, e.g. v4.8.0 - required unless uploads is none + description: version to build, e.g. v4.8.0 - required unless this is a dry run type: string + 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: - # to tag the commit an upload was built from - contents: write + contents: read env: - uploads: ${{ inputs.uploads }} + dry_run: ${{ inputs.dry_run }} jobs: - release: + build: runs-on: ubuntu-24.04 steps: - name: check secrets @@ -79,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 @@ -90,32 +65,34 @@ jobs: - name: checkout uses: actions/checkout@v7 - # the version is not in the checkout - it comes in as an input, and reaches gradle - # below as -Podr.version. Here, right behind the checkout it needs, so that a run - # that cannot name a version ends before six minutes of setup and building - name: resolve version id: version - # through the environment rather than interpolated into the command: the input - # is a string 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: given: ${{ inputs.version }} - run: .github/scripts/resolve-version.py --input "$given" --uploads "$uploads" + run: .github/scripts/resolve-version.py --input "$given" --dry-run "$dry_run" - # same reason: play refuses a version code it has already accepted, so a version - # that is already tagged as uploaded would only fail once the build was done + # 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.uploads != 'none' }} + if: ${{ env.dry_run != 'true' }} env: version: ${{ steps.version.outputs.version }} run: | - for flavor in $([ "$uploads" = both ] && echo pro lite || echo "$uploads"); do - tag="build/$flavor/v${version#v}" - if git ls-remote --exit-code --tags origin "$tag" > /dev/null 2>&1; then - echo "::error::$tag exists, so $flavor $version has already been uploaded" - exit 1 - fi - done + 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 @@ -126,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 }} @@ -146,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 @@ -179,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: @@ -205,10 +149,8 @@ jobs: path: app/build/outputs/bundle/*/*.aab if-no-files-found: error - # how a release gets test flown - including lite, which is otherwise only - # installable once it is live in the store. attach-apk.yml fetches the pro one - # from here when the version tag is pushed, so this is also what ends up on the - # release page rather than a second build of the same commit + # 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: @@ -217,37 +159,131 @@ jobs: if-no-files-found: error compression-level: 0 - # each flavor is tagged as it lands rather than both at the end: the release - # that goes up half is the one the record exists for, and a lite failure must - # not take pro's tag with it. per flavor for the same reason - the two halves - # can be finished from different commits - - 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: bundle exec fastlane android ${{ matrix.lane }} + + - name: drop credentials + if: always() + 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: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - # 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 flavor in $([ "$uploads" = both ] && echo pro lite || echo "$uploads"); do - case "$flavor" in - pro) bundle exec fastlane android uploadPro ;; - lite) bundle exec fastlane android uploadLite ;; - esac - - tag="build/$flavor/v${version#v}" - # attach-apk.yml reads the run back out of the message + 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 "$flavor v${version#v} uploaded to the play store internal track" \ + -m "v${version#v} uploaded to the play store internal track" \ -m "run: $GITHUB_RUN_ID" git push origin "$tag" - echo "tagged \`$GITHUB_SHA\` as \`$tag\`" >> "$GITHUB_STEP_SUMMARY" - done + fi + echo "\`$GITHUB_SHA\` is \`$tag\`" >> "$GITHUB_STEP_SUMMARY" - - name: drop credentials - if: always() - run: rm -f "${RUNNER_TEMP}/fastlane_google_play.json" "${RUNNER_TEMP}/google_play.keystore" + - name: fetch the apks + uses: actions/download-artifact@v8 + with: + name: apks + + # 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 }} + version: ${{ steps.version.outputs.version }} + run: | + .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 + + 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 c5e485cce841..186fb78e0b7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ 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. -Nothing in the build reads this file, and it is not the store copy either - +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. diff --git a/CLAUDE.md b/CLAUDE.md index eb5e0f9e7dc0..c94dcfccefed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -135,10 +135,11 @@ what it replaced, and the second round of tapping is always the expensive 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 that builds is - triggered by one. `release.yml` is dispatch-only and tags each commit it uploaded as - `build//`; the plain `v` tag is pushed by hand once the - release is live, and `attach-apk.yml` runs on it. See the README's "Tags" section +- 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 aee8becba31b..4ced07834370 100644 --- a/README.md +++ b/README.md @@ -49,13 +49,22 @@ internal track - the same thing the fastlane lanes did from a laptop. It is disp by hand, with the version it should build: ```sh -gh workflow run release.yml -f version=v4.14.0 -f uploads=both +gh workflow run release.yml -f version=v4.14.0 ``` -Nothing triggers it on a tag. Both APKs are archived on the run, and the Pro one - -the sideloadable copy every release up to v4.6 carried - goes onto the GitHub release -page later, when the `v*` tag is written; see [Tags](#tags). Both flavors always go -out together. +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 @@ -63,15 +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. -`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. -`version` is the only place a version comes from, and a run without one has to be a -dry run. `.github/scripts/resolve-version.py` decides what a run builds and refuses -the runs that cannot name a version; run it by hand to see what a dispatch would do. +`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. + +`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: @@ -105,26 +119,32 @@ Tags are written afterwards instead, in two kinds: | tag | who writes it | what it means | |---|---|---| -| `build//` | the release workflow, after each upload | this commit went to the internal track | -| `` | you, once the release is live | this is what shipped | +| `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 | -Per flavor, because the two halves of a half uploaded release get finished from -different commits. A lane run from a laptop leaves no tag, so an upload made by hand is -not recorded. +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 version tag stays a human decision because internal is not released: the promotion -to production, and the review it waits on, happen in the Play Console days later. Tag -the build that made it rather than whatever is at the tip of `main`: +**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 -git tag v4.14.0 build/pro/v4.14.0^{} && git push origin v4.14.0 +gh release edit v4.14.0 --draft=false ``` -That runs `attach-apk`, which puts the Pro APK from that upload's own run onto the -GitHub release - which has to exist already, the way it always has. It refuses a -version tag that does not sit on the commit `build/pro/` names, which is the -one thing that catches the two drifting apart. Run artifacts are kept 90 days, so a -version tag written much later has no APK left to attach. +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