From 14290b6c0cb399425c3caf8d37775aec092262da Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:49:06 -0300 Subject: [PATCH 1/3] ci: replace Create iOS Certificates placeholder with the real workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workflow was authored in #510 (merchant POS app), which won't merge soon. It's app-agnostic infra — bundle id is a workflow_dispatch input — so extract it to main along with the two things it needs to run: - scripts/create-certificates.sh: --no-pr mode (pre-creates the certs branch from master over SSH, no GitHub token) and automatic App Store Connect API-key auth when APPLE_KEY_* are set. - fastlane create_certs lane: match with API-key auth, no Apple ID / 2FA. The lane calls setup_ci() so match has a temporary keychain to import the .p12 into — release_testflight already does this; without it match fails on a macOS runner with "User interaction is not allowed". Co-Authored-By: Claude --- .github/workflows/create-ios-certs.yaml | 74 ++++++++++- README.md | 24 +++- fastlane/Fastfile | 36 ++++++ scripts/create-certificates.sh | 165 +++++++++++++++++------- 4 files changed, 246 insertions(+), 53 deletions(-) diff --git a/.github/workflows/create-ios-certs.yaml b/.github/workflows/create-ios-certs.yaml index 2d250c54b..d90c7dfd0 100644 --- a/.github/workflows/create-ios-certs.yaml +++ b/.github/workflows/create-ios-certs.yaml @@ -1,15 +1,79 @@ name: Create iOS Certificates +run-name: "Create iOS Certificates - ${{ inputs.bundle-id }}" + +# Creates the signing certificates / provisioning profiles for an iOS app on CI, so +# developers don't need Ruby / fastlane / CocoaPods locally. Auth uses the App Store +# Connect API key (no Apple ID / 2FA). +# +# PREREQUISITE: the app's bundle id / App ID must already be registered in the Apple +# Developer Portal, and the App Store Connect app record must already exist. `match` does +# NOT create the identifier — it fails with "Couldn't find bundle identifier" otherwise. +# Both are created manually (App Store Connect → Apps → +) because fastlane `produce` only +# supports Apple ID + 2FA auth. + permissions: contents: read on: workflow_dispatch: + inputs: + bundle-id: + description: "App bundle identifier (e.g. com.reown.merchantpos)" + required: true + type: string + certs-repo: + description: "Match certificates repo (owner/repo)" + required: true + default: 'reown-com/mobile-match' + type: string + match-types: + description: "Comma-separated match types to create" + required: true + default: 'appstore,development' + type: string jobs: - placeholder: - name: Placeholder Job - runs-on: ubuntu-latest + create-certs: + runs-on: macos-latest-xlarge steps: - - name: Do nothing - run: echo "Placeholder to enable manual dispatch from feature branches. The real workflow lives on the feature branch — run it via 'Use workflow from'." + - name: Checkout + uses: actions/checkout@v4 + + - name: webfactory/ssh-agent + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.MATCH_SSH_KEY }} + + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.0 + bundler-cache: true + + # Certs are pushed to a branch over SSH (MATCH_SSH_KEY); no GitHub token is used. + # A teammate opens & merges the PR in the certs repo (see job summary). + - name: Create certificates + env: + APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }} + APPLE_ISSUER_ID: ${{ secrets.APPLE_ISSUER_ID }} + APPLE_KEY_CONTENT: ${{ secrets.APPLE_KEY_CONTENT }} + MATCH_PASSWORD: ${{ secrets.MATCH_KEYCHAIN_PASSWORD }} + run: | + chmod +x scripts/create-certificates.sh + IFS=',' read -ra TYPES <<< "${{ inputs.match-types }}" + { + echo "### ⚠️ Action required: merge the certificates PR(s)" + echo "Certs were pushed to branch(es) in \`${{ inputs.certs-repo }}\`. The release" + echo "workflow will fail until they are merged into \`master\`:" + echo "" + } >> "$GITHUB_STEP_SUMMARY" + for type in "${TYPES[@]}"; do + type="$(echo "$type" | xargs)" # trim whitespace + echo "::group::Creating ${type} certificates" + ./scripts/create-certificates.sh "${{ inputs.certs-repo }}" "${{ inputs.bundle-id }}" "" "$type" --no-pr + echo "::endgroup::" + branch="certs/add-${{ inputs.bundle-id }}-${type}" + compare="https://github.com/${{ inputs.certs-repo }}/compare/master...${branch}?expand=1" + echo "- **${type}**: [open PR for \`${branch}\`](${compare})" >> "$GITHUB_STEP_SUMMARY" + done diff --git a/README.md b/README.md index dcaae21cb..8f720c037 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,22 @@ bundle install ### Creating Certificates for a New App -Use the provided script to create new certificates and provisioning profiles. The script handles creating a branch, running fastlane match, and creating a PR (required since the certificates repo has branch protection): +**Preferred: do it on CI.** Run the **Create iOS Certificates** GitHub Action +(`.github/workflows/create-ios-certs.yaml`) — it creates the app's signing certificates and +provisioning profiles via the App Store Connect API key (no Apple ID / 2FA), so you don't need +Ruby, fastlane, or CocoaPods locally. It pushes a branch to the certs repo over SSH and links a +compare URL in the job summary; a teammate opens and merges that PR. + +> **Prerequisite:** the App ID must already be registered in the Developer Portal and the App +> Store Connect app record must already exist. `match` does not create the identifier, and +> fastlane `produce` can't authenticate with the API key — so both are created manually +> (Developer Portal → Identifiers, then App Store Connect → Apps → +). + +**Local fallback.** Use the provided script to create new certificates and provisioning profiles. +The script handles creating a branch, running fastlane match, and creating a PR (required since +the certificates repo has branch protection). When the App Store Connect API key env vars +(`APPLE_KEY_ID`, `APPLE_ISSUER_ID`, `APPLE_KEY_CONTENT`) are set it uses API-key auth (no 2FA); +otherwise it falls back to interactive Apple ID auth: ```bash # Make the script executable (first time only) @@ -76,8 +91,11 @@ chmod +x scripts/create-certificates.sh **Example:** ```bash -./scripts/create-certificates.sh reown-com/mobile-certificates com.reown.myapp dev@reown.com appstore -./scripts/create-certificates.sh reown-com/mobile-certificates com.reown.myapp dev@reown.com development +./scripts/create-certificates.sh reown-com/mobile-match com.reown.myapp dev@reown.com appstore +./scripts/create-certificates.sh reown-com/mobile-match com.reown.myapp dev@reown.com development + +# With API-key auth (no 2FA), the Apple email is optional — pass "" instead: +./scripts/create-certificates.sh reown-com/mobile-match com.reown.myapp "" appstore --auto-merge ``` > **Note:** Requires [GitHub CLI](https://cli.github.com/) (`gh`) to be installed and authenticated. By default, the script creates a PR that requires manual merge. Use `--auto-merge` to automatically merge. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index dcbcad566..cc7890af3 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -190,6 +190,42 @@ platform :ios do UI.success("✅ Simulator .app: #{app_path}") end + # NOTE: There is intentionally no `create_app` lane. fastlane `produce` only supports + # Apple ID + password (web session / 2FA) auth — it ignores the App Store Connect API + # key — so it can't run unattended on CI. Create the App Store Connect app record + # manually (App Store Connect → Apps → +), then use create_certs below for signing. + + # Create / sync certificates and provisioning profiles for an app, pushing them + # to a branch in the match git repo. Uses the App Store Connect API key (no 2FA) + # so it can run unattended on CI. The PR/merge of that branch is handled outside + # this lane (see scripts/create-certificates.sh). + lane :create_certs do + # Temporary keychain — match needs somewhere to import the certificate on CI. + setup_ci() + + api_key = app_store_connect_api_key( + key_id: ENV["APPLE_KEY_ID"], + issuer_id: ENV["APPLE_ISSUER_ID"], + key_content: ENV["APPLE_KEY_CONTENT"], + duration: 1200, + in_house: false, + ) + + match_type = ENV["MATCH_TYPE"] || "appstore" + + match( + readonly: false, + type: match_type, + app_identifier: ENV["BUNDLE_ID"], + git_url: ENV["MATCH_GIT_URL"], + git_branch: ENV["MATCH_GIT_BRANCH"], + api_key: api_key, + include_all_certificates: true, + ) + + UI.success("✅ #{match_type} certificates synced for #{ENV['BUNDLE_ID']}") + end + lane :test_code_signing do ENV['XCODE_PROJECT_PATH'] = 'dapps/pos-app/ios/WPay.xcodeproj' ENV['BUNDLE_ID'] = 'com.reown.mobilepos' diff --git a/scripts/create-certificates.sh b/scripts/create-certificates.sh index e53f12a0e..07c552fa6 100755 --- a/scripts/create-certificates.sh +++ b/scripts/create-certificates.sh @@ -1,91 +1,165 @@ #!/bin/bash set -e -# Usage: ./scripts/create-certificates.sh [match_type] [--auto-merge] -# Example: ./scripts/create-certificates.sh reown-com/mobile-certificates com.reown.myapp dev@reown.com appstore -# Example with auto-merge: ./scripts/create-certificates.sh reown-com/mobile-certificates com.reown.myapp dev@reown.com appstore --auto-merge +# Usage: ./scripts/create-certificates.sh [match_type] [--auto-merge] [--no-pr] +# Example (local): ./scripts/create-certificates.sh reown-com/mobile-match com.reown.myapp dev@reown.com appstore +# Example (auto): ./scripts/create-certificates.sh reown-com/mobile-match com.reown.myapp dev@reown.com appstore --auto-merge +# Example (CI): ./scripts/create-certificates.sh reown-com/mobile-match com.reown.myapp "" appstore --no-pr +# +# --no-pr: don't touch GitHub (no token needed). fastlane match pushes the certs branch over +# SSH; a human then opens and merges the PR in the certs repo. This is the CI default. CERTS_REPO=$1 BUNDLE_ID=$2 APPLE_EMAIL=$3 MATCH_TYPE=${4:-appstore} # Default to appstore AUTO_MERGE=false +SKIP_PR=false -# Check for --auto-merge flag +# Parse flags for arg in "$@"; do if [ "$arg" = "--auto-merge" ]; then AUTO_MERGE=true fi + if [ "$arg" = "--no-pr" ]; then + SKIP_PR=true + fi done -if [ -z "$CERTS_REPO" ] || [ -z "$BUNDLE_ID" ] || [ -z "$APPLE_EMAIL" ]; then - echo "Usage: $0 [match_type] [--auto-merge]" +# Decide auth mode: App Store Connect API key (CI, no 2FA) vs Apple ID (local, interactive). +# The API key is used automatically when its env vars are present. +USE_API_KEY=false +if [ -n "$APPLE_KEY_ID" ] && [ -n "$APPLE_ISSUER_ID" ] && [ -n "$APPLE_KEY_CONTENT" ]; then + USE_API_KEY=true +fi + +# apple_email is only required for interactive (Apple ID) auth. +if [ -z "$CERTS_REPO" ] || [ -z "$BUNDLE_ID" ] || { [ "$USE_API_KEY" = false ] && [ -z "$APPLE_EMAIL" ]; }; then + echo "Usage: $0 [match_type] [--auto-merge] [--no-pr]" echo " certificates_repo: GitHub repo in owner/repo format" echo " bundle_id: App bundle identifier" - echo " apple_email: Apple Developer account email" + echo " apple_email: Apple Developer account email (optional when APPLE_KEY_* env vars are set)" echo " match_type: appstore (default), development, adhoc" - echo " --auto-merge: Automatically merge the PR (default: manual merge required)" + echo " --auto-merge: Automatically merge the PR (requires a GitHub token; ignored with --no-pr)" + echo " --no-pr: Skip all GitHub API calls; match pushes the branch and a human merges the PR" exit 1 fi CERTS_GIT_URL="git@github.com:${CERTS_REPO}.git" BRANCH_NAME="certs/add-${BUNDLE_ID}-${MATCH_TYPE}" +COMPARE_URL="https://github.com/${CERTS_REPO}/compare/master...${BRANCH_NAME}?expand=1" echo "🔐 Creating certificates for ${BUNDLE_ID}" echo " Repo: ${CERTS_REPO}" -echo " Apple account: ${APPLE_EMAIL}" +echo " Apple account: ${APPLE_EMAIL:-}" echo " Type: ${MATCH_TYPE}" echo " Branch: ${BRANCH_NAME}" -echo " Auto-merge: ${AUTO_MERGE}" +echo " PR mode: $([ "$SKIP_PR" = true ] && echo 'skip (manual merge)' || echo "create$([ "$AUTO_MERGE" = true ] && echo ' + auto-merge')")" echo "" -# Cleanup function for error cases +# Cleanup function for error cases — remove the branch we created so a retry is clean. cleanup_branch() { echo "🧹 Cleaning up branch ${BRANCH_NAME}..." - gh api repos/${CERTS_REPO}/git/refs/heads/${BRANCH_NAME} -X DELETE 2>/dev/null || true + if [ "$SKIP_PR" = false ]; then + gh api repos/${CERTS_REPO}/git/refs/heads/${BRANCH_NAME} -X DELETE 2>/dev/null || true + else + git push "${CERTS_GIT_URL}" --delete "${BRANCH_NAME}" 2>/dev/null || true + fi } -# 1. Check if branch already exists -echo "🔍 Checking if branch already exists..." -if gh api repos/${CERTS_REPO}/git/ref/heads/${BRANCH_NAME} &>/dev/null; then - echo "⚠️ Branch ${BRANCH_NAME} already exists." - echo " Delete it first with: gh api repos/${CERTS_REPO}/git/refs/heads/${BRANCH_NAME} -X DELETE" - exit 1 -fi +# The branch MUST be created from master before match runs, so match adds the new +# profile on top of the existing certs (reusing the shared distribution/development +# certificate) and produces a mergeable diff. If match is pointed at a non-existent +# branch it creates an ORPHAN branch with only the new files — unmergeable, and it +# re-mints a duplicate certificate because it can't see the existing one. +if [ "$SKIP_PR" = false ]; then + # gh-based pre-create (uses a GitHub token) + echo "🔍 Checking if branch already exists..." + if gh api repos/${CERTS_REPO}/git/ref/heads/${BRANCH_NAME} &>/dev/null; then + echo "⚠️ Branch ${BRANCH_NAME} already exists." + echo " Delete it first with: gh api repos/${CERTS_REPO}/git/refs/heads/${BRANCH_NAME} -X DELETE" + exit 1 + fi -# 2. Get master SHA with error handling -echo "📌 Creating branch ${BRANCH_NAME} from master..." -MASTER_SHA=$(gh api repos/${CERTS_REPO}/git/ref/heads/master --jq '.object.sha' 2>/dev/null) -if [ -z "$MASTER_SHA" ]; then - echo "❌ Error: Failed to fetch master branch SHA from ${CERTS_REPO}" - echo " Make sure you have access to the repository and the master branch exists." - exit 1 -fi + echo "📌 Creating branch ${BRANCH_NAME} from master..." + MASTER_SHA=$(gh api repos/${CERTS_REPO}/git/ref/heads/master --jq '.object.sha' 2>/dev/null) + if [ -z "$MASTER_SHA" ]; then + echo "❌ Error: Failed to fetch master branch SHA from ${CERTS_REPO}" + echo " Make sure you have access to the repository and the master branch exists." + exit 1 + fi -# 3. Create branch -if ! gh api repos/${CERTS_REPO}/git/refs \ - -f ref="refs/heads/${BRANCH_NAME}" \ - -f sha="${MASTER_SHA}" > /dev/null 2>&1; then - echo "❌ Error: Failed to create branch ${BRANCH_NAME}" - exit 1 + if ! gh api repos/${CERTS_REPO}/git/refs \ + -f ref="refs/heads/${BRANCH_NAME}" \ + -f sha="${MASTER_SHA}" > /dev/null 2>&1; then + echo "❌ Error: Failed to create branch ${BRANCH_NAME}" + exit 1 + fi + echo " ✓ Branch created" +else + # --no-pr: pre-create the branch from master over SSH (no GitHub token needed). + echo "📌 Creating branch ${BRANCH_NAME} from master over SSH..." + WORKDIR=$(mktemp -d) + if ! git clone --depth 1 --branch master "${CERTS_GIT_URL}" "${WORKDIR}" >/dev/null 2>&1; then + echo "❌ Error: failed to clone ${CERTS_GIT_URL} (check SSH access / MATCH_SSH_KEY)" + rm -rf "${WORKDIR}" + exit 1 + fi + git -C "${WORKDIR}" checkout -b "${BRANCH_NAME}" >/dev/null 2>&1 + if ! git -C "${WORKDIR}" push origin "${BRANCH_NAME}" >/dev/null 2>&1; then + echo "❌ Error: failed to push branch ${BRANCH_NAME}." + echo " It probably already exists (e.g. from a previous run). Delete it first:" + echo " git push ${CERTS_GIT_URL} --delete ${BRANCH_NAME}" + rm -rf "${WORKDIR}" + exit 1 + fi + rm -rf "${WORKDIR}" + echo " ✓ Branch created from master" fi -echo " ✓ Branch created" # 4. Run fastlane match echo "🚀 Running fastlane match ${MATCH_TYPE}..." -if ! bundle exec fastlane match ${MATCH_TYPE} \ - --git_url "${CERTS_GIT_URL}" \ - --git_branch "${BRANCH_NAME}" \ - --username "${APPLE_EMAIL}" \ - --app_identifier "${BUNDLE_ID}"; then - echo "" - echo "❌ Error: fastlane match failed" - cleanup_branch - exit 1 +if [ "$USE_API_KEY" = true ]; then + # CI path: API-key auth via the create_certs lane (no Apple ID / 2FA). + echo " 🔑 Using App Store Connect API key auth" + if ! BUNDLE_ID="${BUNDLE_ID}" \ + MATCH_TYPE="${MATCH_TYPE}" \ + MATCH_GIT_URL="${CERTS_GIT_URL}" \ + MATCH_GIT_BRANCH="${BRANCH_NAME}" \ + bundle exec fastlane ios create_certs; then + echo "" + echo "❌ Error: fastlane create_certs failed" + cleanup_branch + exit 1 + fi +else + # Local path: interactive Apple ID auth. + if ! bundle exec fastlane match ${MATCH_TYPE} \ + --git_url "${CERTS_GIT_URL}" \ + --git_branch "${BRANCH_NAME}" \ + --username "${APPLE_EMAIL}" \ + --app_identifier "${BUNDLE_ID}"; then + echo "" + echo "❌ Error: fastlane match failed" + cleanup_branch + exit 1 + fi fi echo " ✓ Certificates created" -# 5. Create PR +# 5/6. Open (and optionally merge) the PR. Skipped entirely in --no-pr mode. +if [ "$SKIP_PR" = true ]; then + echo "" + echo "✅ Done! Certificates for ${BUNDLE_ID} have been pushed to branch ${BRANCH_NAME}." + echo "" + echo "⚠️ The release workflow will FAIL until this branch is merged into master." + echo "" + echo "📋 Next steps (a teammate with access to ${CERTS_REPO}):" + echo " 1. Open a PR: ${COMPARE_URL}" + echo " 2. Review and merge it into master." + exit 0 +fi + echo "📝 Creating pull request..." PR_URL=$(gh pr create \ --repo "${CERTS_REPO}" \ @@ -101,7 +175,6 @@ if [ $? -ne 0 ]; then fi echo " ✓ PR created: ${PR_URL}" -# 6. Merge PR (if auto-merge enabled) if [ "$AUTO_MERGE" = true ]; then echo "🔀 Merging pull request..." if ! gh pr merge "${BRANCH_NAME}" \ @@ -119,6 +192,8 @@ else echo "" echo "✅ Done! Certificates for ${BUNDLE_ID} have been created." echo "" + echo "⚠️ The release workflow will FAIL until this PR is merged into master." + echo "" echo "📋 Next steps:" echo " 1. Review the PR: ${PR_URL}" echo " 2. Merge when ready: gh pr merge ${BRANCH_NAME} --repo ${CERTS_REPO} --merge --delete-branch" From b67fa03832dfc8a16c829a6834ca9c309fedfa89 Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:26:23 -0300 Subject: [PATCH 2/3] ci: address review feedback on cert creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Workflow: pass bundle-id / certs-repo / match-types through env instead of interpolating ${{ inputs.* }} into the run: body, so an input can't break out of the command; skip empty entries from a doubled comma. - Workflow: reword the job summary. It read as a failure ("Action required"), but not opening PRs is deliberate — there is no write token for the certs repo, by design — so say so and frame the links as the expected next step. - cleanup_branch: only delete the branch while it's still identical to master. Once match has pushed, the branch is the sole record of a certificate Apple has already issued; deleting it stranded that cert and removed the "branch already exists" guard, so the retry could mint a duplicate against Apple's distribution-cert cap. - Two unreachable error handlers: under set -e a failing command substitution aborts at the assignment, so `if [ -z "$MASTER_SHA" ]` and `if [ $? -ne 0 ]` after `gh pr create` never ran. Worse, 2>&1 captured gh's error into PR_URL, so a failed PR creation exited silently with no instructions. The PR path now reports the error and says not to re-run, since the certs already exist. - README: note that gh is only needed for local runs; CI uses --no-pr. Co-Authored-By: Claude --- .github/workflows/create-ios-certs.yaml | 25 ++++++++++---- README.md | 6 +++- scripts/create-certificates.sh | 46 ++++++++++++++++++++----- 3 files changed, 61 insertions(+), 16 deletions(-) diff --git a/.github/workflows/create-ios-certs.yaml b/.github/workflows/create-ios-certs.yaml index d90c7dfd0..279ae753b 100644 --- a/.github/workflows/create-ios-certs.yaml +++ b/.github/workflows/create-ios-certs.yaml @@ -59,21 +59,32 @@ jobs: APPLE_ISSUER_ID: ${{ secrets.APPLE_ISSUER_ID }} APPLE_KEY_CONTENT: ${{ secrets.APPLE_KEY_CONTENT }} MATCH_PASSWORD: ${{ secrets.MATCH_KEYCHAIN_PASSWORD }} + # Inputs go through env, never straight into the script body — ${{ }} is + # substituted before the shell parses the line, so a quote in an input would + # otherwise break out of the command. + INPUT_BUNDLE_ID: ${{ inputs.bundle-id }} + INPUT_CERTS_REPO: ${{ inputs.certs-repo }} + INPUT_MATCH_TYPES: ${{ inputs.match-types }} run: | chmod +x scripts/create-certificates.sh - IFS=',' read -ra TYPES <<< "${{ inputs.match-types }}" + IFS=',' read -ra TYPES <<< "$INPUT_MATCH_TYPES" { - echo "### ⚠️ Action required: merge the certificates PR(s)" - echo "Certs were pushed to branch(es) in \`${{ inputs.certs-repo }}\`. The release" - echo "workflow will fail until they are merged into \`master\`:" + echo "### ✅ Certificates created — open and merge the PR(s)" + echo "This is the expected next step, not a failure: the workflow pushes the cert" + echo "branches over SSH and intentionally does **not** open PRs (it holds no write" + echo "token for \`$INPUT_CERTS_REPO\`)." + echo "" + echo "Open each PR below and merge it into \`master\` — the release workflow will" + echo "fail until you do:" echo "" } >> "$GITHUB_STEP_SUMMARY" for type in "${TYPES[@]}"; do type="$(echo "$type" | xargs)" # trim whitespace + [ -z "$type" ] && continue # tolerate a doubled comma in the input echo "::group::Creating ${type} certificates" - ./scripts/create-certificates.sh "${{ inputs.certs-repo }}" "${{ inputs.bundle-id }}" "" "$type" --no-pr + ./scripts/create-certificates.sh "$INPUT_CERTS_REPO" "$INPUT_BUNDLE_ID" "" "$type" --no-pr echo "::endgroup::" - branch="certs/add-${{ inputs.bundle-id }}-${type}" - compare="https://github.com/${{ inputs.certs-repo }}/compare/master...${branch}?expand=1" + branch="certs/add-${INPUT_BUNDLE_ID}-${type}" + compare="https://github.com/${INPUT_CERTS_REPO}/compare/master...${branch}?expand=1" echo "- **${type}**: [open PR for \`${branch}\`](${compare})" >> "$GITHUB_STEP_SUMMARY" done diff --git a/README.md b/README.md index 8f720c037..c85301f17 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,11 @@ chmod +x scripts/create-certificates.sh ./scripts/create-certificates.sh reown-com/mobile-match com.reown.myapp "" appstore --auto-merge ``` -> **Note:** Requires [GitHub CLI](https://cli.github.com/) (`gh`) to be installed and authenticated. By default, the script creates a PR that requires manual merge. Use `--auto-merge` to automatically merge. +> **Note:** These local invocations require [GitHub CLI](https://cli.github.com/) (`gh`) to be +> installed and authenticated, because the script opens the PR for you — by default one that +> needs a manual merge; pass `--auto-merge` to merge it automatically. The CI workflow instead +> passes `--no-pr`, which pushes the branch over SSH and makes no GitHub API calls at all, so it +> needs no `gh` and no token; you open that PR from the link in the job summary. ### Downloading Certificates Locally diff --git a/scripts/create-certificates.sh b/scripts/create-certificates.sh index 07c552fa6..1a9051118 100755 --- a/scripts/create-certificates.sh +++ b/scripts/create-certificates.sh @@ -57,9 +57,31 @@ echo " Branch: ${BRANCH_NAME}" echo " PR mode: $([ "$SKIP_PR" = true ] && echo 'skip (manual merge)' || echo "create$([ "$AUTO_MERGE" = true ] && echo ' + auto-merge')")" echo "" -# Cleanup function for error cases — remove the branch we created so a retry is clean. +# Cleanup for error cases — remove the branch we created so a retry is clean. +# +# Only safe while the branch is still identical to master, i.e. match never got as far as +# committing. Once match has pushed, the branch is the only record of a certificate Apple +# has already issued; deleting it strands that cert AND removes the "branch already exists" +# guard below, so a retry re-runs match and can mint a duplicate. Apple caps distribution +# certificates, so that costs a manual revoke in the portal. Keep the branch instead. cleanup_branch() { - echo "🧹 Cleaning up branch ${BRANCH_NAME}..." + local branch_sha master_sha + branch_sha=$(git ls-remote "${CERTS_GIT_URL}" "refs/heads/${BRANCH_NAME}" 2>/dev/null | cut -f1) || true + master_sha=$(git ls-remote "${CERTS_GIT_URL}" refs/heads/master 2>/dev/null | cut -f1) || true + + if [ -z "$branch_sha" ]; then + return 0 # nothing was ever pushed + fi + + if [ -n "$master_sha" ] && [ "$branch_sha" != "$master_sha" ]; then + echo "" + echo "⚠️ NOT deleting ${BRANCH_NAME} — match already pushed to it." + echo " It may hold a certificate Apple has already issued. Do NOT re-run this script;" + echo " inspect the branch first: ${COMPARE_URL}" + return 0 + fi + + echo "🧹 Cleaning up branch ${BRANCH_NAME} (unchanged from master)..." if [ "$SKIP_PR" = false ]; then gh api repos/${CERTS_REPO}/git/refs/heads/${BRANCH_NAME} -X DELETE 2>/dev/null || true else @@ -82,7 +104,10 @@ if [ "$SKIP_PR" = false ]; then fi echo "📌 Creating branch ${BRANCH_NAME} from master..." - MASTER_SHA=$(gh api repos/${CERTS_REPO}/git/ref/heads/master --jq '.object.sha' 2>/dev/null) + # `|| true` matters: under `set -e` a failing command substitution aborts the script at + # the assignment, so without it the diagnostic below is unreachable and gh's own error is + # swallowed by 2>/dev/null — you'd get a silent exit 1. + MASTER_SHA=$(gh api repos/${CERTS_REPO}/git/ref/heads/master --jq '.object.sha' 2>/dev/null) || true if [ -z "$MASTER_SHA" ]; then echo "❌ Error: Failed to fetch master branch SHA from ${CERTS_REPO}" echo " Make sure you have access to the repository and the master branch exists." @@ -161,16 +186,21 @@ if [ "$SKIP_PR" = true ]; then fi echo "📝 Creating pull request..." -PR_URL=$(gh pr create \ +# Assign inside `if !` rather than checking $? afterwards: under `set -e` a failing command +# substitution aborts at the assignment, so a separate `if [ $? -ne 0 ]` never runs — and +# because 2>&1 captures gh's error into PR_URL, the script would exit silently. +if ! PR_URL=$(gh pr create \ --repo "${CERTS_REPO}" \ --base master \ --head "${BRANCH_NAME}" \ --title "Add ${MATCH_TYPE} certificates for ${BUNDLE_ID}" \ - --body "Adding ${MATCH_TYPE} certificates and provisioning profiles for \`${BUNDLE_ID}\`" 2>&1) - -if [ $? -ne 0 ]; then + --body "Adding ${MATCH_TYPE} certificates and provisioning profiles for \`${BUNDLE_ID}\`" 2>&1); then echo "❌ Error: Failed to create PR" - echo " Branch ${BRANCH_NAME} exists with certificates. Create PR manually or clean up." + echo " ${PR_URL}" + echo "" + echo "⚠️ The certificates were created successfully — only opening the PR failed." + echo " Branch ${BRANCH_NAME} holds them. Do NOT re-run this script; open the PR by hand:" + echo " ${COMPARE_URL}" exit 1 fi echo " ✓ PR created: ${PR_URL}" From 36809bef27cfdb9a784ba3830acc915c5f467f66 Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:38:46 -0300 Subject: [PATCH 3/3] chore: trim comments in the cert creation path Drop comments that restate the code (`# Parse flags`, `# Local path`, the stale `# 4.` / `# 5/6.` step numbers left over from the old numbering) and condense the ones worth keeping. The load-bearing rationale stays: why the branch is cut from master first, why cleanup won't delete a branch match has pushed to, why the two `set -e` workarounds exist, and why inputs go through env. Co-Authored-By: Claude --- .github/workflows/create-ios-certs.yaml | 22 +++++------- fastlane/Fastfile | 16 ++++----- scripts/create-certificates.sh | 45 ++++++++----------------- 3 files changed, 29 insertions(+), 54 deletions(-) diff --git a/.github/workflows/create-ios-certs.yaml b/.github/workflows/create-ios-certs.yaml index 279ae753b..882eb8d53 100644 --- a/.github/workflows/create-ios-certs.yaml +++ b/.github/workflows/create-ios-certs.yaml @@ -2,15 +2,12 @@ name: Create iOS Certificates run-name: "Create iOS Certificates - ${{ inputs.bundle-id }}" -# Creates the signing certificates / provisioning profiles for an iOS app on CI, so -# developers don't need Ruby / fastlane / CocoaPods locally. Auth uses the App Store -# Connect API key (no Apple ID / 2FA). +# Creates an iOS app's signing certificates / provisioning profiles on CI, so developers don't +# need Ruby / fastlane / CocoaPods locally. # -# PREREQUISITE: the app's bundle id / App ID must already be registered in the Apple -# Developer Portal, and the App Store Connect app record must already exist. `match` does -# NOT create the identifier — it fails with "Couldn't find bundle identifier" otherwise. -# Both are created manually (App Store Connect → Apps → +) because fastlane `produce` only -# supports Apple ID + 2FA auth. +# PREREQUISITE: the App ID and the App Store Connect app record must already exist — create both +# by hand first. `match` won't create the identifier (it fails with "Couldn't find bundle +# identifier") and fastlane `produce` can't authenticate with the API key. permissions: contents: read @@ -51,17 +48,16 @@ jobs: ruby-version: 3.3.0 bundler-cache: true - # Certs are pushed to a branch over SSH (MATCH_SSH_KEY); no GitHub token is used. - # A teammate opens & merges the PR in the certs repo (see job summary). + # Certs go to a branch over SSH (MATCH_SSH_KEY), no GitHub token. A teammate opens and + # merges the PR in the certs repo — see the job summary. - name: Create certificates env: APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }} APPLE_ISSUER_ID: ${{ secrets.APPLE_ISSUER_ID }} APPLE_KEY_CONTENT: ${{ secrets.APPLE_KEY_CONTENT }} MATCH_PASSWORD: ${{ secrets.MATCH_KEYCHAIN_PASSWORD }} - # Inputs go through env, never straight into the script body — ${{ }} is - # substituted before the shell parses the line, so a quote in an input would - # otherwise break out of the command. + # Inputs via env, never inline in run: — ${{ }} expands before the shell parses, + # so a quote in an input would break out of the command. INPUT_BUNDLE_ID: ${{ inputs.bundle-id }} INPUT_CERTS_REPO: ${{ inputs.certs-repo }} INPUT_MATCH_TYPES: ${{ inputs.match-types }} diff --git a/fastlane/Fastfile b/fastlane/Fastfile index cc7890af3..da6626784 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -190,17 +190,13 @@ platform :ios do UI.success("✅ Simulator .app: #{app_path}") end - # NOTE: There is intentionally no `create_app` lane. fastlane `produce` only supports - # Apple ID + password (web session / 2FA) auth — it ignores the App Store Connect API - # key — so it can't run unattended on CI. Create the App Store Connect app record - # manually (App Store Connect → Apps → +), then use create_certs below for signing. - - # Create / sync certificates and provisioning profiles for an app, pushing them - # to a branch in the match git repo. Uses the App Store Connect API key (no 2FA) - # so it can run unattended on CI. The PR/merge of that branch is handled outside - # this lane (see scripts/create-certificates.sh). + # No `create_app` lane on purpose: fastlane `produce` only supports Apple ID + 2FA auth and + # ignores the API key, so it can't run unattended. Create the app record by hand instead. + + # Sync certs / profiles to a branch in the match repo, using the API key so it needs no 2FA. + # Opening and merging that branch is handled by scripts/create-certificates.sh. lane :create_certs do - # Temporary keychain — match needs somewhere to import the certificate on CI. + # match needs a keychain to import the certificate into. setup_ci() api_key = app_store_connect_api_key( diff --git a/scripts/create-certificates.sh b/scripts/create-certificates.sh index 1a9051118..4d768754c 100755 --- a/scripts/create-certificates.sh +++ b/scripts/create-certificates.sh @@ -6,17 +6,16 @@ set -e # Example (auto): ./scripts/create-certificates.sh reown-com/mobile-match com.reown.myapp dev@reown.com appstore --auto-merge # Example (CI): ./scripts/create-certificates.sh reown-com/mobile-match com.reown.myapp "" appstore --no-pr # -# --no-pr: don't touch GitHub (no token needed). fastlane match pushes the certs branch over -# SSH; a human then opens and merges the PR in the certs repo. This is the CI default. +# --no-pr: make no GitHub API calls (no token needed). match pushes the branch over SSH and a +# human opens the PR. This is what CI uses. CERTS_REPO=$1 BUNDLE_ID=$2 APPLE_EMAIL=$3 -MATCH_TYPE=${4:-appstore} # Default to appstore +MATCH_TYPE=${4:-appstore} AUTO_MERGE=false SKIP_PR=false -# Parse flags for arg in "$@"; do if [ "$arg" = "--auto-merge" ]; then AUTO_MERGE=true @@ -26,14 +25,12 @@ for arg in "$@"; do fi done -# Decide auth mode: App Store Connect API key (CI, no 2FA) vs Apple ID (local, interactive). -# The API key is used automatically when its env vars are present. +# App Store Connect API key (CI, no 2FA) when available, else interactive Apple ID. USE_API_KEY=false if [ -n "$APPLE_KEY_ID" ] && [ -n "$APPLE_ISSUER_ID" ] && [ -n "$APPLE_KEY_CONTENT" ]; then USE_API_KEY=true fi -# apple_email is only required for interactive (Apple ID) auth. if [ -z "$CERTS_REPO" ] || [ -z "$BUNDLE_ID" ] || { [ "$USE_API_KEY" = false ] && [ -z "$APPLE_EMAIL" ]; }; then echo "Usage: $0 [match_type] [--auto-merge] [--no-pr]" echo " certificates_repo: GitHub repo in owner/repo format" @@ -57,13 +54,9 @@ echo " Branch: ${BRANCH_NAME}" echo " PR mode: $([ "$SKIP_PR" = true ] && echo 'skip (manual merge)' || echo "create$([ "$AUTO_MERGE" = true ] && echo ' + auto-merge')")" echo "" -# Cleanup for error cases — remove the branch we created so a retry is clean. -# -# Only safe while the branch is still identical to master, i.e. match never got as far as -# committing. Once match has pushed, the branch is the only record of a certificate Apple -# has already issued; deleting it strands that cert AND removes the "branch already exists" -# guard below, so a retry re-runs match and can mint a duplicate. Apple caps distribution -# certificates, so that costs a manual revoke in the portal. Keep the branch instead. +# Drop the branch we created so a retry is clean — but only while it still matches master. +# Once match has pushed, the branch is the sole record of a certificate Apple already issued; +# deleting it strands that cert and lets a retry mint a duplicate against Apple's cap. cleanup_branch() { local branch_sha master_sha branch_sha=$(git ls-remote "${CERTS_GIT_URL}" "refs/heads/${BRANCH_NAME}" 2>/dev/null | cut -f1) || true @@ -89,13 +82,10 @@ cleanup_branch() { fi } -# The branch MUST be created from master before match runs, so match adds the new -# profile on top of the existing certs (reusing the shared distribution/development -# certificate) and produces a mergeable diff. If match is pointed at a non-existent -# branch it creates an ORPHAN branch with only the new files — unmergeable, and it -# re-mints a duplicate certificate because it can't see the existing one. +# Branch from master BEFORE match runs. Pointed at a non-existent branch, match builds an +# orphan branch holding only the new files — unmergeable — and re-mints a duplicate +# certificate because it can't see the existing one. if [ "$SKIP_PR" = false ]; then - # gh-based pre-create (uses a GitHub token) echo "🔍 Checking if branch already exists..." if gh api repos/${CERTS_REPO}/git/ref/heads/${BRANCH_NAME} &>/dev/null; then echo "⚠️ Branch ${BRANCH_NAME} already exists." @@ -104,9 +94,7 @@ if [ "$SKIP_PR" = false ]; then fi echo "📌 Creating branch ${BRANCH_NAME} from master..." - # `|| true` matters: under `set -e` a failing command substitution aborts the script at - # the assignment, so without it the diagnostic below is unreachable and gh's own error is - # swallowed by 2>/dev/null — you'd get a silent exit 1. + # `|| true`: without it `set -e` aborts at the assignment and the diagnostic below never runs. MASTER_SHA=$(gh api repos/${CERTS_REPO}/git/ref/heads/master --jq '.object.sha' 2>/dev/null) || true if [ -z "$MASTER_SHA" ]; then echo "❌ Error: Failed to fetch master branch SHA from ${CERTS_REPO}" @@ -122,7 +110,7 @@ if [ "$SKIP_PR" = false ]; then fi echo " ✓ Branch created" else - # --no-pr: pre-create the branch from master over SSH (no GitHub token needed). + # --no-pr: same thing over SSH, no GitHub token. echo "📌 Creating branch ${BRANCH_NAME} from master over SSH..." WORKDIR=$(mktemp -d) if ! git clone --depth 1 --branch master "${CERTS_GIT_URL}" "${WORKDIR}" >/dev/null 2>&1; then @@ -142,10 +130,8 @@ else echo " ✓ Branch created from master" fi -# 4. Run fastlane match echo "🚀 Running fastlane match ${MATCH_TYPE}..." if [ "$USE_API_KEY" = true ]; then - # CI path: API-key auth via the create_certs lane (no Apple ID / 2FA). echo " 🔑 Using App Store Connect API key auth" if ! BUNDLE_ID="${BUNDLE_ID}" \ MATCH_TYPE="${MATCH_TYPE}" \ @@ -158,7 +144,6 @@ if [ "$USE_API_KEY" = true ]; then exit 1 fi else - # Local path: interactive Apple ID auth. if ! bundle exec fastlane match ${MATCH_TYPE} \ --git_url "${CERTS_GIT_URL}" \ --git_branch "${BRANCH_NAME}" \ @@ -172,7 +157,6 @@ else fi echo " ✓ Certificates created" -# 5/6. Open (and optionally merge) the PR. Skipped entirely in --no-pr mode. if [ "$SKIP_PR" = true ]; then echo "" echo "✅ Done! Certificates for ${BUNDLE_ID} have been pushed to branch ${BRANCH_NAME}." @@ -186,9 +170,8 @@ if [ "$SKIP_PR" = true ]; then fi echo "📝 Creating pull request..." -# Assign inside `if !` rather than checking $? afterwards: under `set -e` a failing command -# substitution aborts at the assignment, so a separate `if [ $? -ne 0 ]` never runs — and -# because 2>&1 captures gh's error into PR_URL, the script would exit silently. +# Assign inside `if !`, not a trailing $? check — `set -e` aborts at the assignment, and 2>&1 +# hides gh's error inside PR_URL, so the failure would otherwise be silent. if ! PR_URL=$(gh pr create \ --repo "${CERTS_REPO}" \ --base master \