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
43 changes: 20 additions & 23 deletions .github/scripts/changelog-section.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@
#
# Prints the CHANGELOG.md section for one version, and fails when there is none.
#
# 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.
# 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 when it
# drafts the github release, whose body the section becomes.

import argparse
import os
import re
import sys

# `## [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] - 2026-08-02`, `## [1.38]`, `## Unreleased`. Whatever follows the
# version is not looked at, so an unusual date separator still closes the
# section above it. Not `###`, which belongs to whichever section it sits in
HEADING = re.compile(r"^## +\[?([^\]\s]+)")

# `[1.37]: https://github.com/...compare/1.36...1.37` at the foot of the file:
# inside the last section, but not release copy
Expand All @@ -22,21 +22,17 @@

def section(text, version):
"""The body under `## [<version>]`. 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:
if found:
break
if heading.group(1).removeprefix("v") == wanted:
found = collecting = True
continue
if collecting and not LINK.match(line):
found = heading.group(1).removeprefix("v") == wanted
elif found and not LINK.match(line):
body.append(line)

if not found:
Expand All @@ -45,8 +41,8 @@ def section(text, version):
f"to '## [{wanted}]' before releasing it - that copy is the release body."
)

body = "\n".join(body).strip("\n")
if not body.strip():
body = "\n".join(body).strip()
if not body:
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."
Expand All @@ -63,15 +59,16 @@ def main(argv=None):
args = parser.parse_args(argv)

try:
with open(args.file) as changelog:
print(section(changelog.read(), args.version))
with open(args.file, encoding="utf-8") as changelog:
body = 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)
# stderr rather than a ::error:: annotation, which the runner only reads
# off stdout - and both callers redirect stdout
print(reason, file=sys.stderr)
return 1

sys.stdout.reconfigure(encoding="utf-8")
print(body)
return 0


Expand Down
29 changes: 14 additions & 15 deletions .github/scripts/resolve-version.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,19 @@
#!/usr/bin/env python3
#
# Works out which version a release run builds, and refuses the runs that cannot
# name one:
# Resolves the version a release run builds: the dispatch input, or nothing at
# all on a dry run, which builds 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
#
# 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 version comes from that 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.
#
# 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
# Connect rejects the upload at the very end. Whether the version is above what
# is live is left to the store, 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.
# Writes the resolved version 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
Expand All @@ -39,14 +36,15 @@ def fail(message):

def boolean(value):
"""A workflow input as it reaches a shell: the string "true" or "false"."""
if value.strip().lower() in ("true", "1"):
normalised = value.strip().lower()
if normalised in ("true", "1"):
return True
if value.strip().lower() in ("false", "0", ""):
if normalised in ("false", "0", ""):
return False
raise ValueError(f"'{value}' is not true or false")


def resolve(given, dry_run, log=print):
def resolve(given, dry_run):
"""The version to build, or "" for none. Raises ValueError with the reason."""
version = given.strip()

Expand All @@ -56,15 +54,16 @@ def resolve(given, dry_run, log=print):
"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")
print("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}")
print(f"building {version}")
return version


Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/build_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ on:
branches:
- main
paths-ignore:
- '**/*.md'
- '**.md'
- 'fastlane/metadata/**'
pull_request:
paths-ignore:
- '**/*.md'
- '**.md'
- 'fastlane/metadata/**'

concurrency:
Expand Down Expand Up @@ -52,7 +52,7 @@ jobs:
path: |
/Users/runner/Library/Developer/Xcode/DerivedData/OpenDocumentReader-*/

# the test job only ever builds the simulator slice of one configuration, so
# the test job only builds the simulator slice of one configuration, so
# release-only and device-only breakage used to surface at deploy time
build:
runs-on: macos-26
Expand All @@ -71,7 +71,7 @@ jobs:
xcode-version: ${{ env.xcode_version }}

# signing needs secrets this workflow does not have, so this only proves
# that the device slice compiles and links
# the device slice compiles and links
- name: build
run: >
xcodebuild
Expand Down
9 changes: 3 additions & 6 deletions .github/workflows/format.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
name: format

# swift-format comes with the Xcode toolchain, so this needs no ruby and reports
# style breakage in a minute instead of after a full build.
# Unlike build_test this has no paths-ignore: every file the formatter touches
# gets checked.
# Kept apart from build_test: swift-format comes with the Xcode toolchain, so
# this reports style breakage in a minute instead of after a full build.

on:
workflow_dispatch:
Expand All @@ -20,8 +18,7 @@ permissions:
contents: read

env:
# the runner image only ships the iOS platform bundle for its default Xcode;
# older ones fail in ibtool with "iOS 26.0 Platform Not Installed"
# pins swift-format, which is whatever the selected toolchain ships
xcode_version: "26.5"

jobs:
Expand Down
61 changes: 32 additions & 29 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
name: release

# Builds both apps once, uploads each to App Store Connect, then records what went
# 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.
# Three jobs rather than one so that "Re-run failed jobs" repairs a failed upload
# against the .ipa already built and signed. See the README.

on:
workflow_dispatch:
Expand All @@ -17,9 +15,8 @@ on:
default: false

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
# workflow wide, not per ref: the build number is a live query of what
# TestFlight has, so two overlapping runs would read the same one
group: ${{ github.workflow }}
cancel-in-progress: false

Expand All @@ -43,9 +40,10 @@ jobs:
SIGNING_CERTIFICATE_P12: ${{ secrets.SIGNING_CERTIFICATE_P12 }}
SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.SIGNING_CERTIFICATE_PASSWORD }}
run: |
set -euo pipefail
missing=""
for name in ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_CONTENT SIGNING_CERTIFICATE_P12 SIGNING_CERTIFICATE_PASSWORD; do
[ -z "${!name}" ] && missing="$missing $name"
[ -n "${!name:-}" ] || missing="$missing $name"
done
if [ -n "$missing" ]; then
echo "::error::missing repository secrets:$missing (see README)"
Expand All @@ -55,12 +53,11 @@ jobs:
- name: checkout
uses: actions/checkout@v7

# up front, so a run that cannot name a version ends before the twenty
# minutes of setup and building rather than after
# before the build, so a run that cannot name a version fails in seconds
- name: resolve version
id: version
# through the environment rather than 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"
Expand All @@ -70,7 +67,7 @@ jobs:
if: ${{ steps.version.outputs.version != '' }}
env:
version: ${{ steps.version.outputs.version }}
run: .github/scripts/changelog-section.py --version "$version" > /dev/null
run: .github/scripts/changelog-section.py --version "$version"

- uses: ruby/setup-ruby@v1
with:
Expand All @@ -86,11 +83,14 @@ jobs:
SIGNING_CERTIFICATE_P12: ${{ secrets.SIGNING_CERTIFICATE_P12 }}
SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.SIGNING_CERTIFICATE_PASSWORD }}
run: |
set -euo pipefail
keychain="$RUNNER_TEMP/signing.keychain-db"
password="$(uuidgen)"

security create-keychain -p "$password" "$keychain"
security set-keychain-settings -lut 900 "$keychain"
# long enough to outlast both archives: a keychain that relocks mid run
# fails codesign with "User interaction is not allowed"
security set-keychain-settings -lut 21600 "$keychain"
security unlock-keychain -p "$password" "$keychain"

echo "$SIGNING_CERTIFICATE_P12" | base64 --decode > "$RUNNER_TEMP/certificate.p12"
Expand All @@ -104,10 +104,11 @@ 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

# this is the only certificate the build has - a development one here
# would archive fine and fail the export twenty minutes in
security find-identity -v -p codesigning "$keychain"
if ! security find-identity -v -p codesigning "$keychain" | grep -q "Apple Distribution\|iPhone Distribution"; then
# a development certificate here would archive fine and fail the export
# twenty minutes in
identities="$(security find-identity -v -p codesigning "$keychain")"
echo "$identities"
if ! grep -q "Apple Distribution\|iPhone Distribution" <<< "$identities"; then
echo "::error::SIGNING_CERTIFICATE_P12 holds no Apple Distribution certificate (see README)"
exit 1
fi
Expand All @@ -127,21 +128,22 @@ 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
ODR_VERSION: ${{ steps.version.outputs.version }}
ODR_DRY_RUN: ${{ env.dry_run }}
ODR_DRY_RUN: ${{ inputs.dry_run }}
ODR_BUILD_NUMBER: ${{ steps.build_number.outputs.build_number }}
number: ${{ steps.build_number.outputs.build_number }}
run: |
set -euo pipefail
# empty here means each build would ask the store for its own number
[ -n "$ODR_BUILD_NUMBER" ] || { echo "::error::resolveBuildNumber produced no build number"; exit 1; }

bundle exec fastlane ios buildPro
bundle exec fastlane ios buildLite
# travels with the archives: a re-run may not repeat this job, so record
# cannot depend on its outputs
echo "$number" > build-number.txt
# travels with the archives: a re-run may skip this job, so record
# cannot read its outputs
echo "$ODR_BUILD_NUMBER" > build-number.txt

# archived on a dry run too - that is how the signing path gets exercised
- name: Artifact ipas
# on a dry run too: archiving the signed .ipa is the point of one
- name: archive the ipas
uses: actions/upload-artifact@v7
with:
name: ipas
Expand All @@ -156,6 +158,7 @@ jobs:
- name: collect distribution logs
if: failure()
run: |
set -euo pipefail
mkdir -p distribution-logs
find "${TMPDIR:-/tmp}" -maxdepth 1 -name '*.xcdistributionlogs' \
-exec cp -R {} distribution-logs/ \;
Expand Down Expand Up @@ -191,7 +194,6 @@ jobs:
with:
bundler-cache: true

# back to build/, where upload_ipa looks for it
- name: fetch the ipas
uses: actions/download-artifact@v8
with:
Expand All @@ -205,7 +207,6 @@ jobs:
ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }}
run: bundle exec fastlane ios ${{ matrix.lane }}

# only once both apps are up: a half uploaded release is not recorded
record:
needs: upload
if: ${{ !inputs.dry_run }}
Expand All @@ -232,6 +233,7 @@ jobs:
env:
version: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
build_number=$(cat build-number.txt)
[ -n "$build_number" ] || { echo "::error::build-number.txt is empty"; exit 1; }

Expand Down Expand Up @@ -264,6 +266,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
version: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
.github/scripts/changelog-section.py --version "$version" > "${RUNNER_TEMP}/notes.md"

if gh release view "$version" > /dev/null 2>&1; then
Expand Down
2 changes: 0 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,6 @@ fastlane/test_output

.DS_Store

fastlane/report.xml

graph_info.json
.venv/
__pycache__/
Loading