diff --git a/.github/workflows/create-ios-certs.yaml b/.github/workflows/create-ios-certs.yaml index 2d250c54b..882eb8d53 100644 --- a/.github/workflows/create-ios-certs.yaml +++ b/.github/workflows/create-ios-certs.yaml @@ -1,15 +1,86 @@ name: Create iOS Certificates +run-name: "Create iOS Certificates - ${{ inputs.bundle-id }}" + +# Creates an iOS app's signing certificates / provisioning profiles on CI, so developers don't +# need Ruby / fastlane / CocoaPods locally. +# +# 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 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 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 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 }} + run: | + chmod +x scripts/create-certificates.sh + IFS=',' read -ra TYPES <<< "$INPUT_MATCH_TYPES" + { + 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 "$INPUT_CERTS_REPO" "$INPUT_BUNDLE_ID" "" "$type" --no-pr + echo "::endgroup::" + 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 dcaae21cb..c85301f17 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,11 +91,18 @@ 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. +> **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/fastlane/Fastfile b/fastlane/Fastfile index dcbcad566..da6626784 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -190,6 +190,38 @@ platform :ios do UI.success("✅ Simulator .app: #{app_path}") end + # 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 + # match needs a keychain to import the certificate into. + 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..4d768754c 100755 --- a/scripts/create-certificates.sh +++ b/scripts/create-certificates.sh @@ -1,107 +1,193 @@ #!/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: 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 -# Check for --auto-merge flag 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]" +# 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 + +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 +# 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() { - echo "🧹 Cleaning up branch ${BRANCH_NAME}..." - gh api repos/${CERTS_REPO}/git/refs/heads/${BRANCH_NAME} -X DELETE 2>/dev/null || true + 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 + 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 +# 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 + 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..." + # `|| 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}" + 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: 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 + 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 + 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 + 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 +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 \ +# 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 \ --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}" -# 6. Merge PR (if auto-merge enabled) if [ "$AUTO_MERGE" = true ]; then echo "🔀 Merging pull request..." if ! gh pr merge "${BRANCH_NAME}" \ @@ -119,6 +205,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"