Skip to content

Share one certificate store per Apple account, not per app - #3

Merged
mirzemehdi merged 1 commit into
mainfrom
fix/shared-certs-repo
Aug 16, 2026
Merged

mirzemehdi merged 1 commit into
mainfrom
fix/shared-certs-repo

Conversation

@mirzemehdi

Copy link
Copy Markdown

The previous design stored signing material on a match-certificates branch of each app's own repo. That is wrong, and it fails hard at the fourth app.

Why

  • An iOS distribution certificate belongs to the Apple Developer account, not to an app. Apple issues at most three.
  • A provisioning profile is the per-app piece — it binds one bundle ID to that shared certificate.

match mints a new certificate whenever its store has none. A per-app store therefore means: app 1 creates cert #1, app 2 creates cert #2, app 3 creates cert #3, app 4 cannot ship at all. It also wasted the cap — three identical certificates doing one certificate's job — and left the encrypted material inside app repos whose visibility the user may not control.

Now

One private repo per account, default <owner>/apple-certificates, created automatically:

certs/distribution/ABC123.p12                       ← shared by every app
profiles/appstore/AppStore_com.example.first.mobileprovision
profiles/appstore/AppStore_com.example.second.mobileprovision
  • Configurable per user — --certs-repo, remembered as iosCertsRepo. Nothing is hardcoded to any particular org, so anyone using KAppMaker points it at their own.
  • MATCH_PASSWORD is keyed to the store, not the app; all apps sharing it need the same passphrase.
  • Reaching a second private repo needs a PAT (iosCertsRepoToken) — the runner's built-in GITHUB_TOKEN only reaches its own repo. Set once, reused by every app, and the checklist explains it rather than letting the build fail at match with a confusing clone error.
  • The store is private, so certificates are never exposed regardless of app-repo visibility — strictly better than the previous branch approach.

For users whose apps share a GitHub organisation, the account-level secrets are noted as good candidates for organisation secrets, leaving a new app to set only its own build keys.

Workflow stamp → v4, so existing projects pick this up on the next ios-ci init.

CLI 1.22.0, plugin 2.8.0.

An iOS distribution certificate belongs to the Apple Developer account and Apple
issues at most three; a provisioning profile is the per-app piece. Storing the
certificate on a branch of each app's own repo therefore minted a certificate per
app and locked the account out on the fourth.

Signing material now comes from a separate private repo shared across the
account, supplied as MATCH_GIT_URL, with a PAT for cross-repo read since the
built-in GITHUB_TOKEN only reaches the repo it runs in.
@mirzemehdi
mirzemehdi merged commit 7fd9d96 into main Aug 16, 2026
1 check passed
@mirzemehdi
mirzemehdi deleted the fix/shared-certs-repo branch August 16, 2026 17:31
mirzemehdi added a commit that referenced this pull request Aug 16, 2026
…otlinFoundation#48)

