@bongos/core 1.19.736 → 1.19.737

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,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.736",
6
- "core_contract": "1.19.736",
7
- "source_commit": "917ef53331537c93796fb9db9a8b77be72cffe22",
5
+ "core_version": "1.19.737",
6
+ "core_contract": "1.19.737",
7
+ "source_commit": "1ceb8f6a2ad0321079098910ad8a721dffa67454",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-13T17:30:35.415Z",
9
+ "built_at": "2026-09-14T01:10:42.991Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 479,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2108,
14
+ "functional_verbatim": 2109,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2611,
20
- "tree_sha256": "4cd2b02aa80c6f20919a54413b4983dd572af10e199bf5401e6e75ee8914b543",
19
+ "file_count": 2612,
20
+ "tree_sha256": "c34cdc2f87b36f12f71f276e47ce740238e94841fab60b4245613189e0c527d2",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2492,7 +2492,7 @@
2492
2492
  {
2493
2493
  "path": "docs/module-api-changelog.md",
2494
2494
  "mode": "0000644",
2495
- "sha256": "8dc183ec4dc010d5897f8cca67dd0f55bb6abe5620e358388e9ed07a2f6521a8"
2495
+ "sha256": "d1b64255f150e8c8a1b88cffa7326633b7e5bc22e22744f7b1d9ee47410f9e13"
2496
2496
  },
