From ac8133465007c3473f496df9c18d2a3ca4a37421 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Tue, 4 Aug 2026 00:17:15 +0200 Subject: [PATCH] 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