gowalk-cicd
v1.0.146
Published
Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.
Maintainers
Readme
gowalk-cicd
One installer for iOS TestFlight, Android Google Play, and Flutter web release artifacts. It supports native Swift/SwiftUI iOS projects, native Kotlin/Gradle Android projects, and Flutter apps. Provision encrypted repository secrets, validate a task candidate, and promote its release intent. Store builds and uploads run only in GitHub Actions.
App-specific workflow settings live in .gowalk-cicd.yml. The installer migrates supported
existing settings once and regenerates managed workflows without replacing custom behavior.
See configuration and parallel organization upgrades.
Install
From the root of your app repo:
npx --yes gowalk-cicdWrites three core things into your repo:
.github/actions/swift-app/— the vendored composite action (action.yml + scripts).github/actions/android-app/— Android build/sign action (Flutter or Gradle).github/workflows/deploy.yml— workflow that builds and deploys both platforms
Flutter projects that contain web/index.html also get
.github/workflows/deploy-web.yml. It creates a version-stamped release build
and retains build/web as a 30-day workflow artifact. Native-only repositories
are unaffected.
Re-run the same command anytime to pull the latest version.
Ready task PRs build shipping binaries alongside validation; the protected merge promotes matching binaries through the existing store upload paths. See candidate artifact reuse for identity checks, native dependency caches, version handling and adoption of custom workflows.
Mobile delivery requires an explicit frozen release intent. After source, signing, account transport, and integration readiness pass, run on the task branch:
npx --yes gowalk-cicd release both # or ios / android
# Inspect and commit this deterministic manifest with the candidate.
git add -f .factory/releases/mobile.jsonMerge the exact validated candidate through the repository's protected PR. Only a change to
.factory/releases/mobile.json triggers Mobile Deploy on main. Ordinary checkpoint, backend,
web, documentation and tooling landings do not trigger a store build. Backend and web retain
their own workflows. Web compilation no longer starts just because a plugin version marker changed.
The intent binds committed source and effective organization/repository variables, with repository precedence. A changed source input or compiled variable refuses before builds; prepare a new intent after the repair. Native iOS and Android directories are scoped to their own platform. Unknown inputs, embedded editors, assets, scripts, dependency locks and backend-generated resources remain in the conservative source fingerprint. Documents remain inputs because an app may bundle them. There is no blanket editor/backend exclusion from the input proof. These conservative hashes do not add push triggers.
The gate records each platform in GitHub deployment receipts, scoped to that input fingerprint.
A confirmed upload is reused on subsequent attempts and emits mobile_upload_reused with the
original run/attempt/source. Read that run's annotations and current store state: upload reuse
still requires unfinished metadata, symbols, policy replies and review submission to be completed
through the existing scoped commands. It never proves store review approval.
Store-upload intent is persisted before provider writes, and success immediately after the uploader
confirms. An interrupted upload refuses another build: inspect the retained signed binary/upload
receipt and read back its exact version with the account-pinned store tooling. Resume that same
upload when necessary. After positive upload readback, reconcile the named GitHub deployment to
success with description uploaded, retaining the provider evidence in the app task. Never clear
an uncertain receipt based on elapsed time or a missing response. A terminal attempt that failed
before the upload marker may retry. Rerun the whole workflow so admission and platform jobs share
one run attempt; a failed-jobs-only rerun with an older admission receipt refuses.
Mobile runs serialize without cancelling an in-flight upload. Manual dispatch obeys the same intent and receipts; it cannot silently rebuild an already-uploaded input. Feature-branch dispatch retains backend preview, while store delivery requires the default branch's validated intent. Existing consumer workflows keep their behavior until deliberately adopting this template; action-only updates do not change triggers. Preserve custom build inputs and readiness when adopting.
Flutter's optional SDK and pub cache restores have a one-minute timeout per download segment. A slow segment becomes a cache miss and normal SDK setup continues normally; SDK setup errors still fail the job. This bounds individual cache segments, not the total cache or SDK download, and keeps existing cache keys. A successfully installed SDK is saved before app checks run, so a later test/build failure does not force another cold SDK download. Failed setup never saves an SDK cache; pub dependencies retain their job-end save.
Native dependency preparation uses a committed Gemfile and Gemfile.lock beside
the selected iOS project, falling back to the repository root. It installs missing
bundle gems through the assigned Google relay and runs Pods with bundle exec.
The Flutter workflow uses that same bundle before configuration can invoke Pods;
Ruby settings remain scoped to those commands. Provide a Ruby/Bundler runtime
compatible with the app's lockfile. Changed or uncommitted locks fail before delivery.
When adopting this update, retain the native_bundle.py configuration wrapper in
custom workflows as well as updating the action files. Apps without a Gemfile keep
their existing CocoaPods runtime.
Flutter --config-only can query Xcode and invoke pod install. The workflow first
runs flutter pub get to generate the Podfile's Flutter inputs, then installs locked
Pods and verifies the selected workspace before release configuration. Preserve this
order in customized validation and deployment workflows; updating action directories
alone cannot repair a configuration step that runs before the action.
Flutter web release
The conditional deploy-web.yml workflow runs on changes to Flutter source,
web assets, package metadata, localization config, or the workflow itself. It
also supports manual dispatch. The build uses GITHUB_RUN_NUMBER as its build
number and uploads the complete build/web directory as a release artifact.
It builds with --no-web-resources-cdn so Flutter selects the renderer bundled in
that artifact. This does not make an app's fonts or integrations offline: declare
the actual default Roboto fallback and required locale fonts in the app's source
when offline rendering is required. Keep app-specific build inputs when adopting
the workflow; rebuild source rather than editing a retained release artifact.
Private git dependencies use the same optional GIT_PRIVATE_TOKEN secret as
the mobile workflow. No hosting provider is assumed: consumers can deploy the
artifact to their chosen host without granting this package an unrelated cloud
account or domain.
iOS credentials
Use encrypted repository secrets for both the App Store Connect API key and the reusable Apple Distribution identity:
ASC_KEY_P8,ASC_KEY_ID,ASC_ISSUER_IDIOS_DISTRIBUTION_P12_BASE64IOS_DISTRIBUTION_CERT_META_BASE64IOS_DISTRIBUTION_CERT_REGISTRY_BASE64
All three distribution-identity values are base64 so their exact bytes—and the
registry digests over those bytes—survive the secret round trip. The workflow
materializes them with mode 0600 only in the ephemeral runner
checkout. Refreshed signing files are never staged or committed by the
composite action, including when a legacy workflow passes persist-signing-cache: true.
That input is retained as an ignored compatibility input.
The action reuses a supplied identity and never issues a new distribution certificate. A missing,
invalid or revoked legacy identity fails with apple_signing_identity_required; reconcile and
provision retained material through the encrypted inputs above before retrying. A temporary
job cannot be the sole owner of a new private key. Existing legacy cache files remain readable
and are preserved after refusal; registry identities retain their stricter ownership checks.
Distribution identity files come from the signing registry provisioner; pass their paths to the secret tooling without printing their contents.
Legacy private repositories can still provide the API key at:
creds/AuthKey_<KEY_ID>_Issuer_<ISSUER_UUID>.p8The filename encodes both the key ID and issuer ID. The metadata-rich form
*(key_id_<KEY_ID>_issuer_<UUID>_vendor_id_<ID>).p8 is also accepted. Never
commit this fallback to a public repository. The app record must already exist
in App Store Connect; see First-time app setup.
Backend deploy (Python + Postgres, Docker)
When a repo carries a Dockerized backend, the installer also vendors a backend deploy path:
.github/actions/backend-app/ and .github/workflows/deploy-backend.yml. A
mobile-only repo is unaffected (the backend path is not installed).
Both supported layouts keep application source under backend/ or server/:
- the compose file can live beside that source as
backend/docker-compose.yml; or - it can live at the repository root as
docker-compose.ymland build./backend(equivalently forserver/).
The backend workflow runs on a GitHub-hosted runner and, on a push touching
backend/**, deploys to the gowalk host (138.197.36.107, publicly reachable):
it rsyncs the backend dir to /opt/gowalk-backends/<app>/, runs
docker compose up -d --build, auto-detects the published 127.0.0.1:<port>,
wires an nginx vhost + Let's Encrypt cert for the API domain, and health-checks.
Hosts with several existing Let's Encrypt accounts select one deterministically,
and a domain deploy fails unless its public HTTPS certificate and health route validate.
Changing a container's published port preserves its existing HTTPS configuration. An existing
certificate is installed for the requested domain on redeploy, repairing older HTTP-only vhosts too.
Requirements on the consumer repo:
- A supported
docker-compose.ymlthat publishes the API on a loopback host port (127.0.0.1:<port>:<container-port>) so nginx can proxy it. Include a Postgres service (or use a per-app database) and a/healthendpoint. - A
Dockerfilethe compose file builds; pin the base image, run non-root, never bake secrets in. A generated.env(withPOSTGRES_PASSWORD) is created on the host on first deploy and preserved across deploys (rsync excludes it). - Repo secret
BACKEND_DEPLOY_SSH_KEY(a private key with access to the host). - Repo secret
BACKEND_RUNTIME_ENV(optional): newline-delimitedKEY=valueruntime secrets. The action writes it as mode 0600 on the host and passes it to Compose without copying values into the repository or action log. - Repo variables (optional):
BACKEND_API_DOMAIN(its DNS A record must point at the host for the cert; empty = deploy the container only, skip nginx/cert),BACKEND_APP_NAME(defaults to the repo name),BACKEND_HEALTH_PATH(default/health),BACKEND_DEPLOY_HOST(default138.197.36.107).
The action directory is canonical here (like the iOS/Android actions): edit it in
backend-action/, never in a consumer's vendored copy.
For a new app whose separate backend workflow is not registered on the default
branch yet, manually dispatching Mobile Deploy on the feature branch runs
the same backend action as a backend-preview job. The job is feature-branch
only; normal default-branch backend deploys remain in deploy-backend.yml.
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.
These are the same three files whether the app is Flutter or native Kotlin/Gradle — see Android: Flutter or native Gradle.
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).
Choosing which stores a repo ships to
deploy.yml runs iOS and Android in parallel. Set the repository variable
DEPLOY_PLATFORMS when one of them is intentionally out of scope, so an
unrelated failure on the platform you do not care about cannot block the
release you do:
| DEPLOY_PLATFORMS | Result |
|---|---|
| unset / both | iOS + Android (default) |
| android | Android only — the iOS job is skipped |
| ios | iOS only — the Android job is skipped |
gh variable set DEPLOY_PLATFORMS --body androidTrack, status and staged rollout
| Variable | Default | Purpose |
|---|---|---|
| GOOGLE_PLAY_TRACK | internal | internal, alpha, beta, production, or a custom track name |
| GOOGLE_PLAY_STATUS | completed | completed (full rollout) or inProgress (staged) |
| GOOGLE_PLAY_USER_FRACTION | 0.2 | Audience share, only read when the status is inProgress |
Play rejects an inProgress release that does not declare its audience share,
and rejects a completed one that does. The workflow sends userFraction only
in the mode that accepts it, so setting the fraction while leaving the status at
completed is harmless rather than a failed upload.
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.
Choosing the JDK
Flutter apps build on JDK 17 and native Gradle apps on JDK 21. Set the
repository variable JAVA_VERSION to pin a different one — it always wins, and
nothing second-guesses it:
gh variable set JAVA_VERSION --body 17Left unset, the Android job keeps those defaults with one exception: a native
Gradle app whose Kotlin Gradle plugin is older than 1.9.20 gets JDK 17,
because 1.9.20 is the release that added JDK 21 support. Older kapt reaches
into javac internals that JDK 21 moved, and the build dies with
Execution failed for task ':app:kaptGenerateStubsReleaseKotlin'
> Internal compiler error. See log for more detailswhich names neither Kotlin nor the JDK. The job logs the JDK it picked and why.
The downgrade only fires when the Kotlin plugin version can actually be read
out of the repo — build.gradle(.kts), gradle.properties,
gradle/libs.versions.toml, or a buildSrc version object. When it cannot, or
when the project explicitly targets Java 21 or newer, the app keeps JDK 21.
Gradle daemon heap
A gradle.properties sized for a workstation does not fit a CI runner.
-Xmx8G with a 4 GB metaspace reserves 12 GB for the Gradle daemon alone,
before the Kotlin compile daemon spawns its own JVM — on a 16 GB runner the
build is killed mid-compile, and GitHub reports that as a cancelled job
rather than a failure, so it reads as a flake rather than an out-of-memory.
Before building, the Android job caps -Xmx at 4 GB and MaxMetaspaceSize at
2 GB in the checkout, with a ::warning:: naming the original value. Projects
already at or below those numbers are left untouched. Linux only.
When Play will not accept a release for review
Some Play changes require a separate console review submission. The ordinary API commit then returns this explicit refusal:
Changes cannot be sent for review automatically. Please set the query parameter
changesNotSentForReview to true. Once committed, the changes in this edit can be
sent for review from the Google Play Console UI.The uncommitted edit has not published the release. Only this exact refusal lets
the upload wrapper retry the same signed artifact with changesNotSentForReview.
A successful retry records the separate review submission that remains, using a
typed check-run annotation (see Check-run annotations):
::notice title=play_review_pending::{"schema":"gowalk-cicd/play-review-pending.v1",
"package":"<applicationId>","track":"<track>","version_code":"<versionCode>"}There is no API for the Send changes for review press — Google reserves it for the Play Console — so the panel that merged the commit reads the annotation and opens its own Play console step, which promotes the uploaded version code to the track Google named and presses it. Nobody is asked to notice a warning.
In the ordinary case the first upload submits normally and the retry is skipped.
When an App Store version is in review
While any App Store version of the app is WAITING_FOR_REVIEW, IN_REVIEW or
PENDING_DEVELOPER_RELEASE, App Store Connect refuses to create or edit any
version — every POST /appStoreVersions answers
409 ENTITY_ERROR.RELATIONSHIP.INVALID ("You cannot create a new version of the
App in the current state"), whatever version string is asked for. The version
step used to see only rows at the project's own MARKETING_VERSION, so a sibling
in review (1.6.3 under review, project at 1.6.4) went unnoticed, CREATE was
attempted and the deploy failed on every push until the review ended.
The decision is now TESTFLIGHT_ONLY: the build is stamped with the locked
version string (so it joins that version's TestFlight train instead of opening a
new marketing-version train), the build number still advances, the archive is
uploaded to TestFlight, and the App Store metadata steps are skipped. The project
file is never touched — MARKETING_VERSION stays the source of truth for the next
release. The job records a typed annotation so the panel knows the build went to
TestFlight only:
::notice title=store_version_locked::{"schema":"gowalk-cicd/store-version-locked.v1",
"version":"1.6.3","state":"WAITING_FOR_REVIEW","build_number":"239"}Check-run annotations the panel reads
Two situations end a green job with work left for something other than this
workflow. Each is recorded as a GitHub check-run annotation whose title names
the situation and whose message is one line of JSON carrying a versioned
schema. The panel that merged the commit reads them through the check-runs API
— never by parsing log text — so the title and schema are a contract:
| title | schema | fields |
| --- | --- | --- |
| play_review_pending | gowalk-cicd/play-review-pending.v1 | package, track, version_code |
| play_upload_result | gowalk-cicd/play-upload.v1 | ok, code, attempts, review_pending, upstream identity |
| play_upload_diagnostic | gowalk-cicd/play-upload-diagnostic.v1 | Fixed process evidence; see below |
| play_edit_owner | gowalk-cicd/play-owner.v1 | Ownership refusal; no implied upload retry permission |
| store_version_locked | gowalk-cicd/store-version-locked.v1 | version, state, build_number |
| firebase_symbols_pending | gowalk-cicd/firebase-symbols-pending.v1 | platform, app_id, source_sha, artifact, file, sha256, status |
| mobile_upload_reused | gowalk-cicd/mobile-upload-reused.v1 | platform, fingerprint, run_id, run_attempt, source_sha, deployment_id, next_action |
| source_maintenance_pending | gowalk-cicd/source-maintenance-pending.v1 | repo, base_sha, head_sha, branch, run_id |
| apple_build_upload_failed (error) | gowalk-cicd/apple-build-upload-failed.v2 | category, stage, receipt_available, receipt identifiers (cfBundleVersion, upload_id, file_id, state, provider_status, provider_code, provider_pointer), action |
| flutter_download_failed (error) | gowalk-cicd/flutter-download-failed.v1 | phase, curl_exit, http_status, proxy_status, attempts, retained_bytes |
| android_build_failed (error) | gowalk-cicd/android-build-failed.v1 | phase, exit_code, signals (fixed observed patterns; empty means unclassified) |
Android release compilers retain only a bounded private output window. Their failure signals
describe observed error patterns, not retry permission or a unique root cause. Silent builds
emit android_build_progress notices (gowalk-cicd/android-build-progress.v1) every minute
with phase and elapsed_seconds; successful compiler output is not republished.
Native bundle and Pods failures emit native_prepare_failed with schema
gowalk-cicd/native-prepare-failed.v1, phase, a fixed refusal code and fixed observed signals.
Backend failures emit backend_deploy_failed with schema gowalk-cicd/backend-deploy-failed.v1,
the runner/host phase and exit_code. These receipts preserve the failed result and exclude
private command output, commands, hosts and runtime credentials. Empty signals are unclassified.
Timeout and cancellation preserve the bounded, sanitized child diagnostic after owned-process cleanup;
observed signals do not authorize another attempt or change the failed result.
The backend message is gowalk-cicd/backend-deploy-failed.v1 phase=<phase> exit_code=<code>:
fixed fields without JSON braces, which GitHub can mask when a multiline secret has brace-only lines.
Consumers validate the complete envelope, the known phase and exit code 1–255; older JSON messages
remain readable by the panel. Masked fields are never reconstructed and credential masking stays enabled.
An interrupted locked Pod fetch can retry once within the existing 900-second install budget.
native_pods_retry (gowalk-cicd/native-pods-retry.v1) reports attempt:2 and reason:partial_transfer;
successful preparation reports pod_attempts. Dependency locks, relay and TLS checks remain enforced.
SDK transfers also emit flutter_download_progress notices with schema
gowalk-cicd/flutter-download-progress.v1. Each carries phase, event, elapsed_ms
and retained_bytes; attempt_finished adds attempt, curl_exit, http_status
and proxy_status (retained for schema compatibility; always 0 now that CI reaches
providers directly). In-progress notices arrive each minute; elapsed time belongs to
the current attempt. Archives have a 20-minute total transfer budget so a failure
receipt precedes a 30-minute validation job's deadline. Progress is not a success receipt.
When SDK caching is enabled, an exhausted transfer preserves its partial public archive for
the next run under a separate release-specific cache key. Resumption still uses the selected
official manifest digest; only a verified complete archive
can reach SDK extraction. Partial-cache restore uses the existing one-minute cache segment
bound. Optional cache operations do not extend the transfer deadline or turn a failed installation green.
Add a field by bumping the schema version; never change the meaning of an existing one.
The plugin self-update runs first
The vendored iOS action checks npm for a newer gowalk-cicd at the start of
every default-branch run and preserves the refreshed action directories on a verified
task branch before the version, signing and archive steps. The
source_maintenance_pending receipt lets the same app session adopt that exact commit
into its current task branch and land it through the normal checked PR, including
when later deploy steps fail. CI never pushes to the default branch or rebases its
dirty build checkout; a failed preservation is a job failure. The run that
performed the update keeps executing the version it started with: the action
snapshots its scripts to $RUNNER_TEMP. Updates install and publish from a separate,
owned detached worktree that is removed afterward. The active checkout's action
manifests remain unchanged too: GitHub reloads local composites for post hooks using
previously cached step IDs, so changing their step count can crash cleanup.
Google Play upload retry
Android resolves Play readiness and existing AAB/APK version codes together, in one
owned edit before compilation or Crashlytics work. Auth, permission, transport and
invalid inventory failures stop there; they never select a fallback version as though
the package were new. A positively absent package (edit creation returns 404) keeps
the first-release path: build and retain the signed AAB for the app session's console
upload. Explicit version pins are preserved and still require the readiness check.
The action exports play-api-ready; current workflows consume that result without
another store call. A newer workflow with an older action copy retains the original
standalone preflight. Refreshing the workflow alongside actions removes that late call.
The API readiness preflight retries transient OAuth transport failures and cleanup of
its own known edit up to three times, with one- and two-second delays. It never
replays an edit creation whose outcome is unknown. An absent edit after cleanup is
already closed. Exhausted recovery
emits the play_preflight_failed error annotation with schema
gowalk-cicd/play-preflight-failure.v1, phase (oauth_refresh, edit_create,
version_inventory or edit_cleanup), classified code and attempt count. Provider bodies, credential
values and raw network errors are omitted. A cleanup failure also records the known
edit ID and cleanup_required: true so recovery targets that edit.
The upload wrapper at android-action/play-upload/ runs the unchanged official
r0adkll/upload-google-play revision e738b9dd8f2476ea806d921b64aacd24f34515a5 in
Node24. Its fixed GitHub download is checked against the recorded size and SHA256
before execution; an absent or changed program refuses upload. The subprocess inherits
the job's own environment and reaches Google directly.
The wrapper permits at most two attempts within one hour, reusing the existing signed bundle, track, status, rollout fraction and release notes:
| First result | Recovery | | --- | --- | | Exact expired/deleted-edit refusal | Fresh edit with the original normal review behavior | | Exact cannot-auto-submit refusal quoted above | Fresh edit with the explicit review-hold flag | | Missing, multiple, unknown, transport or post-commit-uncertain failure | Fail without replay |
Raw uploader output and exception text are private to the wrapper. It emits a
play_upload_result annotation with schema gowalk-cicd/play-upload.v1, a fixed
code, attempt count, review-pending boolean and verified upstream identity. Only a
successful review-hold retry emits the existing play_review_pending receipt.
Ordinary retry success never misreports an unsubmitted release. The wrapper does
not depend on check-run annotations becoming visible while its job is running.
Every completed uploader attempt also emits play_upload_diagnostic, independently of the
unchanged v1 result. Its brace-free message starts with gowalk-cicd/play-upload-diagnostic.v1
and carries these space-separated fields in order: attempt=<1|2>, phase=<phase>,
exit_code=<0-255|unknown>, signal=<signal>, error_count=<count|unknown>,
cancelled=<true|false>, commit_reported=<true|false>, signals=<signals>.
The phase is the last observed official progress line: startup, edit_create, tracks_read,
bundle_upload, track_update or edit_commit. It does not prove that operation completed.
The signal is none, SIGTERM, SIGINT, SIGKILL or other; observed error signals are
comma-separated connection_reset, connection_refused, connection_timeout, dns_failure,
tls_verification, output_truncated, or none. Each private stdout/stderr line buffer is
bounded to 16 KiB. No provider message, path, URL, track or credential is republished.
These observations never authorize a retry. Missing commit output does not prove no mutation,
and a reported commit does not prove review submission. Unknown failures remain unclassified.
Package-owner acquisition rechecks an occupied record within one 60-second wait budget before
provider work. Every read retains the binding and identity checks; cancellation interrupts the wait.
Time passing never authorizes clearing an owner. A known CI owner still requires exact terminal
run, attempt and source proof before recovery; the panel releases its own requests.
Both entrypoints emit play_edit_owner as gowalk-cicd/play-owner.v1 code=<code> child_exit_code=<0-255|unknown>.
Codes are play_owner_binding_required, play_owner_unverified, play_owner_busy,
play_owner_release_unverified, play_owner_cancelled, play_owner_principal_unverified,
play_owner_child_failed and play_owner_package_unverified. An unknown child exit proves no outcome.
These errors preserve the failed job and existing upload receipt; they do not authorize replay.
An expired/deleted edit does not identify its cause. Another edit opened with the same API user/package can invalidate it; avoid competing read edits while uploading. An external actor is not proven by an expired-edit error alone. Ambiguous outcomes require a fresh authoritative inventory/track readback before another mutation.
Runner disk space
An ubuntu-24.04 runner leaves roughly 14 GB free on /, and a release Flutter
build — Gradle caches, build intermediates, and a 100–200 MB AAB — does not
reliably fit. The failure surfaces far from its cause:
> java.io.IOException: No space left on device
zip I/O error: No space left on deviceThe Android job removes the preinstalled toolchains a Flutter build never uses
(.NET, GHC, PowerShell, Swift, Chromium) and prunes Docker
images, reclaiming roughly 25 GB in a few seconds. It prints df -h / before
and after. Linux only; skipped in Bitrise mode. npm survives on purpose: the
Crashlytics symbol upload runs
the Firebase CLI through npx.
The preinstalled Android SDK and NDKs are retained. Gradle and SDK-manager Java networking reaches Google directly, like every other provider request in CI. Gradle daemons stay disabled for these commands.
Private git dependencies (Flutter)
A Flutter app can depend on private git packages:
dependencies:
my_package:
git: https://github.com/my-org/my_package.gitThe runner has no credentials for those, so flutter pub get fails with
could not read Username for 'https://github.com'. Embedding a PAT in the URL
works but publishes the token into your source and into every copy of the
lockfile.
Set the repository secret GIT_PRIVATE_TOKEN instead — a PAT with repo
read access — and the workflow rewrites https://github.com/ to an
authenticated remote for the duration of the job. Plain URLs keep working and
the token lives in one revocable place:
gh secret set GIT_PRIVATE_TOKEN --repo my-org/my-appWithout the secret the step prints a note and exits 0, so repos that do not need it are unaffected.
The emptiness check happens in the shell rather than in the step's
if:. GitHub does not expose thesecretscontext to a step-levelif:— putting it there does not evaluate to false, it makes the entire workflow file invalid and every run fails before any job starts.
Localized release notes
Both stores pick up per-locale release notes from committed files:
- iOS —
fastlane/metadata/ios/<locale>/release_notes.txt(ASC locale codes:en-US,de-DE,zh-Hans, …). Each localization whose file exists gets that text as its "What's New"; localizations without a file fall back to the singleapp-store-whats-newinput / default text. - Android —
distribution/whatsnew/whatsnew-<bcp47>files (e.g.whatsnew-en-US,whatsnew-de-DE). Whendistribution/whatsnew/exists, the deploy workflow passes it to the Play upload and the notes ship with every release; when absent, releases upload without notes as before.
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. A fleet registry can
provision 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). Registry provisioning must also write
creds/cert.registry.json with managed_by: app-robot, state: ready, and
SHA-256 commit digests for both cert.p12 and cert.meta.json. It must include
the same managed_by field plus the DER certificate digest in
cert.meta.json.
The marker is a fail-closed ownership boundary: the action may reuse the identity and create per-app provisioning profiles, but it never creates, replaces, or revokes a registry-managed distribution certificate. Missing, partially written, corrupt, expired, Apple-revoked, or resource-ID/P12-mismatched managed material aborts with a reconciliation error. Legacy repos without the marker may create a certificate in an available slot, but never revoke another identity. A full certificate cap stops for account-registry reconciliation.
How it works
On every push, iOS and Android run in parallel. The Android job 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.
Android: Flutter or native Gradle
The Android action detects which build system to drive and needs no configuration for either:
| | Flutter | Native Gradle |
|---|---|---|
| Detected by | pubspec.yaml at the repo root | settings.gradle(.kts) + gradlew at the root or under android/ |
| App module | android/app | the one module applying com.android.application (version-catalog aliases are resolved; a module named app wins a tie against a wear/automotive sibling) |
| Tests (run-tests) | flutter analyze + flutter test | gradlew test (all variants, all modules) |
| Build | flutter build appbundle --release --obfuscate --split-debug-info | <module>:bundleRelease |
| Toolchain installed | Flutter + JDK 17 | JDK 21, or 17 on an old Kotlin — see Choosing the JDK |
A Flutter app also carries android/settings.gradle, so pubspec.yaml wins the
tie — Flutter apps must be built through the Flutter tool.
Gradle has no equivalent of flutter build --build-number, so the action does
two things. It rewrites the literal versionCode (and versionName, when
build-name is set) in the app module's build file inside the CI checkout —
the Gradle counterpart to the iOS action patching the .pbxproj, never
committed back — and it applies the same values to the build through AGP's
Variant API, using a Gradle init script.
The second one is what makes the resolved versionCode authoritative. A module
that computes its version has no literal to rewrite:
ext.code = 31
versionCode codeBefore, that build shipped whatever the checkout said and Play answered
Version code 31 has already been used, failing the upload and the
no-auto-submit retry alike — the number was baked into the bundle. The init
script overrides it whatever the build file does, and needs no cooperation from
the app.
AGP's
-Pandroid.injected.version.codelooks like the obvious way to do this and is a silent no-op: the property was removed in AGP 7.3, so every AGP 8.x app ignores it without reporting anything.
Build toolchain floors (Flutter apps)
The Flutter Gradle plugin refuses to apply to a project whose wrapper is older than the SDK's floor:
Your project's Gradle version (8.11.1) is lower than Flutter's minimum
supported version of 8.14.0.CI installs the current stable Flutter, so that floor rises on Flutter's
release cadence rather than the app's — every app in a fleet breaks on the same
day, long after the last commit that could have anticipated it. Before building,
the action reads the floor out of the runner's Flutter SDK and, when the
project's gradle-wrapper.properties is below it, rewrites distributionUrl
in the checkout with a ::warning::. The Android Gradle Plugin and Kotlin
Gradle Plugin versions declared in android/settings.gradle (or the older
buildscript { classpath ... } / ext.kotlin_version form) are raised the same
way against Flutter's errorAGPVersion / errorKGPVersion. Same policy as the
versionCode rewrite: the edits are never committed back, so the project keeps
whatever versions its authors chose. Commit the bumps yourself to silence the
warnings.
(The rewritten name is verified against services.gradle.org because Gradle's
own naming is inconsistent across majors — gradle-8.14-all.zip but
gradle-9.0.0-all.zip.)
Dart obfuscation (Flutter apps)
Obfuscation is mandatory for Flutter apps. Every Flutter release build this
package produces — the Android App Bundle and the iOS archive — is compiled with
--obfuscate --split-debug-info, and there is no input, repository variable or
config key that turns it off. Native Swift and Kotlin/Gradle apps are
unaffected: the flag belongs to the Dart compiler and they have no Dart code.
Flutter web builds are also untouched — flutter build web has no such flag;
dart2js minifies release output instead. An Android app delivered through
Bitrise is built by Bitrise's
workflow, not by this action; put the same two flags in that workflow.
What it does: the Dart AOT compiler replaces class, function and library names
with short random identifiers, so the app's Dart symbols cannot be read out of
the binary. What it does not do: it is not encryption. String literals,
--dart-define values, assets and everything in pubspec.yaml ship as they
are, and the native halves of the app (Swift, Kotlin, plugins) are compiled by
Xcode and Gradle exactly as before. Do not put secrets in the app.
The two flags are one feature. flutter build refuses --obfuscate without
--split-debug-info, and the symbol files the latter writes are the only way
to read a stack trace from an obfuscated build. Every run therefore keeps them
as a workflow artifact next to the build, and fails when they are missing:
| Platform | Artifact | Contents | Retained by |
|---|---|---|---|
| Android | android-symbols-<package>-<versionCode> | app.android-arm.symbols, app.android-arm64.symbols, app.android-x64.symbols | the Android action, right after the build |
| iOS | ios-symbols-<repo>-<TestFlight build number> | app.ios-arm64.symbols | deploy.yml, after the archive |
The Android action fails the build before anything is signed if no symbol files were written. On iOS the archive, TestFlight upload and App Store metadata steps all run inside one composite action, so the check comes after it: the retain step runs even when that action failed (a metadata error after the upload must not discard the symbols of a build that is already live), and an action that succeeded without writing symbols fails the job.
Artifacts follow the repository's retention setting — 90 days unless you change it under Settings → Actions → Artifact and log retention (private repositories allow up to 400). A production release usually outlives that, so either raise the setting or, better, upload the symbols to your crash reporter, which is where crashes arrive anyway.
To read an obfuscated crash, download the artifact for that exact build and
run flutter symbolize with the file matching the device's architecture:
flutter symbolize -i crash.txt -d app.android-arm64.symbolsA crash reporter never sees the artifact, and from the first obfuscated build
every Dart frame it shows is *** until it has the symbols. For Crashlytics
the workflow uploads them itself once the repository variable
FIREBASE_APP_ID is set — see below. Sentry or others: upload the same
directory to that service from your own pipeline.
Crashlytics symbol delivery (FIREBASE_APP_ID)
Set the repository variable to the Firebase App IDs for the shipping platforms
(1:<project>:ios:<hash> and/or 1:<project>:android:<hash>). The workflow passes it
as the firebase-app-id input.
Android builds and the pinned Firebase CLI upload Crashlytics mapping and native symbols directly. Dart symbols are retained as artifacts and uploaded before the bundle ships.
Before Flutter compilation, the action downloads Crashlytics buildtools 3.0.3 and
verifies its exact size and SHA-256. Verified bytes are
reused from the runner dependency cache and supplied through the Firebase CLI's
CRASHLYTICS_LOCAL_JAR override. GET recovery allows three attempts within three minutes;
TLS, authorization and checksum refusals stop immediately. A bootstrap failure emits
firebase_symbols_bootstrap_failed with schema gowalk-cicd/firebase-symbols-bootstrap.v1,
phase, reason, attempts, exit_code and http_status. Raw curl output is never published.
Native iOS upload-symbols is refused because it does not use this environment
transport. CI defers dedicated native upload phases in its ephemeral projects and
retains ios-crashlytics-symbols-<run>-<attempt> with ios-dsyms.zip and a manifest
containing UUIDs, source SHA and SHA-256. The typed firebase_symbols_pending
annotation uses schema gowalk-cicd/firebase-symbols-pending.v1 and names the exact
artifact. The autonomous app session must upload it through the account-pinned
Firebase console's Crashlytics dSYMs tab and read back processing; no human action
is required. A completed CI run does not mean this deferred upload is complete.
Firebase documents the ZIP upload interface.
iOS delivery
The iOS composite action runs on macos-15 and:
GitHub Actions reaches Apple and Google directly. No store proxy is configured, read
or inherited here: the account-pinned residential exit belongs to the panel and the runner
Macs, which are the hosts that must not appear to the stores as generic infrastructure.
APPLE_STORE_PROXY_URL and GOOGLE_STORE_PROXY_URL are no longer used by any workflow,
action or helper, and must not be reintroduced. Refresh existing workflow files with the
released installer while preserving application-specific build inputs.
The Apple uploader retains apple-upload-<run>-<attempt> receipts with IPA SHA-256,
upload ID and processing readback, and resumes matching bytes after interruption.
GitHub artifact traffic uses a separate, per-action transport scope. The exact pinned
actions/upload-artifact client can reach approved GitHub Results and artifact-storage
HTTPS origins; unknown programs and destinations are refused. The scope ends with that
Node process, and a failed artifact creation, transfer or finalization still fails delivery.
Adoption requires updating workflow upload steps as well as the distributed actions while
preserving each app's custom checks and artifact retention settings. An isolated
ECONNRESET does not identify every possible network fault.
- 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: reconciles the App ID's capabilities with
the target's entitlements (see below), then 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 via Apple's build-upload REST API. - 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.
Feature-branch runs installed from the standard workflow still archive and
upload to TestFlight, but pass manage-app-store-version: 'false'. This keeps
branch validation independent of the editable App Store release slot, so a
build can upload while the current release is locked in review. Default-branch
runs retain version creation and metadata automation — and while a version is
locked in review they take the same TestFlight-only path on their own (see
When an App Store version is in review).
Direct action callers keep the historical behavior because the new input
defaults to true.
App ID capabilities
A provisioning profile carries only the capabilities enabled on its App ID. Xcode's automatic signing hides this by turning them on as you edit entitlements; the ASC API does not, so a CI-issued profile omits them and the archive fails with
Provisioning profile "CI-com.example.app" doesn't include the App Attest capability.Before creating a profile, the action reads the target's
CODE_SIGN_ENTITLEMENTS plist and enables the matching capabilities on the App
ID — Push Notifications, Associated Domains, HealthKit, SiriKit, HomeKit,
network extensions and the other plain on/off toggles.
The cached profile under creds/profiles/ is checked against those same
entitlements and regenerated when it does not carry them. The cache manifest
records only a UUID and an expiry, so without that check a profile issued
before a capability was turned on — whether by this action or by you in the
developer portal — would be reused indefinitely, and the archive would keep
failing on a capability the App ID already has.
App Groups require registration and assignment to each participating App ID through
the account's Apple Developer console. The action reads the literal identifiers from
each archive target's entitlements and verifies their exact membership in both cached
and fresh profiles. A stale cache regenerates; a fresh profile missing a required group
fails before installation with apple_app_groups_required, schema
gowalk-cicd/apple-app-groups-required.v1, containing the bundle ID and missing groups.
Complete those assignments through the scoped console and rerun CI. This check creates
no group or certificate and does not replace the account's capability provisioning.
Two other classes of capability are not enabled automatically, and each gets a
::warning:: naming what was seen:
- Values the action cannot infer — iCloud containers, Apple Pay merchant IDs, Wallet pass types, Data Protection. Configure these on the App ID yourself.
- App Attest — a real App ID capability in the developer portal, but absent
from the ASC API's
capabilityTypeenum, so no API call can turn it on. Any app using Firebase App Check'sAppAttestProvidermust have App Attest ticked once by hand under Certificates, Identifiers & Profiles → Identifiers → <your App ID>. Until then the archive fails withProvisioning profile "CI-<bundle>" doesn't include the App Attest capability.
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) |
| certificate-cap-policy | Only fail is accepted (the default); existing identities are always preserved. |
| 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 |
| manage-app-store-version | false to upload without creating/editing the App Store release slot |
| 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) |
Certificate creation sends the complete PEM CSR in Apple's csrContent field, including its framing and newlines.
Certificate creation refusals emit apple_certificate_rejected with schema
gowalk-cicd/apple-certificate-rejected.v1, operation, http_status, reason and error_codes.
HTTP 409 is classified as certificate_limit only when Apple's returned detail identifies the limit;
otherwise it remains unclassified_conflict. Codes come from a fixed allowlist. Raw provider text and
CSR data are excluded. Neither outcome permits automatic certificate deletion or another creation attempt.
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.
Automatic certificate revocation is disabled for every account. The action may add a certificate when Apple has a free slot, but a full-cap response aborts without listing or revoking existing identities. If signing preparation fails after creating a certificate, the default-branch workflow first commits any completed cache files, then re-raises the failure so the private key is not lost.
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.
"Provisioning profile doesn't include the <X> capability" — the App ID lacks a capability the entitlements declare. The action enables the simple toggles itself (see App ID capabilities); if the warning names App Groups, iCloud, Apple Pay, Wallet or Data Protection, enable and configure that one in the Apple Developer portal, then re-run.
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 preserves the staged source on a
verified task branch. The app session resolves its source_maintenance_pending
receipt through its normal checked PR before source delivery is complete.
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 retained source commit identifies the version change. Signing credentials are refused from maintenance commits; use encrypted repository secrets.
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 in a temporary detached worktree and preserves its action files
on a verified task branch (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) | | Landing | The app session adopts the exact receipt on its current task branch and uses the protected PR | | Isolation | CI leaves HEAD and worktree bytes intact; no default-branch push or rebase | | Candidates | Action updates and later version changes have separate verified receipts | | Failure mode | npm discovery remains optional; unconfirmed source preservation fails the job |
The shipped deploy.yml watches only the frozen release intent. Adopting an action-directory
update on its own therefore does not launch another mobile upload. Review and adopt the template
as well as the action directories to enable this behavior in an existing consumer.
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 the app session runs the pinned npx --yes gowalk-cicd adoption in its task
workspace, preserving app-specific workflow inputs and landing the full change through
its checked PR. The CI-side update does not commit the workflow.
Changes that need that manual run:
- Flutter iOS obfuscation — the
--obfuscate --split-debug-infoflags on the iOSflutter build ios --config-onlycall and the step that retainsios-symbols-*. See Dart obfuscation. - Crashlytics symbol upload —
firebase-app-id: ${{ vars.FIREBASE_APP_ID }}passed to both actions. Until it is, setting the variable does nothing. See Crashlytics symbol upload.
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
Recovering an Apple REST upload failure
The uploader writes its receipt (apple-upload-<run>-<attempt>, schema
gowalk-cicd/apple-build-upload-receipt.v1) after every stage — identity (local
SHA-256, file name/size, bundle and versions), app_verified, upload_reserved
(upload_id), file_reserved (file_id), transferred, processing and complete
(build_id) — so a job that fails at any point still names what the provider holds. It
commits completed part transfers with uploaded: true, following Apple's
BuildUpload walkthrough.
The IPA endpoint rejected an optional sourceFileChecksums SHA_256 object even though
the generic schema lists that algorithm; the uploader no longer sends that attribute.
A completed file is reused only when its content-derived filename and size match, and
any returned file checksum is verified.
A failed upload emits the typed apple_build_upload_failed error annotation with schema
gowalk-cicd/apple-build-upload-failed.v2: category (ipa_invalid,
configuration_missing, app_mismatch, provider_request_failed, upload_identity_conflict,
upload_destination_invalid, ipa_changed, part_refused, processing_failed,
processing_pending, or a fallback such as local_io), stage, receipt_available and
the receipt's own identifiers (cfBundleVersion, upload_id, file_id, state,
provider_status, provider_code, provider_pointer). Exception text is never
published. The job also retains the exact exported IPA as apple-ipa-<run>-<attempt>
(14 days) so a continuation can complete that same upload identity instead of rebuilding
different bytes under the same number. Build numbers come from /builds,
/preReleaseVersions and /apps/{id}/buildUploads, so an unfinished or refused
reservation is never reused; delete such a reservation only after reading it back.
Flutter SDK setup retries interrupted manifest and archive GETs, with four attempts
within 60 seconds for manifests or 30 minutes for archives, with an 8 GiB archive bound. Retries resume private bytes
with HTTP Range; if the origin ignores Range, a bounded full restart is permitted. The completed archive
must match the selected official manifest's SHA-256 before extraction. Authentication and certificate
refusals remain terminal. Exhausted transfers emit flutter_download_failed with fixed status codes and
retained-byte counts, without URLs or private curl stderr. Cache identities remain those of the pinned action.
Read fixed backend identity refusal labels
The installed Backend Deploy workflow offers operation=identity-diagnostics on manual dispatch.
Set `diagnostic-sin
