gowalk-cicd
v1.0.3
Published
Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.
Maintainers
Readme
gowalk-cicd
One installer for iOS TestFlight and Android Google Play delivery. It supports
native Swift/SwiftUI iOS projects and Flutter apps. Put the platform keys under
creds/, push to main, and both store builds run only on GitHub Actions.
Install
From the root of your app repo:
npx --yes gowalk-cicdWrites three things into your repo:
.github/actions/swift-app/— the vendored composite action (action.yml + scripts).github/actions/android-app/— Flutter Android build/sign action.github/workflows/deploy.yml— workflow that builds and deploys both platforms
Re-run the same command anytime to pull the latest version.
iOS credentials
- 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.
- The existing metadata-rich form
*(key_id_<KEY_ID>_issuer_<UUID>_vendor_id_<ID>).p8is also accepted.
Android credentials
Place these files under creds/:
android-upload-key.jks— the long-lived Google Play upload keystore.android-signing.properties— signing metadata in this exact format:storeFile=android-upload-key.jks storePassword=<keystore password> keyAlias=<upload key alias> keyPassword=<upload key password>One Google service-account JSON key. Its filename is unrestricted (
Gowalk.jsonis valid); the action identifies it by thetype: service_accountJSON fields.
The service account must have Google Play Console access to the app and the Android Publisher API must be enabled. Keep the repository private. The workflow reads these files from the checkout, so they must be available to GitHub Actions (this package's zero-config convention is to track them only in a private repository).
Google Play requires the first AAB to be uploaded through Play Console. The
first CI run still succeeds and retains the signed AAB as an
android-<package>-<versionCode> workflow artifact. Upload that artifact once
in Play Console; every later run detects API readiness and uploads to the
internal track automatically. Override the track or status with repository
variables GOOGLE_PLAY_TRACK and GOOGLE_PLAY_STATUS.
Android delivery via Bitrise (alternative mode)
Use this when the Android upload keystore password lives in Bitrise (not in the repo) — the GitHub runner then cannot sign the AAB locally. In this mode the Android job triggers a Bitrise workflow (which holds the keystore + Play service-account secrets) over the Bitrise API and waits for it, mirroring the build result back into the GitHub Actions run. iOS still ships via GitHub Actions as normal.
Enable it by committing creds/bitrise.json:
{
"enabled": true,
"app_slug": "8b2a7a24-....",
"workflow": "android-deploy",
"branch": "main",
"api_token": "bitpat_...."
}app_slug— the Bitrise app slug (from the app's Bitrise URL orGET https://api.bitrise.io/v0.1/apps).workflow— the Bitrise workflow that builds, signs, and runsgoogle-play-deploy(defaultandroid-deploy).branch— optional; defaults to the pushed ref.api_token— a Bitrise personal access token. Committing it to a private repo'screds/follows the same trust model as the ASC.p8. For non-private repos, omit it here and set theBITRISE_API_TOKENActions secret instead.
When creds/bitrise.json is present with enabled: true, the local
build/sign/upload steps are skipped and bitrise_deploy.py drives the Bitrise
build; otherwise the local path above runs unchanged.
The Bitrise android-deploy workflow must produce a versionCode higher than
what is already on Play. The convention used by this fleet is a +200 offset
over $BITRISE_BUILD_NUMBER via a change-android-versioncode-and-versionname
step, with the versionName read from pubspec.yaml.
Sharing one distribution cert across a team's apps
The iOS action caches the Apple Distribution cert under creds/ and reuses it
across runs (cert alive + NotAfter > 30 days). But Apple caps a team at 2
distribution certs, and the cache is per-repo. If three or more apps share one
Apple Developer team, they cannot each hold their own cert — every run finds
its cached cert already revoked by a sibling, mints a new one, and revokes
another. The symptom is a "Your Certificate Has Been Revoked" email on nearly
every deploy.
Fix: apps in the same Apple team should share one cert. Commit the same
creds/cert.p12 + creds/cert.meta.json into every repo of that team (an Apple
Distribution cert is team-scoped, not app-scoped, so one cert signs them all).
Each app then gets a cache hit and no new cert is ever created. Provisioning
profiles stay per-app (named CI-<bundle_id>) and regenerate against the shared
cert on the next run. When onboarding a new app into an existing team, copy a
sibling's cert.p12 + cert.meta.json in rather than letting the first run
create a fresh cert.
How it works
On every push, iOS and Android run in parallel. Flutter is installed only on
the GitHub-hosted runners. The Android job runs flutter analyze,
flutter test, builds an AAB with GITHUB_RUN_NUMBER as its monotonically
increasing version code, replaces any development signature with the upload
key, and retains the signed bundle before contacting Google Play.
iOS delivery
The iOS 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 iOS action requires only 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 gowalk-cicd 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 gowalk-cicdOverwrites both vendored actions and .github/workflows/deploy.yml with the
latest versions. Because the actions are vendored locally, builds use the
reviewed copy in your app repository; updates arrive through 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. Create it once in the App Store Connect web interface with the same bundle ID. Do not run store deployment tools locally; builds, signing, and uploads remain inside GitHub Actions.
Once 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
gowalk-cicd, 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 both .github/actions/swift-app/ and
.github/actions/android-app/).
.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 both action directories |
| 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 entries for both action directories. If you have an
older deploy.yml checked in, add both entries 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 gowalk-cicd
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 gowalk-cicd 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 gowalk-cicd 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 gowalk-cicd once locally if you want to skip
even that first auto-bootstrap.
License
MIT
