Skip to main content
Extends 0257 (nobody reads a value) to generated machine secrets and preflights, and keeps 0267 (Agent Vault deferred). Numbered after the highest ADR in flight (0479); the ADR steward reconciles the number.

Context

The Academorix launch (2026-10-22 to 10-24) lost a day to store and secret work that was data, not code: no App Store Connect API key with the Admin role, so the iOS build went through Transporter by hand; iPad and Play screenshot sets re-exported from App Store Connect because the designed frames were not in the repository; the review demo account half-seeded on production; privacy and Data safety answers typed from memory; and credentials pasted into chat. On 2026-10-29 the owner asked for launches without that manual work: a few keys created once, every other step done through the platform APIs by agents, an owner approval only where a step is irreversible, and still nobody reading a secret value.

Decision

  1. A root kit, created once by the owner. Four credentials, each delivered through ~/Secrets-inbox and pnpm secrets:import into Infisical prod:/ci/store (except the Infisical identity, which is an id, not a secret): an App Store Connect API key with the Admin role (ASC_API_KEY_ID, ASC_API_ISSUER_ID, ASC_API_PRIVATE_KEY); a Google Play Console organisation account with a service account granted API access (GOOGLE_PLAY_SERVICE_ACCOUNT_JSON); an EAS robot token (EXPO_TOKEN); and an Infisical machine identity that trusts only each product repository’s production environment over GitHub OIDC and reads /ci/store (its id is passed to the workflow as a repository variable). Nothing else in a launch needs a new owner credential.
  2. Store presence is data in the product repository (0441 decisions 1 to 3). The app’s identity (bundle id, package, name per locale) and capabilities come from the root runway.yaml mobile.<app> entry; its listing from the assets/store/<app>/<locale>/ folder that entry names (name.txt, subtitle.txt, description.md, keywords.txt, privacy.yaml, and optional shortDescription.txt, promotionalText.txt, whatsNew.txt); app-level facts from assets/store/<app>/store.yaml (the App Store Connect app id once the record exists, urls, review contact and notes, the demo account, seed and probe as secret NAMES, TestFlight groups, app groups). privacy.yaml holds one row per data type and source, naming a @stackra/consent category, and is the same in every locale. Screenshots are captured in CI from the app built from its template line (0441 decision 7) and never committed. pnpm store build --root <product> --app <key> checks all of it and generates every store screenshot size, the App Store App Privacy answers, the Play Data safety answers and a checklist into the gitignored .stackra/store/<app>/. An analytics or crash SDK in the product’s or the template checkout’s dependencies with no row naming it fails the build, so privacy answers travel with the feature. Core’s apps/mobile is a reference app and has no store data.
  3. API first. The releaser drives the App Store Connect API (bundle ids, capabilities, versions, metadata, review details with the demo account, screenshots, TestFlight groups and beta review, submit for review), the Google Play Developer API (one edit per run: listings, screenshots, tracks, validate, commit) and EAS (build and submit). The clients are tools/scripts/store-asc.mjs and store-play.mjs, with no SDK dependency: requests are signed with node:crypto (ES256 for Apple, RS256 for Google).
  4. Browser only where no API exists. Creating the first App Store Connect app record, creating app group identifiers, the App Privacy form, creating the Play app and its first content-rating, target-audience and Data safety forms. These are done with the owner signed in, from the generated checklist and answers. No agent types a password, and no agent signs in.
  5. Dry run by default; real calls only from CI. Every external call goes through store-http.mjs, whose default mode sends nothing and prints the planned calls; --replay serves recorded fixtures (the tests); --live and --record run only inside GitHub Actions. Nothing calls Apple, Google or Expo for real until the owner has created the root kit and approved the first live run.
  6. One owner approval per irreversible step. .github/workflows/runway-store.yml, Runway’s reusable store provider, runs each launch step from the product’s main, building in Runway’s checkout of the app’s template line. A live run of any step runs in the product’s production environment, which accepts only the protected main branch: the store and signing keys are production secrets, and production secrets live only there, never repository-wide (owner decision 2026-10-29). A dry run reads no secret and takes no environment. The CLI refuses a live submit (App Review), promote (Play production) or seed (a production write) outside production, and promote is the owner’s own dispatch: no agent starts it (the promote-guard hook). Production deploys keep their own approval under 0170.
  7. The review demo account is a release check. pnpm store review seed calls the app’s seed endpoint and pnpm store review probe signs in with the demo account on production and must see data before a submission.
  8. The vault-keeper agent works by name. pnpm vault preflight --env <e> lists every secret a deploy or store release of e needs (the runway CI and member names, and for prod the root kit and the review secrets) as present, missing or placeholder, by NAME only, and fails on any gap. pnpm vault generate NAME [--rotate] creates or rotates a random machine secret written blind into Infisical through the secrets-import core; production needs the dry run’s one-time token, bound to the operation, as in 0257. Third-party credentials are never generated here: they arrive through the inbox.
  9. Agent Vault stays deferred (0267). Credentials reach the CI job as environment values over OIDC and never the model.

Consequences

  • A launch is: the owner creates the root kit once, does the few browser steps from the checklist, approves the submit in the production environment and dispatches the promote itself. Every other step is a workflow dispatch an agent can make.
  • The iPad set, the 9:16 Play set, the privacy answers and the demo account can no longer be forgotten: store build and the probe fail first.
  • Apple and Google change their APIs; the fixtures are in the documented response shapes until the first live run re-records them (--record), and a drift shows up as a replay failure, not on launch day.
  • The App Store Connect key has the Admin role because app management needs it; it lives only in prod:/ci/store, readable only by jobs in the production environment.
  • Data safety and App Privacy stay browser forms until the APIs accept them; the answers are generated, so the form is copying, not deciding.

Enforcement

  • tools/scripts/__tests__/store.test.mjs: the dry run sends nothing; live and record refuse outside CI; recorded fixtures for the App Store Connect and Play flows; submit, promote and seed refuse outside production; no credential or demo password reaches the output; store build checks consent categories, SDK rows, text limits and screenshot counts, and renders the exact store sizes.
  • tools/scripts/__tests__/vault-keeper.test.mjs: preflight is names-only; generate and rotate write blind; the production token is bound to the operation.
  • .github/workflows/runway-store.yml: a live run takes the production environment; its protection is applied by the owner (runbook), not checked by CI.
  • .agents/agents/releaser.md and .agents/agents/vault-keeper.md: each agent is allowed only its own commands (pnpm store, the dispatch of the product’s store workflow; pnpm vault).
  • Planned — a vault preflight step in each deploy workflow (STA-1598).

Supersedes and sources

Supersedes: none. Extends 0257; keeps 0267. Sources: owner request 2026-10-29; Academorix launch lessons 2026-10-24 (stackra notes); epic STA-1590, root kit STA-1591, which supersedes the manual store steps of STA-1555, STA-1557 and STA-1558.