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..a2c917a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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: @@ -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 }} @@ -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 @@ -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 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..aac9e4a 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -60,7 +60,33 @@ 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. 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 @@ -80,7 +106,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 +121,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