npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

gowalk-cicd

v1.0.146

Published

Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.

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-cicd

Writes 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.json

Merge 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_ID
  • IOS_DISTRIBUTION_P12_BASE64
  • IOS_DISTRIBUTION_CERT_META_BASE64
  • IOS_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>.p8

The 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.yml and build ./backend (equivalently for server/).

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.yml that 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 /health endpoint.
  • A Dockerfile the compose file builds; pin the base image, run non-root, never bake secrets in. A generated .env (with POSTGRES_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-delimited KEY=value runtime 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 (default 138.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/:

  1. android-upload-key.jks — the long-lived Google Play upload keystore.

  2. 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>
  3. One Google service-account JSON key. Its filename is unrestricted (Gowalk.json is valid); the action identifies it by the type: service_account JSON 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 android

Track, 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 17

Left 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 details

which 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 device

The 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.git

The 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-app

Without 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 the secrets context to a step-level if: — 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:

  • iOSfastlane/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 single app-store-whats-new input / default text.
  • Androiddistribution/whatsnew/whatsnew-<bcp47> files (e.g. whatsnew-en-US, whatsnew-de-DE). When distribution/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 or GET https://api.bitrise.io/v0.1/apps).
  • workflow — the Bitrise workflow that builds, signs, and runs google-play-deploy (default android-deploy).
  • branch — optional; defaults to the pushed ref.
  • api_token — a Bitrise personal access token. Committing it to a private repo's creds/ follows the same trust model as the ASC .p8. For non-private repos, omit it here and set the BITRISE_API_TOKEN Actions 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 code

Before, 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.code looks 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.symbols

A 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.

  1. Auto-detects your .xcodeproj / .xcworkspace, scheme, bundle ID, and team_id (from the ASC API key). No ci.config.yaml required — override via action inputs only if auto-detection fails.
  2. Reads the ASC key from creds/AuthKey_*.p8 and uses it to authenticate to App Store Connect via JWT.
  3. Decides the marketing version: either reuses the current PREPARE_FOR_SUBMISSION version on App Store Connect, or creates a new version if the highest declared version is already live.
  4. Computes the next build number by querying ASC for the latest uploaded build and incrementing.
  5. 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 .pbxproj to use Manual signing against those profiles.
  6. Archives with xcodebuild archive, exports the IPA, and uploads via Apple's build-upload REST API.
  7. Sets "What's New" on every declared localization (reads fastlane/metadata/ios/<locale>/release_notes.txt if present, or from the app-store-whats-new input).
  8. 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 capabilityType enum, so no API call can turn it on. Any app using Firebase App Check's AppAttestProvider must have App Attest ticked once by hand under Certificates, Identifiers & Profiles → Identifiers → <your App ID>. Until then the archive fails with Provisioning profile "CI-<bundle>" doesn't include the App Attest capability.

AI metadata auto-fill

On every run, after the TestFlight upload succeeds, the action:

  1. Queries App Store Connect for every appInfoLocalization and appStoreVersionLocalization on the editable version.
  2. Computes the set of empty fields per locale (URL fields are always skipped — you must set those manually in ASC).
  3. Scans your repo for context (README, Info.plist, dependency files, top Swift files) and feeds it to openai/gpt-4o via GitHub Models with a strict JSON schema.
  4. 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: read

Existing 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-mini

gpt-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-cicd

Overwrites 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.91.1.0), and at minor=9 it cascades into the next major (1.9.92.0.0). Major has no upper limit (9.9.910.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.51.0.6; 1.0.91.1.0 | Natural progression, carry at .9. | | patch | 1.0.51.0.6; 1.0.91.0.10 | Legacy unbounded patch — pinned for backward compat. | | minor | 1.0.51.1.0; 1.0.91.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.91.1.0 (patch overflow), 1.9.92.0.0 (minor cascade), 9.9.910.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:

  1. xcodegen project.yml (preferred when present): regex-rewrite of the MARKETING_VERSION: key, preserving formatting. The generated *.xcodeproj is regenerated on every build, so editing it directly would lose the bump.
  2. *.xcconfig sitting alongside the project: handles non-xcodegen projects that hoist MARKETING_VERSION into xcconfig.
  3. *.xcodeproj/project.pbxproj: only when no xcodegen spec is present.
  4. Info.plist CFBundleShortVersionString: 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-info flags on the iOS flutter build ios --config-only call and the step that retains ios-symbols-*. See Dart obfuscation.
  • Crashlytics symbol uploadfirebase-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