Reusable GitHub Actions workflows and composite actions for the privacykey macOS app portfolio. One pipeline, one place to fix it.
| Path | What it is |
|---|---|
.github/workflows/macos-sparkle-release.yml |
Tag-triggered release: test gate → sign → notarize → DMG → appcast → GitHub Release → optional Homebrew cask PR against the tap |
.github/workflows/macos-app-ci.yml |
Push/PR CI: unsigned build + tests, zero secrets |
actions/setup-apple-keychain |
Ephemeral keychain + Developer ID cert import (-T codesign, set-key-partition-list, masked password) |
actions/write-asc-api-key |
Stages the App Store Connect .p8 as a mode-600 file |
actions/install-sparkle-cli |
Pinned Sparkle release tarball with SHA-256 verification |
actions/publish-gh-pages-file |
Worktree-based single-file publish to a branch (appcast.xml → gh-pages) |
actions/assert-trusted-runner |
Fail-closed guard: on a self-hosted runner, refuses to continue unless the repository is private and the pull request is not from a fork |
A consumer release.yml is ~15 lines:
name: Release
on:
push:
tags: ['v*']
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: write
jobs:
release:
uses: privacykey/gh-workflows/.github/workflows/macos-sparkle-release.yml@v1
with:
xcodeproj: MyApp/MyApp.xcodeproj
scheme: MyApp
uses_xcodegen: true
secrets: inheritNotes on that snippet:
secrets: inheritis the recommended mode. The release job inside the reusable workflow declaresenvironment: macos-signing; environment secrets (SPARKLE_PRIVATE_KEYlives there) only resolve inside that job. Explicitsecrets:mappings are resolved in the caller's context, which has no environment, and would come back empty. If all your secrets are repo/org-scoped, explicit mapping works too.permissions: contents: writeon the caller is required — a called workflow can only reduce the caller's token permissions, never raise them.concurrencyin the caller is deliberate duplication: GitHub's handling of workflow-levelconcurrencyinside a called workflow is inconsistent, so both sides declare it.- The
macos-signingenvironment is auto-created (unprotected) on first run. Add a required-reviewers rule to it so releases pause for human approval before any secret is read. Tests run before that gate.
Frequently used inputs (see the workflow header for the full list and the release-script contract):
| Input | Default | Notes |
|---|---|---|
xcodeproj |
— (required) | Path to the .xcodeproj |
scheme |
— (required) | Also assumed to be the target name |
app_name |
scheme | Used in the DMG filename |
uses_xcodegen |
false |
brew install xcodegen + xcodegen generate first |
sparkle_version / sparkle_sha256 |
2.9.5 / pinned digest |
Bump together, always |
dmg_name |
{app}-{version}.dmg |
Verified against the release script's output |
cask_name / tap_repo |
empty | Both set (plus HOMEBREW_TAP_TOKEN) enables the cask PR flow (see below); template lives at packaging/homebrew/<cask_name>.rb |
appcast_branch |
gh-pages |
Where appcast.xml is pushed |
runner |
macos-15 |
Plain label, or a JSON array string for the self-hosted fleet — see Self-hosted runners |
release_runner |
same as runner |
Set separately to keep signing off the ordinary CI machine |
macos_runner |
empty | Deprecated, superseded by runner; a non-empty value still wins so existing callers keep working |
publish_dsym |
false |
true attaches the dSYM zip to the public Release (it is always kept as a private 365-day workflow artefact) |
release_script |
./scripts/release.sh |
Must honour the env contract below |
appcast_script |
empty (built-in) | Only set if your repo needs a custom appcast |
The consumer repo owns its build script (so releases can be dry-run locally). The workflow invokes it with:
- reads (env):
APPLE_SIGNING_IDENTITY,APPLE_API_KEY_PATH,APPLE_API_KEY_ID,APPLE_API_ISSUER,KEYCHAIN_PATH,SCHEME - writes:
dist/<app>-<version>.dmg(signed + notarized + stapled), optionallysymbols/<app>-<version>.app.dSYM.zip
A typical implementation: xcodebuild archive → export → notarytool submit --wait → staple → hdiutil DMG → codesign + notarize + staple the DMG.
When cask_name, tap_repo, and the HOMEBREW_TAP_TOKEN secret are all
set, the release job:
- renders
packaging/homebrew/<cask_name>.rbfrom the consumer repo's template, substituting@@VERSION@@/@@SHA256@@/@@URL@@; - pushes the rendered cask to a
release/<cask_name>-<version>branch in the tap repo; - opens a pull request in the tap (via
gh, authenticated withHOMEBREW_TAP_TOKEN) whose body links the triggering GitHub Release and carries the version/SHA-256/DMG details.
Nothing is ever pushed to the tap's default branch. Merging the tap PR is
the publish action — that's when brew upgrade --cask <cask_name> starts
seeing the new version. Re-running a release force-refreshes the same
release/<cask_name>-<version> branch and reuses the already-open PR
instead of stacking duplicates. If any of the three settings is missing,
the step skips cleanly and the rest of the release is unaffected.
Token requirements: HOMEBREW_TAP_TOKEN must be a fine-grained PAT
scoped to only the tap repo, with Contents: read & write and
Pull requests: read & write. A token that only carries Contents
fails at gh pr create.
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
ci:
uses: privacykey/gh-workflows/.github/workflows/macos-app-ci.yml@v1
with:
xcodeproj: MyApp/MyApp.xcodeproj
scheme: MyApp
uses_xcodegen: trueNo secrets, CODE_SIGNING_ALLOWED=NO, xcresult uploaded as a 7-day
artifact. test_script overrides the default xcodebuild test invocation
if a repo needs something custom.
Repos that predate the current secret scheme use gen-1 names. Same values where noted — mostly this is a rename plus swapping Apple-ID notarization for an App Store Connect API key.
| gen-1 (old) | gen-3 (this repo) | Migration |
|---|---|---|
APPLE_DEVELOPER_ID_CERT |
APPLE_CERTIFICATE |
Rename — same base64 .p12 |
APPLE_DEVELOPER_ID_PASSWORD |
APPLE_CERTIFICATE_PASSWORD |
Rename — same passphrase |
| (none — script probed the keychain) | APPLE_SIGNING_IDENTITY |
New: the exact "Developer ID Application: … (TEAMID)" string |
APPLE_NOTARY_USER (Apple ID) |
(retired) | Replaced by ASC API key auth |
APPLE_NOTARY_PASSWORD (app-specific password) |
(retired) | Replaced by ASC API key auth |
APPLE_NOTARY_TEAM_ID |
(retired) | Team ID is derived from the signing identity |
| (none) | APPLE_API_KEY |
New: full PEM contents of the ASC .p8 |
| (none) | APPLE_API_KEY_ID |
New: 10-char Key ID |
| (none) | APPLE_API_ISSUER |
New: Issuer UUID |
SPARKLE_PRIVATE_KEY |
SPARKLE_PRIVATE_KEY |
Unchanged — but move it into the macos-signing environment |
| (none) | HOMEBREW_TAP_TOKEN (optional) |
Fine-grained PAT scoped to the tap repo only — Contents and Pull requests, read & write (the cask lands as a tap PR, not a direct push) |
Why the ASC API key beats Apple-ID + app-specific password: revocable per-key in one click, immune to Apple ID 2FA prompts, and org-shareable without sharing an account.
Repo-specific migration steps live in each consumer repo's migration PR. The generic sequence:
- Mint an App Store Connect API key; add
APPLE_API_KEY,APPLE_API_KEY_ID,APPLE_API_ISSUER. Rename any gen-1 secrets per the mapping table above. - Create the
macos-signingenvironment; moveSPARKLE_PRIVATE_KEYinto it; add a required-reviewers rule. - Bring the repo's release script onto the env contract above
(notarytool
--key/--key-id/--issuer; honourKEYCHAIN_PATHviacodesign --keychainon any directcodesigncalls). - Replace
release.ymlwith the ~15-line caller (secrets: inherit) andci.ymlwith themacos-app-ci.ymlcaller. - If the app ships a Homebrew cask: add the
packaging/homebrew/<cask_name>.rbtemplate, setcask_name/tap_repo, and mint aHOMEBREW_TAP_TOKENPAT (Contents and Pull requests, read & write). After each release, merging the tap PR is the cask publish step. - Retire the gen-1 secrets (
APPLE_NOTARY_*,APPLE_DEVELOPER_ID_*) once the new flow is proven.
- Consumers pin this repo's workflows by tag:
...@v1. Cut annotated tags here (v1,v1.x.y) and move the major tag deliberately. - Inside the workflows, the composite actions are referenced as
privacykey/gh-workflows/actions/<name>@main. GitHub resolves those refs at run time, independently of the tag the workflow was pinned at — a relative path can't be used because the job's checkout is the consumer repo, not this one. Consequence: a push tomainhere changes behaviour under consumers pinned to@v1. Treatmainas release-stable, or (more robust) update the@mainrefs to@v1as part of cutting each release tag so the whole dependency chain is tag-pinned. - Third-party actions are SHA-pinned with a version comment. Current pins:
actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10— v6.0.3maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90— v1.7.0softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228— v3.0.2actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a— v7.0.1ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b— v1.321.0
- Sparkle CLI: version
2.9.5, tarball SHA-256015336b601493e05c237964954bff6191370003d94edefe663724c88840d73cc. Bumpsparkle_versionandsparkle_sha256together.
Both workflows default to GitHub-hosted images. Pointing one at self-hosted hardware is an explicit, per-consumer opt-in — pass a JSON array of labels:
with:
runner: '["self-hosted", "macOS", "ARM64", "repo-ci"]'The rule the workflows enforce: self-hosted runners are for private repositories only.
The reasoning is short. A self-hosted runner is a persistent machine that is not wiped between jobs, so a job that runs on it can read whatever the previous job left, plus whatever else that account can reach. A public repository accepts pull requests from anyone, and a pull request is a proposal to run the author's code. Meanwhile Actions minutes for public repositories are free — so a public repository on owned hardware takes on the entire risk in exchange for nothing.
Two independent gates enforce it:
- Job-level
if:— rejects fork pull requests before a runner is allocated, so fork code never reaches the host at all. actions/assert-trusted-runner— the first step of every job, before checkout. Re-checks visibility and fork status on the runner itself and fails the job if either is wrong. On a GitHub-hosted runner it is a no-op, which is what lets it live in a shared workflow.
Visibility is read from the event payload and fails closed: if it cannot be determined, the job stops.
Two things the gates deliberately do not do:
- They do not make a self-hosted runner safe for code from people you don't trust. Anyone who can push a branch to a private repository can run code on the host on purpose — the gates keep out strangers, not collaborators. Keep the runner account free of credentials and personal data regardless.
- They do not replace the account boundary.
repo-ciandrelease-signingare labels; labels route jobs, they don't isolate them. Separate macOS accounts do. Arepo-cirunner must never hold a distribution certificate.
Consumers on the fleet also inherit two behaviour changes, both automatic:
maxim-lobanov/setup-xcode is skipped (it re-points the machine's selected
Xcode, which on a shared host would reach into every other repository's
builds), and xcodegen is expected to be preinstalled rather than
brew installed per job. The workflow checks for it and fails with a clear
message if it is missing.
- Tests before secrets. The
testjob has no secrets and no environment; themacos-signingapproval gate sits between it and thereleasejob. - Fail closed, not degraded. No graceful-degradation ladder (no cert
→ unsigned; no ASC key → signed-not-notarized): for DMG-shipping
consumer apps, a half-signed public release is worse than a failed run.
All signing secrets are
required. - No nested Sparkle re-sign step. The xcodebuild archive/export path signs nested code (Sparkle's XPC services included) correctly on its own; a separate re-sign step is only needed for hand-assembled .app bundles, which this pipeline does not support.
- Build scripts stay in consumer repos (local dry-runs), the orchestration lives here. Appcast generation is built in, including key-format validation.