From 0fe0bb3e15000d4bbe5c676cab5d47cde1c66b0d Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 2 Aug 2026 22:48:34 +0200 Subject: [PATCH 1/2] Sign the release manually instead of through the cloud The first real release run archived fine and died at export with "Cloud signing permission error", then "No profiles for 'at.tomtasche.reader' were found". Automatic signing had xcodebuild ask App Store Connect for signing assets of its own, and minting a distribution certificate that way is something only a key created with Admin access may do. It also meant the certificate the workflow imports was never used, and that a throwaway development certificate landed on the account every run - the archive was signed with "Apple Development: Created via API". So do the signing ourselves. sigh fetches the App Store profile for the bundle id, creating it once if the account has none, and only accepts one that matches a certificate in the keychain. Both the archive and the export are then handed that profile and the distribution identity, and xcodebuild needs no Apple credentials at all. The import step now fails on a certificate that is not a distribution one, rather than letting it archive for twenty minutes and fail at export, and a failed run keeps the .xcdistributionlogs bundle that says why an export was refused - gym's own log only says that it was. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01D8jRjGEngSYrvYZMcy1WoD --- .github/workflows/release.yml | 18 ++++++++ OpenDocumentReader.xcodeproj/project.pbxproj | 2 - README.md | 10 +++-- fastlane/Fastfile | 43 ++++++++++++++------ 4 files changed, 55 insertions(+), 18 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ed079c..8aa6aeb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -118,6 +118,14 @@ jobs: security set-key-partition-list -S apple-tool:,apple: -k "$password" "$keychain" > /dev/null security list-keychain -d user -s "$keychain" login.keychain-db + # this is the only certificate the build has - a development one here + # would archive fine and fail the export twenty minutes in + security find-identity -v -p codesigning "$keychain" + if ! security find-identity -v -p codesigning "$keychain" | grep -q "Apple Distribution\|iPhone Distribution"; then + echo "::error::SIGNING_CERTIFICATE_P12 holds no Apple Distribution certificate (see README)" + exit 1 + fi + # one step for both: a dry run builds the same thing and only stops short # of the upload - name: build and upload to App Store Connect @@ -131,12 +139,22 @@ jobs: ODR_DRY_RUN: ${{ env.dry_run }} run: bundle exec fastlane ${{ matrix.flavor == 'pro' && 'deployPro' || 'deployLite' }} + # gym's log only says the export failed; the reason is in an + # .xcdistributionlogs bundle under $TMPDIR, which dies with the runner + - name: collect distribution logs + if: failure() + run: | + mkdir -p distribution-logs + find "${TMPDIR:-/tmp}" -maxdepth 1 -name '*.xcdistributionlogs' \ + -exec cp -R {} distribution-logs/ \; + - uses: actions/upload-artifact@v7 if: always() with: name: build-log-${{ matrix.flavor }} path: | ~/Library/Logs/gym + distribution-logs *.ipa *.app.dSYM.zip if-no-files-found: warn diff --git a/OpenDocumentReader.xcodeproj/project.pbxproj b/OpenDocumentReader.xcodeproj/project.pbxproj index 03d8b75..132e9c0 100644 --- a/OpenDocumentReader.xcodeproj/project.pbxproj +++ b/OpenDocumentReader.xcodeproj/project.pbxproj @@ -599,7 +599,6 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_IDENTITY = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 5LS6X97G6J; @@ -832,7 +831,6 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_IDENTITY = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 5LS6X97G6J; diff --git a/README.md b/README.md index 54c24a6..346b0ce 100644 --- a/README.md +++ b/README.md @@ -101,15 +101,19 @@ It needs these repository secrets: | secret | what it is | | --- | --- | -| `ASC_KEY_ID` | App Store Connect API key id | +| `ASC_KEY_ID` | App Store Connect API key id, with App Manager access | | `ASC_ISSUER_ID` | issuer id of that key | | `ASC_KEY_CONTENT` | the `.p8` private key, base64 encoded | | `SIGNING_CERTIFICATE_P12` | Apple Distribution certificate + key as a base64 encoded `.p12` | | `SIGNING_CERTIFICATE_PASSWORD` | password of that `.p12` | The certificate is imported into a temporary keychain that is discarded with the -runner. Provisioning profiles are created on demand via -`-allowProvisioningUpdates`. +runner, and signing is manual: fastlane downloads the App Store provisioning +profile for the bundle id, creating it once if the account has none, and both +the archive and the export use that certificate and profile. Automatic signing +would instead have Xcode mint distribution assets of its own, which only an +Admin key may do - anything less fails the export with "Cloud signing permission +error". The same lanes work locally once those variables are exported, and take the version and the dry run the same way the workflow hands them over: diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 5b51048..606dcc8 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -48,10 +48,9 @@ platform :ios do # ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_CONTENT, the last being the base64 of # the .p8 file. # - # The key has to hit the disk because two unrelated consumers need it: the - # Connect API calls fastlane makes itself, and the xcodebuild subprocess that - # build_app spawns. xcodebuild knows nothing about fastlane's key -- see - # -authenticationKeyPath in `xcodebuild -help` -- so it gets the file. + # It is written to a private temporary file because that is the shape + # app_store_connect_api_key takes it in; the lane removes it again when it is + # done. private_lane :api_key_file do path = File.join(Dir.mktmpdir("asc-api-key"), "AuthKey_#{ENV.fetch('ASC_KEY_ID')}.p8") File.write(path, Base64.decode64(ENV.fetch("ASC_KEY_CONTENT"))) @@ -82,6 +81,7 @@ platform :ios do end key_path = api_key_file + profile_dir = nil begin key = app_store_connect_api_key( @@ -102,16 +102,27 @@ platform :ios do UI.message("building #{options[:scheme]} #{version.empty? ? '(unversioned)' : version} as build #{build_number}") - # build_app is gym, which has no api_key option -- passing one aborts the - # lane before xcodebuild ever runs. Automatic signing is authenticated - # through xcargs instead: -allowProvisioningUpdates on its own needs an - # Apple account configured in Xcode, which the release runner does not - # have. + # Signing is manual from here on. Automatic signing has xcodebuild mint + # distribution assets of its own -- cloud signing, which only an Admin key + # may do, so the export failed with "Cloud signing permission error" while + # the imported certificate went unused. sigh downloads the App Store + # profile matching that certificate instead, and both xcodebuild passes + # are handed it. + profile_dir = Dir.mktmpdir("provisioning-profile") + get_provisioning_profile( + api_key: key, + app_identifier: options[:app_identifier], + # out of the working tree; sigh installs a copy where xcodebuild looks + output_path: profile_dir + ) + profile_name = lane_context[SharedValues::SIGH_NAME] + UI.user_error!("sigh returned no profile name") if profile_name.to_s.empty? + xcargs = [ - "-allowProvisioningUpdates", - "-authenticationKeyPath", Shellwords.escape(key_path), - "-authenticationKeyID", Shellwords.escape(ENV.fetch("ASC_KEY_ID")), - "-authenticationKeyIssuerID", Shellwords.escape(ENV.fetch("ASC_ISSUER_ID")), + # the archive is signed the same way the export re-signs it + "CODE_SIGN_STYLE=Manual", + "CODE_SIGN_IDENTITY=#{Shellwords.escape('Apple Distribution')}", + "PROVISIONING_PROFILE_SPECIFIER=#{Shellwords.escape(profile_name)}", # passed on the command line rather than written into project.pbxproj, # so a release never leaves the working tree dirty "CURRENT_PROJECT_VERSION=#{build_number}", @@ -123,6 +134,11 @@ platform :ios do build_app( project: "OpenDocumentReader.xcodeproj", scheme: options[:scheme], + export_method: "app-store", + export_options: { + signingStyle: "manual", + provisioningProfiles: { options[:app_identifier] => profile_name }, + }, xcargs: xcargs.join(" ") ) @@ -142,6 +158,7 @@ platform :ios do end ensure FileUtils.remove_entry(File.dirname(key_path), true) + FileUtils.remove_entry(profile_dir, true) if profile_dir end end end From 437f4c773b553b592bbae9ad5f37839db9436d47 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 2 Aug 2026 22:56:03 +0200 Subject: [PATCH 2/2] Say which parts of signing need an Admin key Creating a provisioning profile wants one; downloading an existing one does not, so a lesser key is fine for every run after the first. The README claimed App Manager was enough for all of it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01D8jRjGEngSYrvYZMcy1WoD --- README.md | 17 +++++++++++------ fastlane/Fastfile | 4 +++- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 346b0ce..5b1be87 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ It needs these repository secrets: | secret | what it is | | --- | --- | -| `ASC_KEY_ID` | App Store Connect API key id, with App Manager access | +| `ASC_KEY_ID` | App Store Connect API key id | | `ASC_ISSUER_ID` | issuer id of that key | | `ASC_KEY_CONTENT` | the `.p8` private key, base64 encoded | | `SIGNING_CERTIFICATE_P12` | Apple Distribution certificate + key as a base64 encoded `.p12` | @@ -109,11 +109,16 @@ It needs these repository secrets: The certificate is imported into a temporary keychain that is discarded with the runner, and signing is manual: fastlane downloads the App Store provisioning -profile for the bundle id, creating it once if the account has none, and both -the archive and the export use that certificate and profile. Automatic signing -would instead have Xcode mint distribution assets of its own, which only an -Admin key may do - anything less fails the export with "Cloud signing permission -error". +profile for the bundle id, and both the archive and the export use that +certificate and profile. Automatic signing would instead have Xcode mint +distribution assets of its own, which only an Admin key may do - anything less +fails the export with "Cloud signing permission error". + +Downloading a profile is something any key may do; creating one wants an Admin +key. So a lesser key works as long as both apps have an App Store profile +already - the run says so in its first seconds otherwise, and either an Admin key +or a profile made by hand in the developer portal gets past it. Profiles expire +after a year, which is the other moment this matters. The same lanes work locally once those variables are exported, and take the version and the dry run the same way the workflow hands them over: diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 606dcc8..47519e2 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -107,7 +107,9 @@ platform :ios do # may do, so the export failed with "Cloud signing permission error" while # the imported certificate went unused. sigh downloads the App Store # profile matching that certificate instead, and both xcodebuild passes - # are handed it. + # are handed it. Downloading one is something any key may do; creating the + # profile, which this also does when the account has none, wants an Admin + # key - and fails here, before the build, rather than after it. profile_dir = Dir.mktmpdir("provisioning-profile") get_provisioning_profile( api_key: key,