@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 +20 -15
- package/docs/module-api-changelog.md +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/provision-repo-auth.js +106 -0
- package/scripts/gds/provision-repo.js +8 -37
- package/scripts/gds/upgrade.js +187 -12
- package/src/module-api.js +1 -1
- package/tests/executed_closure.mjs +8 -0
- package/tests/upgrade.mjs +284 -1
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.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
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-
|
|
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":
|
|
14
|
+
"functional_verbatim": 2109,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
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": "
|
|
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": "
|
|
7540
|
+
"sha256": "fc4587a7fbaaa14aad1e0296015adb626e89da62f4a90a95647f1bbb55a61f1e"
|
|
7541
7541
|
},
|
|
7542
7542
|
{
|
|
7543
7543
|
"path": "package.json",
|
|
7544
7544
|
"mode": "0000644",
|
|
7545
|
-
"sha256": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
292
|
-
//
|
|
293
|
-
|
|
294
|
-
|
|
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.
|
package/scripts/gds/upgrade.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
|
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,
|
|
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.
|
|
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);
|