Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 15 additions & 19 deletions .github/scripts/resolve-version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}$")


Expand All @@ -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 ""
Expand All @@ -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",
Expand All @@ -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))

Expand Down
69 changes: 52 additions & 17 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 }}
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,4 @@ fastlane/report.xml

graph_info.json
.venv/
__pycache__/
23 changes: 18 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<version>.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/<version>.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]

Expand Down
58 changes: 46 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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/<flavor>/<version>/<build>` | the workflow, after each upload | this commit was uploaded as that build |
| `<version>` | 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.
7 changes: 7 additions & 0 deletions fastlane/Fastfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down