ci: auto-submit iOS and macOS App Store builds for review (#7857)

* ci: auto-submit iOS and macOS App Store builds for review

The iOS and Mac App Store workflows previously stopped after uploading the
build to App Store Connect via altool, leaving version creation, "What's New"
and submission as manual steps.

Add fastlane lanes (ios/mac release) that upload the prebuilt .ipa / MAS .pkg
using App Store Connect API key auth (reusing the existing notarization key
secrets), push release notes derived from build/release-notes.md, wait for
processing, submit for review and flag automatic release on approval.

Final version tags submit for review; pre-release tags (RC/beta/alpha) and
manual runs only upload the build. Listing metadata and screenshots remain
curated by hand in App Store Connect.

https://claude.ai/code/session_014c1W1mX7tfvFzpZ6wyWzsJ

* ci: wire iOS and Mac Store workflows to fastlane submit lane

The previous commit added the fastlane lanes but the workflow edits were not
applied. Replace the altool validate/upload steps in the iOS and Mac App Store
workflows with the fastlane submit lane: install fastlane, generate the App
Store "What's New" notes and run `fastlane <platform> release` with App Store
Connect API key auth.

Also extend the Mac workflow's harden-runner egress allowlist with the
rubygems and App Store Connect endpoints used by fastlane.

https://claude.ai/code/session_014c1W1mX7tfvFzpZ6wyWzsJ

* fix(ci): harden Apple App Store auto-submission after review

Address review findings on the iOS/macOS App Store automation:

- Tag gating: submit only when the tag has no "-" (final semver), instead of
  denylisting RC/beta/alpha. GitHub Actions contains() is case-sensitive and
  the repo's RC tags are mostly lowercase (-rc.N), so the old guard would have
  auto-submitted release candidates to production review.
- Fastfile: set skip_metadata so deliver no longer reads back and re-uploads
  curated listing fields; push only "What's New" via an inline release_notes
  hash. Warn against verbose mode (can dump the API key).
- Gemfile.lock: add arm64-darwin/x86_64-darwin platforms so bundle install
  works on the macOS runners.
- Workflows: install deps via pinned ruby/setup-ruby (bundler cache), and
  resolve the artifact path with a strict nullglob check (exactly one match)
  instead of ls | head.
- release-notes script: tighten emphasis regexes so stray * / _ (globs,
  snake_case) survive, anchor footer patterns so legitimate "download" lines
  are not dropped, and drop a duplicate mkdir.
- Docs: document the hyphen-based gate, single-use build numbers, automatic
  release behavior and inline validation.

https://claude.ai/code/session_014c1W1mX7tfvFzpZ6wyWzsJ

* fix(ci): correct deliver metadata + setup-ruby version (second review pass)

Two bugs introduced by the previous review-fix commit, both confirmed against
upstream source:

- Fastfile: skip_metadata: true makes deliver's upload_metadata return early
  (verified in fastlane 2.225.0 deliver/lib/deliver/upload_metadata.rb), so the
  "What's New" notes were never uploaded. Revert to metadata_path pointing at a
  dir that contains only <locale>/release_notes.txt; load_from_filesystem reads
  only that file (next unless File.exist?) with no remote read-back, so other
  listing fields stay untouched. Removed the now-unused inline release_notes
  helper.
- Workflows: ruby/setup-ruby throws when ruby-version is unset and no
  .ruby-version file exists (it does not fall back to system Ruby). Pin
  ruby-version: '3.3' in both workflows.

Docs updated to match the corrected metadata approach.

https://claude.ai/code/session_014c1W1mX7tfvFzpZ6wyWzsJ

* fix(ci): remove invalid wait_for_uploaded_build from deliver lanes

wait_for_uploaded_build is a pilot/upload_to_testflight option, not a deliver one. Passing it to upload_to_app_store makes fastlane raise on the unknown key and fail both iOS and macOS release lanes on every run. deliver already waits for the build to finish processing during submit (select_build -> wait_for_build_processing_to_be_complete), so no replacement is needed.

Also pin the ruby/setup-ruby comment to its resolved version (v1.310.0), and slice the App Store release notes by code point so a multi-byte character is never split at the 4000-char cap.

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Johannes Millan 2026-06-01 11:42:16 +02:00 committed by GitHub
parent 24725c9360
commit dc3523b1c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 353 additions and 26 deletions

View file

@ -208,22 +208,38 @@ jobs:
name: sup-ios-release
path: ${{ runner.temp }}/ipa-output/*.ipa
- name: Validate App
run: |
xcrun altool --type ios --validate-app \
-f "$RUNNER_TEMP/ipa-output/"*.ipa \
-u "${{ secrets.APPLE_ID }}" \
-p "${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}"
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
- name: Set up Ruby and fastlane
# Pinned by SHA per repo policy. ruby-version is required: with no
# .ruby-version file in the repo the action errors out rather than
# using system Ruby. bundler-cache runs `bundle install` and caches the
# ~210-gem fastlane tree so it isn't reinstalled each run.
uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0
with:
ruby-version: '3.3'
bundler-cache: true
- name: Upload to App Store Connect
run: |
xcrun altool --type ios --upload-app \
-f "$RUNNER_TEMP/ipa-output/"*.ipa \
-u "${{ secrets.APPLE_ID }}" \
-p "${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}"
- name: Prepare App Store release notes
run: node tools/prepare-appstore-release-notes.js
# Upload the IPA to App Store Connect and (for final release tags) submit
# it for review with automatic release on approval. Pre-release tags (any
# tag containing "-", e.g. -rc.0/-beta.1) and manual runs only upload the
# build (TestFlight). The "-" check is case-insensitive by construction,
# unlike contains(...,'RC'); the repo's RC tags are mostly lowercase.
- name: Upload to App Store Connect and submit for review
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
ASC_KEY_ID: ${{ secrets.mac_api_key_id }}
ASC_ISSUER_ID: ${{ secrets.mac_api_key_issuer_id }}
ASC_KEY_CONTENT: ${{ secrets.mac_api_key }}
SUBMIT_FOR_REVIEW: ${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }}
run: |
set -euo pipefail
shopt -s nullglob
ipas=("$RUNNER_TEMP"/ipa-output/*.ipa)
if [ ${#ipas[@]} -ne 1 ]; then
echo "Expected exactly 1 .ipa, found ${#ipas[@]}: ${ipas[*]:-none}"
exit 1
fi
export IPA_PATH="${ipas[0]}"
echo "Submitting $IPA_PATH (submit_for_review=$SUBMIT_FOR_REVIEW)"
bundle exec fastlane ios release

View file

@ -23,8 +23,13 @@ jobs:
github.com:443
objects.githubusercontent.com:443
registry.npmjs.org:443
rubygems.org:443
index.rubygems.org:443
www.apple.com:443
appstoreconnect.apple.com:443
api.appstoreconnect.apple.com:443
contentdelivery.itunes.apple.com:443
idmsa.apple.com:443
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
@ -172,14 +177,38 @@ jobs:
- run: ls .tmp/app-builds
shell: bash
- name: Validate App
run: ls .tmp/app-builds; ls .tmp/app-builds/mas-universal; xcrun altool --type macos --validate-app -f .tmp/app-builds/mas-universal/super*.pkg -u ${{secrets.APPLE_ID}} -p ${{secrets.APPLE_APP_SPECIFIC_PASSWORD}}
env:
APPLE_ID: ${{secrets.APPLE_ID}}
APPLE_APP_SPECIFIC_PASSWORD: ${{secrets.APPLE_APP_SPECIFIC_PASSWORD}}
- name: Set up Ruby and fastlane
# Pinned by SHA per repo policy. ruby-version is required: with no
# .ruby-version file in the repo the action errors out rather than
# using system Ruby. bundler-cache runs `bundle install` and caches the
# ~210-gem fastlane tree so it isn't reinstalled each run.
uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0
with:
ruby-version: '3.3'
bundler-cache: true
- name: Push to Store
run: xcrun altool --type macos --upload-app -f .tmp/app-builds/mas-universal/super*.pkg -u ${{secrets.APPLE_ID}} -p ${{secrets.APPLE_APP_SPECIFIC_PASSWORD}}
- name: Prepare App Store release notes
run: node tools/prepare-appstore-release-notes.js
# Upload the MAS .pkg to App Store Connect and (for final release tags)
# submit it for review with automatic release on approval. Pre-release
# tags (any tag containing "-", e.g. -rc.0/-beta.1) and manual runs only
# upload the build. The "-" check is case-insensitive by construction,
# unlike contains(...,'RC'); the repo's RC tags are mostly lowercase.
- name: Upload to App Store Connect and submit for review
env:
APPLE_ID: ${{secrets.APPLE_ID}}
APPLE_APP_SPECIFIC_PASSWORD: ${{secrets.APPLE_APP_SPECIFIC_PASSWORD}}
ASC_KEY_ID: ${{ secrets.mac_api_key_id }}
ASC_ISSUER_ID: ${{ secrets.mac_api_key_issuer_id }}
ASC_KEY_CONTENT: ${{ secrets.mac_api_key }}
SUBMIT_FOR_REVIEW: ${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }}
run: |
set -euo pipefail
shopt -s nullglob
pkgs=(.tmp/app-builds/mas-universal/super*.pkg)
if [ ${#pkgs[@]} -ne 1 ]; then
echo "Expected exactly 1 .pkg, found ${#pkgs[@]}: ${pkgs[*]:-none}"
exit 1
fi
export PKG_PATH="${pkgs[0]}"
echo "Submitting $PKG_PATH (submit_for_review=$SUBMIT_FOR_REVIEW)"
bundle exec fastlane mac release

3
.gitignore vendored
View file

@ -122,3 +122,6 @@ data/
/.secrets.example
.worktrees/
# Generated App Store Connect "What's New" notes (see tools/prepare-appstore-release-notes.js)
/fastlane/appstore_metadata

View file

@ -217,7 +217,9 @@ GEM
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
arm64-darwin
ruby
x86_64-darwin
x86_64-linux
DEPENDENCIES

View file

@ -0,0 +1,96 @@
# Apple (iOS & macOS) release automation
Pushing a final version tag (`vX.Y.Z`) builds, signs, uploads **and submits**
the iOS and macOS App Store builds for review, set to release automatically once
Apple approves them. The only step that is not automated is Apple's human
review.
## Pipeline
| Target | Workflow | Output |
| ---------------------------------------------------- | ------------------------------------------------------------- | ------------------------------ |
| iOS App Store | `.github/workflows/build-ios.yml` | `.ipa` → App Store Connect |
| Mac App Store | `.github/workflows/build-publish-to-mac-store-on-release.yml` | MAS `.pkg` → App Store Connect |
| Mac direct download (notarized DMG/zip, auto-update) | `.github/workflows/build.yml` (`mac-bin`) | GitHub release asset |
On a tag push each workflow builds and signs the artifact, then runs a fastlane
lane (`fastlane/Fastfile`, `ios release` / `mac release`) that:
1. Uploads the artifact to App Store Connect. Apple's binary validation runs
inline during the upload (this replaces the previous standalone
`altool --validate-app` step).
2. Pushes only the "What's New" release notes (derived from
`build/release-notes.md` by `tools/prepare-appstore-release-notes.js`). The
lane points `metadata_path` at a dir containing **only**
`<locale>/release_notes.txt`; deliver reads just that file and skips every
other field (no remote read-back), so the description, keywords, screenshots,
… curated by hand in App Store Connect are left untouched. (`skip_metadata`
is intentionally **not** set — it would make deliver upload no notes at all.)
3. Waits for App Store Connect to finish processing the build.
4. Submits the version for review with **automatic release on approval**.
`build/release-notes.md` is a committed snapshot regenerated at release time
(see `tools/release-notes.js`). If a tag is pushed without that file refreshed
for the new version, stale notes upload silently — make sure the release-notes
commit lands before tagging.
### Submit vs. upload-only
`SUBMIT_FOR_REVIEW` is computed per run as
`startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-')`:
- **Final tag** (`vX.Y.Z`, no hyphen) → upload **and** submit for review.
- **Pre-release tag** (any tag containing `-`, e.g. `v18.0.0-rc.0`,
`v17.0.0-RC.13`, `-beta.1`, `-alpha.0`) or **manual `workflow_dispatch`**
upload only (build lands in App Store Connect / TestFlight, no store
submission).
> The gate keys on the presence of `-` rather than denylisting `RC`/`beta`/
> `alpha`, because GitHub Actions `contains()` is case-sensitive and this repo's
> RC tags are predominantly **lowercase** `-rc.N`. Every pre-release tag in the
> repo's history contains `-`; no final tag does.
## Required secrets
Authentication uses an **App Store Connect API key** (reused from the
notarization secrets), which is more robust in CI than an Apple ID +
app-specific password:
| Secret | Used as | Purpose |
| ----------------------- | ----------------- | ----------------------------------------------------------------------------------------------- |
| `mac_api_key` | `ASC_KEY_CONTENT` | Contents of the `.p8` key file (raw PEM, including the `-----BEGIN/END PRIVATE KEY-----` lines) |
| `mac_api_key_id` | `ASC_KEY_ID` | API key id |
| `mac_api_key_issuer_id` | `ASC_ISSUER_ID` | API issuer id |
> **Important:** the API key must belong to a user with the **App Manager** role
> (or higher). A key with only the **Developer** role can upload/notarize but
> **cannot create a version or submit it for review**. If submission fails with
> a permissions error, mint a new key with the App Manager role and update the
> three secrets above.
## Caveats
- **Apple review is the only manual gate** — it is performed by humans (~12
days) and can be rejected. Everything up to and including submission is
automated.
- **`automatic_release: true`** ships the version to 100% of users the moment
Apple approves it (no manual "Release this version" click, no staged
rollout). If you'd prefer a human go-live or phased rollout, set
`automatic_release: false` (and/or `phased_release: true` for iOS) in
`fastlane/Fastfile`.
- **Build numbers are single-use.** If the lane fails _after_ the binary
uploads but _before_ the submission completes (network drop, App-Manager-role
error, export-compliance pause), simply re-running won't work — App Store
Connect rejects a duplicate build number. Recovery means finishing the
submission by hand in App Store Connect, or bumping the build number and
re-tagging.
- **"What's New" locales:** only `en-US` notes are generated. If the App Store
listing has additional active locales, Apple may require "What's New" text for
them on submission. Add more `release_notes.txt` files (or extend
`tools/prepare-appstore-release-notes.js`) as needed.
- **Export compliance:** if `ios/App/App/Info.plist` does not set
`ITSAppUsesNonExemptEncryption`, App Store Connect will pause the submission
to ask the encryption question. Set it once to keep submission fully hands-off.
- **Never enable fastlane verbose mode** (`--verbose` / `FASTLANE_VERBOSE`) in
these lanes — verbose output can dump the deliver options hash, which carries
the API key material.

91
fastlane/Fastfile Normal file
View file

@ -0,0 +1,91 @@
# iOS and macOS App Store submission lanes.
#
# The signed release binaries are produced in CI:
# - iOS: .github/workflows/build-ios.yml -> .ipa
# - macOS: .github/workflows/build-publish-to-mac-store-on-release.yml -> MAS .pkg
#
# These lanes take the finished artifact, upload it to App Store Connect, set
# the "What's New" text, submit the version for review and flag it to be
# released automatically once Apple approves it. The only step that cannot be
# automated is Apple's human review itself.
#
# Authentication uses an App Store Connect API key (not an Apple ID +
# app-specific password), which is far more robust in CI.
#
# IMPORTANT: the API key must belong to a user with the "App Manager" role (or
# higher). A key with only the "Developer" role can upload/notarize but cannot
# create a version or submit it for review.
#
# SECURITY: never enable verbose mode (--verbose / FASTLANE_VERBOSE / verbose:
# true) in these lanes. Verbose output can dump the deliver options hash, which
# contains the App Store Connect API key material.
#
# Listing metadata (description, keywords, screenshots, ...) is managed manually
# in App Store Connect and is intentionally NOT overwritten here. METADATA_PATH
# is a dedicated dir that contains ONLY <locale>/release_notes.txt, so deliver's
# load_from_filesystem reads just the release notes (it skips fields with no
# local file: `next unless File.exist?`, and upload skips nil fields). There is
# no remote read-back, so other listing fields are left untouched. Do NOT set
# `skip_metadata` here: it makes deliver return early and upload no release notes
# at all. `skip_screenshots` is safe — screenshots live elsewhere.
#
# Required env vars (wired from GitHub secrets in the workflows):
# ASC_KEY_ID App Store Connect API key id (secrets.mac_api_key_id)
# ASC_ISSUER_ID App Store Connect API issuer id (secrets.mac_api_key_issuer_id)
# ASC_KEY_CONTENT Contents of the .p8 key file (secrets.mac_api_key)
# IPA_PATH Path to the built .ipa (ios lane)
# PKG_PATH Path to the built MAS .pkg (mac lane)
# Optional:
# METADATA_PATH release-notes dir (default: fastlane/appstore_metadata)
# SUBMIT_FOR_REVIEW "false" to only upload the build without submitting
APP_IDENTIFIER = 'com.super-productivity.app'
def asc_api_key
app_store_connect_api_key(
key_id: ENV.fetch('ASC_KEY_ID'),
issuer_id: ENV.fetch('ASC_ISSUER_ID'),
key_content: ENV.fetch('ASC_KEY_CONTENT'),
is_key_content_base64: false,
)
end
def submit_for_review?
ENV.fetch('SUBMIT_FOR_REVIEW', 'true') != 'false'
end
# Shared deliver options. `extra` carries the platform-specific binary path.
def deliver_options(extra)
{
api_key: asc_api_key,
app_identifier: APP_IDENTIFIER,
# Dir holding only <locale>/release_notes.txt -> deliver uploads just the
# "What's New" text and leaves every other listing field as-is. (Do not add
# skip_metadata; see the header comment.)
metadata_path: ENV.fetch('METADATA_PATH', 'fastlane/appstore_metadata'),
skip_screenshots: true,
submit_for_review: submit_for_review?,
automatic_release: true,
# Precheck inspects listing metadata we don't manage from here.
run_precheck_before_submit: false,
submission_information: {
add_id_info_uses_idfa: false,
},
# Non-interactive: skip the HTML report confirmation prompt.
force: true,
}.merge(extra)
end
platform :ios do
desc 'Upload the prebuilt iOS .ipa to App Store Connect and submit for review'
lane :release do
upload_to_app_store(deliver_options(platform: 'ios', ipa: ENV.fetch('IPA_PATH')))
end
end
platform :mac do
desc 'Upload the prebuilt macOS .pkg to App Store Connect and submit for review'
lane :release do
upload_to_app_store(deliver_options(platform: 'osx', pkg: ENV.fetch('PKG_PATH')))
end
end

View file

@ -0,0 +1,90 @@
#!/usr/bin/env node
// Derive a plain-text "What's New" file for App Store Connect from the
// generated GitHub release notes (build/release-notes.md).
//
// App Store Connect release notes are plain text (no markdown), so this strips
// headings, emphasis and links and drops the GitHub-only downloads footer. The
// result is written for the App Store deliver lanes (fastlane/Fastfile).
//
// Usage: node tools/prepare-appstore-release-notes.js [outFile] [locale]
// outFile defaults to fastlane/appstore_metadata/<locale>/release_notes.txt
// locale defaults to en-US
const fs = require('fs');
const path = require('path');
const ROOT_DIR = path.join(__dirname, '..');
const SOURCE_FILE = path.join(ROOT_DIR, 'build', 'release-notes.md');
// App Store Connect caps "What's New" at 4000 characters.
const MAX_CHARS = 4000;
const locale = process.argv[3] || 'en-US';
const outFile =
process.argv[2] ||
path.join(ROOT_DIR, 'fastlane', 'appstore_metadata', locale, 'release_notes.txt');
// GitHub-only footer lines that don't belong in an App Store "What's New".
// Anchored to the start of the line so they can't swallow a legitimate content
// line that merely mentions "download" (e.g. a fix about a download dialog).
const FOOTER_PATTERNS = [
/check the wiki/i,
/^\s*for all current downloads/i,
/^\s*for the latest version/i,
/^\s*(see|view|read) the (full )?changelog/i,
/releases\/latest\b/i,
/^\s*visit:?\s*$/i,
/^\s*https?:\/\/\S+\s*$/i,
];
const toPlainText = (markdown) =>
markdown
.split('\n')
.filter((line) => !FOOTER_PATTERNS.some((re) => re.test(line)))
.map((line) =>
line
// headings: "## Features" -> "Features"
.replace(/^#{1,6}\s*/, '')
// bold: "**text**" -> "text"
.replace(/\*\*(?=\S)([^*\n]+?)\*\*/g, '$1')
// italic "*text*" -> "text" (markers must hug non-space and be bounded
// by whitespace/edges, so stray asterisks like "*.md" or "a * b" survive)
.replace(/(^|\s)\*(?=\S)([^*\n]+?)\*(?=\s|$)/g, '$1$2')
// italic "_text_" -> "text" (intra-word underscores like snake_case
// are left untouched by the boundary requirements)
.replace(/(^|\s)_(?=\S)([^_\n]+?)_(?=\s|$)/g, '$1$2')
// links: "[text](url)" -> "text"
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
// list bullets: "- item" / "* item" -> "• item"
.replace(/^\s*[-*]\s+/, '• '),
)
.join('\n')
// collapse 3+ blank lines down to a single blank line
.replace(/\n{3,}/g, '\n\n')
.trim();
// Always ensure the deliver metadata dir exists so the App Store lanes never
// fail on a missing metadata_path; an empty dir simply leaves "What's New"
// untouched in App Store Connect.
fs.mkdirSync(path.dirname(outFile), { recursive: true });
if (!fs.existsSync(SOURCE_FILE)) {
console.error(`No release notes source found at ${SOURCE_FILE}; skipping.`);
process.exit(0);
}
let text = toPlainText(fs.readFileSync(SOURCE_FILE, 'utf8'));
if (!text) {
console.error('Release notes are empty after processing; skipping.');
process.exit(0);
}
const chars = [...text];
if (chars.length > MAX_CHARS) {
// Slice by code points (not UTF-16 units) so a multi-byte character (e.g. an
// emoji surrogate pair) is never cut in half. Keeps us within ASC's cap.
text = `${chars.slice(0, MAX_CHARS - 1).join('').trimEnd()}`;
}
fs.writeFileSync(outFile, `${text}\n`, 'utf8');
console.log(`Wrote App Store release notes (${text.length} chars) to ${outFile}`);