From ac8133465007c3473f496df9c18d2a3ca4a37421 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Tue, 4 Aug 2026 00:17:15 +0200 Subject: [PATCH 1/3] Tag what a release built instead of what it was meant to build A version tag had to be pushed before the build it named, which is a promise the release cannot keep: a version often takes more than one build to clear review, and the second one is built from a different commit. Tag 1.37 is the case in point - it points at 6e97420, three commits behind what was actually submitted, and the build that shipped has no name in git at all. So the tag stops being the trigger and becomes the record. The workflow is dispatched by hand with the version it should build, and writes build/// at the commit it built once the upload goes through. Per flavor, because Pro and Lite count their builds separately: one commit uploaded to both apps is two builds with two different numbers. The plain version tag is left to a human, once the release is actually live. The build number was only ever known inside the lane, so the Fastfile hands it back through GITHUB_OUTPUT. Dropping the tag trigger also matters on its own: the receipt tag would have matched '[0-9]*' and started another release. resolve-version.py loses its tag arm, and the version input is now the only source. Nobody bumps a version in the tree - a commit on main is not a release, and the 0.0.0 in project.pbxproj says so. CHANGELOG.md gets the matching rule: the heading is cut at submission rather than at a tag, on main, in the same pull request that writes the store copy, and a fix that goes up as a second build of the same version belongs under the heading already cut rather than back under Unreleased. That is the case #134 fell into. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HcHeDGMHcFChp6oHiaiEDV --- .github/scripts/resolve-version.py | 34 +++++++-------- .github/workflows/release.yml | 69 ++++++++++++++++++++++-------- .gitignore | 1 + CHANGELOG.md | 23 +++++++--- README.md | 58 +++++++++++++++++++------ fastlane/Fastfile | 7 +++ 6 files changed, 139 insertions(+), 53 deletions(-) diff --git a/.github/scripts/resolve-version.py b/.github/scripts/resolve-version.py index 426737f..319b890 100755 --- a/.github/scripts/resolve-version.py +++ b/.github/scripts/resolve-version.py @@ -3,10 +3,13 @@ # Works out which version a release run builds, and refuses the runs that cannot # name one: # -# tag push the tag; a version input has to agree or stay empty -# dispatched off a branch the version input, which is how a release whose -# upload failed gets finished off its branch -# neither only a dry run, on the 0.0.0 in project.pbxproj +# a version input that version +# no input only a dry run, on the 0.0.0 in project.pbxproj +# +# The version arrives as a dispatch input rather than a tag the run was pushed +# on. A tag is a promise made before the upload, and one version often takes +# more than one build to get through review; the tags this repository carries +# are written afterwards by the release workflow, naming what was really built. # # The shape is checked here because xcodebuild never checks it: MARKETING_VERSION # is a free-form string to the build, so a typo would only surface when App Store @@ -22,7 +25,8 @@ import sys # what CFBundleShortVersionString accepts: one to three numeric parts. A leading -# v is allowed because tags are often written that way, and stripped below +# v is tolerated and stripped below: the input is typed by hand, and the tags +# this repository has always used are bare numbers VERSION = re.compile(r"^v?[0-9]{1,3}(\.[0-9]{1,3}){0,2}$") @@ -44,22 +48,15 @@ def boolean(value): raise ValueError(f"'{value}' is not true or false") -def resolve(tag, given, dry_run, log=print): +def resolve(given, dry_run, 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 not dry_run: raise ValueError( - "nothing to take a version from. push this as a 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 the 0.0.0 in project.pbxproj") return "" @@ -77,8 +74,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( "--dry-run", default="false", @@ -87,7 +83,7 @@ def main(argv=None): args = parser.parse_args(argv) try: - version = resolve(args.tag, args.input, boolean(args.dry_run)) + 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 8aa6aeb..9621cf0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,13 @@ name: release -# Uploads to App Store Connect on a version tag, or by hand. The version is the -# tag: project.pbxproj holds 0.0.0 and the tag reaches xcodebuild as +# Uploads to App Store Connect by hand. The version is the dispatch input: +# project.pbxproj holds 0.0.0 and the input reaches xcodebuild as # MARKETING_VERSION through fastlane's ODR_VERSION, so a release needs no commit. +# +# Nothing here is triggered by a tag. A version can take several builds to get +# through review, so a tag pushed beforehand names a commit that may never ship; +# this workflow writes the tags instead, after each upload lands, naming the +# commit and build number that really went out. See the README. on: workflow_dispatch: inputs: @@ -14,20 +19,14 @@ on: - pro - lite - both - # only for dispatched runs, which have no tag to read version: - description: version to build, e.g. 1.36 - defaults to the tag + description: version to build, e.g. 1.36 - required unless this is a dry run type: string # exercises the signing path without putting a build on TestFlight dry_run: description: build and archive only, do not upload type: boolean default: false - push: - # bare numbers, as this repo has always used; a v prefix is stripped - tags: - - '[0-9]*' - - 'v[0-9]*' concurrency: # every release run, not just the ones on the same ref: the build number is a @@ -37,12 +36,12 @@ concurrency: cancel-in-progress: false permissions: - contents: read + # to tag the commit an upload was built from + contents: write env: xcode_version: "26.5" - # false on a tag push, which always uploads - dry_run: ${{ inputs.dry_run || false }} + dry_run: ${{ inputs.dry_run }} jobs: upload: @@ -51,8 +50,7 @@ jobs: fail-fast: true max-parallel: 1 matrix: - # the env context is unavailable here, so the tag push fallback is inline - flavor: ${{ (!inputs.flavor || inputs.flavor == 'both') && fromJSON('["pro","lite"]') || fromJSON(format('["{0}"]', inputs.flavor)) }} + flavor: ${{ inputs.flavor == 'both' && fromJSON('["pro","lite"]') || fromJSON(format('["{0}"]', inputs.flavor)) }} steps: # a dry run signs too, so it needs the same secrets - name: check secrets @@ -80,11 +78,10 @@ jobs: - name: resolve version id: version # through the environment rather than interpolated into the run: line, - # where a tag name or an input would be a shell injection + # where the input 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" --dry-run "$dry_run" + run: .github/scripts/resolve-version.py --input "$given" --dry-run "$dry_run" - uses: ruby/setup-ruby@v1 with: @@ -129,6 +126,7 @@ jobs: # one step for both: a dry run builds the same thing and only stops short # of the upload - name: build and upload to App Store Connect + id: build env: ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} @@ -139,6 +137,43 @@ jobs: ODR_DRY_RUN: ${{ env.dry_run }} run: bundle exec fastlane ${{ matrix.flavor == 'pro' && 'deployPro' || 'deployLite' }} + # The record of what went out. Neither half of the version is in the tree + # - the input names one, App Store Connect names the other - so without + # this nothing connects a build on TestFlight to the commit it came from. + # + # Per flavor, because Pro and Lite count their builds separately: one + # commit uploaded to both apps is two builds with two different numbers. + # The plain version tag is not written here; that one means "this is + # live", which is known days later. The README has the command. + # + # success() is spelled out because naming an `if` drops the implicit one, + # and a tag claiming an upload that failed is worse than no tag at all. + - name: tag the uploaded build + if: ${{ success() && env.dry_run != 'true' }} + env: + version: ${{ steps.version.outputs.version }} + build_number: ${{ steps.build.outputs.build_number }} + flavor: ${{ matrix.flavor }} + run: | + if [ -z "$build_number" ]; then + echo "::error::the build recorded no number, so the upload cannot be tagged (it did go through)" + exit 1 + fi + + tag="build/$flavor/$version/$build_number" + if git ls-remote --exit-code --tags origin "$tag" > /dev/null 2>&1; then + echo "::error::$tag is taken, so something has already been uploaded under it (this upload did go through)" + exit 1 + fi + + 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 $version, build $build_number + uploaded to App Store Connect by $GITHUB_WORKFLOW run $GITHUB_RUN_ID" + git push origin "$tag" + + echo "tagged $GITHUB_SHA as $tag" >> "$GITHUB_STEP_SUMMARY" + # gym's log only says the export failed; the reason is in an # .xcdistributionlogs bundle under $TMPDIR, which dies with the runner - name: collect distribution logs diff --git a/.gitignore b/.gitignore index 5d336b6..d360c93 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,4 @@ fastlane/report.xml graph_info.json .venv/ +__pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e62233..785df01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,26 @@ shipped them. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Entries go under `Unreleased` as the change lands, in the same pull request. -Cutting a release renames that heading to the version and adds its compare -link; nothing in the build reads this file, so `project.pbxproj` keeps its -`0.0.0` and the version still comes from the tag. +This file lives on `main` and only on `main`, so its history stays complete and +linear; nothing in the build reads it, so `project.pbxproj` keeps its `0.0.0` +and the version still comes from the dispatch input. + +The heading is cut when the release is **submitted**, not when it is live and +not when anything is tagged, in one pull request on `main` that also writes +`fastlane/metadata/en-US/changelogs/.txt`. Both pieces of release copy +land together, and the commit that gets built is then the one whose changelog +names the version. + +If a rebuild is needed after that - review comes back, something is wrong, a +second build goes up under the same version - **the fix goes under the already +cut heading, not back under `Unreleased`.** The version has not been released +yet, so the section is still open, and the fix really did ship in it. Date the +heading when the release goes live, and point its compare link at the version +tag, which the README explains is written once the release is actually out. The copy that App Store Connect shows under "What's New" is a different, shorter -register, and lives in `fastlane/metadata/en-US/changelogs/.txt`. It is -pasted into App Store Connect at submission time; see the README there. +register. It is pasted into App Store Connect at submission time; see the README +there. ## [Unreleased] diff --git a/README.md b/README.md index 5b1be87..a10a691 100644 --- a/README.md +++ b/README.md @@ -64,38 +64,42 @@ committing; CI runs `scripts/format.sh --check` and fails on any difference. | --- | --- | | `format` | `scripts/format.sh --check`, on every push and pull request | | `build_test` | unit tests on the simulator plus a device build of both flavors | -| `release` | upload to App Store Connect on a version tag, see below | +| `release` | upload to App Store Connect, by hand, see below | `format` needs nothing but the Xcode toolchain and reports style breakage in a minute, so it is kept apart from the build. ## Releasing -The `release` workflow uploads a build to App Store Connect. It runs on a -version tag or by hand (`workflow_dispatch`), and never submits for review, so -promoting a build stays a deliberate step in App Store Connect. +The `release` workflow uploads a build to App Store Connect. It is dispatched by +hand, and never submits for review, so promoting a build stays a deliberate step +in App Store Connect: + +```sh +gh workflow run release.yml -f version=1.38 -f flavor=both +``` Nothing has to be committed to cut a release, and a release leaves no commit behind either. Both halves of the version come from outside the tree: | | where it comes from | what is checked in | | --- | --- | --- | -| `MARKETING_VERSION` (`CFBundleShortVersionString`) | the git tag | `0.0.0` | +| `MARKETING_VERSION` (`CFBundleShortVersionString`) | the `version` input | `0.0.0` | | `CURRENT_PROJECT_VERSION` (`CFBundleVersion`) | latest TestFlight build + 1 | `1` | -So a release is `git tag 1.36 && git push --tags`, and the version in -`project.pbxproj` is a placeholder that only local and CI builds ever see. The -tag has to be above what is live in the store - App Store Connect is the only -thing that knows what that is, and it rejects the upload otherwise. +The version in `project.pbxproj` is a placeholder that only local and CI builds +ever see. Nobody bumps it: a commit on `main` is not a release, and `0.0.0` says +so. The version has to be above what is live in the store - App Store Connect is +the only thing that knows what that is, and it rejects the upload otherwise. `.github/scripts/resolve-version.py` decides which version a run builds and refuses runs that cannot name one; run it by hand to see what a dispatch would -do. A dispatched run takes a `version` input instead of a tag, which is how a -release whose upload failed gets finished off the branch it was cut from. +do. The `dry_run` input builds, signs and archives the `.ipa` without uploading it - the only way to exercise the signing path without putting a build on TestFlight. -It is also the only kind of run allowed to go without a version. +It is also the only kind of run allowed to go without a version, and the only +one that leaves no tag. It needs these repository secrets: @@ -128,3 +132,33 @@ ODR_VERSION=1.36 bundle exec fastlane deployPro ODR_VERSION=1.36 bundle exec fastlane deployLite ODR_DRY_RUN=true bundle exec fastlane deployPro # build and sign only ``` + +### Tags + +Nothing is triggered by a tag, and no tag is pushed before a build. A version +often takes more than one build to get through review, so a tag pushed up front +names a commit that may never ship - which is what happened to `1.37`, whose tag +points at a commit that was superseded before submission. + +Tags are written afterwards instead, in two kinds: + +| tag | who writes it | what it means | +| --- | --- | --- | +| `build///` | the workflow, after each upload | this commit was uploaded as that build | +| `` | you, once the release is live | this is what shipped | + +The build tag is per flavor because Pro and Lite count their builds separately: +one commit uploaded to both apps is two builds with two different numbers. It is +never moved, and a rebuild simply gets the next number. A lane run locally +leaves no tag, so an upload made by hand off a laptop is not recorded. + +Once a release is live, tag the build that made it rather than whatever is at +the tip of `main`, so the two cannot drift: + +```sh +git tag 1.38 build/pro/1.38/4^{} && git push origin 1.38 +``` + +If Pro clears review and Lite does not, wait - the build tags already record +what went out, so nothing is lost by leaving the version tag until both are +through. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index e715640..7641a1a 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -100,6 +100,13 @@ platform :ios do initial_build_number: 0 ) + 1 + # the workflow tags the commit with this once the upload goes through, and + # a number only App Store Connect knows is the one thing it cannot work + # out for itself + if (github_output = ENV["GITHUB_OUTPUT"]) + File.open(github_output, "a") { |out| out.write("build_number=#{build_number}\n") } + end + UI.message("building #{options[:scheme]} #{version.empty? ? '(unversioned)' : version} as build #{build_number}") # Signing is manual from here on. Automatic signing has xcodebuild mint From 8810fb459a6830bccdb1c241e9d91f648896a32c Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Wed, 5 Aug 2026 20:08:08 +0200 Subject: [PATCH 2/3] Build once, upload per app, and record what actually shipped Follows the same change in OpenDocument.droid. The release workflow was a matrix over a `flavor` input, pinned to max-parallel 1, so one checkout, one Xcode select, one ruby setup, one keychain import and one version resolve all ran twice on two macOS runners. Both apps always go out together now, and nothing chooses one. Pro and Lite are the same app with ads and tracking switched off, so anything worth rebuilding one for is worth rebuilding the other for. 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 .ipa already built and signed - build number included, since it is baked in at archive time. That is what makes the next part possible. Pro and Lite now share a build number: one above the highest either app has, resolved once and given to both builds. So one (version, build) pair names one commit in both listings, and the build tag loses its per-flavor component. Asking App Store Connect once rather than once per app is required, not just tidier - querying again after Pro's upload would hand Lite a higher number. The Fastfile splits `deploy` into build_ipa and upload_ipa, with buildPro / uploadPro / resolveBuildNumber lanes for the workflow. deployPro and deployLite still build and upload in one go, so a hand run off a laptop is unchanged. The version tag is no longer written by hand. `record` drafts a github release at the built commit; a draft creates no tag, and publishing it creates one there. A version that needs a second build to clear review re-points the same draft. 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 apps are uploaded. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Nhfz8qVwtcDrJ6wdkrrcJr --- .github/scripts/changelog-section.py | 83 +++++++++ .github/workflows/release.yml | 252 +++++++++++++++++++-------- CHANGELOG.md | 7 +- README.md | 71 ++++++-- fastlane/Fastfile | 196 ++++++++++++++------- 5 files changed, 451 insertions(+), 158 deletions(-) create mode 100755 .github/scripts/changelog-section.py diff --git a/.github/scripts/changelog-section.py b/.github/scripts/changelog-section.py new file mode 100755 index 0000000..6d026a4 --- /dev/null +++ b/.github/scripts/changelog-section.py @@ -0,0 +1,83 @@ +#!/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 apps are on App Store +# Connect, 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.droid has the same script against plain `## 4.13.0` headings. + +import argparse +import os +import re +import sys + +# Keep a Changelog: `## [1.37] - 2026-08-02`, and `## [1.38]` while the date is +# still unknown. Not `###`, which belongs to whichever section it sits in +HEADING = re.compile(r"^## +\[?([^\]\s]+)\]?(?: *- *.+)?\s*$") + +# `[1.37]: https://github.com/...compare/1.36...1.37` - markdown plumbing at the +# foot of the file, which falls inside the last section but is not release copy +LINK = re.compile(r"^\[[^\]]+\]: +\S+\s*$") + + +def section(text, version): + """The body under `## []`. Raises ValueError if 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 and not LINK.match(line): + 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. 1.38") + 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/workflows/release.yml b/.github/workflows/release.yml index 9621cf0..30de006 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,28 +1,29 @@ name: release -# Uploads to App Store Connect by hand. The version is the dispatch input: -# project.pbxproj holds 0.0.0 and the input reaches xcodebuild as -# MARKETING_VERSION through fastlane's ODR_VERSION, so a release needs no commit. +# Builds both apps once, uploads each to App Store Connect, 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 .ipa already built and signed - build number included, since it is baked in +# at archive time. # -# Nothing here is triggered by a tag. A version can take several builds to get -# through review, so a tag pushed beforehand names a commit that may never ship; -# this workflow writes the tags instead, after each upload lands, naming the -# commit and build number that really went out. See the README. +# Both apps always go out together and nothing chooses one. They are the same app +# with ads and tracking switched off, and they share a build number so that one +# (version, build) pair names one commit in both listings. +# +# Nothing is triggered by a tag. A version can take several builds to get through +# review, so a tag pushed beforehand names a commit that may never ship; +# build// is written after each upload, and the plain tag +# appears only when the drafted release is published, once it is really live. +# +# project.pbxproj holds 0.0.0 and 1: the version comes from the dispatch input and +# the build number from App Store Connect, so a release needs no commit. + on: workflow_dispatch: inputs: - flavor: - description: which app to build - type: choice - default: both - options: - - pro - - lite - - both version: description: version to build, e.g. 1.36 - required unless this is a dry run type: string - # exercises the signing path without putting a build on TestFlight dry_run: description: build and archive only, do not upload type: boolean @@ -30,27 +31,21 @@ on: concurrency: # every release run, not just the ones on the same ref: the build number is a - # live query of what TestFlight has, so two overlapping runs would read the - # same one and hand the second upload a pair the store has already taken + # live query of what TestFlight has, so two overlapping runs would read the same + # one and hand the second upload a pair the store has already taken group: ${{ github.workflow }} cancel-in-progress: false permissions: - # to tag the commit an upload was built from - contents: write + contents: read env: xcode_version: "26.5" dry_run: ${{ inputs.dry_run }} jobs: - upload: + build: runs-on: macos-26 - strategy: - fail-fast: true - max-parallel: 1 - matrix: - flavor: ${{ inputs.flavor == 'both' && fromJSON('["pro","lite"]') || fromJSON(format('["{0}"]', inputs.flavor)) }} steps: # a dry run signs too, so it needs the same secrets - name: check secrets @@ -77,12 +72,21 @@ jobs: # minutes of setup and building rather than after - name: resolve version id: version - # through the environment rather than interpolated into the run: line, - # where the input would be a shell injection + # through the environment rather than the run: line, where the input would + # be a shell injection env: given: ${{ inputs.version }} run: .github/scripts/resolve-version.py --input "$given" --dry-run "$dry_run" + # 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. no + # build tag is checked - unlike Android, a second build of one version is normal + - 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 + - uses: ruby/setup-ruby@v1 with: bundler-cache: true @@ -123,56 +127,45 @@ jobs: exit 1 fi - # one step for both: a dry run builds the same thing and only stops short - # of the upload - - name: build and upload to App Store Connect - id: build + # once, for both: querying again after Pro's upload would hand Lite a higher + # number and lose the very thing sharing one is for + - name: resolve build number + id: build_number env: ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }} - # environment rather than lane options, which fastlane passes through - # as strings - a false would arrive as "false" and read as true + run: bundle exec fastlane ios resolveBuildNumber + + - name: build both apps + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }} + # environment rather than lane options, which fastlane passes through as + # strings - a false would arrive as "false" and read as true ODR_VERSION: ${{ steps.version.outputs.version }} ODR_DRY_RUN: ${{ env.dry_run }} - run: bundle exec fastlane ${{ matrix.flavor == 'pro' && 'deployPro' || 'deployLite' }} - - # The record of what went out. Neither half of the version is in the tree - # - the input names one, App Store Connect names the other - so without - # this nothing connects a build on TestFlight to the commit it came from. - # - # Per flavor, because Pro and Lite count their builds separately: one - # commit uploaded to both apps is two builds with two different numbers. - # The plain version tag is not written here; that one means "this is - # live", which is known days later. The README has the command. - # - # success() is spelled out because naming an `if` drops the implicit one, - # and a tag claiming an upload that failed is worse than no tag at all. - - name: tag the uploaded build - if: ${{ success() && env.dry_run != 'true' }} - env: - version: ${{ steps.version.outputs.version }} - build_number: ${{ steps.build.outputs.build_number }} - flavor: ${{ matrix.flavor }} + ODR_BUILD_NUMBER: ${{ steps.build_number.outputs.build_number }} + number: ${{ steps.build_number.outputs.build_number }} run: | - if [ -z "$build_number" ]; then - echo "::error::the build recorded no number, so the upload cannot be tagged (it did go through)" - exit 1 - fi + bundle exec fastlane ios buildPro + bundle exec fastlane ios buildLite + # travels with the archives: the record job cannot re-derive this the way + # it re-derives the version, and a job output of a job that a re-run did + # not repeat is not something to depend on + echo "$number" > build-number.txt - tag="build/$flavor/$version/$build_number" - if git ls-remote --exit-code --tags origin "$tag" > /dev/null 2>&1; then - echo "::error::$tag is taken, so something has already been uploaded under it (this upload did go through)" - exit 1 - fi - - 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 $version, build $build_number - uploaded to App Store Connect by $GITHUB_WORKFLOW run $GITHUB_RUN_ID" - git push origin "$tag" - - echo "tagged $GITHUB_SHA as $tag" >> "$GITHUB_STEP_SUMMARY" + # archived on a dry run too - that is how the signing path gets exercised + - name: Artifact ipas + uses: actions/upload-artifact@v7 + with: + name: ipas + path: | + build/*.ipa + build-number.txt + if-no-files-found: error + compression-level: 0 # gym's log only says the export failed; the reason is in an # .xcdistributionlogs bundle under $TMPDIR, which dies with the runner @@ -186,10 +179,123 @@ jobs: - uses: actions/upload-artifact@v7 if: always() with: - name: build-log-${{ matrix.flavor }} + name: build-log path: | ~/Library/Logs/gym distribution-logs - *.ipa - *.app.dSYM.zip + build/*.app.dSYM.zip if-no-files-found: warn + + # a job per app rather than two calls in one, 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: macos-26 + strategy: + fail-fast: false + matrix: + include: + - app: pro + lane: uploadPro + - app: lite + lane: uploadLite + steps: + - name: checkout + uses: actions/checkout@v7 + + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + # back to build/, where upload_ipa looks for it + - name: fetch the ipas + uses: actions/download-artifact@v8 + with: + name: ipas + + # no keychain and no profile: the archive is signed already + - name: upload ${{ matrix.app }} to App Store Connect + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }} + run: bundle exec fastlane ios ${{ matrix.lane }} + + # only once both apps 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 every time + - name: resolve version + id: version + env: + given: ${{ inputs.version }} + run: .github/scripts/resolve-version.py --input "$given" --dry-run "$dry_run" + + - name: fetch the ipas + uses: actions/download-artifact@v8 + with: + name: ipas + + - name: tag the build that went out + env: + version: ${{ steps.version.outputs.version }} + run: | + build_number=$(cat build-number.txt) + [ -n "$build_number" ] || { echo "::error::build-number.txt is empty"; exit 1; } + + tag="build/$version/$build_number" + + # 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 (this upload did go through)" + 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 "$version, build $build_number, uploaded to App Store Connect" \ + -m "run: $GITHUB_RUN_ID" + git push origin "$tag" + fi + echo "\`$GITHUB_SHA\` is \`$tag\`" >> "$GITHUB_STEP_SUMMARY" + + # 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. + # updated rather than created twice when a version needs another build to + # clear review + - 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" + + if gh release view "$version" > /dev/null 2>&1; then + gh release edit "$version" --target "$GITHUB_SHA" --notes-file "${RUNNER_TEMP}/notes.md" + else + # --generate-notes appends the pull requests below the changelog section + gh release create "$version" \ + --draft \ + --target "$GITHUB_SHA" \ + --title "$version" \ + --notes-file "${RUNNER_TEMP}/notes.md" \ + --generate-notes + fi + + echo "drafted \`$version\`. publishing it writes the tag - do that once it is live." >> "$GITHUB_STEP_SUMMARY" diff --git a/CHANGELOG.md b/CHANGELOG.md index 785df01..4d9b5a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Entries go under `Unreleased` as the change lands, in the same pull request. This file lives on `main` and only on `main`, so its history stays complete and -linear; nothing in the build reads it, so `project.pbxproj` keeps its `0.0.0` +linear; the build reads nothing from it, so `project.pbxproj` keeps its `0.0.0` and the version still comes from the dispatch input. The heading is cut when the release is **submitted**, not when it is live and @@ -17,6 +17,11 @@ not when anything is tagged, in one pull request on `main` that also writes land together, and the commit that gets built is then the one whose changelog names the version. +The release run reads that section: it refuses a version without one, and makes +it the body of the GitHub release it drafts. So a version submitted before its +heading is cut fails in the run's first seconds rather than after both apps are +uploaded. + If a rebuild is needed after that - review comes back, something is wrong, a second build goes up under the same version - **the fix goes under the already cut heading, not back under `Unreleased`.** The version has not been released diff --git a/README.md b/README.md index a10a691..ea2ccbf 100644 --- a/README.md +++ b/README.md @@ -76,30 +76,53 @@ hand, and never submits for review, so promoting a build stays a deliberate step in App Store Connect: ```sh -gh workflow run release.yml -f version=1.38 -f flavor=both +gh workflow run release.yml -f version=1.38 ``` +It runs as three jobs: + +| job | what it does | +| --- | --- | +| `build` | one run producing both signed `.ipa`s, archived on the run | +| `upload` | one job per app, uploading its `.ipa` | +| `record` | once both landed: tag the build, draft the GitHub release | + +Both apps always go out together, and nothing chooses one. Pro and Lite are the +same app - the target switches ads and tracking off, nothing else - so anything +worth rebuilding one for is worth rebuilding the other for. + +**If one app's upload fails, press "Re-run failed jobs".** Only that upload runs +again, against the `.ipa` already built and signed - build number included, since +it is baked in at archive time - and `record` runs behind it once it lands. + Nothing has to be committed to cut a release, and a release leaves no commit behind either. Both halves of the version come from outside the tree: | | where it comes from | what is checked in | | --- | --- | --- | | `MARKETING_VERSION` (`CFBundleShortVersionString`) | the `version` input | `0.0.0` | -| `CURRENT_PROJECT_VERSION` (`CFBundleVersion`) | latest TestFlight build + 1 | `1` | +| `CURRENT_PROJECT_VERSION` (`CFBundleVersion`) | one above the highest build either app has | `1` | The version in `project.pbxproj` is a placeholder that only local and CI builds ever see. Nobody bumps it: a commit on `main` is not a release, and `0.0.0` says so. The version has to be above what is live in the store - App Store Connect is the only thing that knows what that is, and it rejects the upload otherwise. +The build number is resolved once and given to both apps, so one `(version, build)` +pair names one commit in both listings. App Store Connect only requires the number +to increase, not to be contiguous, so whichever app was behind simply skips ahead. + `.github/scripts/resolve-version.py` decides which version a run builds and -refuses runs that cannot name one; run it by hand to see what a dispatch would -do. +refuses runs that cannot name one. Before building, the run also refuses a version +with no `CHANGELOG.md` section - that section becomes the release body, and finding +it missing afterwards leaves nothing to fix but the version number; +`changelog-section.py` is what reads it. Run either by hand to see what a dispatch +would do. -The `dry_run` input builds, signs and archives the `.ipa` without uploading it - -the only way to exercise the signing path without putting a build on TestFlight. -It is also the only kind of run allowed to go without a version, and the only -one that leaves no tag. +The `dry_run` input builds, signs and archives both `.ipa`s without uploading +either - the only way to exercise the signing path without putting a build on +TestFlight. It is also the only kind of run allowed to go without a version, and +the only one that leaves neither tag nor draft. It needs these repository secrets: @@ -133,6 +156,10 @@ ODR_VERSION=1.36 bundle exec fastlane deployLite ODR_DRY_RUN=true bundle exec fastlane deployPro # build and sign only ``` +`deployPro` is `buildPro` followed by `uploadPro`, which the workflow runs as +separate jobs. `uploadPro` takes the `.ipa` already in `build/` rather than making +one, and `resolveBuildNumber` prints the number both apps would get. + ### Tags Nothing is triggered by a tag, and no tag is pushed before a build. A version @@ -144,21 +171,31 @@ Tags are written afterwards instead, in two kinds: | tag | who writes it | what it means | | --- | --- | --- | -| `build///` | the workflow, after each upload | this commit was uploaded as that build | -| `` | you, once the release is live | this is what shipped | +| `build//` | the workflow, once both apps are up | this commit was uploaded as that build | +| `` | publishing the drafted release | this is what shipped | -The build tag is per flavor because Pro and Lite count their builds separately: -one commit uploaded to both apps is two builds with two different numbers. It is -never moved, and a rebuild simply gets the next number. A lane run locally -leaves no tag, so an upload made by hand off a laptop is not recorded. +One build tag, not one per app: both share a build number, so there is one +`(version, build)` pair and one commit to name. It is never moved, and a rebuild +simply gets the next number - a version that takes three builds to clear review +leaves three build tags, which is the point of having the number in there. A half +uploaded release gets no tag at all, and a lane run locally leaves none either, so +an upload made by hand off a laptop is not recorded. -Once a release is live, tag the build that made it rather than whatever is at -the tip of `main`, so the two cannot drift: +**The version tag is written neither by hand nor by the workflow.** `record` drafts +a GitHub release named `` pointing at the built commit. A draft creates no +tag; publishing it does, at exactly that commit: ```sh -git tag 1.38 build/pro/1.38/4^{} && git push origin 1.38 +gh release edit 1.38 --draft=false ``` +That is the whole manual step, and it stays human because App Store Connect is the +only thing that knows a build passed review and went live. A rebuild re-points the +same draft rather than making a second one. + +The release body is the version's `CHANGELOG.md` section with the generated list of +pull requests below it. + If Pro clears review and Lite does not, wait - the build tags already record what went out, so nothing is lost by leaving the version tag until both are through. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 7641a1a..7a77afa 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -18,19 +18,66 @@ require "tmpdir" default_platform(:ios) APPS = { - pro: { scheme: "ODR Full", app_identifier: "at.tomtasche.reader" }, - lite: { scheme: "ODR Lite", app_identifier: "at.tomtasche.reader.lite1" }, + pro: { name: "pro", scheme: "ODR Full", app_identifier: "at.tomtasche.reader" }, + lite: { name: "lite", scheme: "ODR Lite", app_identifier: "at.tomtasche.reader.lite1" }, }.freeze +# gitignored, and where the release workflow archives the .ipa from +IPA_DIR = "build".freeze + +def dry_run? + ENV["ODR_DRY_RUN"].to_s.strip == "true" +end + +def github_output(name, value) + output = ENV["GITHUB_OUTPUT"] + File.open(output, "a") { |out| out.write("#{name}=#{value}\n") } if output +end + platform :ios do - desc "Push a new release build of the paid app to the App Store" - lane :deployPro do |options| - deploy(options.merge(APPS[:pro])) + desc "Build a signed .ipa of the paid app" + lane :buildPro do + build_ipa(APPS[:pro]) + end + + desc "Build a signed .ipa of the ad supported app" + lane :buildLite do + build_ipa(APPS[:lite]) + end + + desc "Upload an already built Pro .ipa to App Store Connect" + lane :uploadPro do + upload_ipa(APPS[:pro]) + end + + desc "Upload an already built Lite .ipa" + lane :uploadLite do + upload_ipa(APPS[:lite]) end - desc "Push a new release build of the ad supported app to the App Store" - lane :deployLite do |options| - deploy(options.merge(APPS[:lite])) + desc "Build and upload the paid app" + lane :deployPro do + build_ipa(APPS[:pro]) + upload_ipa(APPS[:pro]) unless dry_run? + end + + desc "Build and upload the ad supported app" + lane :deployLite do + build_ipa(APPS[:lite]) + upload_ipa(APPS[:lite]) unless dry_run? + end + + desc "The build number both apps get, for the workflow to hand to both builds" + lane :resolveBuildNumber do + key_path = api_key_file + begin + number = next_build_number(key: asc_key(path: key_path)) + UI.message("building both apps as build #{number}") + github_output("build_number", number) + number + ensure + FileUtils.remove_entry(File.dirname(key_path), true) + end end lane :tests do @@ -43,14 +90,12 @@ platform :ios do ) end - # An App Store Connect API key replaces the interactive Apple ID login, which - # is what kept releases tied to one person's machine. Supply it through - # ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_CONTENT, the last being the base64 of - # the .p8 file. + # An App Store Connect API key replaces the interactive Apple ID login, which is + # what kept releases tied to one person's machine. Supply it through ASC_KEY_ID / + # ASC_ISSUER_ID / ASC_KEY_CONTENT, the last being the base64 of the .p8. # - # It is written to a private temporary file because that is the shape - # app_store_connect_api_key takes it in; the lane removes it again when it is - # done. + # Written to a private temporary file because that is the shape + # app_store_connect_api_key takes it in; every caller removes it again. private_lane :api_key_file do path = File.join(Dir.mktmpdir("asc-api-key"), "AuthKey_#{ENV.fetch('ASC_KEY_ID')}.p8") File.write(path, Base64.decode64(ENV.fetch("ASC_KEY_CONTENT"))) @@ -59,21 +104,42 @@ platform :ios do path end - # ODR_VERSION is the marketing version - the git tag in CI, since the - # repository has no real one. Unset it builds the 0.0.0 in project.pbxproj, - # which is only ever wanted for a dry run. + private_lane :asc_key do |options| + app_store_connect_api_key( + key_id: ENV.fetch("ASC_KEY_ID"), + issuer_id: ENV.fetch("ASC_ISSUER_ID"), + key_filepath: options[:path], + in_house: false + ) + end + + # One above the highest either app has. They share a number on purpose, so that + # one (version, build) pair names one commit in both listings - App Store Connect + # only requires the number to increase, not to be contiguous. + private_lane :next_build_number do |options| + APPS.values.map { |app| + latest_testflight_build_number( + api_key: options[:key], + app_identifier: app[:app_identifier], + initial_build_number: 0 + ) + }.max + 1 + end + + # ODR_VERSION is the marketing version - the dispatch input in CI, since the + # repository has no real one. ODR_BUILD_NUMBER is the number resolved once for + # the whole release; unset, this asks App Store Connect itself, which is what a + # hand run off a laptop does. # - # Both come from the environment rather than lane options, which fastlane - # passes through as strings: `dry_run:false` would arrive as "false" and read - # as true. - private_lane :deploy do |options| + # Both come from the environment rather than lane options, which fastlane passes + # through as strings: `dry_run:false` would arrive as "false" and read as true. + private_lane :build_ipa do |options| version = ENV["ODR_VERSION"].to_s.strip - dry_run = ENV["ODR_DRY_RUN"].to_s.strip == "true" # resolve-version.py refuses this in CI, but these lanes are meant to be # runnable by hand, where nothing else stops an unversioned build before the # upload rejects 0.0.0 twenty minutes in - if version.empty? && !dry_run + if version.empty? && !dry_run? UI.user_error!( "no version to build: set ODR_VERSION (e.g. ODR_VERSION=1.36), " \ "or ODR_DRY_RUN=true to build without uploading" @@ -84,39 +150,21 @@ platform :ios do profile_dir = nil begin - key = app_store_connect_api_key( - key_id: ENV.fetch("ASC_KEY_ID"), - issuer_id: ENV.fetch("ASC_ISSUER_ID"), - key_filepath: key_path, - in_house: false - ) + key = asc_key(path: key_path) - # the build number used to be bumped by hand in project.pbxproj. Derive it - # from what App Store Connect already has instead, so two releases cannot - # collide and nothing has to be committed to make a build - build_number = latest_testflight_build_number( - api_key: key, - app_identifier: options[:app_identifier], - initial_build_number: 0 - ) + 1 - - # the workflow tags the commit with this once the upload goes through, and - # a number only App Store Connect knows is the one thing it cannot work - # out for itself - if (github_output = ENV["GITHUB_OUTPUT"]) - File.open(github_output, "a") { |out| out.write("build_number=#{build_number}\n") } - end + build_number = ENV["ODR_BUILD_NUMBER"].to_s.strip + build_number = next_build_number(key: key) if build_number.empty? UI.message("building #{options[:scheme]} #{version.empty? ? '(unversioned)' : version} as build #{build_number}") # Signing is manual from here on. Automatic signing has xcodebuild mint # distribution assets of its own -- cloud signing, which only an Admin key # may do, so the export failed with "Cloud signing permission error" while - # the imported certificate went unused. sigh downloads the App Store - # profile matching that certificate instead, and both xcodebuild passes - # are handed it. Downloading one is something any key may do; creating the - # profile, which this also does when the account has none, wants an Admin - # key - and fails here, before the build, rather than after it. + # the imported certificate went unused. sigh downloads the App Store profile + # matching that certificate instead, and both xcodebuild passes are handed + # it. Downloading one is something any key may do; creating the profile, + # which this also does when the account has none, wants an Admin key - and + # fails here, before the build, rather than after it. profile_dir = Dir.mktmpdir("provisioning-profile") get_provisioning_profile( api_key: key, @@ -148,27 +196,41 @@ platform :ios do signingStyle: "manual", provisioningProfiles: { options[:app_identifier] => profile_name }, }, - xcargs: xcargs.join(" ") + xcargs: xcargs.join(" "), + # a name per flavor: one job builds both, and gym would otherwise write + # one over the other + output_directory: IPA_DIR, + output_name: "#{options[:name]}.ipa" ) - - if dry_run - UI.success("dry run: #{lane_context[SharedValues::IPA_OUTPUT_PATH]} built and signed, not uploaded") - else - upload_to_app_store( - api_key: key, - app_identifier: options[:app_identifier], - skip_screenshots: true, - skip_metadata: true, - # uploading is not the same as shipping: promoting a build stays a - # deliberate step in App Store Connect. deliver's option is - # submit_for_review; skip_submission is pilot's and is rejected here. - submit_for_review: false, - precheck_include_in_app_purchases: false - ) - end ensure FileUtils.remove_entry(File.dirname(key_path), true) FileUtils.remove_entry(profile_dir, true) if profile_dir end end + + # Takes the .ipa built earlier rather than building one, so a failed upload can + # be retried against the very bytes its other half went up with. Needs no + # keychain and no profile: the archive is already signed. + private_lane :upload_ipa do |options| + ipa = File.join(IPA_DIR, "#{options[:name]}.ipa") + UI.user_error!("no #{ipa} to upload - run the matching build lane first") unless File.exist?(ipa) + + key_path = api_key_file + begin + upload_to_app_store( + api_key: asc_key(path: key_path), + app_identifier: options[:app_identifier], + ipa: ipa, + skip_screenshots: true, + skip_metadata: true, + # uploading is not the same as shipping: promoting a build stays a + # deliberate step in App Store Connect. deliver's option is + # submit_for_review; skip_submission is pilot's and is rejected here. + submit_for_review: false, + precheck_include_in_app_purchases: false + ) + ensure + FileUtils.remove_entry(File.dirname(key_path), true) + end + end end From f0d80f2371b85977ce48591de8c12f4c36b48634 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 6 Aug 2026 20:22:49 +0200 Subject: [PATCH 3/3] Say the same things in fewer words The release design is explained three times over - once in the workflow header, once in the README, once in each script - and each telling had grown to the length of an essay. Cut the repetition and the reflow-only churn, keeping the reasons that are not visible from the code: why the build number is resolved once, why record re-derives the version, why a draft rather than a tag. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016UQrTtiKcM16vduYq1hRgX --- .github/scripts/changelog-section.py | 18 ++++----- .github/scripts/resolve-version.py | 10 ++--- .github/workflows/release.yml | 56 +++++++++----------------- CHANGELOG.md | 33 ++++++--------- README.md | 60 +++++++++++----------------- fastlane/Fastfile | 42 +++++++++---------- 6 files changed, 86 insertions(+), 133 deletions(-) diff --git a/.github/scripts/changelog-section.py b/.github/scripts/changelog-section.py index 6d026a4..510d8d9 100755 --- a/.github/scripts/changelog-section.py +++ b/.github/scripts/changelog-section.py @@ -2,25 +2,21 @@ # # 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 apps are on App Store -# Connect, 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.droid has the same script against plain `## 4.13.0` headings. +# The release run reads it before building, so a version without release copy fails +# in seconds rather than once both apps are uploaded, and again in the record job, +# where the section becomes the body of the drafted github release. import argparse import os import re import sys -# Keep a Changelog: `## [1.37] - 2026-08-02`, and `## [1.38]` while the date is -# still unknown. Not `###`, which belongs to whichever section it sits in +# `## [1.37] - 2026-08-02`, and `## [1.38]` while the date is still unknown. Not +# `###`, which belongs to whichever section it sits in HEADING = re.compile(r"^## +\[?([^\]\s]+)\]?(?: *- *.+)?\s*$") -# `[1.37]: https://github.com/...compare/1.36...1.37` - markdown plumbing at the -# foot of the file, which falls inside the last section but is not release copy +# `[1.37]: https://github.com/...compare/1.36...1.37` at the foot of the file: +# inside the last section, but not release copy LINK = re.compile(r"^\[[^\]]+\]: +\S+\s*$") diff --git a/.github/scripts/resolve-version.py b/.github/scripts/resolve-version.py index 319b890..bf2f8fc 100755 --- a/.github/scripts/resolve-version.py +++ b/.github/scripts/resolve-version.py @@ -6,10 +6,9 @@ # a version input that version # no input only a dry run, on the 0.0.0 in project.pbxproj # -# The version arrives as a dispatch input rather than a tag the run was pushed -# on. A tag is a promise made before the upload, and one version often takes -# more than one build to get through review; the tags this repository carries -# are written afterwards by the release workflow, naming what was really built. +# It comes from a dispatch input rather than a tag the run was pushed on: a tag +# would be a promise made before the upload, and a version often takes more than +# one build to clear review. The workflow writes the tags afterwards instead. # # The shape is checked here because xcodebuild never checks it: MARKETING_VERSION # is a free-form string to the build, so a typo would only surface when App Store @@ -25,8 +24,7 @@ import sys # what CFBundleShortVersionString accepts: one to three numeric parts. A leading -# v is tolerated and stripped below: the input is typed by hand, and the tags -# this repository has always used are bare numbers +# v is tolerated and stripped below, since the input is typed by hand VERSION = re.compile(r"^v?[0-9]{1,3}(\.[0-9]{1,3}){0,2}$") diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 30de006..6df27b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,22 +1,9 @@ name: release # Builds both apps once, uploads each to App Store Connect, 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 .ipa already built and signed - build number included, since it is baked in -# at archive time. -# -# Both apps always go out together and nothing chooses one. They are the same app -# with ads and tracking switched off, and they share a build number so that one -# (version, build) pair names one commit in both listings. -# -# Nothing is triggered by a tag. A version can take several builds to get through -# review, so a tag pushed beforehand names a commit that may never ship; -# build// is written after each upload, and the plain tag -# appears only when the drafted release is published, once it is really live. -# -# project.pbxproj holds 0.0.0 and 1: the version comes from the dispatch input and -# the build number from App Store Connect, so a release needs no commit. +# out. Split in three so that "Re-run failed jobs" can repair one failed upload +# against the .ipa already built and signed. Both apps go out together and share a +# build number; tags are written afterwards, never before. See the README. on: workflow_dispatch: @@ -31,8 +18,8 @@ on: concurrency: # every release run, not just the ones on the same ref: the build number is a - # live query of what TestFlight has, so two overlapping runs would read the same - # one and hand the second upload a pair the store has already taken + # live query of what TestFlight has, so two overlapping runs would read the + # same one and hand the second upload a pair the store has already taken group: ${{ github.workflow }} cancel-in-progress: false @@ -78,9 +65,7 @@ jobs: given: ${{ inputs.version }} run: .github/scripts/resolve-version.py --input "$given" --dry-run "$dry_run" - # 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. no - # build tag is checked - unlike Android, a second build of one version is normal + # this section becomes the release body, so a dry run checks it too - name: check the changelog names this version if: ${{ steps.version.outputs.version != '' }} env: @@ -128,7 +113,7 @@ jobs: fi # once, for both: querying again after Pro's upload would hand Lite a higher - # number and lose the very thing sharing one is for + # number - name: resolve build number id: build_number env: @@ -142,8 +127,8 @@ jobs: ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }} - # environment rather than lane options, which fastlane passes through as - # strings - a false would arrive as "false" and read as true + # environment rather than lane options, which fastlane passes through + # as strings - a false would arrive as "false" and read as true ODR_VERSION: ${{ steps.version.outputs.version }} ODR_DRY_RUN: ${{ env.dry_run }} ODR_BUILD_NUMBER: ${{ steps.build_number.outputs.build_number }} @@ -151,9 +136,8 @@ jobs: run: | bundle exec fastlane ios buildPro bundle exec fastlane ios buildLite - # travels with the archives: the record job cannot re-derive this the way - # it re-derives the version, and a job output of a job that a re-run did - # not repeat is not something to depend on + # travels with the archives: a re-run may not repeat this job, so record + # cannot depend on its outputs echo "$number" > build-number.txt # archived on a dry run too - that is how the signing path gets exercised @@ -186,8 +170,7 @@ jobs: build/*.app.dSYM.zip if-no-files-found: warn - # a job per app rather than two calls in one, so "Re-run failed jobs" can retry - # one half. fail-fast off for the same reason + # a job per app, fail-fast off, so "Re-run failed jobs" can retry one half upload: needs: build if: ${{ !inputs.dry_run }} @@ -222,7 +205,7 @@ jobs: ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }} run: bundle exec fastlane ios ${{ matrix.lane }} - # only once both apps are up, so a half uploaded release is not recorded at all + # only once both apps are up: a half uploaded release is not recorded record: needs: upload if: ${{ !inputs.dry_run }} @@ -233,8 +216,7 @@ jobs: - 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 every time + # re-resolved rather than carried over: the input is the same every time - name: resolve version id: version env: @@ -255,8 +237,8 @@ jobs: tag="build/$version/$build_number" - # this job re-running behind a repaired upload is expected, so an - # existing tag is only wrong when it names a different commit + # a re-run 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") @@ -275,10 +257,8 @@ jobs: fi echo "\`$GITHUB_SHA\` is \`$tag\`" >> "$GITHUB_STEP_SUMMARY" - # 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. - # updated rather than created twice when a version needs another build to - # clear review + # a draft creates no tag; publishing it does. --target takes the sha, not a + # branch, which would resolve to whatever main had become by then - name: draft the github release env: GH_TOKEN: ${{ github.token }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d9b5a3..b9c1c3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,27 +7,18 @@ shipped them. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Entries go under `Unreleased` as the change lands, in the same pull request. -This file lives on `main` and only on `main`, so its history stays complete and -linear; the build reads nothing from it, so `project.pbxproj` keeps its `0.0.0` -and the version still comes from the dispatch input. - -The heading is cut when the release is **submitted**, not when it is live and -not when anything is tagged, in one pull request on `main` that also writes -`fastlane/metadata/en-US/changelogs/.txt`. Both pieces of release copy -land together, and the commit that gets built is then the one whose changelog -names the version. - -The release run reads that section: it refuses a version without one, and makes -it the body of the GitHub release it drafts. So a version submitted before its -heading is cut fails in the run's first seconds rather than after both apps are -uploaded. - -If a rebuild is needed after that - review comes back, something is wrong, a -second build goes up under the same version - **the fix goes under the already -cut heading, not back under `Unreleased`.** The version has not been released -yet, so the section is still open, and the fix really did ship in it. Date the -heading when the release goes live, and point its compare link at the version -tag, which the README explains is written once the release is actually out. +This file lives on `main` and only on `main`. Nothing in the build reads it, so +`project.pbxproj` keeps its `0.0.0` and the version comes from the dispatch +input. + +The heading is cut when the release is **submitted**, in one pull request that +also writes `fastlane/metadata/en-US/changelogs/.txt`. The release run +refuses a version with no section here, and makes that section the body of the +GitHub release it drafts. + +Until the release is out the section stays open: **a second build under the same +version goes under the already cut heading, not back under `Unreleased`.** Date +the heading and point its compare link at the version tag once it is live. The copy that App Store Connect shows under "What's New" is a different, shorter register. It is pasted into App Store Connect at submission time; see the README diff --git a/README.md b/README.md index ea2ccbf..c41621c 100644 --- a/README.md +++ b/README.md @@ -87,9 +87,8 @@ It runs as three jobs: | `upload` | one job per app, uploading its `.ipa` | | `record` | once both landed: tag the build, draft the GitHub release | -Both apps always go out together, and nothing chooses one. Pro and Lite are the -same app - the target switches ads and tracking off, nothing else - so anything -worth rebuilding one for is worth rebuilding the other for. +Both apps always go out together, and nothing chooses one: Pro and Lite are the +same app with ads and tracking switched off. **If one app's upload fails, press "Re-run failed jobs".** Only that upload runs again, against the `.ipa` already built and signed - build number included, since @@ -104,20 +103,18 @@ behind either. Both halves of the version come from outside the tree: | `CURRENT_PROJECT_VERSION` (`CFBundleVersion`) | one above the highest build either app has | `1` | The version in `project.pbxproj` is a placeholder that only local and CI builds -ever see. Nobody bumps it: a commit on `main` is not a release, and `0.0.0` says -so. The version has to be above what is live in the store - App Store Connect is -the only thing that knows what that is, and it rejects the upload otherwise. +ever see; nobody bumps it, because a commit on `main` is not a release. The +version has to be above what is live in the store - App Store Connect is the only +thing that knows what that is, and it rejects the upload otherwise. The build number is resolved once and given to both apps, so one `(version, build)` -pair names one commit in both listings. App Store Connect only requires the number -to increase, not to be contiguous, so whichever app was behind simply skips ahead. +pair names one commit in both listings. App Store Connect only requires it to +increase, not to be contiguous, so whichever app was behind skips ahead. `.github/scripts/resolve-version.py` decides which version a run builds and -refuses runs that cannot name one. Before building, the run also refuses a version -with no `CHANGELOG.md` section - that section becomes the release body, and finding -it missing afterwards leaves nothing to fix but the version number; -`changelog-section.py` is what reads it. Run either by hand to see what a dispatch -would do. +refuses runs that cannot name one; `changelog-section.py` refuses a version with +no `CHANGELOG.md` section, before anything is built, since that section becomes +the release body. Run either by hand to see what a dispatch would do. The `dry_run` input builds, signs and archives both `.ipa`s without uploading either - the only way to exercise the signing path without putting a build on @@ -162,40 +159,31 @@ one, and `resolveBuildNumber` prints the number both apps would get. ### Tags -Nothing is triggered by a tag, and no tag is pushed before a build. A version +Nothing is triggered by a tag, and no tag is pushed before a build: a version often takes more than one build to get through review, so a tag pushed up front -names a commit that may never ship - which is what happened to `1.37`, whose tag -points at a commit that was superseded before submission. - -Tags are written afterwards instead, in two kinds: +names a commit that may never ship. That is what happened to `1.37`. Tags are +written afterwards instead, in two kinds: | tag | who writes it | what it means | | --- | --- | --- | | `build//` | the workflow, once both apps are up | this commit was uploaded as that build | | `` | publishing the drafted release | this is what shipped | -One build tag, not one per app: both share a build number, so there is one -`(version, build)` pair and one commit to name. It is never moved, and a rebuild -simply gets the next number - a version that takes three builds to clear review -leaves three build tags, which is the point of having the number in there. A half -uploaded release gets no tag at all, and a lane run locally leaves none either, so -an upload made by hand off a laptop is not recorded. +One build tag, not one per app, since both share a build number. It is never +moved: a rebuild gets the next number, so a version that takes three builds to +clear review leaves three build tags. A half uploaded release gets none, and +neither does a lane run locally. **The version tag is written neither by hand nor by the workflow.** `record` drafts -a GitHub release named `` pointing at the built commit. A draft creates no -tag; publishing it does, at exactly that commit: +a GitHub release named `` - the changelog section with the generated list +of pull requests below it - pointing at the built commit. A draft creates no tag; +publishing it does, at exactly that commit: ```sh gh release edit 1.38 --draft=false ``` -That is the whole manual step, and it stays human because App Store Connect is the -only thing that knows a build passed review and went live. A rebuild re-points the -same draft rather than making a second one. - -The release body is the version's `CHANGELOG.md` section with the generated list of -pull requests below it. - -If Pro clears review and Lite does not, wait - the build tags already record -what went out, so nothing is lost by leaving the version tag until both are -through. +That step stays human because App Store Connect is the only thing that knows a +build went live. A rebuild re-points the same draft rather than making a second +one, and if Pro clears review while Lite does not, wait: the build tags already +record what went out. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 7a77afa..4b78f55 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -67,7 +67,7 @@ platform :ios do upload_ipa(APPS[:lite]) unless dry_run? end - desc "The build number both apps get, for the workflow to hand to both builds" + desc "Print the build number both apps would get" lane :resolveBuildNumber do key_path = api_key_file begin @@ -90,11 +90,12 @@ platform :ios do ) end - # An App Store Connect API key replaces the interactive Apple ID login, which is - # what kept releases tied to one person's machine. Supply it through ASC_KEY_ID / - # ASC_ISSUER_ID / ASC_KEY_CONTENT, the last being the base64 of the .p8. + # An App Store Connect API key replaces the interactive Apple ID login, which + # is what kept releases tied to one person's machine. Supply it through + # ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_CONTENT, the last being the base64 of + # the .p8 file. # - # Written to a private temporary file because that is the shape + # It is written to a private temporary file because that is the shape # app_store_connect_api_key takes it in; every caller removes it again. private_lane :api_key_file do path = File.join(Dir.mktmpdir("asc-api-key"), "AuthKey_#{ENV.fetch('ASC_KEY_ID')}.p8") @@ -113,9 +114,9 @@ platform :ios do ) end - # One above the highest either app has. They share a number on purpose, so that - # one (version, build) pair names one commit in both listings - App Store Connect - # only requires the number to increase, not to be contiguous. + # One above the highest either app has: they share a number, so one + # (version, build) pair names one commit in both listings. App Store Connect only + # requires the number to increase, not to be contiguous. private_lane :next_build_number do |options| APPS.values.map { |app| latest_testflight_build_number( @@ -127,9 +128,8 @@ platform :ios do end # ODR_VERSION is the marketing version - the dispatch input in CI, since the - # repository has no real one. ODR_BUILD_NUMBER is the number resolved once for - # the whole release; unset, this asks App Store Connect itself, which is what a - # hand run off a laptop does. + # repository has no real one. ODR_BUILD_NUMBER is the number resolved once for the + # whole release; unset, this asks App Store Connect itself, as a hand run does. # # Both come from the environment rather than lane options, which fastlane passes # through as strings: `dry_run:false` would arrive as "false" and read as true. @@ -160,11 +160,11 @@ platform :ios do # Signing is manual from here on. Automatic signing has xcodebuild mint # distribution assets of its own -- cloud signing, which only an Admin key # may do, so the export failed with "Cloud signing permission error" while - # the imported certificate went unused. sigh downloads the App Store profile - # matching that certificate instead, and both xcodebuild passes are handed - # it. Downloading one is something any key may do; creating the profile, - # which this also does when the account has none, wants an Admin key - and - # fails here, before the build, rather than after it. + # the imported certificate went unused. sigh downloads the App Store + # profile matching that certificate instead, and both xcodebuild passes + # are handed it. Downloading one is something any key may do; creating the + # profile, which this also does when the account has none, wants an Admin + # key - and fails here, before the build, rather than after it. profile_dir = Dir.mktmpdir("provisioning-profile") get_provisioning_profile( api_key: key, @@ -197,8 +197,8 @@ platform :ios do provisioningProfiles: { options[:app_identifier] => profile_name }, }, xcargs: xcargs.join(" "), - # a name per flavor: one job builds both, and gym would otherwise write - # one over the other + # a name per app: one job builds both, and gym would otherwise write one + # over the other output_directory: IPA_DIR, output_name: "#{options[:name]}.ipa" ) @@ -208,9 +208,9 @@ platform :ios do end end - # Takes the .ipa built earlier rather than building one, so a failed upload can - # be retried against the very bytes its other half went up with. Needs no - # keychain and no profile: the archive is already signed. + # Takes the .ipa built earlier rather than building one, so a failed upload can be + # retried against the same bytes. Needs no keychain and no profile: the archive is + # already signed. private_lane :upload_ipa do |options| ipa = File.join(IPA_DIR, "#{options[:name]}.ipa") UI.user_error!("no #{ipa} to upload - run the matching build lane first") unless File.exist?(ipa)