2497
2497
  {
2498
2498
  "path": "docs/modules-contract.md",
@@ -7537,12 +7537,12 @@
7537
7537
  {
7538
7538
  "path": "package-lock.json",
7539
7539
  "mode": "0000644",
7540
- "sha256": "014dc18dcf764c17d7927fe5475436f4d6e2acf18e7baa6f16b7852306dc89e0"
7540
+ "sha256": "fc4587a7fbaaa14aad1e0296015adb626e89da62f4a90a95647f1bbb55a61f1e"
7541
7541
  },
7542
7542
  {
7543
7543
  "path": "package.json",
7544
7544
  "mode": "0000644",
7545
- "sha256": "96f124d7dee1ff76d006c102798790ab7b59b27c0fbacdb36c545d658c185f39"
7545
+ "sha256": "d7595c39c515821f76d0b5045c3a9cbb400f2c36a663046d19dfc7cab098cf69"
7546
7546
  },
7547
7547
  {
7548
7548
  "path": "public-docs/index.html",
@@ -8369,10 +8369,15 @@
8369
8369
  "mode": "0000644",
8370
8370
  "sha256": "502b1aeb571d463aea02ef4db3e848d23fdd8c865e7bfc37f66679c09cc203a9"
8371
8371
  },
8372
+ {
8373
+ "path": "scripts/gds/provision-repo-auth.js",
8374
+ "mode": "0000644",
8375
+ "sha256": "b08032667c9f06495c750e4dd8d41fdc2f5aa587093cf7b4de8e7a46390f0724"
8376
+ },
8372
8377
  {
8373
8378
  "path": "scripts/gds/provision-repo.js",
8374
8379
  "mode": "0000644",
8375
- "sha256": "3612ebe8f54da7498a6c4998e0d1d1c4596a80d54f70b5543b4a46dfca2ffbcf"
8380
+ "sha256": "77ca585d99f4058427f1abbe629d66ce5ec9f87b6c974aebc114d1c0e734d8e2"
8376
8381
  },
8377
8382
  {
8378
8383
  "path": "scripts/gds/provision-units.js",
@@ -8902,7 +8907,7 @@
8902
8907
  {
8903
8908
  "path": "scripts/gds/upgrade.js",
8904
8909
  "mode": "0000644",
8905
- "sha256": "4f30ee3036846f449f6fc8143da32b3767376352743d373c39ace2f50f66e913"
8910
+ "sha256": "64b2571770a5be9dc046f6c64adf45a09642c6961a6036c9405e1c933be6c3b7"
8906
8911
  },
8907
8912
  {
8908
8913
  "path": "scripts/gds/validate-design.js",
@@ -9342,7 +9347,7 @@
9342
9347
  {
9343
9348
  "path": "src/module-api.js",
9344
9349
  "mode": "0000644",
9345
- "sha256": "ad6748bac62f397b729ead21e7f591bc8e03fe771fff22855eb3813b066663da"
9350
+ "sha256": "9ad95d6241cc8f3dfda5d02445246c1f7fd62b75b5014a15a9254c15a620aef7"
9346
9351
  },
9347
9352
  {
9348
9353
  "path": "src/module-loader/catalog.js",
@@ -10432,7 +10437,7 @@
10432
10437
  {
10433
10438
  "path": "tests/executed_closure.mjs",
10434
10439
  "mode": "0000644",
10435
- "sha256": "8c0ed9407dd3f19dab03f45fd2778fae0d2a5595bcd0daefc0e3e5a63137aa04"
10440
+ "sha256": "1e1a899e8838c7c84bdad9ea9d8533aca9173ed1e72f15e13c3616493e11900f"
10436
10441
  },
10437
10442
  {
10438
10443
  "path": "tests/explore_visible_states.mjs",
@@ -12917,7 +12922,7 @@
12917
12922
  {
12918
12923
  "path": "tests/upgrade.mjs",
12919
12924
  "mode": "0000644",
12920
- "sha256": "492b9dc7efdd6f63b81f1662d5fdc9a7541b7f8ccc6c0ec77a456ad3f574ee74"
12925
+ "sha256": "010c9ac8b05e25c10b9d43c7efd02454facf0d5663df11df555ea913697e23e8"
12921
12926
  },
12922
12927
  {
12923
12928
  "path": "tests/upgrade_persist_pin.mjs",
@@ -1946,5 +1946,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1946
1946
  landed since 1.19.734 with no explicit bump. run 34768136326. (task 1002620)
1947
1947
  1.19.736 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1948
1948
  landed since 1.19.735 with no explicit bump. run 34771773838. (task 1002620)
1949
+ 1.19.737 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1950
+ landed since 1.19.736 with no explicit bump. run 34795026282. (task 1002620)
1949
1951
  ---------------------------------------------------------------------------
1950
1952
  ```
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.736",
3
+ "version": "1.19.737",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.736",
9
+ "version": "1.19.737",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.736",
3
+ "version": "1.19.737",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -0,0 +1,106 @@
1
+ // scripts/gds/provision-repo-auth.js — the repo-identity + owner-credential leaf: parse a
2
+ // `target_ref` into { owner, name }, build an instance's SSH deploy remote, find the
3
+ // provisioning row that owns a checkout, and read that owner's stored GitHub token
4
+ // (task 1003081).
5
+ //
6
+ // WHY THIS FILE EXISTS AT ALL. scripts/gds/upgrade.js is inside the merge resolver's
7
+ // EXECUTED CLOSURE (modules/lifecycle/executed-closure.js; 2026-08-29 security audit ref
8
+ // B1) — the set of files the server may run out of a CLONED BUILDER BRANCH. That closure
9
+ // is a STATIC walk of string-literal requires, and it does not care whether a require is
10
+ // lazy or inside a function: upgrade.js reaching these helpers through `./provision`
11
+ // dragged provision.js plus provision-config/-repo/-units/-net.js into the closure with
12
+ // it — five more files a branch could edit into the live server's process, three of them
13
+ // carrying computed requires (provision-config 7, provision-repo 1, provision-units 1)
14
+ // that tests/executed_closure.mjs has no account of. Carving the helpers into this LEAF
15
+ // instead grows the closure by exactly ONE small file, whose computed-require count is
16
+ // pinned in that test.
17
+ //
18
+ // THE RULE THAT KEEPS IT THAT WAY. Top level requires node builtins ONLY. Every
19
+ // non-builtin require is lazy AND inside a function body, so nothing on the generator
20
+ // path ever resolves it. Never require provision-config.js (or any other provision file)
21
+ // from here — REPO_ROOT is recomputed below precisely so we don't have to.
22
+ //
23
+ // provision-repo.js requires this module and re-exports parseTargetRef,
24
+ // instanceRepoRemote and resolveOwnerGithubToken unchanged, so provision.js,
25
+ // provision-units.js, modules/provisioning/credential-preflight.js and their tests see
26
+ // exactly the surface they saw before.
27
+
28
+ 'use strict';
29
+
30
+ const path = require('node:path');
31
+
32
+ // The same value provision-config.js exports as REPO_ROOT, recomputed here rather than
33
+ // imported: that module carries seven computed requires, and importing it would put all
34
+ // of them inside the executed closure (see the header).
35
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
36
+
37
+ // Parse an 'owner/repo' (or a github SSH/HTTPS URL) target_ref → { owner, name }.
38
+ // Null when unparseable, so the scaffold can fail with actionable guidance.
39
+ function parseTargetRef(ref) {
40
+ const m = String(ref || '').trim()
41
+ .replace(/^git@[^:]+:/, '')
42
+ .replace(/^https?:\/\/[^/]+\//, '')
43
+ .match(/^([A-Za-z0-9][A-Za-z0-9_.-]*)\/([A-Za-z0-9][A-Za-z0-9_.-]*?)(?:\.git)?$/);
44
+ return m ? { owner: m[1], name: m[2] } : null;
45
+ }
46
+
47
+ // The git remote for an instance's OWN repo. Reuses the SAME SSH deploy alias the
48
+ // control-plane checkout uses (git@<alias>:<owner>/<repo>.git) — NO secret in the
49
+ // command (SSH-key auth, nothing to leak into journald), consistent with how the
50
+ // platform repo is wired. Standing up a new instance repo therefore needs only that
51
+ // the control plane's deploy key has WRITE access to that repo (ADR 0125). Alias is
52
+ // overridable via PROVISION_GIT_SSH_ALIAS.
53
+ function instanceRepoRemote(repo, { sshAlias = process.env.PROVISION_GIT_SSH_ALIAS || 'github-deploy' } = {}) {
54
+ return `git@${sshAlias}:${repo.owner}/${repo.name}.git`;
55
+ }
56
+
57
+ // The instance owner's stored `repo`-scoped OAuth token (ADR 0155), or null (none
58
+ // stored, org-owned repo, secret-box off). Shared by the scaffold push, the
59
+ // private-repo deploy-key wiring, the teardown revoke, and upgrade.js's pin push.
60
+ // `deps.getGithubToken` is injectable for tests; production resolves it lazily against
61
+ // the core db slice with the runner's pool.
62
+ async function resolveOwnerGithubToken(inst, deps, { tag = '[repo-auth]' } = {}) {
63
+ if (!inst.owner_builder_id) return null;
64
+ try {
65
+ const fetchTok = deps.getGithubToken
66
+ || ((bid) => require(path.join(REPO_ROOT, 'src/bongos/db')).getGithubToken({ builderId: bid, p: deps.db })); // eslint-disable-line global-require
67
+ const r = await fetchTok(inst.owner_builder_id);
68
+ return r && r.token ? r.token : null;
69
+ } catch (e) {
70
+ (deps.log || (() => {}))(` ${tag} owner OAuth token lookup failed (${e && e.message})`);
71
+ return null;
72
+ }
73
+ }
74
+
75
+ // The provisioning row that owns a checkout — where `owner_builder_id` (whose token) and
76
+ // `target_ref` (which repo) come from. A control-plane caller hands it over as
77
+ // `deps.instance`; a CLI run on the control plane (`--instance /srv/bongos/<slug>`) finds
78
+ // it by the checkout's basename, which standaloneRoot makes the slug. An instance's own
79
+ // database has no provisioning table and answers null — that is the origin path, not an
80
+ // error, so every failure here is swallowed. The default lookup requires the core pool
81
+ // in-process (a CLI run exits right after; a programmatic caller passes `deps.db` or
82
+ // `deps.getInstanceBySlug`).
83
+ //
84
+ // It lives HERE rather than in its only caller, upgrade.js: both of its default requires
85
+ // are computed, and upgrade.js is in the executed closure with a pinned count of its own
86
+ // (see the header). Keeping them on this side leaves upgrade.js string-literal-only.
87
+ async function lookupInstanceRow(instanceDir, deps = {}) {
88
+ if (deps.instance) return deps.instance;
89
+ const slug = path.basename(instanceDir);
90
+ try {
91
+ const bySlug = deps.getInstanceBySlug || (async (s) => {
92
+ const provisioning = require(path.join(REPO_ROOT, 'modules/provisioning/provisioning')); // eslint-disable-line global-require
93
+ const db = deps.db || require(path.join(REPO_ROOT, 'src/bongos/pool')).pool; // eslint-disable-line global-require
94
+ return provisioning.getInstanceBySlug(db, s);
95
+ });
96
+ const row = await bySlug(slug);
97
+ return row && row.owner_builder_id && row.target_ref ? row : null;
98
+ } catch { return null; }
99
+ }
100
+
101
+ module.exports = {
102
+ instanceRepoRemote,
103
+ lookupInstanceRow,
104
+ parseTargetRef,
105
+ resolveOwnerGithubToken,
106
+ };
@@ -15,6 +15,10 @@ const fs = require('node:fs');
15
15
  const os = require('node:os');
16
16
  const path = require('node:path');
17
17
  const { CONFIG, REPO_ROOT, coreVersionSafe, hasNpmToken, provisionerBotEmail, registryCoreRequested } = require('./provision-config.js');
18
+ // The repo-identity + owner-credential leaf (task 1003081). Carved OUT of this file so
19
+ // upgrade.js can reach these three without pulling provision*.js into the merge
20
+ // resolver's executed closure; re-exported below so every existing caller is unchanged.
21
+ const { instanceRepoRemote, parseTargetRef, resolveOwnerGithubToken } = require('./provision-repo-auth.js');
18
22
 
19
23
  function dbName(inst) { return inst.db_name || inst.slug; }
20
24
 
@@ -288,25 +292,10 @@ function dbRoleGrantsCmd(inst, { privileged = false } = {}) {
288
292
  // THROW if the instance repo wasn't already cloned. These generate + run the scaffold
289
293
  // that clones an EMPTY target_ref and turns it into an installable, bootable instance.
290
294
 
291
- // Parse an 'owner/repo' (or a github SSH/HTTPS URL) target_ref → { owner, name }.
292
- // Null when unparseable, so the scaffold can fail with actionable guidance.
293
- function parseTargetRef(ref) {
294
- const m = String(ref || '').trim()
295
- .replace(/^git@[^:]+:/, '')
296
- .replace(/^https?:\/\/[^/]+\//, '')
297
- .match(/^([A-Za-z0-9][A-Za-z0-9_.-]*)\/([A-Za-z0-9][A-Za-z0-9_.-]*?)(?:\.git)?$/);
298
- return m ? { owner: m[1], name: m[2] } : null;
299
- }
300
-
301
- // The git remote for an instance's OWN repo. Reuses the SAME SSH deploy alias the
302
- // control-plane checkout uses (git@<alias>:<owner>/<repo>.git) — NO secret in the
303
- // command (SSH-key auth, nothing to leak into journald), consistent with how the
304
- // platform repo is wired. Standing up a new instance repo therefore needs only that
305
- // the control plane's deploy key has WRITE access to that repo (ADR 0125). Alias is
306
- // overridable via PROVISION_GIT_SSH_ALIAS.
307
- function instanceRepoRemote(repo, { sshAlias = process.env.PROVISION_GIT_SSH_ALIAS || 'github-deploy' } = {}) {
308
- return `git@${sshAlias}:${repo.owner}/${repo.name}.git`;
309
- }
295
+ // parseTargetRef + instanceRepoRemote (and resolveOwnerGithubToken, below the scaffold)
296
+ // now live in the ./provision-repo-auth.js leaf and are re-exported unchanged from here —
297
+ // upgrade.js needs them and is inside the merge resolver's executed closure, which a require of
298
+ // this file would have widened by five (task 1003081; see that module's header).
310
299
 
311
300
  // A minimal, VALID `bongos init` spec (init.validateSpec greenfield) from the
312
301
  // provisioning row + the owner's github login. The scaffold produces a bootable,
@@ -711,24 +700,6 @@ async function scaffoldStandaloneRepo(inst, deps) {
711
700
 
712
701
  // ── private-repo deploy key (ADR 0176, task 1002735; walk finding N4) ────────────
713
702
 
714
- // The instance owner's stored `repo`-scoped OAuth token (ADR 0155), or null (none
715
- // stored, org-owned repo, secret-box off). Shared by the scaffold push, the
716
- // private-repo deploy-key wiring, and the teardown revoke. `deps.getGithubToken` is
717
- // injectable for tests; production resolves it lazily against the core db slice
718
- // with the runner's pool.
719
- async function resolveOwnerGithubToken(inst, deps, { tag = '[repo-auth]' } = {}) {
720
- if (!inst.owner_builder_id) return null;
721
- try {
722
- const fetchTok = deps.getGithubToken
723
- || ((bid) => require(path.join(REPO_ROOT, 'src/bongos/db')).getGithubToken({ builderId: bid, p: deps.db }));
724
- const r = await fetchTok(inst.owner_builder_id);
725
- return r && r.token ? r.token : null;
726
- } catch (e) {
727
- (deps.log || (() => {}))(` ${tag} owner OAuth token lookup failed (${e && e.message})`);
728
- return null;
729
- }
730
- }
731
-
732
703
  function deployKeyTitle(inst) { return `cloudbongos-deploy-${inst.slug}`; }
733
704
  // Beside the checkout's own metadata: untracked by definition, survives the deploy's
734
705
  // `git reset --hard`, and dies with the checkout rather than accreting in ~/.ssh.
@@ -27,7 +27,9 @@
27
27
  // against `--instance <consumer>` — see docs/recipes for the bootstrap note.
28
28
 
29
29
  const fs = require('node:fs');
30
+ const os = require('node:os');
30
31
  const path = require('node:path');
32
+ const crypto = require('node:crypto');
31
33
  const { spawnSync } = require('node:child_process');
32
34
  const { arg, hasFlag } = require('./cli-lib');
33
35
  const { writeNpmrc, hasNpmToken, resolveNpmToken } = require('./npmrc'); // ADR 0134 (task 2090): the --registry channel's env-fed .npmrc; resolveNpmToken task 1002754
@@ -170,18 +172,133 @@ function dirtyPinFiles(instanceDir, run = spawnSync) {
170
172
  return (r.stdout || '').split('\n').map((l) => l.slice(3).trim()).filter(Boolean);
171
173
  }
172
174
 
175
+ // ---- which credential the pin push rides (task 1003081, ADR 0176 follow-up) ----
176
+ //
177
+ // THE BUG THIS EXISTS FOR. A PRIVATE standalone instance's `origin` authenticates with
178
+ // a per-repo deploy key that GitHub registers READ-ONLY (ADR 0176): it can pull, never
179
+ // push. The push leg below went `git push origin HEAD:main` regardless, so on such an
180
+ // instance GitHub refused the pin push, the bump was not durable, and the next
181
+ // pull-deploy `git reset --hard origin/main` + `npm ci` reinstalled the OLD core
182
+ // against an already-migrated DB — the exact silent rollback task 1002712 closed for
183
+ // every other path, back through a warning line nobody read. The scaffold push and the
184
+ // re-provision pin refresh already prefer the owner's stored `repo`-scoped OAuth token
185
+ // over the SSH origin (provision.js resolveOwnerGithubToken + buildCorePinRefreshCommit);
186
+ // the pin push now has the same preference — the token when one is stored, the git
187
+ // remote otherwise — and a refused push is LOUD, naming the read-only key and the fix.
188
+
189
+ // The GitHub owner/name behind `remote`, or null when the remote is not a GitHub
190
+ // repository (there is nothing to push a GitHub token at) or cannot be read. Accepts
191
+ // the plain github.com forms and the shared SSH alias instanceRepoRemote wires
192
+ // (`git@github-deploy:owner/repo.git`) — never another host, so a stored token is
193
+ // never pointed at github.com/<owner>/<repo> for a repo that lives elsewhere.
194
+ function remoteGithubRepo(instanceDir, remote = 'origin', run = spawnSync) {
195
+ const r = run('git', ['remote', 'get-url', remote], { cwd: instanceDir, encoding: 'utf8' });
196
+ if (!r || r.error || r.status !== 0) return null;
197
+ const url = String(r.stdout || '').trim();
198
+ let auth;
199
+ try { auth = require('./provision-repo-auth'); } catch { return null; } // a package without the provisioner has no token path
200
+ const repo = auth.parseTargetRef(url);
201
+ if (!repo) return null;
202
+ const host = (url.match(/^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?([^/:]+)[/:]/i) || [])[1] || '';
203
+ if (host.toLowerCase() !== 'github.com' && url !== auth.instanceRepoRemote(repo)) return null;
204
+ return repo;
205
+ }
206
+
207
+ // The deploy key `remote` authenticates with when the checkout is wired the ADR 0176
208
+ // way (`core.sshCommand … -i <key>` in the repo's own config); null when it is not.
209
+ // What a refused origin push is diagnosed against — GitHub's SSH refusal is a terse
210
+ // one-liner that names no key.
211
+ function pinnedDeployKey(instanceDir, run = spawnSync) {
212
+ const r = run('git', ['config', '--get', 'core.sshCommand'], { cwd: instanceDir, encoding: 'utf8' });
213
+ if (!r || r.error || r.status !== 0) return null;
214
+ const m = String(r.stdout || '').match(/(?:^|\s)-i\s+(\S+)/);
215
+ return m ? m[1] : null;
216
+ }
217
+
218
+ // Resolve { token, repo } for the pin push, or null → push via the git remote.
219
+ // repo — the remote's GitHub owner/name (remoteGithubRepo);
220
+ // row — provision-repo-auth lookupInstanceRow, used ONLY when its target_ref IS that
221
+ // repo, so another instance's credential is never spent on this checkout;
222
+ // token — provision-repo-auth resolveOwnerGithubToken (deps.getGithubToken / deps.db
223
+ // are its test seams), the same resolver the scaffold push uses.
224
+ // Every miss is the origin path, not an error, and the token is never logged. The row
225
+ // lookup and the token read BOTH live in the leaf rather than here because their default
226
+ // requires are computed, and this file is inside the merge resolver's executed closure
227
+ // with a pinned computed-require count of its own (task 1003081).
228
+ async function resolvePinPushAuth({ instanceDir, remote = 'origin' }, deps = {}, run = spawnSync) {
229
+ const log = deps.log || console.log;
230
+ const repo = remoteGithubRepo(instanceDir, remote, run);
231
+ if (!repo) return null;
232
+ const auth = require('./provision-repo-auth');
233
+ const inst = await auth.lookupInstanceRow(instanceDir, deps);
234
+ if (!inst) return null;
235
+ const rowRepo = auth.parseTargetRef(inst.target_ref);
236
+ const same = (a, b) => a && b && a.owner.toLowerCase() === b.owner.toLowerCase() && a.name.toLowerCase() === b.name.toLowerCase();
237
+ if (!same(rowRepo, repo)) {
238
+ log(` • pin push: the provisioning row for ${path.basename(instanceDir)} names ${inst.target_ref}, not ${remote}'s ${repo.owner}/${repo.name} — its owner's token is not for this checkout; pushing via ${remote}`);
239
+ return null;
240
+ }
241
+ const token = await auth.resolveOwnerGithubToken(inst, { ...deps, log }, { tag: '[pin-push]' });
242
+ if (!token) {
243
+ log(` • pin push: no owner GitHub token stored for ${repo.owner}/${repo.name} — pushing via ${remote}`);
244
+ return null;
245
+ }
246
+ return { token, repo };
247
+ }
248
+
249
+ // The pin push over the owner's token: a chmod-600 credential-store file in the temp
250
+ // dir carries the secret, git reads it through `credential.helper=store`, and the file
251
+ // is removed in `finally` whatever the push did — the token is never in argv (ps,
252
+ // journald) and never in a log line. provision.js buildCorePinRefreshCommit's shape,
253
+ // as argv instead of a shell string.
254
+ function pushPinViaToken({ instanceDir, token, repo, target }, run = spawnSync) {
255
+ const credFile = path.join(os.tmpdir(), `bongos-gitcred-pin-${process.pid}-${crypto.randomBytes(6).toString('hex')}`);
256
+ try {
257
+ fs.writeFileSync(credFile, `https://x-access-token:${token}@github.com\n`, { mode: 0o600 });
258
+ return run('git', ['-c', `credential.helper=store --file=${credFile}`, 'push', `https://x-access-token@github.com/${repo.owner}/${repo.name}.git`, target], {
259
+ cwd: instanceDir, encoding: 'utf8',
260
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, // a rejected credential must fail, not sit at a password prompt
261
+ });
262
+ } finally {
263
+ fs.rmSync(credFile, { force: true });
264
+ }
265
+ }
266
+
267
+ // What git said on stderr, for a refusal line — GitHub's own reason ("ERROR: The key you
268
+ // are authenticating with has been marked as read only", "remote: Write access to
269
+ // repository not granted") is the fact an operator wants beside the diagnosis, and it
270
+ // is never the LAST line: git closes with its generic `fatal:`. Prefer the server's
271
+ // line, fall back to the last one.
272
+ function gitSaid(r) {
273
+ const lines = String((r && r.stderr) || '').split('\n').map((l) => l.trim()).filter(Boolean);
274
+ const server = lines.filter((l) => /^(ERROR:|remote:)/i.test(l));
275
+ const said = server.length ? server[server.length - 1] : lines[lines.length - 1];
276
+ return said ? ` (git: ${said})` : '';
277
+ }
278
+
173
279
  // `push: false` is CO-TENANT MODE (task 1003521). The reasoning above assumes the repo
174
280
  // of record is OURS; on a provisioned instance it is the customer's own GitHub repo, so
175
281
  // pushing there is both wrong and uncredentialed. Such an instance also has no
176
282
  // pull-deploy timer, so nothing will `reset --hard` the pin away — the commit alone
177
283
  // makes it durable, and leaves the tree clean for the customer's own pulls.
178
- function persistPin({ instanceDir, fromVersion, toVersion, commitPin, push = true, remote = 'origin', dryRun = false },
284
+ //
285
+ // `pushAuth` ({ token, repo }, from resolvePinPushAuth) makes the push ride the owner's
286
+ // GitHub token over HTTPS (task 1003081); null pushes via `remote`. The result carries
287
+ // `via: 'owner-token'` on the token path and `refused` ('read-only-deploy-key' |
288
+ // 'owner-token') with a plain-sentence `error` when GitHub turned the push away.
289
+ function persistPin({ instanceDir, fromVersion, toVersion, commitPin, push = true, remote = 'origin', dryRun = false, pushAuth = null },
179
290
  run = spawnSync, log = console.log, err = console.error) {
180
291
  const dirty = dirtyPinFiles(instanceDir, run);
181
292
  if (dirty === null) return { applicable: false, reason: 'not a git repo' };
182
293
  if (!dirty.length) return { applicable: false, reason: 'pin files already match the repo' };
183
294
 
184
295
  const branch = gitCurrentBranch(instanceDir, run);
296
+ const target = `HEAD:${branch || 'main'}`;
297
+ // Single-quote the interpolated values. Nothing here executes these strings — they
298
+ // are printed for a human to paste — but an unquoted branch or remote carrying shell
299
+ // metacharacters would misbehave once pasted, so emit them paste-safe.
300
+ const q = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
301
+ const at = `git -C ${q(instanceDir)}`;
185
302
 
186
303
  if (!commitPin) {
187
304
  if (!push) {
@@ -198,12 +315,7 @@ function persistPin({ instanceDir, fromVersion, toVersion, commitPin, push = tru
198
315
  err(' On a pull-deploy instance the next push to the repo of record makes deploy.sh');
199
316
  err(` 'git reset --hard ${remote}/${branch || 'main'}', which REVERTS this pin and lets npm ci`);
200
317
  err(` reinstall ${fromVersion || 'the previous core'}. Re-run with --commit-pin, or commit + push:`);
201
- // Single-quote the interpolated values. Nothing here executes this string — it is
202
- // printed for a human to paste — but an unquoted branch or remote carrying shell
203
- // metacharacters would misbehave once pasted, so emit it paste-safe.
204
- const q = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
205
- const at = `git -C ${q(instanceDir)}`;
206
- err(` ${at} add ${PIN_FILES.map(q).join(' ')} && ${at} commit -m ${q(`pin @bongos/core ${toVersion}`)} && ${at} push ${q(remote)} ${q(`HEAD:${branch || 'main'}`)}`);
318
+ err(` ${at} add ${PIN_FILES.map(q).join(' ')} && ${at} commit -m ${q(`pin @bongos/core ${toVersion}`)} && ${at} push ${q(remote)} ${q(target)}`);
207
319
  return { applicable: true, committed: false, pushed: false, dirty, warned: true };
208
320
  }
209
321
 
@@ -232,11 +344,62 @@ function persistPin({ instanceDir, fromVersion, toVersion, commitPin, push = tru
232
344
  return { applicable: true, committed: true, pushed: false, dirty, pushSkipped: true };
233
345
  }
234
346
 
235
- const pushed = run('git', ['push', remote, `HEAD:${branch || 'main'}`], { cwd: instanceDir, encoding: 'utf8' });
347
+ // Committed but not pushed is still doomed: deploy.sh resets to the REMOTE. So every
348
+ // refusal below is LOUD (task 1003081) — the pull-deploy consequence, the diagnosis,
349
+ // and a by-hand push that can actually write.
350
+ const revert = () => err(` The next pull-deploy 'git reset --hard ${remote}/${branch || 'main'}' + npm ci then reinstalls ${fromVersion || 'the previous core'}.`);
351
+
352
+ if (pushAuth && pushAuth.token && pushAuth.repo) {
353
+ const { repo } = pushAuth;
354
+ let pushed = null;
355
+ try {
356
+ pushed = pushPinViaToken({ instanceDir, token: pushAuth.token, repo, target }, run);
357
+ } catch (e) {
358
+ // The credential file could not even be written (an unwritable temp dir): say so
359
+ // and fall through to the remote — a public origin may still take it.
360
+ err(` ! could not stage the owner's GitHub credential (${e && e.message}) — pushing via ${remote} instead`);
361
+ }
362
+ if (pushed && !pushed.error && pushed.status === 0) {
363
+ log(` ✓ pushed the pin to ${repo.owner}/${repo.name}@${branch || 'main'} via the owner's GitHub token — a pull-deploy reset can no longer revert it`);
364
+ return { applicable: true, committed: true, pushed: true, dirty, via: 'owner-token' };
365
+ }
366
+ if (pushed) {
367
+ err(` ! PIN NOT PUSHED — GitHub refused the push over the owner's GitHub token, so this bump is NOT durable.`);
368
+ err(` The stored token could not write ${repo.owner}/${repo.name}: it may have expired (they live an hour), or`);
369
+ err(` lack the \`repo\` scope (\`public_repo\` cannot write a private repository — ADR 0155).${gitSaid(pushed)}`);
370
+ revert();
371
+ err(' Fix: have the instance owner reconnect GitHub with repository access, then re-run `bongos upgrade --commit-pin`;');
372
+ err(' or push the commit yourself with a credential that can write:');
373
+ err(` ${at} push ${q(`https://github.com/${repo.owner}/${repo.name}.git`)} ${q(target)}`);
374
+ return {
375
+ applicable: true, committed: true, pushed: false, dirty, refused: 'owner-token',
376
+ error: `GitHub refused the pin push over the owner's GitHub token (expired, or missing the repo scope) — the pin is committed locally but not on ${repo.owner}/${repo.name}`,
377
+ };
378
+ }
379
+ }
380
+
381
+ const pushed = run('git', ['push', remote, target], { cwd: instanceDir, encoding: 'utf8' });
236
382
  if (!pushed || pushed.error || pushed.status !== 0) {
237
- // Committed but not pushed is still doomed: deploy.sh resets to the REMOTE.
383
+ const key = pinnedDeployKey(instanceDir, run);
384
+ if (key) {
385
+ // A wired private instance (ADR 0176): the key can pull, never push, and no owner
386
+ // token was stored to push with instead — the bug this leg exists for.
387
+ const repo = remoteGithubRepo(instanceDir, remote, run);
388
+ err(` ! PIN NOT PUSHED — GitHub refused the push to ${remote}, so this bump is NOT durable.`);
389
+ err(` ${remote} authenticates with the deploy key at ${key}, which is registered READ-ONLY on the`);
390
+ err(` repository (ADR 0176): it can pull, never push. No owner GitHub token is stored to push with instead.${gitSaid(pushed)}`);
391
+ revert();
392
+ err(' Fix: have the instance owner reconnect GitHub with repository access (that stores a short-lived repo token),');
393
+ err(` then re-run \`bongos upgrade --commit-pin\` where that token is readable — the control plane, with --instance ${q(instanceDir)};`);
394
+ err(' or push the commit yourself with a credential that can write:');
395
+ err(` ${at} push ${q(`https://github.com/${repo ? `${repo.owner}/${repo.name}` : '<owner>/<repo>'}.git`)} ${q(target)}`);
396
+ return {
397
+ applicable: true, committed: true, pushed: false, dirty, refused: 'read-only-deploy-key',
398
+ error: `GitHub refused the pin push: ${remote} authenticates with the read-only deploy key at ${key}, and no owner GitHub token is stored to push with — the pin is committed locally but not on the remote`,
399
+ };
400
+ }
238
401
  err(` ! commit landed locally but the push to ${remote} FAILED — this bump is still not durable.`);
239
- err(` Push it: git -C ${instanceDir} push ${remote} HEAD:${branch || 'main'}`);
402
+ err(` Push it: git -C ${instanceDir} push ${remote} ${target}`);
240
403
  return { applicable: true, committed: true, pushed: false, dirty, error: 'git push failed' };
241
404
  }
242
405
  log(` ✓ pushed the pin to ${remote}/${branch || 'main'} — a pull-deploy reset can no longer revert it`);
@@ -1144,14 +1307,23 @@ async function runUpgrade(opts, deps = {}) {
1144
1307
 
1145
1308
  // Make the pin durable (task 1002712). Runs LAST — only a verified-healthy
1146
1309
  // upgrade is worth persisting, and a rolled-back one returned long before here.
1310
+ //
1311
+ // Which credential the push rides is resolved HERE (async — it may read the token
1312
+ // store) and handed to persistPin, which stays synchronous — and only when a push is
1313
+ // actually coming: --commit-pin without --pin-no-push, not a dry run, and the pin
1314
+ // files really are dirty. Nothing reads a credential it will not spend (task 1003081).
1315
+ const remote = opts.gitRemote || 'origin';
1316
+ const pushComing = !!opts.commitPin && !opts.pinNoPush && !dryRun && (dirtyPinFiles(instanceDir, run) || []).length > 0;
1317
+ const pushAuth = pushComing ? await (deps.resolvePinPushAuth || resolvePinPushAuth)({ instanceDir, remote }, deps, run) : null;
1147
1318
  const pin = persistPin({
1148
1319
  instanceDir,
1149
1320
  fromVersion,
1150
1321
  toVersion: targetVersion,
1151
1322
  commitPin: opts.commitPin,
1152
1323
  push: !opts.pinNoPush,
1153
- remote: opts.gitRemote || 'origin',
1324
+ remote,
1154
1325
  dryRun: opts.dryRun,
1326
+ pushAuth,
1155
1327
  }, run, log, err);
1156
1328
 
1157
1329
  const ok = versionOk && healthOk !== false && servedOk !== false;
@@ -1211,6 +1383,7 @@ async function main(argv = process.argv.slice(2)) {
1211
1383
  ' --skip-pin-verify do not verify the integrity pin (tree_sha256 + tarball.sha256) after install',
1212
1384
  ' --require-pin treat a MISSING reference manifest as fatal (default: warn — a MISMATCH is always fatal)',
1213
1385
  ' --no-rollback-on-failure do NOT auto-revert on a failed install/pin-verify/migrate/health check (default: auto-rollback ON)',
1386
+ ' --commit-pin commit + push package.json/-lock so a pull-deploy reset cannot revert the bump. The push rides the instance owner\'s stored GitHub token when one is stored (a PRIVATE instance\'s origin is a read-only deploy key — ADR 0176), else the git remote (--git-remote <name>, default origin); a refused push is reported loudly with the fix',
1214
1387
  ' --pin-no-push with --commit-pin: commit the pin but do NOT push it — for an instance whose repo of record is not ours (a provisioned co-tenant), where the push is wrong and no pull-deploy can revert the commit',
1215
1388
  ' --dry-run show the plan; change nothing',
1216
1389
  ' --force skip the clean-tree + same-version guards',
@@ -1233,7 +1406,9 @@ async function main(argv = process.argv.slice(2)) {
1233
1406
  module.exports = {
1234
1407
  runUpgrade, parseArgs, main, rollback,
1235
1408
  versionFromTgzPath, versionFromDep, readInstalledCoreVersion, readPinnedCoreVersion, writePin, writeRegistryPin, restorePin,
1236
- vendorTarball, gitTreeClean, persistPin, dirtyPinFiles, gitCurrentBranch, PIN_FILES, preflightDbIdentity, resolveInstanceDb, preflightModules, reportModulePreflight, npmInstall, runMigrate, regenerateApiArtifacts, regenerateNavDocs, NAV_WHOLE_FILE_GENERATORS, missingDocAssets, healDocAssets, DOC_ASSET_GENERATORS, restartService, recordLedger, pollHealth, deriveVersionUrl, pollServedVersion,
1409
+ vendorTarball, gitTreeClean, persistPin, dirtyPinFiles, gitCurrentBranch, PIN_FILES, preflightDbIdentity,
1410
+ resolveInstanceDb, preflightModules, reportModulePreflight, npmInstall, runMigrate, regenerateApiArtifacts, regenerateNavDocs, NAV_WHOLE_FILE_GENERATORS, missingDocAssets, healDocAssets, DOC_ASSET_GENERATORS, restartService, recordLedger, pollHealth, deriveVersionUrl, pollServedVersion,
1411
+ resolvePinPushAuth, remoteGithubRepo, pinnedDeployKey, // task 1003081 — the pin push prefers the owner's GitHub token (ADR 0176 follow-up)
1237
1412
  readManifest, resolveReferenceManifest, verifyInstalledPin, // task 1002216 (audit H8) — integrity-pin verification
1238
1413
  CORE_PKG, ARTIFACT, vendorRelFor,
1239
1414
  };
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.736'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.737'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -97,6 +97,14 @@ test('every computed require inside the closure is one we have accounted for', (
97
97
  // floor) is lazy and inside a function.
98
98
  ['scripts/gds/package-core.js', 6], // publish-manifest/mirror-redact/secret-scrub/leak-scan-allowlist/…
99
99
  ['scripts/gds/upgrade.js', 1], // a `node -e` child, not a require in this process
100
+ // Added by task 1003081: upgrade.js's pin push needs the owner-credential helpers,
101
+ // and requiring provision.js for them would have pulled provision-config/-repo/-units
102
+ // (7/1/1 unaccounted computed requires) into the closure. This LEAF is the whole of
103
+ // that growth: 3 computed requires — src/bongos/db in resolveOwnerGithubToken, and
104
+ // modules/provisioning + src/bongos/pool in lookupInstanceRow — each inside a function
105
+ // body reached only from the pin push, which no generator calls. Its top level requires
106
+ // node builtins only, so it drags nothing else in behind it.
107
+ ['scripts/gds/provision-repo-auth.js', 3],
100
108
  ['scripts/gds/gen-api-docs.js', 1], // require(path.join(CORE_ROOT,'package.json')) — JSON
101
109
  ]);
102
110
 
package/tests/upgrade.mjs CHANGED
@@ -9,12 +9,15 @@
9
9
  // 5. dry-run — plans without mutating package.json or spawning npm.
10
10
  // 6. full happy-path — pre-flight → pin → install → migrate → materialize → restart → verify,
11
11
  // with every side effect injected (DB-free: the ledger is skipped when no DATABASE_URL).
12
+ // 10. the pin push on a PRIVATE instance rides the owner's GitHub token through a chmod-600
13
+ // credential-store file, falls back to the remote only when none is stored, and a refused
14
+ // push is LOUD, naming the read-only deploy key and the fix (task 1003081, ADR 0176).
12
15
  //
13
16
  // Run: node tests/upgrade.mjs
14
17
 
15
18
  import { strict as assert } from 'node:assert';
16
19
  import { createRequire } from 'node:module';
17
- import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
20
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, statSync } from 'node:fs';
18
21
  import { tmpdir } from 'node:os';
19
22
  import { join, dirname } from 'node:path';
20
23
 
@@ -1390,6 +1393,286 @@ t('pollServedVersion: gives up rather than spinning when the clock never advance
1390
1393
  assert.match(r.error, /no coreVersion/);
1391
1394
  });
1392
1395
 
1396
+ // ---- 10. the pin push rides the owner's GitHub token on a private instance (task 1003081, ADR 0176 follow-up) ----
1397
+ //
1398
+ // A PRIVATE standalone instance's origin authenticates with a deploy key GitHub registers
1399
+ // READ-ONLY, so `git push origin HEAD:main` is refused and the pin bump silently un-does
1400
+ // itself at the next pull-deploy. persistPin now prefers the owner's stored `repo` token
1401
+ // (the scaffold's + refreshStandaloneCorePin's credential-store shape), falls back to the
1402
+ // remote only when none is stored, and a refused push is LOUD. `run` is injected, so no
1403
+ // real repo, remote, or network — the assertions are on the argv persistPin CHOOSES and
1404
+ // on the credential file it writes beside it.
1405
+
1406
+ const PIN_DIRTY = ' M package.json\n M package-lock.json\n';
1407
+ const hush = () => {};
1408
+ // A `run` fake for persistPin: `git -c <cfg> push …` is routed as the verb `push`, so a
1409
+ // handler sees the token-path push and the origin push alike. Unset config / no such
1410
+ // remote by default (exit 1), the way git answers a checkout that is not wired.
1411
+ function pinRun(handlers = {}) {
1412
+ const calls = [];
1413
+ const fn = (cmd, args, opts) => {
1414
+ calls.push(`${cmd} ${(args || []).join(' ')}`.trim());
1415
+ const verb = args[0] === '-c' ? args[2] : args[0];
1416
+ const h = handlers[verb];
1417
+ if (typeof h === 'function') return h(args, opts);
1418
+ if (h) return h;
1419
+ if (verb === 'status') return { status: 0, stdout: PIN_DIRTY };
1420
+ if (verb === 'rev-parse') return { status: 0, stdout: 'main\n' };
1421
+ if (verb === 'config' || verb === 'remote') return { status: 1, stdout: '' };
1422
+ return { status: 0, stdout: '' };
1423
+ };
1424
+ fn.calls = calls;
1425
+ return fn;
1426
+ }
1427
+ const pinBase = { instanceDir: '/srv/bongos/acme', fromVersion: '1.19.56', toVersion: '1.19.69', commitPin: true };
1428
+ const acme = { owner: 'acme', name: 'widgets' };
1429
+ const credFileOf = (args) => (String(args[1] || '').match(/^credential\.helper=store --file=(.+)$/) || [])[1] || null;
1430
+
1431
+ t('persistPin: a stored owner token pushes over HTTPS through a chmod-600 credential-store file, and the token never rides argv', () => {
1432
+ const token = 'gho_SECRET_OWNER_TOKEN';
1433
+ let seen = null;
1434
+ const run = pinRun({
1435
+ push: (args, opts) => {
1436
+ const credFile = credFileOf(args);
1437
+ const there = !!credFile && existsSync(credFile);
1438
+ seen = {
1439
+ credFile, there,
1440
+ mode: there ? statSync(credFile).mode & 0o777 : null,
1441
+ body: there ? readFileSync(credFile, 'utf8') : null,
1442
+ url: args[3], target: args[4],
1443
+ noPrompt: opts && opts.env && opts.env.GIT_TERMINAL_PROMPT,
1444
+ };
1445
+ return { status: 0, stdout: '' };
1446
+ },
1447
+ });
1448
+ const r = u.persistPin({ ...pinBase, pushAuth: { token, repo: acme } }, run, hush, hush);
1449
+ assert.deepEqual(r, { applicable: true, committed: true, pushed: true, dirty: u.PIN_FILES, via: 'owner-token' });
1450
+ assert.ok(seen, 'the push ran');
1451
+ assert.ok(seen.credFile, `the push reads a credential-store file; argv was ${JSON.stringify(run.calls)}`);
1452
+ assert.equal(seen.url, 'https://x-access-token@github.com/acme/widgets.git', 'HTTPS at the remote repo, the placeholder user in the URL');
1453
+ assert.equal(seen.target, 'HEAD:main');
1454
+ assert.equal(seen.there, true, 'the credential file exists while git runs');
1455
+ if (process.platform !== 'win32') assert.equal(seen.mode, 0o600, 'chmod 600 — the secret is readable by this user only');
1456
+ assert.equal(seen.body, `https://x-access-token:${token}@github.com\n`, 'the token lives in the file, credential-store shape');
1457
+ assert.equal(seen.noPrompt, '0', 'a rejected credential fails instead of sitting at a password prompt');
1458
+ assert.ok(!existsSync(seen.credFile), 'removed in finally');
1459
+ assert.ok(run.calls.every((c) => !c.includes(token)), `the token never rides argv: ${JSON.stringify(run.calls)}`);
1460
+ assert.ok(!run.calls.some((c) => /^git push /.test(c)), 'the SSH origin was not used');
1461
+ assert.ok(run.calls.some((c) => c === `git add ${u.PIN_FILES.join(' ')}`) && run.calls.some((c) => c.startsWith('git commit -m pin @bongos/core 1.19.69')), 'stage + commit are unchanged');
1462
+ });
1463
+
1464
+ t('persistPin: no stored token → the push falls back to the git remote, with no credential helper anywhere', () => {
1465
+ const run = pinRun();
1466
+ const r = u.persistPin({ ...pinBase }, run, hush, hush);
1467
+ assert.deepEqual(r, { applicable: true, committed: true, pushed: true, dirty: u.PIN_FILES }, 'the origin-path result is byte-for-byte what every existing caller reads');
1468
+ assert.ok(run.calls.some((c) => c === 'git push origin HEAD:main'), 'pushes via the remote');
1469
+ assert.ok(!run.calls.some((c) => /credential\.helper|x-access-token/.test(c)), 'no credential helper, no token URL');
1470
+ // `pushAuth: null` — what resolvePinPushAuth returns on every miss — is the same path.
1471
+ const run2 = pinRun();
1472
+ assert.equal(u.persistPin({ ...pinBase, pushAuth: null }, run2, hush, hush).pushed, true);
1473
+ assert.ok(run2.calls.some((c) => c === 'git push origin HEAD:main'));
1474
+ });
1475
+
1476
+ t('persistPin: a refused origin push on a WIRED private instance is loud — names the read-only key, the consequence, and the fix', () => {
1477
+ const warnings = [];
1478
+ const run = pinRun({
1479
+ push: { status: 128, stdout: '', stderr: 'ERROR: The key you are authenticating with has been marked as read only.\nfatal: Could not read from remote repository.\n' },
1480
+ config: { status: 0, stdout: 'ssh -F /dev/null -i /srv/bongos/acme/.git/cloudbongos-deploy-key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new\n' },
1481
+ remote: { status: 0, stdout: 'git@github.com:acme/widgets.git\n' },
1482
+ });
1483
+ const r = u.persistPin({ ...pinBase }, run, hush, (m) => warnings.push(m));
1484
+ assert.equal(r.committed, true);
1485
+ assert.equal(r.pushed, false);
1486
+ assert.equal(r.refused, 'read-only-deploy-key');
1487
+ // The tokenless refusal is a plain sentence, not the bare status line.
1488
+ assert.match(r.error, /read-only deploy key at \/srv\/bongos\/acme\/\.git\/cloudbongos-deploy-key/, 'names the key');
1489
+ assert.match(r.error, /no owner GitHub token is stored/, 'and why nothing else was tried');
1490
+ assert.notEqual(r.error, 'git push failed');
1491
+ const text = warnings.join('\n');
1492
+ assert.match(text, /PIN NOT PUSHED/, 'shouts');
1493
+ assert.match(text, /\/srv\/bongos\/acme\/\.git\/cloudbongos-deploy-key/, 'names the key on stderr too');
1494
+ assert.match(text, /READ-ONLY/);
1495
+ assert.match(text, /reset --hard/, 'names the mechanism that reverts it');
1496
+ assert.match(text, /npm ci/);
1497
+ assert.match(text, /1\.19\.56/, 'and the version it would fall back to');
1498
+ assert.match(text, /reconnect GitHub with repository access/, 'names the fix');
1499
+ assert.match(text, /--commit-pin/);
1500
+ assert.match(text, /--instance '\/srv\/bongos\/acme'/, 'and where to re-run it');
1501
+ assert.match(text, /push 'https:\/\/github\.com\/acme\/widgets\.git' 'HEAD:main'/, 'plus a by-hand push at the real repo, paste-safe');
1502
+ assert.match(text, /marked as read only/, "git's own reason rides along");
1503
+ });
1504
+
1505
+ t('persistPin: a refused origin push on an UN-wired checkout keeps the plain failure (no false read-only diagnosis)', () => {
1506
+ const warnings = [];
1507
+ const run = pinRun({ push: { status: 1, stdout: '', stderr: 'fatal: unable to access: Could not resolve host\n' } });
1508
+ const r = u.persistPin({ ...pinBase }, run, hush, (m) => warnings.push(m));
1509
+ assert.equal(r.pushed, false);
1510
+ assert.equal(r.error, 'git push failed');
1511
+ assert.equal(r.refused, undefined);
1512
+ assert.ok(!/READ-ONLY|deploy key/.test(warnings.join('\n')), 'no read-only story when no key is pinned');
1513
+ assert.match(warnings.join('\n'), /still not durable/i);
1514
+ });
1515
+
1516
+ t('persistPin: a refused push OVER the token is loud too, and the credential file is still removed', () => {
1517
+ const token = 'gho_EXPIRED_TOKEN';
1518
+ const warnings = [];
1519
+ let credFile = null;
1520
+ const run = pinRun({
1521
+ push: (args) => {
1522
+ credFile = credFileOf(args);
1523
+ return { status: 128, stdout: '', stderr: 'remote: Write access to repository not granted.\nfatal: unable to access \'https://github.com/acme/widgets.git/\': The requested URL returned error: 403\n' };
1524
+ },
1525
+ });
1526
+ const r = u.persistPin({ ...pinBase, pushAuth: { token, repo: acme } }, run, hush, (m) => warnings.push(m));
1527
+ assert.equal(r.committed, true);
1528
+ assert.equal(r.pushed, false);
1529
+ assert.equal(r.refused, 'owner-token');
1530
+ assert.match(r.error, /over the owner's GitHub token/);
1531
+ assert.match(r.error, /expired, or missing the repo scope/);
1532
+ assert.ok(credFile && !existsSync(credFile), 'the credential file is gone even on failure');
1533
+ const text = warnings.join('\n');
1534
+ assert.match(text, /PIN NOT PUSHED/);
1535
+ assert.match(text, /expired/);
1536
+ assert.match(text, /repo.*scope/);
1537
+ assert.match(text, /reconnect GitHub with repository access/, 'names the fix');
1538
+ assert.match(text, /Write access to repository not granted/, "GitHub's own reason rides along, not git's generic fatal line");
1539
+ assert.match(text, /push 'https:\/\/github\.com\/acme\/widgets\.git' 'HEAD:main'/, 'and a by-hand push');
1540
+ assert.ok(!text.includes(token), 'the token is never logged');
1541
+ assert.ok(!run.calls.some((c) => /^git push /.test(c)), 'a token refusal does not retry the read-only origin');
1542
+ });
1543
+
1544
+ t('pinnedDeployKey / remoteGithubRepo: read the ADR 0176 wiring off the checkout, and only GitHub remotes count', () => {
1545
+ const wired = pinRun({ config: { status: 0, stdout: 'ssh -F /dev/null -i /srv/bongos/acme/.git/cloudbongos-deploy-key -o IdentitiesOnly=yes\n' } });
1546
+ assert.equal(u.pinnedDeployKey('/srv/bongos/acme', wired), '/srv/bongos/acme/.git/cloudbongos-deploy-key');
1547
+ assert.equal(u.pinnedDeployKey('/srv/bongos/acme', pinRun()), null, 'unset core.sshCommand → not wired');
1548
+ assert.equal(u.pinnedDeployKey('/srv/bongos/acme', pinRun({ config: { status: 0, stdout: 'ssh -o BatchMode=yes\n' } })), null, 'a sshCommand without -i pins no key');
1549
+
1550
+ const at = (url) => pinRun({ remote: { status: 0, stdout: `${url}\n` } });
1551
+ assert.deepEqual(u.remoteGithubRepo('/srv/bongos/acme', 'origin', at('git@github.com:acme/widgets.git')), acme, 'the ADR 0176 private wiring');
1552
+ assert.deepEqual(u.remoteGithubRepo('/srv/bongos/acme', 'origin', at('https://github.com/acme/widgets')), acme);
1553
+ assert.deepEqual(u.remoteGithubRepo('/srv/bongos/acme', 'origin', at('https://x-access-token@github.com/acme/widgets.git')), acme);
1554
+ const P = require('../scripts/gds/provision.js');
1555
+ assert.deepEqual(u.remoteGithubRepo('/srv/bongos/acme', 'origin', at(P.instanceRepoRemote(acme))), acme, 'the shared SSH alias the public scaffold wires is GitHub too');
1556
+ assert.equal(u.remoteGithubRepo('/srv/bongos/acme', 'origin', at('https://gitlab.com/acme/widgets.git')), null, 'another host is never pointed at github.com');
1557
+ assert.equal(u.remoteGithubRepo('/srv/bongos/acme', 'origin', at('git@bitbucket.org:acme/widgets.git')), null);
1558
+ assert.equal(u.remoteGithubRepo('/srv/bongos/acme', 'origin', pinRun()), null, 'no such remote');
1559
+ // --git-remote is honoured: the URL of THAT remote is read.
1560
+ const deploy = pinRun({ remote: (args) => (args[2] === 'deploy' ? { status: 0, stdout: 'git@github.com:acme/widgets.git\n' } : { status: 1, stdout: '' }) });
1561
+ assert.deepEqual(u.remoteGithubRepo('/srv/bongos/acme', 'deploy', deploy), acme);
1562
+ });
1563
+
1564
+ t('resolvePinPushAuth: the owner token is spent only when one is stored AND the provisioning row names the remote repo', async () => {
1565
+ const inst = { slug: 'acme', owner_builder_id: 7, target_ref: 'acme/widgets', hosting_shape: 'standalone' };
1566
+ const at = (url) => pinRun({ remote: { status: 0, stdout: `${url}\n` } });
1567
+ const dir = { instanceDir: '/srv/bongos/acme' };
1568
+
1569
+ // stored token + matching row → { token, repo }, the repo parsed from the REMOTE
1570
+ const a = await u.resolvePinPushAuth(dir, { log: hush, instance: inst, getGithubToken: async (bid) => (bid === 7 ? { token: 'gho_T', scope: 'read:user,repo' } : null) }, at('git@github.com:acme/widgets.git'));
1571
+ assert.deepEqual(a, { token: 'gho_T', repo: acme });
1572
+ // case never decides it (GitHub owner/repo names are case-insensitive)
1573
+ const b = await u.resolvePinPushAuth(dir, { log: hush, instance: { ...inst, target_ref: 'Acme/Widgets' }, getGithubToken: async () => ({ token: 'gho_T' }) }, at('git@github.com:acme/widgets.git'));
1574
+ assert.equal(b && b.token, 'gho_T');
1575
+ // no token stored → null: the origin path
1576
+ assert.equal(await u.resolvePinPushAuth(dir, { log: hush, instance: inst, getGithubToken: async () => null }, at('git@github.com:acme/widgets.git')), null);
1577
+ // a row for ANOTHER repo → null: never another instance's credential for this checkout
1578
+ let asked = 0;
1579
+ const other = await u.resolvePinPushAuth(dir, { log: hush, instance: { ...inst, target_ref: 'acme/other' }, getGithubToken: async () => { asked++; return { token: 'gho_T' }; } }, at('git@github.com:acme/widgets.git'));
1580
+ assert.equal(other, null);
1581
+ assert.equal(asked, 0, 'the token store is not even consulted for a mismatched row');
1582
+ // a non-GitHub remote → null, before any row or token lookup
1583
+ let rows = 0;
1584
+ assert.equal(await u.resolvePinPushAuth(dir, { log: hush, getInstanceBySlug: async () => { rows++; return inst; }, getGithubToken: async () => ({ token: 'gho_T' }) }, at('https://gitlab.com/acme/widgets.git')), null);
1585
+ assert.equal(rows, 0);
1586
+ // no row at all (an instance's own DB, a laptop) → null, and a THROWING lookup is the same answer
1587
+ assert.equal(await u.resolvePinPushAuth(dir, { log: hush, getInstanceBySlug: async () => null, getGithubToken: async () => ({ token: 'gho_T' }) }, at('git@github.com:acme/widgets.git')), null);
1588
+ assert.equal(await u.resolvePinPushAuth(dir, { log: hush, getInstanceBySlug: async () => { throw new Error('relation "provisioning_instances" does not exist'); }, getGithubToken: async () => ({ token: 'gho_T' }) }, at('git@github.com:acme/widgets.git')), null);
1589
+ // the by-slug lookup uses the checkout's basename — standaloneRoot is <base>/<slug>
1590
+ const slugs = [];
1591
+ const c = await u.resolvePinPushAuth(dir, { log: hush, getInstanceBySlug: async (s) => { slugs.push(s); return inst; }, getGithubToken: async () => ({ token: 'gho_T' }) }, at('git@github.com:acme/widgets.git'));
1592
+ assert.deepEqual(slugs, ['acme']);
1593
+ assert.equal(c && c.token, 'gho_T');
1594
+ // a row without an owner (or a token lookup that throws) is the origin path, never a crash
1595
+ assert.equal(await u.resolvePinPushAuth(dir, { log: hush, getInstanceBySlug: async () => ({ ...inst, owner_builder_id: null }), getGithubToken: async () => ({ token: 'gho_T' }) }, at('git@github.com:acme/widgets.git')), null);
1596
+ assert.equal(await u.resolvePinPushAuth(dir, { log: hush, instance: inst, getGithubToken: async () => { throw new Error('secret-box off'); } }, at('git@github.com:acme/widgets.git')), null);
1597
+ });
1598
+
1599
+ t('runUpgrade --commit-pin: on a wired private instance the pin lands via the owner token, end to end', async () => {
1600
+ const dir = scratchConsumer({ pinned: '1.14.1', installed: '1.14.1' });
1601
+ const src = mkdtempSync(join(tmpdir(), 'otb-upgrade-src-'));
1602
+ writeFileSync(join(src, 'bongos-core-1.15.0.tgz'), 'TGZ');
1603
+ const token = 'gho_END_TO_END_TOKEN';
1604
+ const calls = [];
1605
+ let pushArgv = null;
1606
+ const run = (cmd, args, opts) => {
1607
+ calls.push(`${cmd} ${(args || []).join(' ')}`.trim());
1608
+ // pre-flight `git status --porcelain` is clean; persistPin's pathspec-limited look
1609
+ // (`-- package.json package-lock.json`) sees the moved pin.
1610
+ if (cmd === 'git' && args[0] === 'status') return { status: 0, stdout: args.includes('--') ? PIN_DIRTY : '' };
1611
+ if (cmd === 'git' && args[0] === 'rev-parse') return { status: 0, stdout: 'main\n' };
1612
+ if (cmd === 'git' && args[0] === 'remote') return { status: 0, stdout: 'git@github.com:acme/widgets.git\n' };
1613
+ if (cmd === 'git' && args[0] === 'config') return { status: 0, stdout: 'ssh -F /dev/null -i /srv/bongos/acme/.git/cloudbongos-deploy-key -o IdentitiesOnly=yes\n' };
1614
+ if (cmd === 'git' && args[0] === '-c') { pushArgv = args; return { status: 0, stdout: '' }; }
1615
+ if (cmd === 'npm' && args[0] === 'install') {
1616
+ writeFileSync(join(dir, 'node_modules', '@bongos', 'core', 'package.json'), JSON.stringify({ name: '@bongos/core', version: '1.15.0' }) + '\n');
1617
+ }
1618
+ return { status: 0 }; // no stdout: resolveInstanceDb reads "could not run" (not fatal), as the happy-path test's shim does
1619
+ };
1620
+ const res = await u.runUpgrade(
1621
+ { to: '1.15.0', from: join(src, 'bongos-core-1.15.0.tgz'), instance: dir, service: 'acme.service', healthCheck: false, commitPin: true },
1622
+ {
1623
+ log: hush, err: hush, run,
1624
+ materialize: () => ({ skills: 35, settings: true, hooks: 3 }),
1625
+ regenerateApiArtifacts: () => ({ ok: true, ran: ['gen-api-docs.js', 'gen-api-client.js'] }),
1626
+ databaseUrl: '', pgDatabase: '',
1627
+ instance: { slug: 'acme', owner_builder_id: 7, target_ref: 'acme/widgets', hosting_shape: 'standalone' },
1628
+ getGithubToken: async () => ({ token, scope: 'read:user,repo' }),
1629
+ },
1630
+ );
1631
+ assert.equal(res.ok, true, `upgrade should succeed: ${res.error}`);
1632
+ assert.equal(res.pin && res.pin.pushed, true, 'the pin was pushed');
1633
+ assert.equal(res.pin.via, 'owner-token', 'over the owner token, not the read-only origin');
1634
+ assert.ok(pushArgv, 'pushed via the credential-store helper');
1635
+ assert.equal(pushArgv[3], 'https://x-access-token@github.com/acme/widgets.git');
1636
+ assert.equal(pushArgv[4], 'HEAD:main');
1637
+ assert.ok(!calls.some((c) => c.includes(token)), 'the token never rides argv');
1638
+ assert.ok(!calls.some((c) => /^git push /.test(c)), 'the read-only origin was never asked');
1639
+ });
1640
+
1641
+ t('runUpgrade: the token store is consulted only when a push is actually coming', async () => {
1642
+ // --pin-no-push, --dry-run, and a clean pin all skip resolvePinPushAuth entirely:
1643
+ // nothing reads a credential it will not spend.
1644
+ const cases = [
1645
+ { name: 'no --commit-pin', opts: {}, dirtyPin: true },
1646
+ { name: '--pin-no-push', opts: { commitPin: true, pinNoPush: true }, dirtyPin: true },
1647
+ { name: '--dry-run', opts: { commitPin: true, dryRun: true }, dirtyPin: true },
1648
+ { name: 'pin already committed', opts: { commitPin: true }, dirtyPin: false },
1649
+ ];
1650
+ for (const c of cases) {
1651
+ const dir = scratchConsumer({ pinned: '1.14.1', installed: '1.14.1' });
1652
+ const src = mkdtempSync(join(tmpdir(), 'otb-upgrade-src-'));
1653
+ writeFileSync(join(src, 'bongos-core-1.15.0.tgz'), 'TGZ');
1654
+ let resolved = 0;
1655
+ const run = (cmd, args) => {
1656
+ if (cmd === 'git' && args[0] === 'status') return { status: 0, stdout: args.includes('--') && c.dirtyPin ? PIN_DIRTY : '' };
1657
+ if (cmd === 'git' && args[0] === 'rev-parse') return { status: 0, stdout: 'main\n' };
1658
+ if (cmd === 'npm' && args[0] === 'install') {
1659
+ writeFileSync(join(dir, 'node_modules', '@bongos', 'core', 'package.json'), JSON.stringify({ name: '@bongos/core', version: '1.15.0' }) + '\n');
1660
+ }
1661
+ return { status: 0 };
1662
+ };
1663
+ await u.runUpgrade(
1664
+ { to: '1.15.0', from: join(src, 'bongos-core-1.15.0.tgz'), instance: dir, service: 'acme.service', healthCheck: false, ...c.opts },
1665
+ {
1666
+ log: hush, err: hush, run,
1667
+ materialize: () => ({}), regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
1668
+ databaseUrl: '', pgDatabase: '',
1669
+ resolvePinPushAuth: async () => { resolved++; return null; },
1670
+ },
1671
+ );
1672
+ assert.equal(resolved, 0, `${c.name}: must not consult the token store`);
1673
+ }
1674
+ });
1675
+
1393
1676
  await Promise.all(pending);
1394
1677
  console.log(`\nupgrade: ${passed} passed, ${failed} failed`);
1395
1678
  process.exit(failed ? 1 : 0);