Splitting the macOS Code Signing Handover Between the Product Team and the IT/SRE Team — Separating Account Holder Work from GitHub Environment Secret Registration
Distributing a macOS desktop app outside the Mac App Store means signing it with a Developer ID Application certificate and notarizing it through notarytool; without those two steps, Gatekeeper will not accept the app. This holds no matter what framework built the app (Electron, Tauri, Flutter desktop, native Swift/AppKit, and so on), so the question of where signing and notarization run is unavoidable. Running both inside a GitHub Actions macOS runner takes the manual keychain steps out of the release process, but when the team that holds the corporate Apple Developer Program membership and the team that owns the product’s repo sit in different parts of the org, the handoff needs an explicit plan.
This post lays out the handover between an IT/SRE team that owns the corporate Apple Developer Program membership and a product team that owns the repo’s GitHub Actions workflows.
The Target Shape: Program Membership and the CI Pipeline Live in Different Teams
The structure I have in mind looks like this.
- The IT/SRE team holds the corporate Apple Developer Program membership and the Account Holder seat in App Store Connect.
- The product team owns the macOS app’s GitHub repo and runs a GitHub Actions workflow that builds, signs, notarizes, and staples the app using electron-builder, Tauri CLI, xcodebuild, or similar tooling.
- Both teams can see the same repo under one GitHub organization, but registering Environment secrets or editing Environment settings needs the repo’s Admin role.
- The corporate Apple ID and the signing keychain live on an MDM-managed Mac that the IT/SRE team controls; the product team’s machines never hold the private key.
This is a close cousin of the business-vs-vendor split (where a corporate parent owns the program membership and an outside vendor builds the app), but it also shows up inside a single company where the InfoSec / IT / SRE functions are grouped together and the product organization sits separately. Both variants share one trait: Apple’s workflow that needs the Account Holder role and GitHub’s workflow that needs repo Admin both have to be driven, and neither seat is the same person.
Group the Handoff Values into Four Classes with Matching Confidentiality
Before deciding who does what, it helps to name the values that actually have to cross the boundary.
| Class | Content | Use in CI | Confidentiality |
|---|---|---|---|
| A | Team ID (10-character alphanumeric) | Signer identity, APPLE_TEAM_ID | Public (embedded in every signed app) |
| B | Developer ID Application certificate .p12 (with the private key) and the export password | App body signing, DESKTOP_MAC_CSC_LINK / DESKTOP_MAC_CSC_KEY_PASSWORD | Secret |
| C | App Store Connect API key (AuthKey_XXXXXXXXXX.p8), Key ID, Issuer ID | Submission to Apple’s notarization service, APPLE_API_KEY / APPLE_API_KEY_ID / APPLE_API_ISSUER | Secret (Issuer ID is semi-public) |
| D | Ledger entries (certificate name, Team ID, expiry, Key ID, owner) | Who to contact on renewal or revocation, audit evidence | Shareable |
A and D are fine to post in an issue comment or a repo README; B and C are private-key material. With the classes in place, “which team, in which storage, under which role” is decided per class. Public values can live in issues and workflow logs, while secret values are confined to the registration paths described below.
Keep Work That Needs the Account Holder Role Inside the IT/SRE Team
Apple’s official guide is explicit: issuing a Developer ID Application certificate requires the Account Holder role. In corporate memberships, the Account Holder is usually an IT/SRE administrator or an executive, and product-team members with Admin cannot complete the issuance by themselves on the route this post uses (creating a CSR on a local Mac). Apple also offers cloud-managed certificates as a separate mechanism, but this post assumes a setup where a .p12 is handed to CI, so it is out of scope here.
With that constraint, the following chain stays on the IT/SRE side, in order.
- Record the Team ID from Membership details.
- Open Keychain Access, run the Certificate Assistant, and create a CSR with “Specify Key Pair Information” set to RSA 2048.
- In Certificates, Identifiers & Profiles, pick Developer ID Application, and download the
.cer(for replacing certificates issued from the previous Sub-CA, see Apple’s guide). - Import the
.cerinto the same Mac’s login keychain, and confirm withsecurity find-identity -v -p codesigningthatDeveloper ID Application: <corporate name> (<Team ID>)appears. - Right-click the certificate, Export…, choose Personal Information Exchange (.p12), and set a long random export password.
The private key is created in the keychain of the Mac that generated the CSR, so CSR creation through .p12 export stays on one machine. Pinning that machine to an MDM-managed Mac inside the IT/SRE perimeter limits the private key to a single IT/SRE machine, and it never touches a product-team laptop.
If the Account Holder is a different person from the operator (common in large companies), the operator can prepare the .certSigningRequest, hand it to the Account Holder, and receive the issued .cer back; on this route, the issuance step itself (the Developer ID choice and the Download button) is performed by the Account Holder.
Issue the Notarization API Key as a Team Key, One per Organizational Boundary
Notarization uses an App Store Connect API Key rather than Apple-ID-plus-password credentials, so CI never has to carry a 2FA factor. notarytool accepts a specific flavor of key, so this is not quite “any API key will do.”
- Issue the key from Team Keys, not Individual Keys (notarytool does not accept individual keys).
- Pick the
Developeraccess role (the minimum sufficient for notarization). - Name it so the organizational boundary is identifiable, e.g.
inhouse-notarizationorvendor-foo-notarization. - Save the
AuthKey_<Key ID>.p8on the one-time download screen (there is no re-download). - Record the Issuer ID and Key ID.
The axis for splitting is the same as for certificates (see the section below), not the number of products. In-house CI uses one key; separate contracting vendors each get their own. Reusing an existing key across boundaries means a leak or a forced revocation stops every boundary at once, so each boundary gets a new key. Key ID appears in the Apple-side log, so attribution and independent revocation remain tractable per boundary.
Scoping it to the Developer role (and nothing like Admin or Developer Resources from the roles table) keeps the key out of API operations beyond notarization.
Prefer the Path Where IT/SRE Registers the Environment Secrets Directly
B (.p12 plus export password) and C (.p8, Key ID, Issuer ID) have to end up in the product repo’s GitHub Environment secrets. There are two paths.
- Path A (preferred): IT/SRE registers the Environment secrets directly on the repo. The private key and the API key never leave the IT/SRE team.
- Path B: Hand the secret material to the product team through a safe channel such as a 1Password shared vault, and have the product team register the values on GitHub.
In Path A, the product team temporarily grants the IT/SRE registrant Admin on the repo and restores their original role once registration is done. Environment secret registration requires repo Admin; the Write role cannot write to the Settings › Environments page. During that short window the IT/SRE registrant has Admin-level visibility on the whole repo, so the product team should take a short pre-registration pass through the repo for sensitive content left in-tree.
Six secrets land on both development and production environments.
| Secret name | Value |
|---|---|
APPLE_TEAM_ID | A’s Team ID |
DESKTOP_MAC_CSC_LINK | Base64 of the .p12 on one line (base64 -i xxx.p12 | tr -d '\n' | pbcopy) |
DESKTOP_MAC_CSC_KEY_PASSWORD | The .p12 export password |
APPLE_API_KEY | The entire file contents of AuthKey_<Key ID>.p8 (PEM from BEGIN to END) |
APPLE_API_KEY_ID | C’s Key ID |
APPLE_API_ISSUER | C’s Issuer ID |
The secret names are repo-specific and do not match the environment variables the signing tools read. For electron-builder, for example, the workflow maps DESKTOP_MAC_CSC_LINK to CSC_LINK and DESKTOP_MAC_CSC_KEY_PASSWORD to CSC_KEY_PASSWORD when it passes them in.
Path B is the fallback for cases where IT/SRE cannot be granted repo access, or cannot find the time slot to do the registration themselves. The .p12 and .p8 move across a 1Password shared vault with a time-boxed invite, or across an ITIL-approved file-share link with the password sent over a separate channel. In that path the private-key material lands on a product-team workstation, so questions about secret hygiene training, MDM coverage on the download machine, and prompt disposal of the download folder become part of the preconditions.
Which path to pick depends on whether the product team has a workstation where private-key material can land safely, and whether IT/SRE can run a time-boxed Admin grant. The deciding factors are permissions and endpoint management, not inter-team trust. When both operations are in place, choose Path A: the private key never leaves the IT/SRE team, so there is one fewer handoff and less to explain in an audit.
The Product Team Owns Workflow Wiring, Environment Separation, and Verification
Once the secrets are in place, the product team drives. The build-and-release workflow is theirs, and so is the verification that the signature and the notarization actually took.
- Run the
developmentEnvironment with no protection rule, as a build-only (non-distribution) run that confirms the.appand.dmgsign, notarize, and staple cleanly. - Attach Required reviewers to the
productionEnvironment so production distribution runs only after an approval. - In the verification job, assert that
spctl --assess --verbose=4returnsaccepted / Notarized Developer IDand thatcodesign -dv --verbose=4reports the IT/SRE-side corporate name underAuthority. - Once
developmentis clean, repeat the exercise onproductionfor the real distribution path.
Wiring from the GitHub secrets to the signing tool’s environment variables (for electron-builder that is CSC_LINK, CSC_KEY_PASSWORD, APPLE_API_KEY, APPLE_API_KEY_ID, APPLE_API_ISSUER; for Tauri CLI it is APPLE_CERTIFICATE, APPLE_CERTIFICATE_PASSWORD; for xcrun notarytool the --key, --key-id, and --issuer flags) lives in the repo’s yaml, and the yaml only knows the secret names, not the values. GitHub Actions masks secret values in workflow logs.
Scope Certificates by Organizational Boundary, Not by Product
Apple lets a Team hold up to five Developer ID Application certificates. The question is not how to divide this ceiling across products, but how to divide it across the organizational boundaries that own private-key custody.
- When everything is signed and shipped in-house, one certificate can cover any number of products. The private-key custody sits in a single IT/SRE team, and splitting further buys little.
- When a business company delegates development to multiple contracting vendors, issue one certificate per vendor so that a revocation or leak does not take out another vendor’s CI.
- When subsidiaries, business divisions, or other legally or operationally separate groups each run their own CI, give each group its own certificate.
- When one product has a lifecycle so different from the others that a revocation on either side should not drag the other down, carve it onto its own certificate so that a revocation on one side does not stop signing and notarization on the other.
Spent this way, the five-slot allowance tracks “the number of private-key-custody boundaries you want to keep independent,” not the number of products, and the slots do not run out as new products appear. An in-house team plus three contracting vendors adds up to four certificates with one to spare, even if those four boundaries host ten products in total.
When multiple products share one certificate under the same boundary, Path A from the earlier section (IT/SRE registers the GitHub Environment secrets directly) still works unchanged: the shared private key and its export password are registered once per repo on the IT/SRE side, and the product team never holds the private key.
If the number of boundaries exceeds five, enrolling a separate Apple Developer Program under a different legal entity or division adds a Team ID and brings another five-slot pool, at the cost of a second contract, annual fee, and ledger. Weigh that against how many boundaries actually need the separation.
The Apple Distribution certificate (for the App Store route) is a different category and does not share the five-slot allowance, so Developer ID and Mac App Store distribution can proceed in parallel without a slot collision.
Keep a Shared Ledger and Agree on the Renewal Cadence
The class-D ledger is shared across both teams, not owned by either one alone. Renewal and revocation always need both teams to act in order.
The baseline entries are these.
- Certificate common name (corporate name) and Team ID
- Certificate expiry (the date shown on the Certificates page)
.p12fingerprint (SHA-256) and storage location (1Password item URL)- Key ID, Issuer ID, API key name, and creation date
- Named owners on both the IT/SRE side and the product team side
- Apple Developer Program annual renewal date
Renewal always runs in the order “issue the new values → swap them into GitHub → verify → revoke the old values,” without skipping steps. The IT/SRE team drives issuance and revocation, the Environment-secret swap follows either Path A or Path B, and both teams update the ledger. Set up a dedicated alert for the Developer Program annual renewal; a lapsed membership does not break already-distributed apps, but it blocks issuing new certificates.
The Handoff, as a Flow
Here is the pattern put together, with the IT/SRE lane and the product-team lane drawn separately.
flowchart TB
subgraph IT["IT/SRE team"]
direction TB
s1["Record Team ID"]
s2["Create a CSR on the Mac"]
s3["Issue the Developer ID Application certificate<br/>(Account Holder role required)"]
s4["Import on the Mac and verify"]
s5["Export .p12 (with password)"]
s6["Issue an App Store Connect API Team Key<br/>(Developer role)"]
s7{"Secret registration path"}
s7a["Path A (preferred)<br/>IT/SRE registers the GitHub Environment secrets directly<br/>Private key stays inside IT/SRE"]
s7b["Path B<br/>Hand B and C to the product team via 1Password shared vault"]
s1 --> s2 --> s3 --> s4 --> s5 --> s6 --> s7
s7 -->|preferred| s7a
s7 --> s7b
end
subgraph PD["Product team"]
direction TB
s8["Path A: grant registrant temporary Admin, restore original role after registration<br/>Path B: register received values into the Environment"]
s9["Build-only build-and-release run on development<br/>Verify sign / notarize / staple with spctl and codesign"]
s10["Verify the production distribution path<br/>Ship after Required reviewers approve"]
s8 --> s9 --> s10
end
subgraph JOINT["Both teams"]
direction TB
s11["Record in the shared ledger<br/>Name, Team ID, expiry, Key ID, owners"]
end
s7a --> s9
s7b --> s8
s10 --> s11
That’s all from splitting the macOS code signing handover between an IT/SRE team and a product team, from the Gemba.
References
- Apple: Create Developer ID certificates
- Apple: Create a certificate signing request
- Apple: Locate your Team ID
- Apple: Program roles
- Apple: Creating API Keys for App Store Connect API
- Apple: Customizing the notarization workflow
- electron-builder: macOS
- Tauri: Signing macOS applications
- Apple: Packaging Mac software for distribution
- Apple Support: Gatekeeper and runtime protection in macOS
- GitHub Docs: Managing environments for deployment