# 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](docs/CONFIGURATION.md).

## Install

From the root of your app repo:

```bash
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](docs/CANDIDATE_ARTIFACTS.md)
for identity checks, native dependency caches, version handling and adoption of custom workflows.

Mobile delivery requires an explicit release intent. After source, signing, account transport,
and integration readiness pass, run on the task branch:

```bash
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
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](#first-time-app-setup).

## Backend deploy (Python + Postgres, Docker)

New ordinary Compose projects receive small isolated networks inside Docker's verified existing pools.
Existing/custom/external networks and authored IPAM retain their contracts. Read-only
`network-diagnostics` uses the same deployment identity; see [capacity and diagnosis](docs/BACKEND_NETWORKS.md).

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:

```sh
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 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; nginx must accept
the change, or the previous vhost is restored and the deploy fails. An existing certificate is
installed for the requested domain on redeploy, repairing older HTTP-only vhosts too.
A redeploy whose vhost already holds that certificate behind Certbot's redirect, whose certificate and
key form a pair, whose running nginx presents it for the domain, and whose name no other server block in
nginx's loaded configuration declares, skips the install, which would otherwise save a checkpoint copy of
every nginx file on the host. It still tests and reloads nginx as the install did, and fails the deploy
when nginx refuses either.

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 **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 **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`.

For optional app-owned Cloudflare ingress, follow [backend edge delivery](docs/BACKEND_EDGE.md).

## 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:

   ```properties
   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](#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 |

```bash
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:

```bash
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](#check-run-annotations-the-panel-reads)):

```
::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](#crashlytics-symbol-delivery-firebase_app_id) 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:

```yaml
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:

```bash
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.

## 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`:

```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](#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:

```groovy
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](#android-delivery-via-bitrise-alternative-mode) 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:

```bash
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](https://firebase.google.com/docs/crashlytics/ios/get-deobfuscated-reports).


### 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](#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](#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
  → &lt;your App ID&gt;**. 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:

```yaml
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:

```yaml
- 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:

```yaml
- uses: ./.github/actions/swift-app
  with:
    ai-metadata: 'false'
```

## Update

```bash
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`:

```yaml
- 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 &lt;X&gt; capability"** — the App ID
lacks a capability the entitlements declare. The action enables the simple
toggles itself (see [App ID capabilities](#app-id-capabilities)); if the
warning names App Groups, iCloud, Apple Pay, Wallet or Data Protection, enable
and configure that one in the Apple Developer portal, then re-run.

## Auto-bumping MARKETING_VERSION

When the ASC combined floor (max of pending review, `preReleaseVersions`,
or builds-via-`preReleaseVersion`) exceeds your project's
`MARKETING_VERSION`, the action auto-bumps and preserves the staged source on a
verified task branch. The app session resolves its `source_maintenance_pending`
receipt through its normal checked PR before source delivery is complete.

Default policy is `rollover` — patch with carry: at `.9` it rolls into
the next minor (`1.0.9` → `1.1.0`), and at minor=9 it cascades into the
next major (`1.9.9` → `2.0.0`). Major has no upper limit (`9.9.9` →
`10.0.0`). This produces the more natural human progression most
projects want — patch numbers never silently grow past 9.

**Four policies** are supported:

| Policy | Example bump | When to use |
|--------|--------------|-------------|
| `rollover` (default) | `1.0.5` → `1.0.6`; `1.0.9` → `1.1.0` | Natural progression, carry at .9. |
| `patch` | `1.0.5` → `1.0.6`; `1.0.9` → `1.0.10` | Legacy unbounded patch — pinned for backward compat. |
| `minor` | `1.0.5` → `1.1.0`; `1.0.9` → `1.1.0` | Projects that ship every release as a minor. |
| `none` | (fails the build) | Explicit semver control via human bump. |

Full rollover behaviour: `1.0.9` → `1.1.0` (patch overflow), `1.9.9`
→ `2.0.0` (minor cascade), `9.9.9` → `10.0.0` (major no upper limit).

**Backward compat:** existing consumers on `0.0.27` that explicitly pin
`marketing-version-auto-bump: 'patch'` keep their current unbounded
behavior — the `'patch'` policy is unchanged. The default change from
`'patch'` → `'rollover'` only affects new installs and consumers that
do not override the input.

**Opt out** via the action input:

```yaml
- 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 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](#dart-obfuscation-flutter-apps).
- **Crashlytics symbol upload** — `firebase-app-id: ${{ vars.FIREBASE_APP_ID }}`
  passed to both actions. Until it is, setting the variable does nothing.
  See [Crashlytics symbol upload](#crashlytics-symbol-delivery-firebase_app_id).

### Opt out

Pin the vendored copy by passing `auto-update: 'false'` to the action:

```yaml
- 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](https://developer.apple.com/videos/play/wwdc2025/324/).
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-since` to a UTC `YYYY-MM-DDTHH:MM:SSZ` within the previous 24 hours.
This uses the existing backend action and SSH binding to inspect the exact app's `api` Compose service.
Custom workflows can set `diagnostic-service` explicitly. Retain the generated operation binding and
skip app-specific runtime preparation when this mode is selected; preserve all normal deployment inputs.

The action performs no deployment, file sync, database query or runtime-configuration read. It returns
`gowalk-cicd/backend-identity-diagnostics.v1` with `ok`, a closed `failure` code (empty on success), and at most100 `{stage,reason}` records from the
fixed Firebase refusal vocabulary. Raw logs stay on the host. Missing/ambiguous containers, scope mismatch,
transport failure or read bounds fail closed. An empty successful record list means no matching observation;
it does not prove authentication succeeded. Match the bounded observation window to the owned client attempt.

Reader failures retain only: `invalid_scope`, `invalid_window`, `invalid_destination`,
`container_ambiguous_or_absent`, `container_scope_mismatch`, `command_timeout`, `output_limit`,
`command_failed`, `transport_failure`, `remote_invalid`, `artifact_write_failed`, or `unclassified`. Structured remote refusals
survive a remote exit1. SSH transport failures remain distinct. Unknown exceptions never contribute text.
The optional standard `WARNING:<logger>:` prefix is discarded; logger names and trailing fields are never returned.

Download the dedicated `backend-identity-diagnostics-<job>-<attempt>` artifact from that exact run.
Its `result.json` contains the validated envelope on success and classified failure, without raw logs,
headers, environment values, provider exceptions or identities. Normal GitHub artifact integrity checks
apply. The console copy is supplementary: secret masking can replace JSON punctuation in logs.
Each invocation uses a fresh local directory so a failed reader cannot publish a prior result.
