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

Published

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

Readme

gowalk-cicd

One installer for iOS TestFlight and Android Google Play delivery, plus the optional backend deploy. 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.

Tests must run locally, never in GitHub Actions. Run only the focused tests needed for the changed behavior before pushing. The iOS and Android actions never run tests or Flutter analysis; legacy run-tests, test-command, and test-destination inputs are accepted but ignored, including run-tests: 'true'. Build, signing, upload, and delivery health checks remain in Actions. See local validation for focused execution, receipts and CI verification.

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.

Install

From the root of your app repo:

npx --yes gowalk-cicd

To update only backend delivery while preserving mobile tooling and workflows, use npx --yes gowalk-cicd --backend-only. The lock records the backend release separately; see scoped installation.

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

It also writes:

  • .github/workflows/apple-upload-recovery.yml — explicit verification-only recovery for a retained iOS upload; it checks the original producer and Apple build before completing that deployment, without rebuilding or uploading
  • .github/workflows/mobile-candidate.yml — shipping binaries for ready task PRs (dispatch-only while candidate artifacts are off)
  • .github/workflows/warm-android-cache.yml — only while Android candidates are enabled: after this app's successful default-branch release that includes Android, compiles the Android bundle once, unsigned, when main has no Gradle cache for it (Gradle dependency retention)

The installer no longer adds .github/workflows/deploy-web.yml. On re-adoption it removes a deploy-web.yml that is still byte-for-byte the generated file and has no overrides in .gowalk-cicd.yml; a customized one keeps upgrading (see Flutter web release).

Re-run the same command anytime to pull the latest version. The vendored action directories then mirror the package: a file the package no longer ships is deleted, except the .daemux-version markers and any file a workflow under .github/workflows/ still names by path (the installer prints what it kept and removed; --dry-run deletes nothing). Tests and fixtures are not shipped. A workflow the package does not manage that fails to parse is skipped with [warn] skipped .github/workflows/<file>: <code> instead of aborting the install.

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. For the 2–5 minute delivery target, immutable native bootstrap, verified native-check reuse and runner-time receipts, see CI performance.

Mobile delivery requires an explicit 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 CLI reads no GitHub variables. Its v2 manifest contains only schema and a platforms map: source hashes make each changed-source release declaration a file change, but never freeze admission. A push changing the intent admits the pushed tree for its declared platforms. Other events, including manual dispatch, admit only the tree of the last intent-changing commit. Candidate repairs included in the protected merge therefore need no re-freeze. A dispatch of a later landing still refuses. Existing v1 intents remain accepted.

Upload and binary reuse identities use the checkout actually built plus its current build variables. Native iOS and Android directories are scoped to their platform. Unknown inputs, embedded editors, assets, scripts, dependency locks, documents and backend-generated resources remain in the conservative source fingerprint. These hashes add no push triggers and do not gate admission.

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. An earlier admission attempt of the same run, source and platform supports failed-job retries while preserving the required upload phase.

