diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index 0fd728242d..5748ca31c3 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -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 diff --git a/.github/workflows/build-publish-to-mac-store-on-release.yml b/.github/workflows/build-publish-to-mac-store-on-release.yml index b46a5c7938..c7f6e4be25 100644 --- a/.github/workflows/build-publish-to-mac-store-on-release.yml +++ b/.github/workflows/build-publish-to-mac-store-on-release.yml @@ -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 diff --git a/.gitignore b/.gitignore index 949b92f904..b8abb40d27 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index 1a6358ccdd..e19c1eb1db 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -217,7 +217,9 @@ GEM xcpretty (~> 0.2, >= 0.0.7) PLATFORMS + arm64-darwin ruby + x86_64-darwin x86_64-linux DEPENDENCIES diff --git a/docs/apple-release-automation.md b/docs/apple-release-automation.md new file mode 100644 index 0000000000..5795c978f4 --- /dev/null +++ b/docs/apple-release-automation.md @@ -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** + `/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 (~1–2 + 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. diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 0000000000..bcb4b4394f --- /dev/null +++ b/fastlane/Fastfile @@ -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 /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 /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 diff --git a/tools/prepare-appstore-release-notes.js b/tools/prepare-appstore-release-notes.js new file mode 100644 index 0000000000..2f5e00c917 --- /dev/null +++ b/tools/prepare-appstore-release-notes.js @@ -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//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}`);