* Fix the iOS publish workflow so it can actually run (#1)

The workflow has never run on any generated app — it needs eight secrets that
nobody can produce without a Mac, so every KAppMaker user has been told to buy
one to ship iOS. Three separate problems, all fixable:

1. Signing needed a hand-exported .p12 (IOS_APP_CERTIFICATE_P12_BASE64), and
   exporting one requires a Mac — the very thing this workflow exists to avoid.
   fastlane match now creates the certificate on the runner from the App Store
   Connect API key and stores it encrypted in a private repo, so the second and
   later builds reuse it. Xcode automatic signing is deliberately NOT used: it
   mints a new distribution certificate per run and Apple caps those at three
   per account, so it works three times and then fails forever.

2. local.properties was written with three keys, of which two
   (REVENUECAT_ANDROID_API_KEY, REVENUECAT_IOS_API_KEY) no longer exist — the
   build reads SUBSCRIPTION_PROVIDER_*. Meanwhile fourteen keys the build DOES
   read were missing. Every getRequiredProperty() carries a defaultValue, so
   this never failed the build: it shipped green binaries carrying  and
   testValue, with dead sign-in, analytics and paywall. That is the worst
   failure mode available and it was silent.

3. Archive used CODE_SIGN_STYLE=Manual with a hardcoded profile UUID while
   export declared signingStyle=automatic — the two halves disagreed. Also a
   duplicated -destination flag.

The build now goes through the project's own Fastfile rather than a parallel
xcodebuild invocation, so   -- Publishing Android to production track...
  -- Fastlane: Android Play Store release
[15:58:45]: �[31mWARNING: fastlane requires your locale to be set to UTF-8. To learn more go to https://docs.fastlane.tools/getting-started/ios/setup/#set-up-environment-variables�[0m
[15:58:45]: Detected package name: com.kotlinfoundation.koko
[15:58:45]: �[32mDriving the lane 'android playstore_release' 🚀�[0m
[15:58:45]: 🏗 Building Android AAB...
[15:58:45]: �[32m--------------------------------------------------------------------�[0m
[15:58:45]: �[32mStep: cd /private/tmp/claude-501/-Users-mirzemehdi-Documents-SAAS-KAppMaker-KAppMaker-AI/932620e6-77bb-4ad5-9df3-8db04538eacc/scratchpad/kappmaker-all/MobileApp && ./gradlew :androidApp:bundleRelease�[0m
[15:58:45]: �[32m--------------------------------------------------------------------�[0m
[15:58:45]: �[36m$ cd /private/tmp/claude-501/-Users-mirzemehdi-Documents-SAAS-KAppMaker-KAppMaker-AI/932620e6-77bb-4ad5-9df3-8db04538eacc/scratchpad/kappmaker-all/MobileApp && ./gradlew :androidApp:bundleRelease�[0m
[15:58:46]: ▸ �[35mStarting a Gradle Daemon, 1 incompatible Daemon could not be reused, use --status for details�[0m
[15:58:46]: ▸ �[35mFAILURE: Build failed with an exception.�[0m
[15:58:46]: ▸ �[35m* What went wrong:�[0m
[15:58:46]: ▸ �[35mGradle requires JVM 17 or later to run. Your build is currently configured to use JVM 11.�[0m
[15:58:46]: ▸ �[35m* Try:�[0m
[15:58:46]: ▸ �[35m> Run with --stacktrace option to get the stack trace.�[0m
[15:58:46]: ▸ �[35m> Run with --info or --debug option to get more log output.�[0m
[15:58:46]: ▸ �[35m> Run with --scan to get full insights from a Build Scan (powered by Develocity).�[0m
[15:58:46]: ▸ �[35m> Get more help at https://help.gradle.org.�[0m
+-------------------------------------------+
|               �[33mLane Context�[0m                |
+---------------+---------------------------+
| PLATFORM_NAME | android                   |
| LANE_NAME     | android playstore_release |
+---------------+---------------------------+
[15:58:46]: �[31mCalled from Fastfile at line 108�[0m
[15:58:46]: �[31m```�[0m
[15:58:46]: �[31m    106:	    # Build AAB�[0m
[15:58:46]: �[31m    107:	    UI.message("🏗 Building Android AAB...")�[0m
[15:58:46]: �[31m => 108:	    sh("cd #{root_dir} && ./gradlew :androidApp:bundleRelease")�[0m
[15:58:46]: �[31m    109:	�[0m
[15:58:46]: �[31m    110:	    # Find generated AAB�[0m
[15:58:46]: �[31m```�[0m
[15:58:46]: �[31mExit status of command 'cd /private/tmp/claude-501/-Users-mirzemehdi-Documents-SAAS-KAppMaker-KAppMaker-AI/932620e6-77bb-4ad5-9df3-8db04538eacc/scratchpad/kappmaker-all/MobileApp && ./gradlew :androidApp:bundleRelease' was 1 instead of 0.
Starting a Gradle Daemon, 1 incompatible Daemon could not be reused, use --status for details

FAILURE: Build failed with an exception.

* What went wrong:
Gradle requires JVM 17 or later to run. Your build is currently configured to use JVM 11.

* Try:
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights from a Build Scan (powered by Develocity).
> Get more help at https://help.gradle.org.
�[0m

+---------------------------------------------+
|              �[32mfastlane summary�[0m               |
+------+------------------------+-------------+
| Step | Action                 | Time (in s) |
+------+------------------------+-------------+
| 💥   | �[31mcd �[0m                    | 0           |
|      | �[31m/private/tmp/claude-5 �[0m |             |
|      | �[31m01/-Users-mirzemehdi- �[0m |             |
|      | �[31mDocuments-SAAS-K�[0m       |             |
+------+------------------------+-------------+

[15:58:46]: �[31mfastlane finished with errors�[0m

Looking for related GitHub issues on fastlane/fastlane...

#######################################################################
# fastlane 2.238.0 is available. You are on 2.231.1.
# You should use the latest version.
# Please update using `bundle update fastlane`.
#######################################################################

�[32m2.238.0 Improvements�[0m
* [ci] support releases with PRs that were deleted (or users) (#30105) via Connor Tumbleson (@iBotPeaches)
* [pilot] Match groups by either name or id (#30104) via Alexander Chapliuk (@krin-san)
* [update_project_team] add support for sdk conditionals (#30142) via Connor Tumbleson (@iBotPeaches)
* harden: detected non-static command inside ` in... (#30148) via Anupam Mediratta (@anupamme)
* [fastlane_core] Create a unique package directory for non-macOS uploads (#30144) via Victor Amorim dos Santos (@victoramsantos)
* [gem] move to webrick 1.9.x (#30135) via Connor Tumbleson (@iBotPeaches)
* [gem] upgrade 'terminal-table' to drop monkey patch (#30137) via Connor Tumbleson (@iBotPeaches)
* [core] Fix video resolution parsing when audio track precedes video track (#30143) via Thomas Walther (@tcwalther)
* [gym] Fix Mac Catalyst package type detection when catalyst_platform is not set (#29829) via Josh Holtz (@joshdholtz)
* [deliver/pilot] change instruction on how to run iTMSTransporter (#21155) via Merlin Ran (@merlinran)
* [gem] move to sinatra 4 (#30134) via Connor Tumbleson (@iBotPeaches)
* [action][spm] add `--skip-update` (#21127) via Atsuto Yamashita (@att55)
* Fix precheck copyright rule wording (#19500) via Andreas Ganske (@ChaosCoder)
* docs: add detailed notation of what metrics collected (#30132) via Connor Tumbleson (@iBotPeaches)
* Bump actions/labeler from 6 to 7 (#30133) via dependabot[bot] (@dependabot[bot])
* [upload_symbols_to_crashlytics] Adds option to let the action fail when an upload fails (#29700) via Tobias Kreß (@Myrronth)
* [deliver] add support for social media rating keys (#30128) via Connor Tumbleson (@iBotPeaches)
* [core] Support for Ruby 4 (+ testing) (#29849) via Connor Tumbleson (@iBotPeaches)
* [register_devices] Ignore blank lines and comment lines in devices_file (#30129) via David Cordero (@dcordero)
* [core] detect installed Developer ID intermediate certificates by their common name (#30126) via Rinat Kutuev (@ReilganInt)
* [core] move to xcodeproj 1.28.1 (from 1.27.0) (#30124) via Nuno de Carvalho (@nmcc24)
* [core] normalize FastlaneSwiftRunner.xcodeproj plist quoting (#30125) via Nuno de Carvalho (@nmcc24)
* [register_devices] Register devices normalization for UDIDs. (#29981) via Muhammet Ilendemli (@ilendemli)
* [crashlytics] improve logging during error (#30077) via Connor Tumbleson (@iBotPeaches)
* [setup_ci] Add option to opt out of setting the default keychain (#30121) via Eyüp Can Akman (@eyupcanakman)
* [snapshot] Open the requested simulator for non-headless runs (#30119) via huven (@huven)
* [core] improve operating_system detection (#30118) via Connor Tumbleson (@iBotPeaches)
* [core] fix: upgrade yard to >= 0.9.42 (CVE-2026-41493) (#30117) via OrbisAI Security (@orbisai0security)
* [core] move to Faraday 2 (#30089) via Connor Tumbleson (@iBotPeaches)
* [docs] clarify platform resolution behavior (#22185) via Douglas Hill (@douglashill)
* [core] fix crash when uploading large `.pkg` by using `Digest::MD5.file` instead of `Digest::MD5.hexdigest` (#21999) via reflexing (@reflexing)
* [docs] fix a missing negation in `pilot` error message (#22331) via Gemma Barlow (@gemmakbarlow)
* [notarize] Fix erroring out with `bundle_id` parameter being absent (#29736) via Timur Sharifianov (@Scvairy)
* [metrics] move to GA4 and improve metrics (#30111) via Connor Tumbleson (@iBotPeaches)

�[32m2.237.0 Improvements�[0m
* [ci] drop CircleCI for GHA (#30108) via Connor Tumbleson (@iBotPeaches)
* [fastlane_core] Restore altool -f flag, keep -assetFile only for iTMSTransporter (#30106) via Roman Gardukevich (@OdNairy)
* [gym] handle xcpretty errors cleaner & promote xcbeautify (#30099) via Connor Tumbleson (@iBotPeaches)
* [docs] Reflect specification changes regarding iPad Pro 12.9-inch screenshots in `deliver` (#30103) via 417-72KI (@417-72KI)
* [core] Reduce duplication on output and noise during failure. (#30097) via Connor Tumbleson (@iBotPeaches)
* Bump actions/checkout from 6 to 7 (#30100) via dependabot[bot] (@dependabot[bot])
* [match] Restore renew expired certs (opt in) (#30096) via Roman Gardukevich (@OdNairy)
* [match] fail fast for SSH authentication prompts in non-interactive shell (#30078) via Pratik Patil (@PratikPatil131)
* [gem] move to faraday 1.10.6 (#30095) via Connor Tumbleson (@iBotPeaches)
* [core] Skip repeating the output again when an sh command fails (#19545) via Peter Tutervai (@tutipeti)
* [scan] Forward force_legacy_xcresulttool to trainer (#30092) via Eyüp Can Akman (@eyupcanakman)
* [trainer] Fix output_remove_retry_attempts not stripping failures for skipped-on-retry tests (modern xcresult parser) (#30081) via Oliver Fox (@foxware00)
* [scan] Handle array destination in xcodebuild_destination_parameter (#30069) via Ricardo Sawir (@sawirricardo)
* [action][swiftlint] Fix path option for SwiftLint 0.48.0 and above (#30080) via Augusto Xavier (@augustocbx)
* [ci] move more workflows from CircleCI -> GHA (#30090) via Connor Tumbleson (@iBotPeaches)
* [core] remove upper bound on excon (#30085) via Connor Tumbleson (@iBotPeaches)
* Bump actions/checkout from 6 to 7 (#30091) via dependabot[bot] (@dependabot[bot])
* [spaceship] Fix appInfoLocalizations create/delete on the Tunes API (#30084) via Marcel Hoppe (@hoppsen)
* [match] Fix keychain import path escaping (#30072) via Ricardo Sawir (@sawirricardo)
* [precheck] Fix copyright year check to allow past years (#30076) via Augusto Xavier (@augustocbx)
* [supply] Fix version_codes_to_retain causing promote-only flow to use… (#30073) via Pratik Patil (@PratikPatil131)
* Bump rubygems/configure-rubygems-credentials from 2.0.0 to 2.1.0 (#30074) via dependabot[bot] (@dependabot[bot])
* [fastlane_core] Fix deprecated Transporter -f flag, use -assetFile fo… (#30061) via Pratik Patil (@PratikPatil131)
* [core] fix: update addressable to 2.9.0 min for CVE-2026-35611 (#30060) via OrbisAI Security (@orbisai0security)

�[32m2.236.1 Improvements�[0m
* Revert "[transporter] Decode Base64 key content for .p8 file generation in Al…" (#30066) via Connor Tumbleson (@iBotPeaches)

�[32mTo see all new releases, open https://github.com/fastlane/fastlane/releases�[0m

�[32mPlease update using `bundle update fastlane`�[0m
[15:58:47]: �[31mWARNING: fastlane requires your locale to be set to UTF-8. To learn more go to https://docs.fastlane.tools/getting-started/ios/setup/#set-up-environment-variables�[0m locally and CI run the same code
and cannot drift. Adds workflow_dispatch (on-demand, and drivable by tooling)
alongside the existing tag trigger, keeps the GitHub release step for tags, and
uploads the .ipa as an artifact so a failed upload does not cost another
20-minute rebuild.

[1/6] Checking prerequisites
  -- Repo:       KAppMaker/KAppMaker-All
  -- App dir:    MobileApp
  -- Bundle ID:  com.kotlinfoundation.koko
  WARN KAppMaker/KAppMaker-All is PUBLIC. Secrets stay private, but anyone can read your source and build logs.

[2/6] Setting up the signing-certificate repo
  OK Created private repo KAppMaker/KAppMaker-All-certs

[3/6] Resolving the certificate password

[4/6] Pushing secrets to GitHub provisions the certs repo and every secret listed at the
top of the file.

* Fix the iOS signing configuration that six real builds exposed (#2)

PR #1 merged before the pipeline had been run end to end. Three problems
remained on main, each fatal on its own:

xcargs: '-allowProvisioningUpdates NO' — the flag takes no value, so xcodebuild
read NO as a build action and refused to start.

The lane took a bump_build option the workflow never declared as an input, so
every tool-triggered dispatch was rejected with HTTP 422. Dropped: update-version
owns the build number and keeps it in git where it is auditable.

Signing could not go through xcargs at all, because those settings hit every
target and the Swift Package dependencies reject a provisioning profile. It is
now applied per target with update_code_signing_settings, and manually: on
automatic Xcode looks for a Development profile while match installs an App Store
one. DEVELOPMENT_TEAM comes from match, since the project sets none.

Verified against a live Apple account up to and including the Kotlin/Native iOS
compile; the remaining failure is in the test app's own source.

* Use the template's own app name in the iOS publish workflow

Carried over from the source repo: the workflow named that app and kept an
internal version marker. refactor_package.sh rewrites app_name in this file
from rootProject.name, so the template has to ship Koko for a generated app to
get its own name.

* Changelog entry for the iOS publishing fixes

* Write every build key in the Android and PR workflows too

The iOS fix only covered one of three workflows. Android release builds and PR
checks still wrote the same three-key block, two of which
(REVENUECAT_ANDROID_API_KEY, REVENUECAT_IOS_API_KEY) no longer exist — the build
reads SUBSCRIPTION_PROVIDER_*. So Play Store releases shipped with the same
silent placeholders the iOS workflow was just fixed for: getRequiredProperty
carries a defaultValue, the build stays green, and the app ships with dead
sign-in, ads, AI and paywall.

All three workflows now derive the key list from local.properties.example, so
adding a key there needs no workflow edit. The release workflows warn about any
key with no matching repo secret; PR checks stay silent because they ship
nothing and fork PRs get no secrets at all.

Verified the two lists agree: the build reads 15 keys and the example file
lists exactly those 15.

* Changelog entry for the Android and PR workflow key fix

* Update the signing and publishing skills for the new secret set

setup-signing still told developers to export a p12 and collect provisioning
profile UUIDs, none of which the workflow uses now. It documents the four iOS
secrets that exist, says plainly that MATCH_PASSWORD is theirs to invent rather
than something Apple issues, warns that Apple caps distribution certificates at
three per account, and adds the rule the workflows depend on: every key in
local.properties.example needs a repo secret of the same name, because a missing
one ships a placeholder instead of failing.

Also bump the Documentation submodule for the matching docs-site changes.

* Changelog entry for the publishing docs update

* Share one certificate store per Apple account, not per app (#3)

An iOS distribution certificate belongs to the Apple Developer account and Apple
issues at most three; a provisioning profile is the per-app piece. Storing the
certificate on a branch of each app's own repo therefore minted a certificate per
app and locked the account out on the fourth.

Signing material now comes from a separate private repo shared across the
account, supplied as MATCH_GIT_URL, with a PAT for cross-repo read since the
built-in GITHUB_TOKEN only reaches the repo it runs in.

* Point at the manual certs-repo setup instead of a CLI the template has no

* Share one certificate store per Apple account in the skills and docs

Follows the workflow change: setup-signing now explains why the certs repo is
separate from the app repo (Apple issues three distribution certificates per
account, so a per-app store locks you out on the fourth), lists MATCH_GIT_URL
and MATCH_GIT_BASIC_AUTHORIZATION with the commands that produce them, and
corrects APPSTORE_PRIVATE_KEY to the base64 of the .p8 — the Fastfile passes
is_key_content_base64, so the raw contents would fail.

Every value now says where it comes from: which App Store Connect page, which
role the API key needs, and why the built-in GITHUB_TOKEN cannot reach the certs
repo.

* Run match read-only by default (#4)

Apple issues at most two Apple Distribution certificates per account, shared by
every app. With readonly:false match mints a new one whenever it is not satisfied
with what it finds, so a couple of builds can consume both slots and lock the
account out of iOS releases entirely.

Builds now only use the certificate store; MATCH_READONLY=false covers the single
bootstrap run that populates an empty one.

* Correct the certificate limit to two and document MATCH_READONLY

The count came from the earlier commit and was wrong: Apple issues two Apple
Distribution certificates per account, not three, which makes the per-app store
worse than described and the read-only default more important.

Releases now run match read-only so no build can mint a certificate. Document
the one exception — MATCH_READONLY=false for the single bootstrap run against an
empty certs repo — and say plainly to unset it afterwards, since leaving it on
lets any later build exhaust the account.

* Re-trigger CI
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant