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
123 changes: 123 additions & 0 deletions .github/scripts/resolve-version.py
Original file line number Diff line number Diff line change
@@ -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())
72 changes: 66 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,20 +1,51 @@
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:
# 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

Expand All @@ -23,6 +54,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:
Expand All @@ -31,8 +64,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 }}
Expand All @@ -53,6 +90,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
Expand Down Expand Up @@ -91,11 +143,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
Expand Down
16 changes: 8 additions & 8 deletions OpenDocumentReader.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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)";
Expand Down Expand Up @@ -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";
Expand All @@ -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)";
Expand All @@ -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;
Expand All @@ -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)";
Expand Down
40 changes: 31 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:

Expand All @@ -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
```
Loading