remedies.json contains failure recovery guidance keyed by schema, code and fixed_in. Each remedy is at most 4 KB. mobile_release_refused emits gowalk-cicd/mobile-release-refused.v1 with its fixed failure code; success notices carry no remedy.

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 builds run, so a later build failure does not force another cold SDK download. Failed setup never saves an SDK cache; pub dependencies retain their job-end save. Pull-request runs (refs/pull/*, which includes every candidate build) restore these caches but never save them; task branches and the default branch still save. When the caller names no Flutter version, a committed .flutter-version (x.y.z, a regular file of at most 64 bytes) pins the SDK; a symlink or malformed pin fails with Invalid project Flutter pin, and no file keeps the channel's latest release.

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.

The iOS action then skips bundle install and pod install when the Pods are already in sync: Pods/Manifest.lock equals Podfile.lock, the lock's PODFILE CHECKSUM matches the Podfile, every local :path: pod exists, a workspace exists and xcodegen did not just generate the project. Its receipt then reports pods: in_sync, and its own native Pods cache is not restored or saved for that run.

Flutter web release

A repository whose .gowalk-cicd.yml customizes deploy-web.yml keeps it. That 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.

Delivery timing and cache cost

Keep routine push-to-verified-production delivery within about 2–5 minutes while preserving checks and coverage. CI performance receipts distinguish runner time, queueing, retries and provider work. Count conditional dependency priming as auxiliary cost.

Promoted candidates now seed missing default-branch SDK/pub/Flutter Pods/Gradle caches through verified key/path/compression identities and dependency-only work. They never promote PR cache bytes or duplicate a compile. Default-branch macOS fallbacks transfer the IPA and leave Apple processing to a required Linux verification job. Apple must positively confirm the exact upload, file, eligible build and app before delivery completes; its processing time remains external. The final complete upload receipt and verification proof are in apple-verified-<run>-<attempt>. See candidate artifacts and cache behavior.

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/compose.yaml; or
  • it can live at the repository root as compose.yaml and build ./backend (equivalently for server/).

The standard names compose.yaml, compose.yml, docker-compose.yaml and docker-compose.yml are supported without renaming source. Installation, both workflows and the backend action use the same selector. Multiple matching files or source directories refuse with backend_layout_ambiguous before installer writes or SSH. Select the intended existing paths using backend.backend-dir and backend.compose-file in .gowalk-cicd.yml; custom source subdirectories are supported through that configuration. Source at the repository root (backend-dir: .) remains unsupported. The host receives and explicitly uses the selected filename, even if an older alternate file exists there. Other root-level files are not implicitly copied.

Before preparing the complete app candidate, run the released installer with --dry-run, then run it without that option. Verify .github/workflows/deploy-backend.yml exists and run the ready local selector from the app checkout:

node .github/actions/backend-app/layout.cjs
# For an explicitly selected layout, pass the same nonsecret configuration values:
BACKEND_DIR_INPUT=service COMPOSE_FILE_INPUT=service/compose.yml \
  BACKEND_LAYOUT_REQUIRED=true node .github/actions/backend-app/layout.cjs

These commands only inspect file locations and return a JSON layout receipt; they do not read Compose contents, contact a provider or deploy. Preserve app-owned configuration and generated workflow changes in the same validated candidate. A layout receipt proves selection, not production readiness.

The backend workflow runs on a GitHub-hosted runner and, on a push touching backend/**, deploys to the explicitly bound, publicly reachable BACKEND_DEPLOY_HOST: 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; nginx must accept the change, or the previous vhost is restored and the deploy fails. When the domain's vhost has exactly one ssl_certificate, and that file exists, stays valid for more than a day and lists DNS:<domain> in its subjectAltName, the deploy runs no Certbot, only follows the published port, and logs certificate state=current. Otherwise the name is issued by webroot: the vhost serves /.well-known/acme-challenge/ from /var/lib/gowalk-acme on port 80 (and proxies the API there until HTTPS exists); the deploy waits up to 20 s for this host's nginx to serve a probe file under the domain's name; certbot certonly --webroot --cert-name <domain> --keep-until-expiring issues it (a lineage of that name that is not due is kept); then the vhost is rendered with HTTPS: port 80 answers challenges and redirects, port 443 proxies with /etc/letsencrypt/live/<domain>/. Certbot saves the webroot and a deploy hook that reloads nginx under the host nginx lock, so the host's renew timer renews the lineage. Vhosts that older releases configured with certbot --nginx keep their TLS directives; only their upstream port changes. Every Certbot call runs under flock -w 900 /run/gowalk-certbot.lock, so deploys on one host take turns; when the host's renew timer holds Certbot's own lock, the call retries every 10 s for up to 10 minutes. Vhost writes with their nginx test and reload, and the host port reservation, run under the host nginx lock /run/app-robot-nginx.lock (flock -w 120), which the panel's domain applier also takes. New Compose networks come from Docker's own address pools; the deploy hosts' default-address-pools hand out /24s. After a healthy deploy the replaced build's images are pruned (docker image prune -f --filter label=com.docker.compose.project=<app>); a refusal because another prune is running defers it to the next deploy.

Each app gets one stable loopback port, reserved once in the host's .env as BACKEND_HTTP_PORT (starting at 20000 + cksum(app) % 10000, skipping ports other apps reserved or already bound). A Compose file opts in by publishing 127.0.0.1:${BACKEND_HTTP_PORT:-}:<container-port>; a redeploy then leaves the nginx upstream unchanged. A literal 127.0.0.1:<port>:<container-port> keeps working as before.

Requirements on the consumer repo:

  • A supported Compose file 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 variable BACKEND_DEPLOY_KNOWN_HOSTS (required): trusted known_hosts lines for the exact host string the workflow connects to (BACKEND_DEPLOY_HOST, for example backend.example.com ssh-ed25519 AAAA...). Deploys require that key (StrictHostKeyChecking=yes). Missing pins refuse with backend_host_key_required before SSH or writing the private key; the action never discovers and trusts an unverified host key.
  • 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. Existing quoted values, comments and ordinary dotenv assignments retain their syntax. Raw multiline PEM blocks and JSON objects/arrays are converted to quoted literal values, preserving embedded dollars and backslashes. Already quoted values and single-line JSON retain their existing interpolation semantics. Malformed or ambiguous multiline structures refuse before deployment. Transport is bounded to 1 MiB of UTF-8 input and 512 KiB per value, uses private SSH stdin, and atomically replaces .runtime.env; application sync preserves the last installed file if validation fails.
  • Repo variable BACKEND_API_DOMAIN: the bound API host name (for example api.<app-domain>); its DNS A record must point at the host for the cert. Backend Deploy runs only when it is set: a repository with no bound backend origin holds no deploy key, so deploy-backend.yml is skipped and the feature-branch backend-preview deploy ends with a backend_origin_unbound notice.
  • Repo variable BACKEND_DEPLOY_HOST: the bound SSH destination (DNS name or IPv4 address), required for a bound backend; no panel host or historical address is used as a fallback.
  • Repo variables (optional): BACKEND_APP_NAME (defaults to the repo name), BACKEND_HEALTH_PATH (default /health). The action refuses an api-domain that is not a lowercase DNS name, a health-path outside /[A-Za-z0-9_./-]* and a malformed cert-email.

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.

For optional app-owned Cloudflare ingress, follow backend edge delivery.

Backend starts use the resolved Compose model with effective CPU, memory and PID limits. Missing limits default to 0.5 CPU, 256 MiB memory, a 64 MiB memory reservation (or the smaller hard limit), and 128 PIDs. Explicit workload limits remain authoritative. Both Compose resource syntaxes agree in the generated model; JSON/local logs default to 10 MiB with three files. Health probes run at least 60 seconds apart in steady state, retaining fast startup with a 5-second start interval and at least 30 seconds of grace. The app owns a cheap probe and effective worker/pool limits; this helper never rewrites application code. Docker Engine 25+ and Compose 2.20.2+ support the startup interval.

Before building and again immediately before starting, deployment checks live host memory and pressure. Other factory containers plus the proposed stack may reserve at most 70% of physical RAM; at least 10% (and 512 MiB) stays available after additional reservations. One-minute load above twice the CPU count or full memory pressure of 1% refuses a start. Starts share a host lock (120-second acquisition limit); image builds stay parallel. A failed admission emits backend_runtime_refused with a fixed gowalk-cicd/backend-runtime-refused.v1 code=… and leaves existing containers running. A shared host's memory agent keeps running reservations at measured use, and a redeploy is charged only for growth beyond the reservations its project already holds.

An operator-managed /opt/gowalk-backend-runtime/<app>.json, outside the application mirror, may set schema: "gowalk-cicd/backend-runtime.v1", disabled: true, remove_services: ["db"], and per-service services overrides for environment, command, entrypoint, mem_limit, mem_reservation, cpus and pids_limit. A disabled binding refuses builds/starts. After a verified database migration, this file can remove the retired database service and override the app's connection environment on every redeploy. Only dependencies on explicitly removed services are removed; volumes are preserved. Keep the file private (0600), use distinct scoped database roles, and complete compatibility, restore and isolation checks before removing a database. This helper provisions no shared database and deletes no database volume. Resolved credentials stay in a private temporary file removed on success or failure; literal dollars survive the second Compose parse.

For a shared host, use the host-owned SSH deployment entry. It scopes each panel's key, keeps new project claims autonomous, and redirects older action invocations to the released host helpers. Its global disabled gate permits readiness while refusing mutations during a migration.

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>

    A relative storeFile resolves beside this file; when no keystore is there, it resolves from the repository root (creds/android-upload-key.jks).

  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. A runner with at least 16 GiB of MemTotal (for example an 8-core ANDROID_RUNNER) keeps the app's own settings. Linux only.

Android runner size

The Android job runs on ${{ vars.ANDROID_RUNNER || 'ubuntu-24.04' }}. Set the repository or organization variable ANDROID_RUNNER to a larger hosted runner label to shorten the release build; unset it before deleting that runner. The variable is not a build input: setting, changing or removing it never discards a candidate binary.

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), optional hosts, artifacts and excerpt (error lines plus the next line, URL userinfo removed, at most 4 KB) |

Android release compilers stream their output to the job log inside ::stop-commands:: markers, so no build line can act as a workflow command. Their failure signals describe observed error patterns, not retry permission or a unique root cause.

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. Protected vhost failures add backend_vhost_failed (gowalk-cicd/backend-vhost-failed.v1) with fixed brace-free operation, stage, reason, child_exit and signals; see docs/BACKEND_EDGE.md. It preserves the existing refusal and records only bounded observed facts, never private child output. Edge health failures add backend_health_failed (gowalk-cicd/backend-health-failed.v1) with fixed brace-free probe, reason, curl_exit, http_status and attempt; see docs/BACKEND_EDGE.md. Only the final failed probe's bounded observations appear, with no URLs, bodies, headers or credentials. Ingress refusal, strict TLS, exact-build acceptance, retry bounds and the original failed result remain. 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.

The wrapper also writes validated gowalk-cicd/play-upload-evidence.v1 JSON outside its private upload directory: attempt-1.json, optional attempt-2.json, and terminal.json. Each record binds the GitHub run/attempt, source SHA, compiled version code (the optional versionCode input), and immutable uploader identity. Missing bindings are null, never inferred. Attempt records add observed HTTP status/RPC codes and exact known message classes from complete, bounded error lines; raw provider prose, paths, URLs and identifiers are discarded. The pinned uploader sometimes emits only an error message, so missing status/code fields remain empty. These fields do not change retry policy or the original result/diagnostic annotations. The action exports diagnostic-path; the managed deploy template retains its JSON as play-upload-diagnostics-<run_id>-<run_attempt> on success or failure. Adopt both action and template to retain artifacts. Failed evidence validation/storage emits a fixed refusal and fails the job without replaying provider work or changing its result.

Explicit panel-provisioned panel-v2 bindings coordinate through the panel database without Git commits. The binding supplies an HTTPS coordinator_url ending in /pub/play-edit; GitHub OIDC tokens use that exact URL as audience. Both mobile workflows require id-token: write. Repository tokens never reach the panel. Acquisition and release replay one immutable owner identity after a lost reply; release seals delayed acquisitions. An unreachable panel refuses new provider work and never falls back to Git. Existing v1 bindings retain the old protocol until the panel retires their Git owner record and activates v2.

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 prints df -h / and does nothing more when / already has 40 GB or more free (a larger runner). Otherwise it removes the preinstalled toolchains a Flutter build never uses (.NET, GHC, PowerShell, Swift, Chromium) and prunes Docker images in the background, reclaiming roughly 25 GB while the JDK, Gradle restore and Flutter setup run. Linux only. 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:

  • 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 single app-store-whats-new input / default text.
  • Android — distribution/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.

Sharing one distribution cert across a team's apps

The iOS action signs with the retained Apple Distribution identity in creds/ (cert alive + NotAfter > 30 days) and never creates or revokes a certificate. Apple caps a team at 2 distribution certs, so 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. A legacy repo without the marker whose retained identity is missing or unusable fails with apple_signing_identity_required (gowalk-cicd/apple-signing-identity-required.v1) and keeps its existing signing files.

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

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 artifact retention setting, which an organization can cap far below GitHub's 90-day default (Settings → Actions → Artifact and log retention). A production release outlives that, so the durable copy belongs in your crash reporter: with FIREBASE_APP_ID the Android job uploads its Dart symbols to Crashlytics. iOS Dart symbols exist only as this artifact.

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; promoting a verified candidate IPA runs its first attempt on ubuntu-24.04 (see Candidate artifacts). It:

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 editable version on App Store Connect (PREPARE_FOR_SUBMISSION, a READY_FOR_REVIEW draft you have not submitted yet, or a rejected state), 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 signs with the retained Apple Distribution identity and the per-target App Store provisioning profile named CI-<bundle_id> that App Store Connect already holds, creating the profile when it is missing or stale. 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).

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.

An existing CI-<bundle_id> profile is found by name and reused only when it is ACTIVE, issued for the current certificate, more than 30 days from expiry, and carries those same entitlements. Otherwise it is deleted and regenerated, so a profile issued before a capability was turned on — whether by this action or by you in the developer portal — is never reused.

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 reused and fresh profiles. A stale profile 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.

Update

npx --yes gowalk-cicd

Overwrites both vendored actions and .github/workflows/deploy.yml with the latest versions, and deletes vendored files the package no longer ships (see Install). 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
    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-polic