From 562c6d09e34278a662310515d1112fd9cc8b13b3 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Mon, 27 Jul 2026 22:07:40 +0200 Subject: [PATCH 1/2] Take the version from the git tag instead of project.pbxproj The marketing version was a number committed in project.pbxproj, which describes either a release that already went out or a guess at the next one, so it was wrong almost all of the time and had to be bumped by hand before every release. The build number stopped being committed when the release moved into CI - it comes from what TestFlight already has - and this does the same for the other half: project.pbxproj holds 0.0.0, the release workflow runs on a version tag, and the tag reaches xcodebuild as MARKETING_VERSION by way of fastlane's ODR_VERSION. Cutting a release is now a tag, and needs no commit before or after it. resolve-version.py decides which run may build which version, and says why: a tag push builds the tag, a dispatched run builds its version input, and neither is only allowed for a dry run. It sits right behind the checkout, because a run that cannot name a version should end before twenty minutes of conan and xcodebuild, not after. Being a script rather than a run: block, it can be run by hand to see what a dispatch would do. The dry run is the second half: it builds, signs and archives the ipa without uploading it, which is 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, since uploading 0.0.0 would be rejected by App Store Connect at the very end. Both settings reach fastlane through the environment rather than as lane options, which fastlane passes through as strings - dry_run:false would have arrived as the string "false" and read as true. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01W5UtM8wFTKh9dGjnx2X2cs --- .github/scripts/resolve-version.py | 123 +++++++++++++++++++ .github/workflows/release.yml | 69 +++++++++-- OpenDocumentReader.xcodeproj/project.pbxproj | 16 +-- README.md | 40 ++++-- fastlane/Fastfile | 48 ++++++-- 5 files changed, 259 insertions(+), 37 deletions(-) create mode 100755 .github/scripts/resolve-version.py diff --git a/.github/scripts/resolve-version.py b/.github/scripts/resolve-version.py new file mode 100755 index 0000000..337cce8 --- /dev/null +++ b/.github/scripts/resolve-version.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +# +# Works out which version a release run is building, and refuses the runs that +# cannot sensibly build one. +# +# There is no real version number in the repository: project.pbxproj holds +# 0.0.0, and the version that ships is the git tag, handed to fastlane as +# ODR_VERSION and from there to xcodebuild as MARKETING_VERSION. A number +# committed on main describes either a release that already went out or a guess +# at the next one, so it is wrong almost all of the time. +# +# What is left to decide is which string fastlane is handed, and that is only +# interesting when the run has no tag to read: +# +# tag push the tag, and the version input has to agree with +# it or stay empty - building anything else would +# put a version on the store that no tag names +# 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 the 0.0.0 in project.pbxproj. +# uploading that would mean uploading a version +# App Store Connect refuses, twenty minutes into +# the run +# +# The shape is checked here rather than left to xcodebuild, which never checks +# it at all: MARKETING_VERSION is a free-form string to the build, and a typo +# only surfaces when App Store Connect rejects the upload at the very end. +# +# Unlike the android side there is no version code to derive - CFBundleVersion +# is the build number, which fastlane takes from what TestFlight already has. +# The store does insist the version climbs, so the tag has to be above whatever +# is live; that is left to App Store Connect, which is the only thing that knows. +# +# Prints the resolved version and writes it to GITHUB_OUTPUT as `version`, empty +# when there is none. Run it by hand to see what a dispatch would build. + +import argparse +import os +import re +import sys + +# what CFBundleShortVersionString accepts: one to three numeric parts. A leading +# v is allowed here because tags are often written that way, and stripped below - +# "v1.36" as a marketing version would be rejected by the store +VERSION = re.compile(r"^v?[0-9]{1,3}(\.[0-9]{1,3}){0,2}$") + + +def fail(message): + if os.environ.get("GITHUB_ACTIONS"): + # shown in the log the same way, and additionally as an annotation on the + # run itself rather than only somewhere in the middle of a step + print(f"::error::{message}") + else: + print(message, file=sys.stderr) + return 1 + + +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(tag, 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 = 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." + ) + log("no version given - building the 0.0.0 in project.pbxproj") + return "" + + if not VERSION.match(version): + raise ValueError( + f"'{version}' is not a version: expected up to three numbers, like 1.36 or 1.36.1" + ) + version = version.removeprefix("v") + log(f"building {version}") + return version + + +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( + "--dry-run", + default="false", + help="whether the run publishes nothing; only a dry run may go without a version", + ) + args = parser.parse_args(argv) + + try: + version = resolve(args.tag, args.input, boolean(args.dry_run)) + except ValueError as reason: + return fail(str(reason)) + + output = os.environ.get("GITHUB_OUTPUT") + if output: + with open(output, "a") as out: + out.write(f"version={version}\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 85baca9..d152763 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,21 +1,47 @@ name: release -# Manual only. Releasing stays a deliberate act, this just moves it off -# whoever's laptop happened to have the Apple ID signed in. +# Releasing stays a deliberate act - this just moves it off whoever's laptop +# happened to have the Apple ID signed in. It runs on a version tag, or by hand. +# +# The version is the tag and nothing else. project.pbxproj holds 0.0.0, and the +# tag reaches xcodebuild as MARKETING_VERSION by way of fastlane's ODR_VERSION, +# so cutting a release needs no commit and leaves no working tree dirty. A +# dispatched run has no tag to read, so it fills in the version input instead. +# The build number is not in the repository either: fastlane derives it from +# what TestFlight already has. on: workflow_dispatch: inputs: flavor: - description: which app to upload - required: true + description: which app to build type: choice + default: both options: - pro - lite - 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 release whose + # upload failed off the branch it was cut from, or putting a real version + # on a dry run + version: + description: version to build, e.g. 1.36 - defaults to the tag + type: string + # the dry run: build, sign and archive the ipa, upload nothing. the only + # way to exercise the signing path without putting a build on TestFlight + dry_run: + description: build and archive only, do not upload + type: boolean + default: false + push: + # the tags this repo has always used are bare numbers; a v prefix is + # accepted too and stripped by the version script + tags: + - '[0-9]*' + - 'v[0-9]*' concurrency: - group: ${{ github.workflow }} + group: release-${{ github.ref }} cancel-in-progress: false permissions: @@ -23,6 +49,8 @@ permissions: env: xcode_version: "26.5" + # a tag push builds both flavors and uploads them, the way a release always has + dry_run: ${{ inputs.dry_run || false }} jobs: upload: @@ -31,8 +59,12 @@ jobs: fail-fast: true max-parallel: 1 matrix: - flavor: ${{ inputs.flavor == 'both' && fromJSON('["pro","lite"]') || fromJSON(format('["{0}"]', inputs.flavor)) }} + # the env context is not available here, so the tag push fallback is + # spelled out rather than read from env.flavor + flavor: ${{ (!inputs.flavor || inputs.flavor == 'both') && fromJSON('["pro","lite"]') || fromJSON(format('["{0}"]', inputs.flavor)) }} steps: + # signing is needed for a dry run too - it builds and signs everything a + # real release does, it just keeps the result - name: check secrets env: ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} @@ -53,6 +85,21 @@ jobs: - name: checkout uses: actions/checkout@v7 + # the version is not in the checkout - it is the tag. 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 twenty minutes of setup and building, not after + - name: resolve version + id: version + # through the environment rather than interpolated into the command: a + # tag name and a dispatch input are both strings from outside the + # workflow, and a run: line is the one place where that would be a shell + # injection + 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" + - uses: ruby/setup-ruby@v1 with: bundler-cache: true @@ -91,11 +138,19 @@ jobs: security set-key-partition-list -S apple-tool:,apple: -k "$password" "$keychain" > /dev/null security list-keychain -d user -s "$keychain" login.keychain-db - - name: upload to App Store Connect + # one step for both, because a dry run has to build exactly what a release + # builds to be worth anything - the lane only stops short of the upload + - name: build and upload 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 }} + # empty on a dry run with no version, which leaves the 0.0.0 that is + # checked in. passed as an environment variable rather than a lane + # option: fastlane hands lane options through as strings, so a false + # would arrive as the string "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' }} - uses: actions/upload-artifact@v7 diff --git a/OpenDocumentReader.xcodeproj/project.pbxproj b/OpenDocumentReader.xcodeproj/project.pbxproj index 19e1d86..42c300c 100644 --- a/OpenDocumentReader.xcodeproj/project.pbxproj +++ b/OpenDocumentReader.xcodeproj/project.pbxproj @@ -565,7 +565,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 42; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DEVELOPMENT_ASSET_PATHS = "conan-assets"; @@ -580,7 +580,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.35; + MARKETING_VERSION = 0.0.0; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = at.tomtasche.reader.lite1; @@ -662,7 +662,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 42; + CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; DEVELOPMENT_ASSET_PATHS = "conan-assets"; ENABLE_BITCODE = NO; @@ -676,7 +676,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.35; + MARKETING_VERSION = 0.0.0; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; PRODUCT_BUNDLE_IDENTIFIER = at.tomtasche.reader.lite1; PRODUCT_NAME = "$(BUNDLE_DISPLAY_NAME)"; @@ -898,7 +898,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 42; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DEVELOPMENT_ASSET_PATHS = "conan-assets"; @@ -913,7 +913,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.35; + MARKETING_VERSION = 0.0.0; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = "at.tomtasche.reader$(BUNDLE_ID_SUFFIX)"; @@ -937,7 +937,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 42; + CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; DEVELOPMENT_ASSET_PATHS = "conan-assets"; ENABLE_BITCODE = NO; @@ -951,7 +951,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.35; + MARKETING_VERSION = 0.0.0; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; PRODUCT_BUNDLE_IDENTIFIER = "at.tomtasche.reader$(BUNDLE_ID_SUFFIX)"; PRODUCT_NAME = "$(BUNDLE_DISPLAY_NAME)"; diff --git a/README.md b/README.md index 4ce65d3..f7c33b4 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ 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` | manual upload to App Store Connect, see below | +| `release` | upload to App Store Connect on a version tag, see below | `format` needs nothing but the Xcode toolchain and reports style breakage in a minute, so it is kept apart from the native build. Everything that does need @@ -76,11 +76,31 @@ dependencies with conan and caches them per Xcode version and profile. ## Releasing -The `release` workflow uploads a build to App Store Connect. It is manual -(`workflow_dispatch`) and never submits for review, so -promoting a build stays a deliberate step in App Store Connect. Build numbers -come from whatever TestFlight already has, so nothing needs to be committed to -cut a release. +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. + +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` | +| `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. + +`.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. + +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 needs these repository secrets: @@ -96,9 +116,11 @@ The certificate is imported into a temporary keychain that is discarded with the runner. Provisioning profiles are created on demand via `-allowProvisioningUpdates`. -The same lanes work locally once those variables are exported: +The same lanes work locally once those variables are exported, and take the +version and the dry run the same way the workflow hands them over: ```bash -bundle exec fastlane deployPro -bundle exec fastlane deployLite +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 ``` diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 9f3ee76..a0260e0 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -60,7 +60,21 @@ platform :ios do path end + # Both come from the environment rather than lane options: fastlane passes lane + # options through as strings, so `dry_run:false` would arrive as the string + # "false" and read as true. They work the same way by hand as in CI: + # + # ODR_VERSION=1.36 bundle exec fastlane deployPro + # ODR_DRY_RUN=true bundle exec fastlane deployPro + # + # ODR_VERSION is the marketing version, and there is no real one in the + # repository - project.pbxproj holds 0.0.0, and the release workflow passes the + # git tag. An unset one therefore builds 0.0.0, which is only ever wanted for a + # dry run; the workflow refuses to upload without a version. private_lane :deploy do |options| + version = ENV["ODR_VERSION"].to_s.strip + dry_run = ENV["ODR_DRY_RUN"].to_s.strip == "true" + key_path = api_key_file begin @@ -80,7 +94,7 @@ platform :ios do initial_build_number: 0 ) + 1 - UI.message("building #{options[:scheme]} as build #{build_number}") + UI.message("building #{options[:scheme]} #{version.empty? ? '(unversioned)' : version} as build #{build_number}") # build_app is gym, which has no api_key option -- passing one aborts the # lane before xcodebuild ever runs. Automatic signing is authenticated @@ -95,24 +109,32 @@ platform :ios do # passed on the command line rather than written into project.pbxproj, # so a release never leaves the working tree dirty "CURRENT_PROJECT_VERSION=#{build_number}", - ].join(" ") + ] + # left out entirely when there is none, rather than passed as 0.0.0: that + # is what project.pbxproj already says, and overriding a setting with the + # value it already has only hides where it came from + xcargs << "MARKETING_VERSION=#{version}" unless version.empty? build_app( project: "OpenDocumentReader.xcodeproj", scheme: options[:scheme], - xcargs: xcargs + xcargs: xcargs.join(" ") ) - 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 - skip_submission: true, - precheck_include_in_app_purchases: false - ) + 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 + skip_submission: true, + precheck_include_in_app_purchases: false + ) + end ensure FileUtils.remove_entry(File.dirname(key_path), true) end From a0e5154031d44e02c8428f38340083a6c66e85c7 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Mon, 27 Jul 2026 22:13:18 +0200 Subject: [PATCH 2/2] Keep releases serialized, and refuse an unversioned one by hand too Two things the tag change got wrong, both from copying the android side where they are safe. The concurrency key was narrowed to the ref. Droid can do that because its version code is derived from the tag, so two runs cannot collide on one. Here the build number is a live query of what TestFlight has, and two overlapping runs read the same answer - the second upload then carries a version and build pair App Store Connect has already taken. Back to one release at a time, whatever ref it is on. The lane trusted the workflow to have checked for a version, but these lanes are documented as runnable by hand, where nothing had checked: an unversioned local deploy built 0.0.0 and only found out at the upload, twenty minutes later, that it is below what is live. It now says so before the build, the way resolve-version.py does in CI. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01W5UtM8wFTKh9dGjnx2X2cs --- .github/workflows/release.yml | 7 ++++++- fastlane/Fastfile | 14 +++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d152763..a2c917a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,12 @@ on: - 'v[0-9]*' concurrency: - group: release-${{ github.ref }} + # every release run, not merely the ones on the same ref. The build number is a + # live query of what TestFlight already has, so two overlapping runs would read + # the same number and hand the second upload a version/build pair App Store + # Connect has already taken. The android side can key this by ref because its + # version code is derived from the tag, with nothing to race against + group: ${{ github.workflow }} cancel-in-progress: false permissions: diff --git a/fastlane/Fastfile b/fastlane/Fastfile index a0260e0..aac9e4a 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -70,11 +70,23 @@ platform :ios do # ODR_VERSION is the marketing version, and there is no real one in the # repository - project.pbxproj holds 0.0.0, and the release workflow passes the # git tag. An unset one therefore builds 0.0.0, which is only ever wanted for a - # dry run; the workflow refuses to upload without a version. + # dry run. private_lane :deploy do |options| version = ENV["ODR_VERSION"].to_s.strip dry_run = ENV["ODR_DRY_RUN"].to_s.strip == "true" + # the workflow refuses this too, in resolve-version.py, before any of the + # setup. Checked again here because these lanes are meant to be runnable by + # hand, and nothing else would then stop an unversioned build from reaching + # the upload - where App Store Connect rejects 0.0.0 for being below what is + # live, twenty minutes in + 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" + ) + end + key_path = api_key_file begin