@daemux/swift-app-ci
v0.0.28
Published
Zero-config TestFlight CI for native Swift/iOS apps — just drop a p8 in creds/ and push.
Downloads
3,448
Maintainers
Readme
@daemux/swift-app-ci
Zero-config TestFlight CI for native Swift / SwiftUI iOS apps. Drop a p8 in
creds/, push to main, and your app ships to TestFlight on GitHub Actions.
No fastlane to configure, no Apple Developer certificates to juggle, no
provisioning profiles to manage by hand.
::warning:: SECURITY ADVISORY — rotate your ASC API key if you ran 0.0.1–0.0.11
Versions 0.0.1 through 0.0.11 of this package exported the raw contents of your App Store Connect
.p8private key as a GitHub Actions environment variable (ASC_KEY_P8). GitHub Actions printsenv:blocks when step groups expand, so the full private key (between-----BEGIN PRIVATE KEY-----and-----END PRIVATE KEY-----) may appear in plaintext in your workflow logs.Even in private repos, log retention, repo admins, and fork permissions can expose these logs. If you ran any build on 0.0.1–0.0.11, treat the key as compromised and rotate it now:
- Go to App Store Connect → Users and Access → Integrations → Keys.
- Revoke the old key.
- Generate a new key with the App Manager role.
- Replace the
.p8undercreds/(the filename encodes the new KEY_ID and ISSUER_UUID:AuthKey_<KEY_ID>_Issuer_<UUID>.p8).- Re-run
npx --yes @daemux/swift-app-cito pick up the fixed action (0.0.12+), which uses the on-disk key path only and registers the key body with::add-mask::as a belt-and-braces redaction.No action is required if you never ran a build on 0.0.1–0.0.11.
Install
From the root of your iOS project repo:
npx --yes @daemux/swift-app-ciWrites two things into your repo:
.github/actions/swift-app/— the vendored composite action (action.yml + scripts).github/workflows/deploy.yml— an 18-line workflow that invokes it on push tomain
Re-run the same command anytime to pull the latest version.
Setup
- Create an App Store Connect API key with the App Manager role (App Store Connect → Users and Access → Keys → generate).
- Download the
.p8and save it into your repo at:
Example:creds/AuthKey_<KEY_ID>_Issuer_<ISSUER_UUID>.p8creds/AuthKey_ABC123DEFG_Issuer_69a6de70-xxxx-47e3-e053-5b8c7c11a4d1.p8The filename encodes both theKEY_IDand theISSUER_ID— the composite action parses them from the filename. - The repo must be private. The p8 is a long-lived credential; never commit it to a public repo.
- (First time only, per app) Create the app record in App Store Connect. See First-time app setup below.
- Commit and push to
main. CI triggers automatically.
How it works
On each push, the composite action runs on macos-15 and:
- Auto-detects your
.xcodeproj/.xcworkspace, scheme, bundle ID, andteam_id(from the ASC API key). Noci.config.yamlrequired — override via action inputs only if auto-detection fails. - Reads the ASC key from
creds/AuthKey_*.p8and uses it to authenticate to App Store Connect via JWT. - Decides the marketing version: either reuses the current
PREPARE_FOR_SUBMISSIONversion on App Store Connect, or creates a new version if the highest declared version is already live. - Computes the next build number by querying ASC for the latest uploaded build and incrementing.
- Provisions signing at runtime: generates a throwaway Apple Distribution
cert + a per-target App Store provisioning profile named
CI-<bundle_id>. Patches the.pbxprojto use Manual signing against those profiles. - Archives with
xcodebuild archive, exports the IPA, and uploads viaxcrun altool. - Sets "What's New" on every declared localization (reads
fastlane/metadata/ios/<locale>/release_notes.txtif present, or from theapp-store-whats-newinput). - Auto-fills empty App Store metadata (name, subtitle, keywords, description, promotional text, what's new) via GitHub Models AI, on every locale that has gaps. See AI metadata auto-fill below.
The only secret required is the p8. Everything else is derived.
AI metadata auto-fill
On every run, after the TestFlight upload succeeds, the action:
- Queries App Store Connect for every
appInfoLocalizationandappStoreVersionLocalizationon the editable version. - Computes the set of empty fields per locale (URL fields are always skipped — you must set those manually in ASC).
- Scans your repo for context (README, Info.plist, dependency files, top
Swift files) and feeds it to
openai/gpt-4ovia GitHub Models with a strict JSON schema. - PATCHes only the fields that were empty — never overwrites existing content.
Fully idempotent: a second run with no empty fields skips the AI step entirely (zero requests, zero PATCHes).
Requirement: permissions: models: read
actions/ai-inference needs the models: read permission. The template
workflow written by npx @daemux/swift-app-ci already includes it:
permissions:
contents: read
models: readExisting consumers must add this block to their deploy.yml at the
workflow or job level. If it's missing, the AI step fails open with a
::warning:: and the rest of the workflow continues unaffected.
Rate limits
GitHub Models free tier allows 50 gpt-4o requests per day (10 per
minute). One workflow run = one request. If you run many apps from the
same GitHub account, or trigger several builds per day, switch to the
cheaper mini model:
- uses: ./.github/actions/swift-app
with:
ai-metadata-model: openai/gpt-4o-minigpt-4o-mini has a much higher free-tier quota.
Disabling AI metadata
Pass ai-metadata: 'false' to skip the AI steps entirely:
- uses: ./.github/actions/swift-app
with:
ai-metadata: 'false'Update
npx --yes @daemux/swift-app-ciOverwrites .github/actions/swift-app/ and .github/workflows/deploy.yml with
the latest versions. Because the action is vendored locally, your workflow
never auto-updates against daemux-plugins's @main — you control updates via
this npm package.
Override config (rare)
Most projects never need this. If auto-detection fails or you have multiple
schemes, pass inputs in .github/workflows/deploy.yml:
- uses: ./.github/actions/swift-app
with:
scheme: MyAppRelease
bundle-id: com.example.myapp
run-tests: 'false'
uses-non-exempt-encryption: 'false'All inputs are declared in .github/actions/swift-app/action.yml. The
common ones:
| Input | Purpose |
|-------|---------|
| project / workspace | Path to .xcodeproj or .xcworkspace |
| scheme | Xcode scheme to archive |
| configuration | Release (default) or custom |
| bundle-id | Override the auto-detected bundle identifier |
| team-id | Override the auto-detected team ID |
| app-store-apple-id | Numeric ASC app ID (override auto-lookup) |
| run-tests | false to skip the simulator test stage |
| uses-non-exempt-encryption | Value for ITSAppUsesNonExemptEncryption |
| archive | false to build-only (PR runs without secrets) |
| upload | false to archive but not upload to TestFlight |
| app-store-whats-new | Inline "What's New" text (overrides files) |
| ai-metadata | false to disable AI auto-fill of empty ASC metadata |
| ai-metadata-model | GitHub Models model id (default openai/gpt-4o) |
First-time app setup
The app record must exist in App Store Connect before the first CI upload. This is a one-time step per app that requires Apple ID + 2FA, so it can't run in CI. Use fastlane locally:
# From your iOS repo (after installing fastlane: gem install fastlane)
APPLE_ID="[email protected]" \
BUNDLE_ID="com.example.myapp" \
APP_NAME="My App" \
fastlane produceOnce the app exists, all subsequent builds and uploads are fully automated via the ASC API key.
Troubleshooting
"No app found for bundle ID" — the app record doesn't exist yet. Run the first-time setup above.
"MARKETING_VERSION is not set" — the action requires MARKETING_VERSION
to be declared in your target's build settings. Open the target in Xcode →
Build Settings → Versioning → set MARKETING_VERSION (and
CURRENT_PROJECT_VERSION) to $(MARKETING_VERSION) /
$(CURRENT_PROJECT_VERSION) respectively.
"You must accept the latest Program License Agreement" — go to developer.apple.com and App Store Connect as the account holder, accept any pending agreements, retry.
Upload fails with provisioning errors — delete any stale profiles named
CI-<bundle_id> on developer.apple.com and re-run; the action will regenerate.
Auto-bumping MARKETING_VERSION
When the ASC combined floor (max of pending review, preReleaseVersions,
or builds-via-preReleaseVersion) exceeds your project's
MARKETING_VERSION, the action auto-bumps and commits the new value as
part of the same bot commit that handles cert refresh / autoupdate.
Default policy is rollover — patch with carry: at .9 it rolls into
the next minor (1.0.9 → 1.1.0), and at minor=9 it cascades into the
next major (1.9.9 → 2.0.0). Major has no upper limit (9.9.9 →
10.0.0). This produces the more natural human progression most
projects want — patch numbers never silently grow past 9.
Four policies are supported:
| Policy | Example bump | When to use |
|--------|--------------|-------------|
| rollover (default) | 1.0.5 → 1.0.6; 1.0.9 → 1.1.0 | Natural progression, carry at .9. |
| patch | 1.0.5 → 1.0.6; 1.0.9 → 1.0.10 | Legacy unbounded patch — pinned for backward compat. |
| minor | 1.0.5 → 1.1.0; 1.0.9 → 1.1.0 | Projects that ship every release as a minor. |
| none | (fails the build) | Explicit semver control via human bump. |
Full rollover behaviour: 1.0.9 → 1.1.0 (patch overflow), 1.9.9
→ 2.0.0 (minor cascade), 9.9.9 → 10.0.0 (major no upper limit).
Backward compat: existing consumers on 0.0.27 that explicitly pin
marketing-version-auto-bump: 'patch' keep their current unbounded
behavior — the 'patch' policy is unchanged. The default change from
'patch' → 'rollover' only affects new installs and consumers that
do not override the input.
Opt out via the action input:
- uses: ./.github/actions/swift-app
with:
marketing-version-auto-bump: 'none'In 'none' mode, the floor check fails the build and you must bump
MARKETING_VERSION manually before retrying.
Side effect: the bot commit subject reflects what was changed, e.g.
ci: refresh signing identity + bump MARKETING_VERSION [skip ci].
Source-of-truth resolution. The auto-bump writes the new value into the file your project actually reads from, in this order:
- xcodegen
project.yml(preferred when present): regex-rewrite of theMARKETING_VERSION:key, preserving formatting. The generated*.xcodeprojis regenerated on every build, so editing it directly would lose the bump. *.xcconfigsitting alongside the project: handles non-xcodegen projects that hoistMARKETING_VERSIONinto xcconfig.*.xcodeproj/project.pbxproj: only when no xcodegen spec is present.Info.plistCFBundleShortVersionString: last-ditch fallback.
If your project uses xcodegen but MARKETING_VERSION lives somewhere
not in project.yml or .xcconfig, the action emits a ::warning::
and falls back to fail-on-floor (refusing to silently edit the
generated pbxproj). Either move MARKETING_VERSION under
settings.base in project.yml, or pin
marketing-version-auto-bump: 'none' and bump manually.
Auto-updates
The vendored action ships with a per-run autoupdate check. On every
push to your default branch, the action queries npm for the latest
@daemux/swift-app-ci, compares against the local marker at
.github/actions/swift-app/.daemux-version, and if newer, re-vendors
the package via npx --yes and commits the refreshed action files
back (under .github/actions/swift-app/ only).
.github/workflows/deploy.yml is NEVER auto-committed — see
"deploy.yml is not auto-updated" below.
| Aspect | Behaviour |
|--------|-----------|
| Trigger | Push to default branch only (PR / branch runs do nothing) |
| Lag | One run — the next push after a new release picks up the update |
| Suppression | [skip ci] in the commit subject + paths-ignore for .github/actions/swift-app/** |
| Combined commit | Cert refresh + autoupdate share a single commit when both fire in the same run |
| Failure mode | Non-fatal: a failed npm view or npx emits ::warning:: and the build continues |
The shipped deploy.yml template already includes the required
paths-ignore entry for .github/actions/swift-app/**. If you have an
older deploy.yml checked in, add that line to prevent the
autoupdate commit-back from re-triggering the workflow.
deploy.yml is not auto-updated
deploy.yml is NOT auto-updated. GitHub's GITHUB_TOKEN cannot
push changes to workflow files (.github/workflows/*.yml) regardless
of contents: write — this is a built-in safeguard against CI
self-modification. When a new version of @daemux/swift-app-ci
requires deploy.yml schema changes (e.g., new permissions, new
paths-ignore entries), the action's release notes will call this out
and you must run npx --yes @daemux/swift-app-ci manually once to
sync your deploy.yml. Existing deploy.yml stays untouched on every
auto-update cycle until you do.
Opt out
Pin the vendored copy by passing auto-update: 'false' to the action:
- uses: ./.github/actions/swift-app
with:
auto-update: 'false'First-run bootstrap
The marker is written by npx @daemux/swift-app-ci itself. A repo
without a marker (e.g. an old hand-vendored copy) will be treated as
out-of-date on its first run, after which updates land incrementally.
Run npx --yes @daemux/swift-app-ci once locally if you want to skip
even that first auto-bootstrap.
License
MIT
