diff --git a/.github/scripts/changelog-section.py b/.github/scripts/changelog-section.py new file mode 100755 index 0000000..510d8d9 --- /dev/null +++ b/.github/scripts/changelog-section.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +# +# 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. + +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]: https://github.com/...compare/1.36...1.37` at the foot of the file: +# inside the last section, but not release copy +LINK = re.compile(r"^\[[^\]]+\]: +\S+\s*$") + + +def section(text, version): + """The body under `## []`. 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: + break + if heading.group(1).removeprefix("v") == wanted: + found = collecting = True + continue + if collecting and not LINK.match(line): + body.append(line) + + if not found: + raise ValueError( + f"CHANGELOG.md has no '## [{wanted}]' section. Cut the Unreleased heading " + f"to '## [{wanted}]' before releasing it - that copy is the release body." + ) + + body = "\n".join(body).strip("\n") + if not body.strip(): + 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." + ) + return body + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Print the CHANGELOG.md section of one version." + ) + parser.add_argument("--version", required=True, help="version to look up, e.g. 1.38") + parser.add_argument("--file", default="CHANGELOG.md", help="changelog to read") + args = parser.parse_args(argv) + + try: + with open(args.file) as changelog: + print(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) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/resolve-version.py b/.github/scripts/resolve-version.py index 426737f..bf2f8fc 100755 --- a/.github/scripts/resolve-version.py +++ b/.github/scripts/resolve-version.py @@ -3,10 +3,12 @@ # 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 +# +# 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 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 +24,7 @@ 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, since the input is typed by hand VERSION = re.compile(r"^v?[0-9]{1,3}(\.[0-9]{1,3}){0,2}$") @@ -44,22 +46,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 +72,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 +81,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..6df27b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,33 +1,20 @@ 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 -# MARKETING_VERSION through fastlane's ODR_VERSION, so a release needs no commit. +# 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. + on: workflow_dispatch: inputs: - flavor: - description: which app to build - type: choice - default: both - options: - - 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 @@ -41,18 +28,11 @@ permissions: 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: + build: runs-on: macos-26 - strategy: - 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)) }} steps: # a dry run signs too, so it needs the same secrets - name: check secrets @@ -79,12 +59,18 @@ jobs: # minutes of setup and building rather than after - 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 + # through the environment rather than the run: line, 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" + + # this section becomes the release body, so a dry run checks it too + - name: check the changelog names this version + if: ${{ steps.version.outputs.version != '' }} + env: + version: ${{ steps.version.outputs.version }} + run: .github/scripts/changelog-section.py --version "$version" > /dev/null - uses: ruby/setup-ruby@v1 with: @@ -126,9 +112,17 @@ jobs: exit 1 fi - # 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 + # once, for both: querying again after Pro's upload would hand Lite a higher + # number + - name: resolve build number + id: build_number + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }} + run: bundle exec fastlane ios resolveBuildNumber + + - name: build both apps env: ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} @@ -137,7 +131,25 @@ jobs: # as strings - a false would arrive as "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' }} + ODR_BUILD_NUMBER: ${{ steps.build_number.outputs.build_number }} + number: ${{ steps.build_number.outputs.build_number }} + run: | + 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 + + # archived on a dry run too - that is how the signing path gets exercised + - name: Artifact ipas + uses: actions/upload-artifact@v7 + with: + name: ipas + path: | + build/*.ipa + build-number.txt + if-no-files-found: error + compression-level: 0 # gym's log only says the export failed; the reason is in an # .xcdistributionlogs bundle under $TMPDIR, which dies with the runner @@ -151,10 +163,119 @@ jobs: - uses: actions/upload-artifact@v7 if: always() with: - name: build-log-${{ matrix.flavor }} + name: build-log path: | ~/Library/Logs/gym distribution-logs - *.ipa - *.app.dSYM.zip + build/*.app.dSYM.zip if-no-files-found: warn + + # a job per app, fail-fast off, so "Re-run failed jobs" can retry one half + upload: + needs: build + if: ${{ !inputs.dry_run }} + runs-on: macos-26 + strategy: + fail-fast: false + matrix: + include: + - app: pro + lane: uploadPro + - app: lite + lane: uploadLite + steps: + - name: checkout + uses: actions/checkout@v7 + + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + # back to build/, where upload_ipa looks for it + - name: fetch the ipas + uses: actions/download-artifact@v8 + with: + name: ipas + + # no keychain and no profile: the archive is signed already + - name: upload ${{ matrix.app }} 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 }} + 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 }} + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - name: checkout + uses: actions/checkout@v7 + + # re-resolved rather than carried over: the input is the same every time + - name: resolve version + id: version + env: + given: ${{ inputs.version }} + run: .github/scripts/resolve-version.py --input "$given" --dry-run "$dry_run" + + - name: fetch the ipas + uses: actions/download-artifact@v8 + with: + name: ipas + + - name: tag the build that went out + env: + version: ${{ steps.version.outputs.version }} + run: | + build_number=$(cat build-number.txt) + [ -n "$build_number" ] || { echo "::error::build-number.txt is empty"; exit 1; } + + tag="build/$version/$build_number" + + # a re-run behind a repaired upload is expected, so an existing tag is + # only wrong when it names a different commit + if git ls-remote --exit-code --tags origin "$tag" > /dev/null 2>&1; then + git fetch --no-tags origin "refs/tags/$tag:refs/tags/$tag" + already=$(git rev-list -n1 "$tag") + if [ "$already" != "$GITHUB_SHA" ]; then + echo "::error::$tag already names $already, not $GITHUB_SHA (this upload did go through)" + exit 1 + fi + echo "$tag was already written by an earlier attempt" + else + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$tag" \ + -m "$version, build $build_number, uploaded to App Store Connect" \ + -m "run: $GITHUB_RUN_ID" + git push origin "$tag" + fi + echo "\`$GITHUB_SHA\` is \`$tag\`" >> "$GITHUB_STEP_SUMMARY" + + # a draft creates no tag; publishing it does. --target takes the sha, not a + # branch, which would resolve to whatever main had become by then + - name: draft the github release + env: + GH_TOKEN: ${{ github.token }} + version: ${{ steps.version.outputs.version }} + run: | + .github/scripts/changelog-section.py --version "$version" > "${RUNNER_TEMP}/notes.md" + + if gh release view "$version" > /dev/null 2>&1; then + gh release edit "$version" --target "$GITHUB_SHA" --notes-file "${RUNNER_TEMP}/notes.md" + else + # --generate-notes appends the pull requests below the changelog section + gh release create "$version" \ + --draft \ + --target "$GITHUB_SHA" \ + --title "$version" \ + --notes-file "${RUNNER_TEMP}/notes.md" \ + --generate-notes + fi + + echo "drafted \`$version\`. publishing it writes the tag - do that once it is live." >> "$GITHUB_STEP_SUMMARY" 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..b9c1c3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,22 @@ 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`. Nothing in the build reads it, so +`project.pbxproj` keeps its `0.0.0` and the version comes from the dispatch +input. + +The heading is cut when the release is **submitted**, in one pull request that +also writes `fastlane/metadata/en-US/changelogs/.txt`. The release run +refuses a version with no section here, and makes that section the body of the +GitHub release it drafts. + +Until the release is out the section stays open: **a second build under the same +version goes under the already cut heading, not back under `Unreleased`.** Date +the heading and point its compare link at the version tag once it is live. 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..c41621c 100644 --- a/README.md +++ b/README.md @@ -64,38 +64,62 @@ 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 +``` + +It runs as three jobs: + +| job | what it does | +| --- | --- | +| `build` | one run producing both signed `.ipa`s, archived on the run | +| `upload` | one job per app, uploading its `.ipa` | +| `record` | once both landed: tag the build, draft the GitHub release | + +Both apps always go out together, and nothing chooses one: Pro and Lite are the +same app with ads and tracking switched off. + +**If one app's upload fails, press "Re-run failed jobs".** Only that upload runs +again, against the `.ipa` already built and signed - build number included, since +it is baked in at archive time - and `record` runs behind it once it lands. 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` | +| `MARKETING_VERSION` (`CFBundleShortVersionString`) | the `version` input | `0.0.0` | +| `CURRENT_PROJECT_VERSION` (`CFBundleVersion`) | one above the highest build either app has | `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 +The version in `project.pbxproj` is a placeholder that only local and CI builds +ever see; nobody bumps it, because a commit on `main` is not a release. 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. +The build number is resolved once and given to both apps, so one `(version, build)` +pair names one commit in both listings. App Store Connect only requires it to +increase, not to be contiguous, so whichever app was behind skips ahead. + `.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. +refuses runs that cannot name one; `changelog-section.py` refuses a version with +no `CHANGELOG.md` section, before anything is built, since that section becomes +the release body. Run either by hand to see what a dispatch would 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. +The `dry_run` input builds, signs and archives both `.ipa`s without uploading +either - 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, and +the only one that leaves neither tag nor draft. It needs these repository secrets: @@ -128,3 +152,38 @@ 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 ``` + +`deployPro` is `buildPro` followed by `uploadPro`, which the workflow runs as +separate jobs. `uploadPro` takes the `.ipa` already in `build/` rather than making +one, and `resolveBuildNumber` prints the number both apps would get. + +### 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. That is what happened to `1.37`. Tags are +written afterwards instead, in two kinds: + +| tag | who writes it | what it means | +| --- | --- | --- | +| `build//` | the workflow, once both apps are up | this commit was uploaded as that build | +| `` | publishing the drafted release | this is what shipped | + +One build tag, not one per app, since both share a build number. It is never +moved: a rebuild gets the next number, so a version that takes three builds to +clear review leaves three build tags. A half uploaded release gets none, and +neither does a lane run locally. + +**The version tag is written neither by hand nor by the workflow.** `record` drafts +a GitHub release named `` - the changelog section with the generated list +of pull requests below it - pointing at the built commit. A draft creates no tag; +publishing it does, at exactly that commit: + +```sh +gh release edit 1.38 --draft=false +``` + +That step stays human because App Store Connect is the only thing that knows a +build went live. A rebuild re-points the same draft rather than making a second +one, and if Pro clears review while Lite does not, wait: the build tags already +record what went out. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index e715640..4b78f55 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -18,19 +18,66 @@ require "tmpdir" default_platform(:ios) APPS = { - pro: { scheme: "ODR Full", app_identifier: "at.tomtasche.reader" }, - lite: { scheme: "ODR Lite", app_identifier: "at.tomtasche.reader.lite1" }, + pro: { name: "pro", scheme: "ODR Full", app_identifier: "at.tomtasche.reader" }, + lite: { name: "lite", scheme: "ODR Lite", app_identifier: "at.tomtasche.reader.lite1" }, }.freeze +# gitignored, and where the release workflow archives the .ipa from +IPA_DIR = "build".freeze + +def dry_run? + ENV["ODR_DRY_RUN"].to_s.strip == "true" +end + +def github_output(name, value) + output = ENV["GITHUB_OUTPUT"] + File.open(output, "a") { |out| out.write("#{name}=#{value}\n") } if output +end + platform :ios do - desc "Push a new release build of the paid app to the App Store" - lane :deployPro do |options| - deploy(options.merge(APPS[:pro])) + desc "Build a signed .ipa of the paid app" + lane :buildPro do + build_ipa(APPS[:pro]) + end + + desc "Build a signed .ipa of the ad supported app" + lane :buildLite do + build_ipa(APPS[:lite]) + end + + desc "Upload an already built Pro .ipa to App Store Connect" + lane :uploadPro do + upload_ipa(APPS[:pro]) + end + + desc "Upload an already built Lite .ipa" + lane :uploadLite do + upload_ipa(APPS[:lite]) end - desc "Push a new release build of the ad supported app to the App Store" - lane :deployLite do |options| - deploy(options.merge(APPS[:lite])) + desc "Build and upload the paid app" + lane :deployPro do + build_ipa(APPS[:pro]) + upload_ipa(APPS[:pro]) unless dry_run? + end + + desc "Build and upload the ad supported app" + lane :deployLite do + build_ipa(APPS[:lite]) + upload_ipa(APPS[:lite]) unless dry_run? + end + + desc "Print the build number both apps would get" + lane :resolveBuildNumber do + key_path = api_key_file + begin + number = next_build_number(key: asc_key(path: key_path)) + UI.message("building both apps as build #{number}") + github_output("build_number", number) + number + ensure + FileUtils.remove_entry(File.dirname(key_path), true) + end end lane :tests do @@ -49,8 +96,7 @@ platform :ios do # the .p8 file. # # It is written to a private temporary file because that is the shape - # app_store_connect_api_key takes it in; the lane removes it again when it is - # done. + # app_store_connect_api_key takes it in; every caller removes it again. private_lane :api_key_file do path = File.join(Dir.mktmpdir("asc-api-key"), "AuthKey_#{ENV.fetch('ASC_KEY_ID')}.p8") File.write(path, Base64.decode64(ENV.fetch("ASC_KEY_CONTENT"))) @@ -59,21 +105,41 @@ platform :ios do path end - # ODR_VERSION is the marketing version - the git tag in CI, since the - # repository has no real one. Unset it builds the 0.0.0 in project.pbxproj, - # which is only ever wanted for a dry run. + private_lane :asc_key do |options| + app_store_connect_api_key( + key_id: ENV.fetch("ASC_KEY_ID"), + issuer_id: ENV.fetch("ASC_ISSUER_ID"), + key_filepath: options[:path], + in_house: false + ) + end + + # One above the highest either app has: they share a number, so one + # (version, build) pair names one commit in both listings. App Store Connect only + # requires the number to increase, not to be contiguous. + private_lane :next_build_number do |options| + APPS.values.map { |app| + latest_testflight_build_number( + api_key: options[:key], + app_identifier: app[:app_identifier], + initial_build_number: 0 + ) + }.max + 1 + end + + # ODR_VERSION is the marketing version - the dispatch input in CI, since the + # repository has no real one. ODR_BUILD_NUMBER is the number resolved once for the + # whole release; unset, this asks App Store Connect itself, as a hand run does. # - # Both come from the environment rather than lane options, which fastlane - # passes through as strings: `dry_run:false` would arrive as "false" and read - # as true. - private_lane :deploy do |options| + # Both come from the environment rather than lane options, which fastlane passes + # through as strings: `dry_run:false` would arrive as "false" and read as true. + private_lane :build_ipa do |options| version = ENV["ODR_VERSION"].to_s.strip - dry_run = ENV["ODR_DRY_RUN"].to_s.strip == "true" # resolve-version.py refuses this in CI, but these lanes are meant to be # runnable by hand, where nothing else stops an unversioned build before the # upload rejects 0.0.0 twenty minutes in - if version.empty? && !dry_run + 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" @@ -84,21 +150,10 @@ platform :ios do profile_dir = nil begin - key = app_store_connect_api_key( - key_id: ENV.fetch("ASC_KEY_ID"), - issuer_id: ENV.fetch("ASC_ISSUER_ID"), - key_filepath: key_path, - in_house: false - ) + key = asc_key(path: key_path) - # the build number used to be bumped by hand in project.pbxproj. Derive it - # from what App Store Connect already has instead, so two releases cannot - # collide and nothing has to be committed to make a build - build_number = latest_testflight_build_number( - api_key: key, - app_identifier: options[:app_identifier], - initial_build_number: 0 - ) + 1 + build_number = ENV["ODR_BUILD_NUMBER"].to_s.strip + build_number = next_build_number(key: key) if build_number.empty? UI.message("building #{options[:scheme]} #{version.empty? ? '(unversioned)' : version} as build #{build_number}") @@ -141,27 +196,41 @@ platform :ios do signingStyle: "manual", provisioningProfiles: { options[:app_identifier] => profile_name }, }, - xcargs: xcargs.join(" ") + xcargs: xcargs.join(" "), + # a name per app: one job builds both, and gym would otherwise write one + # over the other + output_directory: IPA_DIR, + output_name: "#{options[:name]}.ipa" ) - - 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. deliver's option is - # submit_for_review; skip_submission is pilot's and is rejected here. - submit_for_review: false, - precheck_include_in_app_purchases: false - ) - end ensure FileUtils.remove_entry(File.dirname(key_path), true) FileUtils.remove_entry(profile_dir, true) if profile_dir end end + + # Takes the .ipa built earlier rather than building one, so a failed upload can be + # retried against the same bytes. Needs no keychain and no profile: the archive is + # already signed. + private_lane :upload_ipa do |options| + ipa = File.join(IPA_DIR, "#{options[:name]}.ipa") + UI.user_error!("no #{ipa} to upload - run the matching build lane first") unless File.exist?(ipa) + + key_path = api_key_file + begin + upload_to_app_store( + api_key: asc_key(path: key_path), + app_identifier: options[:app_identifier], + ipa: ipa, + skip_screenshots: true, + skip_metadata: true, + # uploading is not the same as shipping: promoting a build stays a + # deliberate step in App Store Connect. deliver's option is + # submit_for_review; skip_submission is pilot's and is rejected here. + submit_for_review: false, + precheck_include_in_app_purchases: false + ) + ensure + FileUtils.remove_entry(File.dirname(key_path), true) + end + end end