@bongos/core 1.19.204 → 1.19.206

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.204",
6
- "core_contract": "1.19.204",
7
- "source_commit": "1b831b99695f793cf8d6e7fdf248e20ea2486c77",
5
+ "core_version": "1.19.206",
6
+ "core_contract": "1.19.206",
7
+ "source_commit": "6ef49e744b96d37db9a338979fb85bb56fb6aa6f",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-17T19:29:56.265Z",
9
+ "built_at": "2026-08-17T20:15:14.217Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 310,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 1627,
20
- "tree_sha256": "a44dd13817d43a88eb9a226b742a2c039888e0390bf5a5cb428c312e95db4aaf",
20
+ "tree_sha256": "f7017bc51e10af4dd65ab8df53ce22c7a0e611c7fb948ee6598ce8c4f7831a6a",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -187,7 +187,7 @@
187
187
  {
188
188
  "path": ".claude/skills/new-project/SKILL.md",
189
189
  "mode": "0000644",
190
- "sha256": "55b86978a60e33eefe024d7c7d521e4093b82235eeb7e5d4a145fbb0e4db0b06"
190
+ "sha256": "9b36d441850c335fa634d892088e4c15a2a87acaa9e52808b74a8b2e6e9af39f"
191
191
  },
192
192
  {
193
193
  "path": ".claude/skills/otb-character-review/SKILL.md",
@@ -367,7 +367,7 @@
367
367
  {
368
368
  "path": "clients/bongos-client/index.d.ts",
369
369
  "mode": "0000644",
370
- "sha256": "b37e4a561554ec579b525ff660246d350a70c4acdda9a16d8e6521c38180bae6"
370
+ "sha256": "38ef88079d260b67161e680ea77fa6ddecb3cc74a1452796cbe28c0e3601c052"
371
371
  },
372
372
  {
373
373
  "path": "clients/bongos-client/index.mjs",
@@ -707,7 +707,7 @@
707
707
  {
708
708
  "path": "docs/adr/0055-server-mediated-branch-publish.md",
709
709
  "mode": "0000644",
710
- "sha256": "6f51c67939eacaf510774894625d0a09e6883e4c8e1d8fdea2cf35f8d9f58ea4"
710
+ "sha256": "11ee013554922e0f9dadb582373c22d4aaa6433ddcb14af28386c4843a39497f"
711
711
  },
712
712
  {
713
713
  "path": "docs/adr/0056-prod-deploy-script-mirror-and-healthcheck-retry.md",
@@ -1137,7 +1137,7 @@
1137
1137
  {
1138
1138
  "path": "docs/adr/0133-github-app-manifest-onboarding-signin.md",
1139
1139
  "mode": "0000644",
1140
- "sha256": "94e1f79e0a32a53b51e5bbf6d10d37d477d676cad33b267dc3ddc0891ae70704"
1140
+ "sha256": "dbd5b30d75631ba2754f4e7da0d43c03b1ed998b4082e8df23de0d7e7d5c5305"
1141
1141
  },
1142
1142
  {
1143
1143
  "path": "docs/adr/0134-private-first-npm-distribution.md",
@@ -1372,7 +1372,7 @@
1372
1372
  {
1373
1373
  "path": "docs/api/openapi.json",
1374
1374
  "mode": "0000644",
1375
- "sha256": "7892d449572d19f058c6ac364d5b697e28d76bcd922d25d33b5203f5e1b5b7aa"
1375
+ "sha256": "c11ec9dcb0726382e15bb7fc9e74086037ed158256ef16b5efcc81fe2db2d078"
1376
1376
  },
1377
1377
  {
1378
1378
  "path": "docs/architecture.md",
@@ -1672,7 +1672,7 @@
1672
1672
  {
1673
1673
  "path": "docs/recipes/ops-gotchas.md",
1674
1674
  "mode": "0000644",
1675
- "sha256": "52a209553565405dca461b1c4c0cef9fb0ec6d98b95ed420c82eaf1fa3eb15f6"
1675
+ "sha256": "d9956abefe8496cbbb8bbc94361431d40823c3a9d1fa8f4145e8b48999b54672"
1676
1676
  },
1677
1677
  {
1678
1678
  "path": "docs/recipes/overnight-builder.md",
@@ -3567,7 +3567,7 @@
3567
3567
  {
3568
3568
  "path": "modules/lifecycle/github-push.js",
3569
3569
  "mode": "0000644",
3570
- "sha256": "aef4e5dca5cbea3c7c2041b877a98204f2cb21c3a9ce017cad57955d2212cf96"
3570
+ "sha256": "127b6666bfbc4b85baebe85b0bf5501ad73b517faf4dafbc1a76a5535ac04ebc"
3571
3571
  },
3572
3572
  {
3573
3573
  "path": "modules/lifecycle/goal-advisory.js",
@@ -3622,7 +3622,7 @@
3622
3622
  {
3623
3623
  "path": "modules/lifecycle/publish-reconciler.js",
3624
3624
  "mode": "0000644",
3625
- "sha256": "6e41bbab9f8277a13178e4f9fcd278b47b8b34421114522ac113e57aedb631ac"
3625
+ "sha256": "1cca02e8eff2b0a7fdc1fa06ad6be2f633e25a29bdd10dddf6147610fdae087a"
3626
3626
  },
3627
3627
  {
3628
3628
  "path": "modules/lifecycle/routes/analytics.js",
@@ -3662,7 +3662,7 @@
3662
3662
  {
3663
3663
  "path": "modules/lifecycle/routes/tasks.js",
3664
3664
  "mode": "0000644",
3665
- "sha256": "68b4f47e3a5016645893c60eca2fe11a242012abf6625c99d6a774356d82d1ce"
3665
+ "sha256": "95193b64dcf1121f10fbc871a07cb7997a27a64d3f7b2176957abdae28979729"
3666
3666
  },
3667
3667
  {
3668
3668
  "path": "modules/lifecycle/routes/versions.js",
@@ -3957,7 +3957,7 @@
3957
3957
  {
3958
3958
  "path": "modules/provisioning/onboard-plan.js",
3959
3959
  "mode": "0000644",
3960
- "sha256": "e06957844e7161ef42e9971cc1a9b952d9ae87a781b2821d882085ca02c6eccf"
3960
+ "sha256": "de7927d2a3c1aac0d40ec5b1ac6c2e98e87cb593c4d3d2bd6ddfe3696371bff6"
3961
3961
  },
3962
3962
  {
3963
3963
  "path": "modules/provisioning/pollers/liveness-sweep.js",
@@ -3967,7 +3967,7 @@
3967
3967
  {
3968
3968
  "path": "modules/provisioning/provisioning.js",
3969
3969
  "mode": "0000644",
3970
- "sha256": "eebcba4b333030f17c06bc6e1423450ae4af124e4075af52cbd512e80d5ba95e"
3970
+ "sha256": "e43977d0c5458de4fde91e6ccb784b112187545fdf03ceed2c352b605686a57e"
3971
3971
  },
3972
3972
  {
3973
3973
  "path": "modules/provisioning/rate-limit.js",
@@ -3977,7 +3977,7 @@
3977
3977
  {
3978
3978
  "path": "modules/provisioning/routes/provisioning.js",
3979
3979
  "mode": "0000644",
3980
- "sha256": "17f6adb4edd7dee2ab339f5d302b61ecfbcf45db2ba4d0315eeeffbad87a9dce"
3980
+ "sha256": "f7cf016e10587302437f5d8ab7b01d8b1be6694e05b9e8c47a6ccb2cdfda8ac4"
3981
3981
  },
3982
3982
  {
3983
3983
  "path": "modules/provisioning/tests/liveness.mjs",
@@ -3987,7 +3987,7 @@
3987
3987
  {
3988
3988
  "path": "modules/provisioning/tests/provisioning.mjs",
3989
3989
  "mode": "0000644",
3990
- "sha256": "98787fad5df787a8ebdbf90fb8bf21b68f1529387b15d65724fdafc8ac0b91d0"
3990
+ "sha256": "4c7bb37582485445e947e233c9fbb9c20c9ab55da9dd159c8062fffa4472a5d7"
3991
3991
  },
3992
3992
  {
3993
3993
  "path": "modules/provisioning/ui/.gitkeep",
@@ -4277,12 +4277,12 @@
4277
4277
  {
4278
4278
  "path": "package-lock.json",
4279
4279
  "mode": "0000644",
4280
- "sha256": "45a21ec8d9e8ffae13d85a59beb598f381a10fb85746c42888f0b8c3be098e0f"
4280
+ "sha256": "8cece7080d6a67c93467f86ee1c02cff85f8b2dbd5e8219519e9bc9df034f895"
4281
4281
  },
4282
4282
  {
4283
4283
  "path": "package.json",
4284
4284
  "mode": "0000644",
4285
- "sha256": "07c27a2b49959b9022ad8465bc02abe9fbce3bceb9068385a457d01c32423f0f"
4285
+ "sha256": "05a1768a85ccd224b9caa4e388b83f5a1d933b8b46761ebe7ce6f5a06f3c6d6a"
4286
4286
  },
4287
4287
  {
4288
4288
  "path": "public-docs/index.html",
@@ -4542,7 +4542,7 @@
4542
4542
  {
4543
4543
  "path": "public-landing/projects.html",
4544
4544
  "mode": "0000644",
4545
- "sha256": "5edfc22fd5d85453df0d83a531fd085b3d5d079b66c7e3f58dcb9a0261c6b4a6"
4545
+ "sha256": "494d1aa1a6ff006a8e8f239c29d90c052f735919937c9dc6fb6b8eeff4902e91"
4546
4546
  },
4547
4547
  {
4548
4548
  "path": "public-landing/world/assets/clouds/cloud-island-large.png",
@@ -5152,7 +5152,7 @@
5152
5152
  {
5153
5153
  "path": "scripts/gds/onboard.js",
5154
5154
  "mode": "0000644",
5155
- "sha256": "c6fb2c531c6d84dcb16daea50984e1b24021b6379cf992a01482951b119bf9e4"
5155
+ "sha256": "71ee037f1e893a1bcbcaca95e6bdc80c5eb4f5d3e613b59b95e1272d673d3d12"
5156
5156
  },
5157
5157
  {
5158
5158
  "path": "scripts/gds/onboarding-analyze.js",
@@ -5197,7 +5197,7 @@
5197
5197
  {
5198
5198
  "path": "scripts/gds/provision.js",
5199
5199
  "mode": "0000644",
5200
- "sha256": "1c348e8859004c69e66b8eede56da0bab68ec4d473790f1c1f9bf1769934211f"
5200
+ "sha256": "242f51c3e2ae991dc8a02d7285c829d3cd7b2a4022c516b218d259cac14b855f"
5201
5201
  },
5202
5202
  {
5203
5203
  "path": "scripts/gds/publish-credential-check.js",
@@ -5892,7 +5892,7 @@
5892
5892
  {
5893
5893
  "path": "src/module-api.js",
5894
5894
  "mode": "0000644",
5895
- "sha256": "acfdae5a37ae7fa878a752cb566ec930dec8a7703cb86ef9d16609f6ec8c1bab"
5895
+ "sha256": "84441072e12b08bf8c5dde09972dc91dd530b586f30f287016d830589476fb5d"
5896
5896
  },
5897
5897
  {
5898
5898
  "path": "src/module-loader/catalog.js",
@@ -7377,7 +7377,7 @@
7377
7377
  {
7378
7378
  "path": "tests/onboard.mjs",
7379
7379
  "mode": "0000644",
7380
- "sha256": "72590fd2d6f99db3020cb5488ec4f31cef2ad15718a63d8b147ab5adefe347f7"
7380
+ "sha256": "c6823a6f0f81bcb4f01ccbd2364d6dcf76eb21e3ff10972738f792438939dee0"
7381
7381
  },
7382
7382
  {
7383
7383
  "path": "tests/onboarding_gate.mjs",
@@ -7517,7 +7517,7 @@
7517
7517
  {
7518
7518
  "path": "tests/provision.mjs",
7519
7519
  "mode": "0000644",
7520
- "sha256": "9e8da984a2a9378809f03cc3f5f1c2cda34f8589ad954db1acd5b12bf424f322"
7520
+ "sha256": "30b55efaa97e695603875ec057901d5333977d41705fc862fc831a424c4e2674"
7521
7521
  },
7522
7522
  {
7523
7523
  "path": "tests/provisioning_default_domain.mjs",
@@ -7572,7 +7572,7 @@
7572
7572
  {
7573
7573
  "path": "tests/publish_branch_route.mjs",
7574
7574
  "mode": "0000644",
7575
- "sha256": "db309db9b8fe350a64708d2a0c9dd1622ad0e5bfdef7c89775721ff3daa7042d"
7575
+ "sha256": "485b2b965bcf44a515571de43851c42a32f278dcdd02510f0d3cd9f6ac53e61c"
7576
7576
  },
7577
7577
  {
7578
7578
  "path": "tests/publish_manifest.mjs",
@@ -7582,7 +7582,7 @@
7582
7582
  {
7583
7583
  "path": "tests/publish_reconciler.mjs",
7584
7584
  "mode": "0000644",
7585
- "sha256": "fed6533bd829adc0fc2126c48b28248c90bd32660650f2af91fee112e3f7369a"
7585
+ "sha256": "1dfca44ba378b69041894371c1a3765eba40edbdec18570d7b67cc849bcb9e8a"
7586
7586
  },
7587
7587
  {
7588
7588
  "path": "tests/rank_gate.mjs",
@@ -8077,7 +8077,7 @@
8077
8077
  {
8078
8078
  "path": "tests/tool_scan_engine.mjs",
8079
8079
  "mode": "0000644",
8080
- "sha256": "c8ae5ea8c8a78d0d2a9a55d7d94b3fd4d40ad36e71510f7d804db5645cc86bec"
8080
+ "sha256": "10a6a41cafcba32406be1aa79e0be1ace1cde2c4163bf0b1a0efd1e5e8f8c403"
8081
8081
  },
8082
8082
  {
8083
8083
  "path": "tests/touches_overlap.mjs",
@@ -9,7 +9,7 @@ You are guiding a new owner from **zero to a live, owned Cloud Bongos instance**
9
9
 
10
10
  A new instance is **standalone** (ADR 0108): it runs from its **own git repo** with `@cloudbongos/core` installed as a **pinned npm dependency**, not a fork of this monorepo. Central updates are a `bongos upgrade` version-pin bump, not a merge. Every step below assumes this shape (`hosting_shape=standalone`).
11
11
 
12
- Four steps are **irreducibly human** and must be rendered as **precise, UI-first, copy-paste-safe prompts** (a non-technical owner does them with browser clicks, no terminal guessing): (1) create the GitHub repo, (2) approve the pre-filled **GitHub App** for sign-in (ONE click — the App Manifest flow, task 2080; no secret to paste), (3) own a domain, (4) the first sign-in. Everything else is automated. Never ask the owner to do in a terminal what they can do with a click.
12
+ Five steps are **irreducibly human** and must be rendered as **precise, UI-first, copy-paste-safe prompts** (a non-technical owner does them with browser clicks, no terminal guessing): (1) create the GitHub repo, (2) approve the pre-filled **GitHub App** for sign-in (ONE click — the App Manifest flow, task 2080; no secret to paste), (3) install that App on the repo (ONE click — the publish credential, task 1003049), (4) own a domain, (5) the first sign-in. Everything else is automated. Never ask the owner to do in a terminal what they can do with a click.
13
13
 
14
14
  ## The canonical sequence
15
15
 
@@ -85,6 +85,8 @@ node scripts/gds/oauth-secret.js place <slug> --client-id <id> --client-secret <
85
85
 
86
86
  > **Guidance the standup learned:** do **NOT** hand-edit `web.env` with a shell `read -s` — during the Emersonian standup that silently left a 26-char truncated secret that broke sign-in with no obvious cause. `oauth-secret` validates the length/format (catching that truncation), rewrites the env line via Node, restarts the service, then **probes `/healthz` for `auth_configured:true`** before it reports success. If it refuses, the creds are wrong — recheck them; do not proceed.
87
87
 
88
+ **Human-only — install the GitHub App on the repo (task 1003049, ADR 0055).** The one-click App doubles as the instance's **publish credential**: the manifest requests Contents rw + PR rw + Actions read, the runner places `GITHUB_APP_ID` + `GITHUB_APP_PRIVATE_KEY` in `web.env`, and the server discovers the installation id on its own — but only an **installed** App can mint push tokens. One click: the wizard's **Install the app on your repo** button (`github.com/apps/<slug>/installations/new`), or GitHub → Settings → Developer settings → GitHub Apps → your app → **Install App** → the project repo. Skip it and every ship strands at `confirmed` behind a manual merge. (Manual OAuth-app path instead? Set a fine-grained `GITHUB_PUSH_TOKEN` in `web.env` — ADR 0055.)
89
+
88
90
  **Human-only — first sign-in.** The owner opens `https://<domain>` and signs in with GitHub **once**. This seats the **founding Archon** — config alone seats no one, so this step is inherent and cannot be automated.
89
91
 
90
92
  ### 7. Verify
@@ -72,7 +72,7 @@ export interface GetProfilesHandleResponse { account: unknown; cross_project: un
72
72
  export interface GetProjectsFeaturedResponse { projects: unknown }
73
73
  export interface GetProjectsResponse { projects: unknown }
74
74
  export interface GetProvisioningFleetResponse { instances: unknown }
75
- export interface GetProvisioningGithubAppStatusResponse { ok: boolean; status: unknown; ready: unknown; terminal: unknown; domain: unknown; instance_status: unknown }
75
+ export interface GetProvisioningGithubAppStatusResponse { ok: boolean; status: unknown; ready: unknown; terminal: unknown; domain: unknown; instance_status: unknown; app_slug: unknown }
76
76
  export interface GetProvisioningHealthResponse { module: unknown; ok: boolean; coreVersion: unknown }
77
77
  export interface GetProvisioningInstancesIdResponse { instance: unknown; open_intent: unknown }
78
78
  export interface GetProvisioningInstancesResponse { instances: unknown }
@@ -42,6 +42,8 @@ A **fine-grained GitHub PAT** scoped to the single repo: `Contents: read/write`
42
42
  >
43
43
  > Check it with `node scripts/gds/publish-credential-check.js` on the host — it lists the repo's workflows as a control call first, so `blind` and `readable_no_workflow` are distinguishable. Task 1002724 makes a blind probe non-fatal (the reconciler falls back to the git-history land proof), but a blind credential means the deploy gate is **not being enforced**, only bypassed.
44
44
 
45
+ > **Update (2026-08-17, task 1003049 — fresh instances get the credential from the manifest App; installation id auto-discovered):** a wizard-born instance had NO publish credential at all — the ADR 0133 manifest requested zero permissions and the conversion's `pem` was discarded — so on every new project the auto-merge + reconciler were dead on arrival and each ship stranded at `confirmed` behind manual archon surgery (gap-list W7, proven live on Spike Harbor). Now: the manifest requests exactly this ADR's scopes (Contents rw + PR rw + Actions read), the control-plane runner places `GITHUB_APP_ID` + `GITHUB_APP_PRIVATE_KEY` into the instance's web.env alongside the OAuth creds, and `github-push.js` **discovers the installation id at runtime** (`GET /repos/{owner}/{repo}/installation` with the App JWT; the env var still wins when set) — so `GITHUB_APP_INSTALLATION_ID` is no longer required. The one human step is installing the App on the repo (the onboarding `app-install` step; the wizard renders the one-click `github.com/apps/<slug>/installations/new` link). <redacted> is a first-class loud state: routes answer a plain-sentence 503 naming the install URL, and the publish-reconciler warns once per streak instead of silently parking ships. **Security trade-off (accepted):** the App is public (the W6 sign-in fix), so third parties can install it on their own repos — those installs sit inside a leaked App key's blast radius even though this server only ever uses its own repo's installation. Mitigation: the reconciler's foreign-install watch (`github-push.foreignInstallations`) warns loudly whenever the App is installed anywhere beyond the repo owner. Pre-existing instances: see the ops-gotchas entry (grant the permissions + generate a key by hand, or use a PAT).
46
+
45
47
  ## Consequences
46
48
 
47
49
  - A dev-box builder ships end-to-end: `/builder-reauth` → claim → edit → `/builder-stage` ([ADR 0046](<redacted>.md)) → `/builder-ship`, with the GDS doing the push. The box holds no GitHub credential, so the [ADR 0053](0053-scoped-dev-box-session.md) blast-radius argument still holds.
@@ -31,9 +31,9 @@ The conversion endpoint is unauthenticated (the code is the auth), so the web ti
31
31
 
32
32
  ### Manifest shape
33
33
 
34
- `redirect_url` = the **mothership** callback (the instance can't do the exchange). `callback_urls` = the **instance's** own `/api/gds/auth/web/callback` (what `auth.js` actually builds) **and** the `/api/bongos/...` alias, so sign-in works regardless of which prefix a build emits. `default_permissions: {}` (sign-in needs none), `hook_attributes.active:false`, `public:true`. Personal vs org differs only in the POST URL (derived from the repo owner); the App works for sign-in either way.
34
+ `redirect_url` = the **mothership** callback (the instance can't do the exchange). `callback_urls` = the **instance's** own `/api/gds/auth/web/callback` (what `auth.js` actually builds) **and** the `/api/bongos/...` alias, so sign-in works regardless of which prefix a build emits. `default_permissions: { contents: 'write', pull_requests: 'write', actions: 'read' }` — sign-in itself needs none, but the App doubles as the instance's ADR 0055 **publish credential** (task 1003049): the conversion's `pem` + app id land in web.env, and once the owner installs the App on the repo the server mints installation tokens to land shipped work itself. `hook_attributes.active:false`, `public:true`. Personal vs org differs only in the POST URL (derived from the repo owner); the App works for sign-in either way.
35
35
 
36
- > **Amended 2026-08-17 (task 1003048, gap-list W6):** the manifest originally set `public:false`. That made every wizard-born project structurally single-builder on the web — GitHub answers a private App's authorize URL for any non-owner account with a bare 404, before the instance is ever involved (reproduced live on Spike Harbor). The App requests zero permissions, so public costs nothing; the manifest now sets `public:true`. Apps minted before the fix must be flipped by hand (GitHub App settings → Advanced → Make public) — see the ops-gotchas entry.
36
+ > **Amended 2026-08-17 (task 1003048, gap-list W6):** the manifest originally set `public:false`. That made every wizard-born project structurally single-builder on the web — GitHub answers a private App's authorize URL for any non-owner account with a bare 404, before the instance is ever involved (reproduced live on Spike Harbor). The App requested zero permissions at the time, so public cost nothing; the manifest now sets `public:true`. (Task 1003049 later added the ADR 0055 repo scopes, which changes the calculus: a public App with write scopes **can** be installed by third parties on their own repos, and a leaked App private key's blast radius includes those installs — the server itself only ever uses its own repo's installation, but the credential could reach further if compromised. Accepted to keep this fix; mitigated by the publish-reconciler's **foreign-install watch**, which warns loudly whenever the App is installed anywhere beyond the repo owner.) Apps minted before the fix must be flipped by hand (GitHub App settings → Advanced → Make public) — see the ops-gotchas entry.
37
37
 
38
38
  **App name (tasks 2093 → 2095).** GitHub App names are globally unique across all of GitHub, so a bare project slug (e.g. `demo`) is almost always already taken. The auto name is the **instance domain** (e.g. `demo.cloudbongos.com`): a domain is inherently unique (so it never collides) and it reads perfectly on GitHub's sign-in consent screen, where the name is shown as *"`<name>` by `<owner>` … will redirect to `https://<domain>`"* — the name IS the destination. GitHub keeps the dots in the display name (only the internal slug is transformed) and caps it at 34 chars. **Fallback** (no domain, or a domain over 34 chars): brand-qualify the project — `<mothership brand> - <project>`, e.g. `Cloud Bongos - demo`, from the mothership's own `branding().identity.productName` (portable, not hardcoded) — still far less collision-prone than a bare slug. Either way it is only a default the owner can rename on GitHub's confirmation screen.
39
39
 
@@ -13402,7 +13402,8 @@
13402
13402
  "ready": {},
13403
13403
  "terminal": {},
13404
13404
  "domain": {},
13405
- "instance_status": {}
13405
+ "instance_status": {},
13406
+ "app_slug": {}
13406
13407
  },
13407
13408
  "required": [
13408
13409
  "ok",
@@ -13410,7 +13411,8 @@
13410
13411
  "ready",
13411
13412
  "terminal",
13412
13413
  "domain",
13413
- "instance_status"
13414
+ "instance_status",
13415
+ "app_slug"
13414
13416
  ]
13415
13417
  },
13416
13418
  "GetProvisioningHealthResponse": {
@@ -131,9 +131,16 @@ Organized by topic, not chronology. New entries go in the matching section; crea
131
131
 
132
132
  - **Symptom:** On a wizard-created instance, any GitHub account other than the App's owner clicks "Sign in with GitHub" and gets a bare GitHub 404 — before the instance is ever involved. The project is effectively single-builder on the web. (Device Flow sign-in for the same non-owner works, which makes the failure look like anything but an App setting.)
133
133
  - **Root cause:** the ADR 0133 manifest flow used to mint the GitHub App with `public: false`, and GitHub 404s a private App's authorize URL for every non-owner. Fixed for newly minted Apps (`buildGithubAppManifest` now sets `public: true` — gap-list W6, task 1003048), but a manifest can't be re-applied to an existing App.
134
- - **Fix for pre-existing instances:** the App owner flips it by hand — github.com → Settings → Developer settings → GitHub Apps → (the instance's App) → **Advanced** → **Make public**. One click, no permission changes (the App requests none).
134
+ - **Fix for pre-existing instances:** the App owner flips it by hand — github.com → Settings → Developer settings → GitHub Apps → (the instance's App) → **Advanced** → **Make public**. One click, no permission changes needed for sign-in.
135
135
  - **Live evidence:** reproduced twice against Spike Harbor (instance 18) with a fresh verified-email account, 2026-08-16 — see `docs/audits/project-creation-gap-list.md` W6.
136
136
 
137
+ ### Manifest-born GitHub Apps created before the task-1003049 fix are NOT a publish credential — every ship strands at `confirmed`
138
+
139
+ - **Symptom:** on a fresh instance every `/builder-ship` parks at `confirmed`; `GET /tasks/:id/publish-status` answers 503 `push_unconfigured`; the archon has to merge PRs and `POST /tasks/:id/ship` by hand. Silent — work just piles up (gap-list W7, proven live on Spike Harbor task 1).
140
+ - **Root cause:** the ADR 0133 manifest used to request **zero** repo permissions and the conversion's App private key (`pem`) was discarded, so no publish credential (ADR 0055) ever existed on a wizard-born instance. Fixed for newly minted Apps (task 1003049): the manifest now requests `contents: write` + `pull_requests: write` + `actions: read`, `placeManifestCreds` writes `GITHUB_APP_ID` + `GITHUB_APP_PRIVATE_KEY` into web.env, and the server discovers the installation id at runtime — the one human step left is **installing the App on the repo** (the onboarding `app-install` step).
141
+ - **Fix for pre-existing instances:** in the App's GitHub settings — (1) **Permissions & events**: grant Contents read/write, Pull requests read/write, Actions read-only; (2) **General → Private keys**: generate a key; (3) put `GITHUB_APP_ID=<id>` and `GITHUB_APP_PRIVATE_KEY=<pem as one line with literal \n>` in `/etc/<slug>/web.env` and restart the unit; (4) **Install App** → pick the project repo (owners must re-approve the new permissions on an already-installed App). Alternative: a fine-grained `GITHUB_PUSH_TOKEN` PAT with the same three scopes (ADR 0055).
142
+ - **How you know it worked:** `publish-status` stops 503ing, and the publish-reconciler log line `WARN … not installed` (if the App wasn't installed yet) goes quiet after the install.
143
+
137
144
  ---
138
145
 
139
146
  ## Mac / local dev
@@ -20,10 +20,12 @@
20
20
  // THE SERVER CREDENTIAL (task 1028 / ADR 0055 hardening). Preferred: a GitHub
21
21
  // APP — the server holds ONLY the App private key and mints a SHORT-LIVED,
22
22
  // repo-scoped INSTALLATION token (1h) per push window, so there's no standing
23
- // push secret at rest. Set GITHUB_APP_ID + GITHUB_APP_INSTALLATION_ID +
24
- // GITHUB_APP_PRIVATE_KEY. Fallback (v1, kept for cutover): a standing fine-grained
25
- // PAT in GITHUB_PUSH_TOKEN. resolveToken() prefers the App, then the PAT; the App
26
- // JWT is RS256-signed with node:crypto (no jsonwebtoken / Octokit dependency).
23
+ // push secret at rest. Set GITHUB_APP_ID + GITHUB_APP_PRIVATE_KEY (the
24
+ // installation id is discovered from the repo at runtime; GITHUB_APP_INSTALLATION_ID
25
+ // still wins when set — task 1003049). Fallback (v1, kept for cutover): a standing
26
+ // fine-grained PAT in GITHUB_PUSH_TOKEN. resolveToken() prefers the App, then the
27
+ // PAT; the App JWT is RS256-signed with node:crypto (no jsonwebtoken / Octokit
28
+ // dependency).
27
29
  //
28
30
  // This module is the server seam: push a bundle, open/find the PR, enable auto-
29
31
  // merge, report status. All git runs through execFile arg-arrays (shell:false,
@@ -58,12 +60,12 @@ const SAFE_SHA_RE = /^[0-9a-f]{7,64}$/;
58
60
 
59
61
  // Configured if EITHER credential is present: a GitHub App (preferred) or the
60
62
  // v1 PAT fallback. The route 503s when neither is set (task 1028).
63
+ // GITHUB_APP_INSTALLATION_ID is OPTIONAL (task 1003049): a fresh instance's
64
+ // manifest-born App has no known installation id at cred-placement time, so the
65
+ // server discovers it at runtime from GET /repos/{owner}/{repo}/installation —
66
+ // the one human onboarding step left is installing the App on the repo.
61
67
  function appConfigured() {
62
- return !!(
63
- process.env.GITHUB_APP_ID &&
64
- process.env.GITHUB_APP_INSTALLATION_ID &&
65
- process.env.GITHUB_APP_PRIVATE_KEY
66
- );
68
+ return !!(process.env.GITHUB_APP_ID && process.env.GITHUB_APP_PRIVATE_KEY);
67
69
  }
68
70
 
69
71
  function isConfigured() {
@@ -200,10 +202,76 @@ function mintAppJwt({ appId, privateKeyPem, nowSec }) {
200
202
 
201
203
  // Module-level cache of the current installation token, reused until ~60s before
202
204
  // expiry. Installation tokens are short-lived by construction (1h) and repo-
203
- // scoped to the single repo this server serves.
205
+ // scoped to the single repo this server serves. The discovered installation id
206
+ // is cached separately — it only changes if the App is uninstalled/reinstalled,
207
+ // and a stale id fails the token exchange loudly (APP_TOKEN_FAILED), never silently.
204
208
  let _instCache = { token: null, expEpoch: 0 };
209
+ let _instIdCache = null;
210
+ // Negative cache for APP_NOT_INSTALLED: during an active ship the publish-status
211
+ // poll fires every few seconds, and re-discovering a known-absent installation
212
+ // each time re-mints a JWT + two GitHub calls for the same answer.
213
+ const NOT_INSTALLED_TTL_MS = 60_000;
214
+ let _notInstalled = { untilMs: 0, err: null };
205
215
  function _resetAppTokenCacheForTests() {
206
216
  _instCache = { token: null, expEpoch: 0 };
217
+ _instIdCache = null;
218
+ _notInstalled = { untilMs: 0, err: null };
219
+ }
220
+
221
+ function appJwtHeaders(jwt) {
222
+ return {
223
+ Authorization: `Bearer ${jwt}`,
224
+ Accept: 'application/vnd.github+json',
225
+ 'X-GitHub-Api-Version': '2022-11-28',
226
+ 'User-Agent': userAgent(),
227
+ };
228
+ }
229
+
230
+ // Discover the App's installation id on THIS repo (task 1003049). A manifest-born
231
+ // instance (ADR 0133) knows its App id + private key at cred-placement time but not
232
+ // the installation id — the owner installs the App as a separate onboarding step —
233
+ // so the server derives it live instead of requiring a third env var. Throws
234
+ // APP_NOT_INSTALLED (with a best-effort install_url) when the App exists but nobody
235
+ // has installed it on the repo yet: that is the one remaining human step, and the
236
+ // refusal must say so in a sentence, not a bare code.
237
+ async function discoverInstallationId(jwt, deps = {}) {
238
+ if (_instIdCache) return _instIdCache;
239
+ const nowMs = deps.now ? deps.now() : Date.now();
240
+ if (_notInstalled.err && nowMs < _notInstalled.untilMs) throw _notInstalled.err;
241
+ const info = deps.repoInfo || loadRepoInfo();
242
+ if (!info || info.error) throw err('NO_REPO_INFO');
243
+ const { owner, repo } = info;
244
+ const doFetch = deps.fetchImpl || fetch;
245
+ const res = await doFetch(`https://api.github.com/repos/${owner}/${repo}/installation`, {
246
+ headers: appJwtHeaders(jwt),
247
+ });
248
+ if (res.status === 404) {
249
+ // The App is real (the JWT authenticated) but not installed on the repo.
250
+ // Fetch the App's own page for a clickable install URL (best-effort — the
251
+ // guidance sentence works without it).
252
+ let installUrl = null;
253
+ try {
254
+ const appRes = await doFetch('https://api.github.com/app', { headers: appJwtHeaders(jwt) });
255
+ if (appRes.ok) {
256
+ const app = await appRes.json();
257
+ if (app && app.html_url) installUrl = `${app.html_url}/installations/new`;
258
+ }
259
+ } catch (_) { /* best-effort */ }
260
+ const e = err('APP_NOT_INSTALLED', { owner, repo, install_url: installUrl });
261
+ _notInstalled = { untilMs: nowMs + NOT_INSTALLED_TTL_MS, err: e };
262
+ throw e;
263
+ }
264
+ if (!res.ok) {
265
+ const detail = await res.text().catch(() => '');
266
+ throw err('APP_TOKEN_FAILED', { status: res.status, detail: detail.slice(0, 200) });
267
+ }
268
+ const data = await res.json();
269
+ if (!data || data.id == null) {
270
+ throw err('APP_TOKEN_FAILED', { status: res.status, detail: 'installation lookup returned no id' });
271
+ }
272
+ _notInstalled = { untilMs: 0, err: null };
273
+ _instIdCache = String(data.id);
274
+ return _instIdCache;
207
275
  }
208
276
 
209
277
  // Returns a fresh/cached App installation token, or null if the App isn't
@@ -219,20 +287,22 @@ async function getInstallationToken(deps = {}) {
219
287
  privateKeyPem: process.env.GITHUB_APP_PRIVATE_KEY,
220
288
  nowSec,
221
289
  });
290
+ // An explicit env id wins (the ADR 0055 v1 shape); otherwise discover it from
291
+ // the repo (task 1003049 — fresh instances never know it up front).
292
+ const instId = process.env.GITHUB_APP_INSTALLATION_ID || (await discoverInstallationId(jwt, deps));
222
293
  const doFetch = deps.fetchImpl || fetch;
223
294
  const res = await doFetch(
224
- `https://api.github.com/app/installations/${process.env.GITHUB_APP_INSTALLATION_ID}/access_tokens`,
295
+ `https://api.github.com/app/installations/${instId}/access_tokens`,
225
296
  {
226
297
  method: 'POST',
227
- headers: {
228
- Authorization: `Bearer ${jwt}`,
229
- Accept: 'application/vnd.github+json',
230
- 'X-GitHub-Api-Version': '2022-11-28',
231
- 'User-Agent': userAgent(),
232
- },
298
+ headers: appJwtHeaders(jwt),
233
299
  }
234
300
  );
235
301
  if (!res.ok) {
302
+ // A DISCOVERED id can go stale (App uninstalled + reinstalled gets a new
303
+ // installation id). Drop it so the next attempt re-discovers instead of
304
+ // failing until a server restart. An env-pinned id is the operator's to fix.
305
+ if (!process.env.GITHUB_APP_INSTALLATION_ID) _instIdCache = null;
236
306
  const detail = await res.text().catch(() => '');
237
307
  throw err('APP_TOKEN_FAILED', { status: res.status, detail: detail.slice(0, 200) });
238
308
  }
@@ -244,6 +314,42 @@ async function getInstallationToken(deps = {}) {
244
314
  return data.token;
245
315
  }
246
316
 
317
+ // Installations of this App on accounts OTHER than this repo's owner (task
318
+ // 1003049 grade, hacker finding). The App is public (W6) and now carries the
319
+ // ADR 0055 write scopes, so any third party CAN install it on their own repos —
320
+ // this server never uses those grants, but a leaked App private key could, so an
321
+ // unexpected install must be visible, never silent. The publish-reconciler calls
322
+ // this once per sweep and WARNs on a non-empty answer. Best-effort by design:
323
+ // returns null when it cannot tell (App unconfigured, repo info missing, API
324
+ // miss) — a watch must never break the sweep it rides on.
325
+ async function foreignInstallations(deps = {}) {
326
+ if (!appConfigured()) return null;
327
+ const info = deps.repoInfo || loadRepoInfo();
328
+ if (!info || info.error) return null;
329
+ const nowSec = Math.floor((deps.now ? deps.now() : Date.now()) / 1000);
330
+ const jwt = mintAppJwt({
331
+ appId: process.env.GITHUB_APP_ID,
332
+ privateKeyPem: process.env.GITHUB_APP_PRIVATE_KEY,
333
+ nowSec,
334
+ });
335
+ const doFetch = deps.fetchImpl || fetch;
336
+ let data;
337
+ try {
338
+ const res = await doFetch('https://api.github.com/app/installations?per_page=100', {
339
+ headers: appJwtHeaders(jwt),
340
+ });
341
+ if (!res.ok) return null;
342
+ data = await res.json();
343
+ } catch (_) {
344
+ return null;
345
+ }
346
+ if (!Array.isArray(data)) return null;
347
+ const owner = String(info.owner || '').toLowerCase();
348
+ return data
349
+ .map((i) => ({ id: i && i.id, account: (i && i.account && i.account.login) || null }))
350
+ .filter((i) => String(i.account || '').toLowerCase() !== owner);
351
+ }
352
+
247
353
  // Resolve the credential for git push + GitHub API: an explicitly-injected token
248
354
  // (tests) first, then a GitHub App installation token, then the v1 PAT, else null.
249
355
  async function resolveToken(deps = {}) {
@@ -863,7 +969,11 @@ async function updateBranchIfBehind({ branch }, deps = {}) {
863
969
  // returns false on any failure, in which case the gate-review check sees no attestation
864
970
  // and escalates — the SAFE direction (no rank bypass without a real server signal).
865
971
  async function setCommitStatus({ sha, state, context, description, targetUrl }, deps = {}) {
866
- const token = await resolveToken(deps);
972
+ // resolveToken can THROW (APP_TOKEN_FAILED / APP_NOT_INSTALLED); this helper's
973
+ // contract is "false on any failure" — the gate-review check then escalates,
974
+ // which is the safe direction.
975
+ let token;
976
+ try { token = await resolveToken(deps); } catch (_) { return false; }
867
977
  if (!token) return false;
868
978
  if (!SAFE_SHA_RE.test(String(sha || ''))) return false;
869
979
  const repoInfo = deps.repoInfo || loadRepoInfo();
@@ -1253,6 +1363,8 @@ module.exports = {
1253
1363
  isAncestorOfDefaultBranch,
1254
1364
  resolveToken,
1255
1365
  getInstallationToken,
1366
+ discoverInstallationId,
1367
+ foreignInstallations,
1256
1368
  // exported for unit tests
1257
1369
  buildAuthUrl,
1258
1370
  mintAppJwt,
@@ -191,13 +191,56 @@ async function runSweep(deps = {}) {
191
191
  if (!warn.unconfigured) {
192
192
  warn.unconfigured = true;
193
193
  errFn(
194
- `[publish-reconciler] WARN ${describeTarget({ configured: false, repo: resolveTarget(deps) })} — confirmed→shipped can NEVER land until a credential is set (GITHUB_APP_ID + GITHUB_APP_INSTALLATION_ID + GITHUB_APP_PRIVATE_KEY, or GITHUB_PUSH_TOKEN). Subsequent sweeps stay quiet until this changes.`
194
+ `[publish-reconciler] WARN ${describeTarget({ configured: false, repo: resolveTarget(deps) })} — confirmed→shipped can NEVER land until a credential is set (GITHUB_APP_ID + GITHUB_APP_PRIVATE_KEY — installation id is auto-discovered — or GITHUB_PUSH_TOKEN). Subsequent sweeps stay quiet until this changes.`
195
195
  );
196
196
  }
197
197
  return { skipped: 'unconfigured', checked: 0, reconciled: 0, merged: 0, resolvedConflicts: 0, errors: 0, strandsFlagged: 0 };
198
198
  }
199
199
  warn.unconfigured = false; // re-arm: a later credential LOSS must warn again
200
200
 
201
+ // task 1003049 — an App that is configured but NOT INSTALLED on the repo would
202
+ // pass the gate above and then throw APP_NOT_INSTALLED inside every per-branch
203
+ // call, where the best-effort catches swallow it — silently re-creating the
204
+ // stranding this sweep exists to prevent. Probe the token once (cached, so a
205
+ // healthy steady state costs nothing) and WARN once per streak instead.
206
+ if (typeof push.getInstallationToken === 'function' && typeof push.appConfigured === 'function' && push.appConfigured()) {
207
+ try {
208
+ await push.getInstallationToken();
209
+ warn.appNotInstalled = false; // re-arm: a later uninstall must warn again
210
+ // Foreign-install watch (task 1003049 grade): the App is public (W6) and
211
+ // carries write scopes, so a stranger CAN install it on their own repo.
212
+ // This server never uses that grant, but a leaked App key could — an
213
+ // unexpected install is surfaced loudly, once per streak, never silently.
214
+ if (typeof push.foreignInstallations === 'function') {
215
+ try {
216
+ const foreign = await push.foreignInstallations();
217
+ if (foreign && foreign.length) {
218
+ if (!warn.foreignInstalls) {
219
+ warn.foreignInstalls = true;
220
+ errFn(
221
+ `[publish-reconciler] WARN this project's GitHub App is installed on ${foreign.length} account(s) beyond the repo owner (${foreign.map((f) => f.account).filter(Boolean).join(', ')}). The server never uses those grants, but the App key's blast radius includes them — uninstall the App there (or rotate the key) if this is unexpected.`
222
+ );
223
+ }
224
+ } else if (foreign) {
225
+ warn.foreignInstalls = false; // re-arm once the roster is clean again
226
+ }
227
+ } catch (_) { /* best-effort watch — never breaks the sweep */ }
228
+ }
229
+ } catch (e) {
230
+ if (e && e.code === 'APP_NOT_INSTALLED') {
231
+ if (!warn.appNotInstalled) {
232
+ warn.appNotInstalled = true;
233
+ errFn(
234
+ `[publish-reconciler] WARN the project's GitHub App is not installed on ${e.owner}/${e.repo} — confirmed→shipped can NEVER land until someone installs it${e.install_url ? ` (${e.install_url})` : ''}. Subsequent sweeps stay quiet until this changes.`
235
+ );
236
+ }
237
+ return { skipped: 'app_not_installed', checked: 0, reconciled: 0, merged: 0, resolvedConflicts: 0, errors: 0, strandsFlagged: 0 };
238
+ }
239
+ // Any other failure (APP_TOKEN_FAILED, network) is transient territory — let
240
+ // the sweep run; the per-branch calls handle their own misses.
241
+ }
242
+ }
243
+
201
244
  let rows;
202
245
  try {
203
246
  rows = await db.listConfirmedTasksForReconcile({ limit: deps.limit || SWEEP_LIMIT });