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