@bongos/core 1.21.116 → 1.21.117
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 +32 -17
- package/docs/adr/0350-the-hub-holds-a-per-project-write-deploy-key-so-a-hosted-upgrade-reaches-github.md +34 -0
- package/docs/module-api-changelog.md +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/scripts/gds/fitness-lib.js +35 -0
- package/scripts/gds/fitness.js +2 -2
- package/scripts/gds/provision-core-upgrade.js +26 -7
- package/scripts/gds/provision-pin-replay.js +126 -0
- package/src/module-api.js +1 -1
- package/tests/fitness_installed_core.mjs +127 -0
- package/tests/pin_replay.mjs +147 -0
- package/tests/pin_write_key.mjs +26 -0
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.21.
|
|
6
|
-
"core_contract": "1.21.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.21.117",
|
|
6
|
+
"core_contract": "1.21.117",
|
|
7
|
+
"source_commit": "24f86969a6ea67bd13ceaa3e1a6b4193a717de4e",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-10-
|
|
9
|
+
"built_at": "2026-10-14T10:20:16.439Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 604,
|
|
13
13
|
"agent_docs_stubbed": 28,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 3113,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 3746,
|
|
20
|
+
"tree_sha256": "a7996793fd481f433e052680171e0844fa40b4f7bd34e9a5533bba236e3979bc",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2212,7 +2212,7 @@
|
|
|
2212
2212
|
{
|
|
2213
2213
|
"path": "docs/adr/0350-the-hub-holds-a-per-project-write-deploy-key-so-a-hosted-upgrade-reaches-github.md",
|
|
2214
2214
|
"mode": "0000644",
|
|
2215
|
-
"sha256": "
|
|
2215
|
+
"sha256": "41102dae0e5743d3963386045a6071513dd81624e473085e12127630da491daa"
|
|
2216
2216
|
},
|
|
2217
2217
|
{
|
|
2218
2218
|
"path": "docs/adr/0351-a-criterion-closes-on-a-uat.md",
|
|
@@ -2917,7 +2917,7 @@
|
|
|
2917
2917
|
{
|
|
2918
2918
|
"path": "docs/module-api-changelog.md",
|
|
2919
2919
|
"mode": "0000644",
|
|
2920
|
-
"sha256": "
|
|
2920
|
+
"sha256": "d8e108772dbf146ba2c2a7bc345e62c49564629e17f7ff1f155cf9bc8aa1a19b"
|
|
2921
2921
|
},
|
|
2922
2922
|
{
|
|
2923
2923
|
"path": "docs/modules-contract.md",
|
|
@@ -10462,12 +10462,12 @@
|
|
|
10462
10462
|
{
|
|
10463
10463
|
"path": "package-lock.json",
|
|
10464
10464
|
"mode": "0000644",
|
|
10465
|
-
"sha256": "
|
|
10465
|
+
"sha256": "0ace635f24556f8dcafe05047ac751909d9059cecc2a69a0870e4bfa317265df"
|
|
10466
10466
|
},
|
|
10467
10467
|
{
|
|
10468
10468
|
"path": "package.json",
|
|
10469
10469
|
"mode": "0000644",
|
|
10470
|
-
"sha256": "
|
|
10470
|
+
"sha256": "38e0e4baa27d16b52a5e5b5ec1a142ed61c2d096b07df5ef684a33ad81e14143"
|
|
10471
10471
|
},
|
|
10472
10472
|
{
|
|
10473
10473
|
"path": "public-docs/index.html",
|
|
@@ -10487,7 +10487,7 @@
|
|
|
10487
10487
|
{
|
|
10488
10488
|
"path": "release-notes.json",
|
|
10489
10489
|
"mode": "0000644",
|
|
10490
|
-
"sha256": "
|
|
10490
|
+
"sha256": "4b3698521da6c1ef14a15c8e3ee3a42f6f7532c4db9f6a9cf40c0e9cd77c4d3e"
|
|
10491
10491
|
},
|
|
10492
10492
|
{
|
|
10493
10493
|
"path": "scripts/cron/style_guide_audit.sh",
|
|
@@ -11057,7 +11057,7 @@
|
|
|
11057
11057
|
{
|
|
11058
11058
|
"path": "scripts/gds/fitness-lib.js",
|
|
11059
11059
|
"mode": "0000644",
|
|
11060
|
-
"sha256": "
|
|
11060
|
+
"sha256": "a5866991d48c6ec5fb9c6ab09acefd299ec612cfa8507795689532fc018a50f8"
|
|
11061
11061
|
},
|
|
11062
11062
|
{
|
|
11063
11063
|
"path": "scripts/gds/fitness-migration-contraction.js",
|
|
@@ -11072,7 +11072,7 @@
|
|
|
11072
11072
|
{
|
|
11073
11073
|
"path": "scripts/gds/fitness.js",
|
|
11074
11074
|
"mode": "0000644",
|
|
11075
|
-
"sha256": "
|
|
11075
|
+
"sha256": "88a99a1a1455477dd9f7987c0f6c48f20464cc4ab98cc2f224c1a5536483fd2d"
|
|
11076
11076
|
},
|
|
11077
11077
|
{
|
|
11078
11078
|
"path": "scripts/gds/gate-review.js",
|
|
@@ -11512,7 +11512,7 @@
|
|
|
11512
11512
|
{
|
|
11513
11513
|
"path": "scripts/gds/provision-core-upgrade.js",
|
|
11514
11514
|
"mode": "0000644",
|
|
11515
|
-
"sha256": "
|
|
11515
|
+
"sha256": "4618801b27992e4f7300b3ba1a4e12c44e6d42d8895b4f15997b051f7dc9c0de"
|
|
11516
11516
|
},
|
|
11517
11517
|
{
|
|
11518
11518
|
"path": "scripts/gds/provision-disconnect.js",
|
|
@@ -11534,6 +11534,11 @@
|
|
|
11534
11534
|
"mode": "0000644",
|
|
11535
11535
|
"sha256": "0635ec599429c8caefad80eb5947bca487d267c2f4286c72c54966815f7c679f"
|
|
11536
11536
|
},
|
|
11537
|
+
{
|
|
11538
|
+
"path": "scripts/gds/provision-pin-replay.js",
|
|
11539
|
+
"mode": "0000644",
|
|
11540
|
+
"sha256": "db5afb35a5d1ad7dae72ea730e6b1e0add815348c7263f1b6b90c3b2112126a3"
|
|
11541
|
+
},
|
|
11537
11542
|
{
|
|
11538
11543
|
"path": "scripts/gds/provision-process.js",
|
|
11539
11544
|
"mode": "0000644",
|
|
@@ -12787,7 +12792,7 @@
|
|
|
12787
12792
|
{
|
|
12788
12793
|
"path": "src/module-api.js",
|
|
12789
12794
|
"mode": "0000644",
|
|
12790
|
-
"sha256": "
|
|
12795
|
+
"sha256": "ad17db745ec1ff7924bd9b01437ae13fa716bd9738b8ac57dceeaa8f591ea2d7"
|
|
12791
12796
|
},
|
|
12792
12797
|
{
|
|
12793
12798
|
"path": "src/module-loader/catalog.js",
|
|
@@ -14304,6 +14309,11 @@
|
|
|
14304
14309
|
"mode": "0000644",
|
|
14305
14310
|
"sha256": "230e532b54a69a3ee2c009b3a018a6cb920c92fb402364427cbdc244434d4c1b"
|
|
14306
14311
|
},
|
|
14312
|
+
{
|
|
14313
|
+
"path": "tests/fitness_installed_core.mjs",
|
|
14314
|
+
"mode": "0000644",
|
|
14315
|
+
"sha256": "c3e685ff6addb1c14d6f9d5cf3fb02f82987a42ba6d1f84c52d147d9045a704a"
|
|
14316
|
+
},
|
|
14307
14317
|
{
|
|
14308
14318
|
"path": "tests/fitness_no_raw_nul.mjs",
|
|
14309
14319
|
"mode": "0000644",
|
|
@@ -16709,10 +16719,15 @@
|
|
|
16709
16719
|
"mode": "0000644",
|
|
16710
16720
|
"sha256": "cd33adb13dcaf667f98444b3df9273da9ea8de77dbe1340b281000157044c093"
|
|
16711
16721
|
},
|
|
16722
|
+
{
|
|
16723
|
+
"path": "tests/pin_replay.mjs",
|
|
16724
|
+
"mode": "0000644",
|
|
16725
|
+
"sha256": "77f0e61c9c3ac0de2b898803c6cd90bcc498d72e1ae370b628525e379e28ee5c"
|
|
16726
|
+
},
|
|
16712
16727
|
{
|
|
16713
16728
|
"path": "tests/pin_write_key.mjs",
|
|
16714
16729
|
"mode": "0000644",
|
|
16715
|
-
"sha256": "
|
|
16730
|
+
"sha256": "6b8973f043a138a08a01a14f4783181c6484daac8397306e6549b00315a6145e"
|
|
16716
16731
|
},
|
|
16717
16732
|
{
|
|
16718
16733
|
"path": "tests/plain_cards.mjs",
|
|
@@ -101,3 +101,37 @@ sets up lasting access, no longer "about an hour".
|
|
|
101
101
|
- Code: `scripts/gds/provision-pin-key.js`, `pushUpgradePin` in
|
|
102
102
|
`scripts/gds/provision-core-upgrade.js`, the revoke in `scripts/gds/provision-teardown.js`,
|
|
103
103
|
the copy in `modules/hall-ui/public/deploy.js`; tests in `tests/pin_write_key.mjs`.
|
|
104
|
+
|
|
105
|
+
## Amendment — a pin refused as "behind" is replayed on top (2026-10-14, task 1004733)
|
|
106
|
+
|
|
107
|
+
**Amends D4 and the "auto-rebasing a pin … stays out of scope" consequence above.** Claude
|
|
108
|
+
wrote this record; the fix was the one the task stated, and the second ruling below is the
|
|
109
|
+
safe default the task asked to be recorded, not a new owner decision.
|
|
110
|
+
|
|
111
|
+
**A1. GitHub ahead is the normal case, so it is no longer left split.** A project the owner
|
|
112
|
+
works from a laptop almost always has commits on GitHub the box lacks, so the pin push was
|
|
113
|
+
refused as "behind" on most moves, and the next deploy from GitHub could roll the core back.
|
|
114
|
+
Now, when a push is refused as behind, `pushUpgradePin` hands the same credential (write key,
|
|
115
|
+
owner token or `origin`) to `replayPinCommit` (`scripts/gds/provision-pin-replay.js`): fetch the
|
|
116
|
+
branch, cherry-pick ONLY the move's pin commit (HEAD, subject `pin @bongos/core …`, one parent)
|
|
117
|
+
onto it in a private scratch worktree outside the checkout, and push the result as an ordinary
|
|
118
|
+
fast-forward. Never forced; a second refusal (someone pushed in between) is reported, not chased.
|
|
119
|
+
|
|
120
|
+
**A2. The live box does not move.** The replay never touches the box's checkout, HEAD or files,
|
|
121
|
+
so the box keeps running exactly what the move's health check proved. GitHub and the box then
|
|
122
|
+
hold the same pin on different histories, which is harmless because nothing pulls the box.
|
|
123
|
+
|
|
124
|
+
**A3. A real conflict stops.** If the owner changed the same lines of `package.json` or
|
|
125
|
+
`package-lock.json`, the cherry-pick is aborted, nothing is pushed, and the move finishes done
|
|
126
|
+
with the new note code `conflict`, naming the files. Choosing between the two is a person's call.
|
|
127
|
+
|
|
128
|
+
**A4. The box does NOT pick up the owner's other commits (recorded default).** After
|
|
129
|
+
provisioning nothing pulls a hosted box: `standalonePullCmd` runs only at provision time
|
|
130
|
+
(`scripts/gds/provision.js`). Merging the owner's commits into the live checkout at this point
|
|
131
|
+
would deploy their code with no install, migrate, restart or health check — a deploy riding
|
|
132
|
+
inside a pin push. It is left out on purpose; if the owner wants a hosted box to follow GitHub,
|
|
133
|
+
that is a deploy-path decision recorded on its own, not a side effect of a core move.
|
|
134
|
+
|
|
135
|
+
Tests: `tests/pin_replay.mjs` (real bare origin ahead of the box: replayed and pushed; conflicting
|
|
136
|
+
lockfile reported, nothing forced; up-to-date origin unchanged) and `tests/pin_write_key.mjs`
|
|
137
|
+
(the replay rides the write key that was refused).
|
|
@@ -3069,5 +3069,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
3069
3069
|
landed since 1.21.114 with no explicit bump. run 38516937959. (task 1002620)
|
|
3070
3070
|
1.21.116 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
3071
3071
|
landed since 1.21.115 with no explicit bump. run 38545604578. (task 1002620)
|
|
3072
|
+
1.21.117 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
3073
|
+
landed since 1.21.116 with no explicit bump. run 38548762428. (task 1002620)
|
|
3072
3074
|
---------------------------------------------------------------------------
|
|
3073
3075
|
```
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.117",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.117",
|
|
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.21.
|
|
3
|
+
"version": "1.21.117",
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -9670,5 +9670,15 @@
|
|
|
9670
9670
|
"id": "1004734",
|
|
9671
9671
|
"text": "When a project's update fails while updating its database, the deploy page and the auto-filed blocker now say which database change failed and why, so nobody has to log into the machine to find out."
|
|
9672
9672
|
}
|
|
9673
|
+
],
|
|
9674
|
+
"1.21.117": [
|
|
9675
|
+
{
|
|
9676
|
+
"id": "1004733",
|
|
9677
|
+
"text": "When a project's owner has saved newer work on GitHub, an automatic platform update now adds its new version number on top of that work instead of leaving GitHub pointing at the old version, so a later deploy can no longer qui"
|
|
9678
|
+
},
|
|
9679
|
+
{
|
|
9680
|
+
"id": "1004737",
|
|
9681
|
+
"text": "Projects that install Cloud Bongos from npm can run the project health check again: it now says plainly that those checks belong to Cloud Bongos itself and skips them, instead of crashing."
|
|
9682
|
+
}
|
|
9673
9683
|
]
|
|
9674
9684
|
}
|
|
@@ -80,7 +80,42 @@ function keyTokenRe(key) {
|
|
|
80
80
|
return new RegExp(`(^|[^\\w-])${esc}($|[^\\w-])`);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// Is this an INSTALLED core (task 1004737)? A project that installs the core from npm has it
|
|
84
|
+
// at <project>/node_modules/@bongos/core: git there answers for the PROJECT's repo, where that
|
|
85
|
+
// folder is ignored, so every git-enumerating check finds nothing and throws a "scan defect".
|
|
86
|
+
//
|
|
87
|
+
// Decided POSITIVELY, never from a failure: only a core under a node_modules directory can
|
|
88
|
+
// be installed. Anywhere else the answer is no, whatever git says — so a core checkout with
|
|
89
|
+
// git missing, no .git, or a misresolved root still runs every check and fails closed the
|
|
90
|
+
// way each check always has, instead of folding an error into the skip below. Under
|
|
91
|
+
// node_modules it is installed unless it is the top of its own git checkout.
|
|
92
|
+
function isInstalledCore(root = CORE_ROOT) {
|
|
93
|
+
if (!path.resolve(root).split(/[\\/]/).includes('node_modules')) return false;
|
|
94
|
+
try {
|
|
95
|
+
const top = require('node:child_process').execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
96
|
+
return fs.realpathSync.native(top) !== fs.realpathSync.native(root);
|
|
97
|
+
} catch { return true; } // a package with no repo around it at all
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// runChecks — run every fitness check, or none (task 1004737). Every check guards the core's
|
|
101
|
+
// own source, and an INSTALLED core's source is not the project's to change: the core repo's
|
|
102
|
+
// CI ran this gate before publishing it (the same stance ship-merge-gate.js takes for a
|
|
103
|
+
// vendored instance). Run there, the git scans enumerate nothing and the checks either throw
|
|
104
|
+
// or report a "scan defect", so fitness crashed and every ship reading it stalled. One honest
|
|
105
|
+
// skipped result instead — not silence, and not a pass dressed up as a measurement.
|
|
106
|
+
function runChecks(checks, { installed = isInstalledCore() } = {}) {
|
|
107
|
+
if (!installed) return checks.map((fn) => fn());
|
|
108
|
+
return [{
|
|
109
|
+
name: 'fitness gate — skipped: the core here is an installed package (task 1004737)',
|
|
110
|
+
ok: true, hardFail: false, violations: [],
|
|
111
|
+
warnings: ['none of the checks ran: this project installs the core rather than developing it'],
|
|
112
|
+
note: `${CORE_ROOT} is an installed package inside another repo, so there is no core source here to measure; the core repo's CI ran these ${checks.length} checks before it was published.`,
|
|
113
|
+
}];
|
|
114
|
+
}
|
|
115
|
+
|
|
83
116
|
module.exports = {
|
|
117
|
+
isInstalledCore,
|
|
118
|
+
runChecks,
|
|
84
119
|
keyTokenRe,
|
|
85
120
|
CORE_ROOT,
|
|
86
121
|
INSTANCE_ROOT,
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -33,7 +33,7 @@ const { resolveCoreRoot, resolveInstanceRoot } = require('../../src/instance-con
|
|
|
33
33
|
// live beside it — the shared roots/walkers and three check clusters. They are
|
|
34
34
|
// IMPORTED (not registered by path) so the CHECKS array and module.exports below
|
|
35
35
|
// stay byte-identical to the single-file version every test was written against.
|
|
36
|
-
const { CORE_ROOT, INSTANCE_ROOT, MODULE_ROOTS, REPO_ROOT, keyTokenRe, rel, walkJs, walkJsAllRoots } = require('./fitness-lib.js');
|
|
36
|
+
const { CORE_ROOT, INSTANCE_ROOT, MODULE_ROOTS, REPO_ROOT, keyTokenRe, rel, runChecks, walkJs, walkJsAllRoots } = require('./fitness-lib.js');
|
|
37
37
|
const { IDENTITY_SCAN_ALLOWED, IDENTITY_SCAN_EXCLUDE, IDENTITY_SCAN_EXTRA_FILES, IDENTITY_SCAN_EXTS, IDENTITY_SCAN_ROOTS, IDENTITY_SCAN_SKIP_DIRS, PROMPT_IDENTITY_LITERALS, checkDesignContract, checkKernelPromptsNoHardcodedIdentity, checkNeutralConfigsNoHostIdentity, collectIdentityFiles, scanPromptIdentity, stripCommentsForIdentity } = require('./fitness-checks-identity.js');
|
|
38
38
|
const { WRITE_VALIDATION_BASELINE, WRITE_VALIDATION_EXEMPT, checkWriteRoutesValidated, scanUnvalidatedWriteRoutes } = require('./fitness-checks-write-validation.js');
|
|
39
39
|
const { GOAL_VOCAB_CITATION, GOAL_VOCAB_LINE_SCOPED, GOAL_VOCAB_SURFACE, LIFECYCLE_DB_FILES, PRELAUNCH_SURFACE, RENAME_IDENTITY_RE, VOCAB_ALLOW, VOCAB_BANNED, VOCAB_CITATION, VOCAB_EXTS, VOCAB_HISTORICAL_REF, VOCAB_SURFACE, checkGoalMembershipVocabulary, checkGovernmentRenameIdentity, checkPrelaunchVocabulary, checkVocabularyWall, vocabFilesToScan } = require('./fitness-checks-vocabulary.js');
|
|
@@ -1404,7 +1404,7 @@ const CHECKS = [
|
|
|
1404
1404
|
];
|
|
1405
1405
|
|
|
1406
1406
|
function runAll() {
|
|
1407
|
-
return CHECKS
|
|
1407
|
+
return runChecks(CHECKS); // task 1004737: an installed core has no core source to measure — one skipped result, not a crash
|
|
1408
1408
|
}
|
|
1409
1409
|
|
|
1410
1410
|
function main() {
|
|
@@ -36,6 +36,7 @@ const { spawnSync } = require('child_process');
|
|
|
36
36
|
const { CONFIG, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, REPO_ROOT } = require('./provision-config.js');
|
|
37
37
|
const { appUser, dbName, instanceUser, parseTargetRef, resolveOwnerGithubToken, selfInstanceRoot, standaloneRoot } = require('./provision-repo.js');
|
|
38
38
|
const { ensurePinWriteKey, hasOwnAccount, pinKeyPath, pinSshCommand } = require('./provision-pin-key.js');
|
|
39
|
+
const { replayPinCommit } = require('./provision-pin-replay.js'); // GitHub ahead → pin on top (task 1004733)
|
|
39
40
|
const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
|
|
40
41
|
const { RELEASE_NOTES_FILE, RELEASE_NOTES_MAX_BYTES, parseChangeRecords, notesBetween } = require('./release-notes.js');
|
|
41
42
|
const outcome = require('./upgrade-outcome.js'); // how far a failed move got, as a class (task 1004446)
|
|
@@ -411,9 +412,13 @@ async function coreUpgradeInstance(inst, deps, intent) {
|
|
|
411
412
|
// parseRepoNote in modules/provisioning/core-upgrade.js (re-stated there because a
|
|
412
413
|
// module may not require a script; tests/core_upgrade_runner.mjs round-trips the two).
|
|
413
414
|
//
|
|
414
|
-
// NOTHING IS FORCED
|
|
415
|
-
//
|
|
416
|
-
//
|
|
415
|
+
// NOTHING IS FORCED, AND THE LIVE BOX IS NEVER REBASED. When GitHub has commits the box
|
|
416
|
+
// does not, moving the box onto them would change what it runs with no restart and no
|
|
417
|
+
// health check. Since task 1004733 the pin commit ALONE is replayed onto GitHub's history in
|
|
418
|
+
// a scratch worktree and pushed as an ordinary fast-forward (provision-pin-replay.js), so
|
|
419
|
+
// GitHub names the new core and the box stays exactly what the move proved. A conflict in
|
|
420
|
+
// the pin files is reported ("conflict"), never resolved here. The box still does not pick
|
|
421
|
+
// up the owner's other commits — that would be a deploy, not a pin push (ADR 0350 amendment).
|
|
417
422
|
//
|
|
418
423
|
// WHAT MAKES IT DURABLE (task 1004291, ADR 0350). The owner's token is kept for an hour
|
|
419
424
|
// after they sign in with repository access (GITHUB_TOKEN_TTL_MS in src/bongos/db.js — one
|
|
@@ -431,6 +436,7 @@ const REPO_NOTES = Object.freeze({
|
|
|
431
436
|
needs_account: 'The hub could not set up lasting write access to the project\'s GitHub repository: this project needs its own account on the server first. Once it has one, reconnecting GitHub with repository access sets up lasting access.',
|
|
432
437
|
refused: 'GitHub turned down the owner\'s GitHub access — it may have expired, or lack permission to write to the repository.',
|
|
433
438
|
behind: 'GitHub has changes the server does not have, so the new version could not be added on top of them. An operator needs to bring the two back in line; the site itself is running normally.',
|
|
439
|
+
conflict: 'GitHub has its own changes to the same version files (package.json or package-lock.json), so the new version was not added on top of them and nothing there was overwritten. An operator needs to choose between the two; the site itself is running normally.',
|
|
434
440
|
failed: 'The hub could not save the new version to GitHub.',
|
|
435
441
|
});
|
|
436
442
|
// A branch name that is safe to interpolate. Narrower than git's own ref grammar on
|
|
@@ -498,6 +504,14 @@ async function pushUpgradePin(inst, deps, { instanceDir }) {
|
|
|
498
504
|
const at = `${repo.owner}/${repo.name}@${branch}`;
|
|
499
505
|
const hardened = `git -C ${instanceDir} -c core.hooksPath=/dev/null`;
|
|
500
506
|
const env = { GIT_TERMINAL_PROMPT: '0' }; // a refused credential fails; it never waits at a prompt
|
|
507
|
+
// GitHub is ahead: replay the pin commit alone on top, over the credential that was just
|
|
508
|
+
// refused as behind, and push again — never forced (task 1004733, provision-pin-replay.js).
|
|
509
|
+
const replayed = (via, git, remote) => {
|
|
510
|
+
const rr = replayPinCommit({ exec, instanceDir, branch, git, remote, env, slug: inst.slug, tmpDir: deps.tmpDir, pushFault, gitLine, log });
|
|
511
|
+
if (!rr.ok) return miss(rr.code, rr.extra);
|
|
512
|
+
if (rr.already) log(` ✓ ${at} already names this version — nothing to add`);
|
|
513
|
+
return { pushed: true, via, branch, ...(rr.replayed ? { replayed: true } : { already: true }) };
|
|
514
|
+
};
|
|
501
515
|
|
|
502
516
|
const token = await resolveOwnerGithubToken(inst, deps, { tag: '[core-upgrade]' });
|
|
503
517
|
|
|
@@ -515,8 +529,9 @@ async function pushUpgradePin(inst, deps, { instanceDir }) {
|
|
|
515
529
|
return { pushed: true, via: 'write-key', branch };
|
|
516
530
|
}
|
|
517
531
|
const fault = pushFault(r);
|
|
518
|
-
// The key WORKED and the history stopped it: no other credential changes that
|
|
519
|
-
|
|
532
|
+
// The key WORKED and the history stopped it: no other credential changes that, so the
|
|
533
|
+
// pin is replayed on top of GitHub's history over this same key (task 1004733).
|
|
534
|
+
if (fault === 'behind') return replayed('write-key', `${hardened} -c ${pinSshCommand(keyPath)}`, `git@github.com:${repo.owner}/${repo.name}.git`);
|
|
520
535
|
log(` • ${inst.slug}: the write key did not push (${fault}) — trying the next credential`);
|
|
521
536
|
}
|
|
522
537
|
|
|
@@ -525,8 +540,11 @@ async function pushUpgradePin(inst, deps, { instanceDir }) {
|
|
|
525
540
|
let r = null;
|
|
526
541
|
try {
|
|
527
542
|
writeFile(credFile, `https://x-access-token:${token}@github.com\n`, { mode: 0o600 });
|
|
528
|
-
|
|
529
|
-
|
|
543
|
+
const viaToken = `${hardened} -c credential.helper= -c credential.helper='store --file=${credFile}'`;
|
|
544
|
+
const tokenRemote = `https://x-access-token@github.com/${repo.owner}/${repo.name}.git`;
|
|
545
|
+
r = exec(`${viaToken} push ${tokenRemote} HEAD:${branch}`, { allowFail: true, env });
|
|
546
|
+
// Behind: replayed while the credential file still exists (the finally removes it).
|
|
547
|
+
if (failedRun(r) && pushFault(r) === 'behind') return replayed('owner-token', viaToken, tokenRemote);
|
|
530
548
|
} catch (e) {
|
|
531
549
|
return miss('failed', `(could not stage the owner's credential: ${String((e && e.message) || e).slice(0, 160)})`);
|
|
532
550
|
} finally {
|
|
@@ -547,6 +565,7 @@ async function pushUpgradePin(inst, deps, { instanceDir }) {
|
|
|
547
565
|
return { pushed: true, via: 'origin', branch };
|
|
548
566
|
}
|
|
549
567
|
const fault = pushFault(r);
|
|
568
|
+
if (fault === 'behind') return replayed('origin', hardened, 'origin');
|
|
550
569
|
// A refusal here is not the owner's access being turned down — there was none to turn
|
|
551
570
|
// down. It is the read-only key, which is the no-access case.
|
|
552
571
|
// ...unless the project has no account of its own, in which case no key CAN be minted
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// provision-pin-replay.js — when GitHub is AHEAD of a hosted project's box, put the core move's
|
|
3
|
+
// pin commit on top of GitHub's history instead of leaving GitHub on the old core (task 1004733,
|
|
4
|
+
// ADR 0350 amendment).
|
|
5
|
+
//
|
|
6
|
+
// WHY. pushUpgradePin pushes the pin commit the move made on the box. For a project the owner
|
|
7
|
+
// works from a laptop, GitHub almost always has commits the box does not, so that push is
|
|
8
|
+
// refused as non-fast-forward ("behind"). Before this file the move then finished `done` with a
|
|
9
|
+
// repo_not_updated note: the box ran the new core, GitHub still named the old one, and the next
|
|
10
|
+
// deploy from GitHub could quietly roll the core back.
|
|
11
|
+
//
|
|
12
|
+
// WHAT IT DOES. Fetch the branch over the SAME credential that was refused, replay ONLY the pin
|
|
13
|
+
// commit (package.json + package-lock.json, and any generated file upgrade.js committed with it)
|
|
14
|
+
// on top of what was fetched, and push that — an ordinary fast-forward, never forced.
|
|
15
|
+
//
|
|
16
|
+
// THE LIVE BOX IS NOT TOUCHED. The replay happens in a scratch worktree (mode 0700, outside the
|
|
17
|
+
// checkout, removed whatever happened). The box's HEAD, files and running site stay exactly what
|
|
18
|
+
// the move's health check proved, which is why the old "nothing is rebased" rule (changing what
|
|
19
|
+
// the box runs with no restart and no health check) still holds.
|
|
20
|
+
//
|
|
21
|
+
// A REAL CONFLICT STOPS. If the owner changed the same lines of those files, choosing between the
|
|
22
|
+
// two is a person's call: the cherry-pick is aborted, nothing is pushed, and the note names the
|
|
23
|
+
// files ("conflict"). Anything else that goes wrong reports, never retries in a loop.
|
|
24
|
+
//
|
|
25
|
+
// OUT OF SCOPE, ON PURPOSE: the box picking up the owner's OTHER commits. Nothing pulls them onto
|
|
26
|
+
// a hosted box after provisioning (standalonePullCmd runs at provision time only, provision.js);
|
|
27
|
+
// merging them into a live checkout here would deploy the owner's code with no install, migrate,
|
|
28
|
+
// restart or health check — a deploy, not a pin push. ADR 0350's amendment records that ruling.
|
|
29
|
+
//
|
|
30
|
+
// NEVER THROWS: the caller turns a throw into a broken instance, and this instance is running.
|
|
31
|
+
|
|
32
|
+
const crypto = require('node:crypto');
|
|
33
|
+
const os = require('node:os');
|
|
34
|
+
|
|
35
|
+
// The subject upgrade.js persistPin gives the pin commit. Anything else at HEAD is not ours to
|
|
36
|
+
// replay onto someone's history.
|
|
37
|
+
const PIN_SUBJECT_RE = /^pin @bongos\/core /;
|
|
38
|
+
const PIN_FILES = ['package.json', 'package-lock.json'];
|
|
39
|
+
const SHA_RE = /^[0-9a-f]{40,64}$/;
|
|
40
|
+
// A scratch path the shell may see unquoted (the tmp dir itself is the runner's, but checked).
|
|
41
|
+
const SAFE_PATH_RE = /^[A-Za-z0-9_./:~-]+$/;
|
|
42
|
+
|
|
43
|
+
const failedRun = (r) => !r || r.softFailed === true || r.ok === false;
|
|
44
|
+
const out = (r) => String((r && r.stdout) || '').trim();
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Replay the box's pin commit onto the branch GitHub has, and push it.
|
|
48
|
+
*
|
|
49
|
+
* @param {object} o
|
|
50
|
+
* exec the CONTROL PLANE executor (the one pushUpgradePin pushed with)
|
|
51
|
+
* instanceDir the project's checkout on the hub
|
|
52
|
+
* branch the branch pushUpgradePin pushed (already BRANCH_RE-checked)
|
|
53
|
+
* git the credentialed, hardened git prefix that was refused as behind
|
|
54
|
+
* remote the remote that same push named
|
|
55
|
+
* env that push's env (GIT_TERMINAL_PROMPT=0)
|
|
56
|
+
* slug the project's (validated) slug, for the scratch path's name
|
|
57
|
+
* tmpDir where the scratch worktree goes (default os.tmpdir())
|
|
58
|
+
* pushFault, gitLine pushUpgradePin's own readers of a git refusal
|
|
59
|
+
* @returns {{ ok:true, replayed?:true, already?:true } | { ok:false, code:string, extra:string }}
|
|
60
|
+
*/
|
|
61
|
+
function replayPinCommit(o) {
|
|
62
|
+
try { return replay(o); } catch (e) {
|
|
63
|
+
return { ok: false, code: 'failed', extra: `(the pin could not be replayed: ${String((e && e.message) || e).slice(0, 160)})` };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function replay({ exec, instanceDir, branch, git, remote, env, slug, tmpDir, pushFault, gitLine, log = () => {} }) {
|
|
68
|
+
const plain = `git -C ${instanceDir} -c core.hooksPath=/dev/null`;
|
|
69
|
+
const run = (cmd, extraEnv) => exec(cmd, { allowFail: true, env: { ...env, ...(extraEnv || {}) } });
|
|
70
|
+
|
|
71
|
+
// 1. HEAD must be the move's own pin commit, with one parent, or there is nothing of ours to replay.
|
|
72
|
+
const head = run(`${plain} log -1 --format=%H%n%P%n%cn%n%ce%n%s HEAD`);
|
|
73
|
+
const [sha, parents, cn, ce, subject] = out(head).split('\n');
|
|
74
|
+
if (failedRun(head) || !SHA_RE.test(sha || '') || String(parents || '').split(' ').length !== 1 || !PIN_SUBJECT_RE.test(subject || '')) {
|
|
75
|
+
return { ok: false, code: 'behind', extra: '(the newest change on the server is not the version change, so it was not added on top)' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 2. What GitHub has now, over the same credential. Only FETCH_HEAD is written for it.
|
|
79
|
+
const fetched = run(`${git} fetch --no-tags ${remote} ${branch}`);
|
|
80
|
+
if (failedRun(fetched)) return { ok: false, code: 'behind', extra: gitLine(fetched) };
|
|
81
|
+
const upR = run(`${plain} rev-parse FETCH_HEAD`);
|
|
82
|
+
const up = out(upR);
|
|
83
|
+
if (failedRun(upR) || !SHA_RE.test(up)) return { ok: false, code: 'behind', extra: '(could not read what GitHub has)' };
|
|
84
|
+
|
|
85
|
+
// Already pinned the same way upstream (the owner bumped it by hand): nothing to add.
|
|
86
|
+
const same = run(`${plain} diff --quiet ${up} ${sha} -- ${PIN_FILES.join(' ')}`);
|
|
87
|
+
if (!failedRun(same)) return { ok: true, already: true };
|
|
88
|
+
|
|
89
|
+
// 3. Replay in a private scratch worktree, so the live checkout never moves.
|
|
90
|
+
const base = String(tmpDir || os.tmpdir()).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
91
|
+
const wt = `${base}/bongos-pin-replay-${slug}-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
|
|
92
|
+
if (!SAFE_PATH_RE.test(wt)) return { ok: false, code: 'failed', extra: '(no safe scratch folder to replay the version change in)' };
|
|
93
|
+
try {
|
|
94
|
+
const made = run(`umask 077 && mkdir ${wt}`);
|
|
95
|
+
const added = failedRun(made) ? made : run(`${plain} worktree add --detach ${wt} ${up}`);
|
|
96
|
+
if (failedRun(added)) return { ok: false, code: 'failed', extra: gitLine(added) };
|
|
97
|
+
const at = `git -C ${wt} -c core.hooksPath=/dev/null`;
|
|
98
|
+
// The pin commit keeps its author and message; its committer stays who committed it on the
|
|
99
|
+
// box (env, not argv — a name is never shell-interpolated).
|
|
100
|
+
const who = cn && ce ? { GIT_COMMITTER_NAME: cn, GIT_COMMITTER_EMAIL: ce } : {};
|
|
101
|
+
const picked = run(`${at} cherry-pick ${sha}`, who);
|
|
102
|
+
if (failedRun(picked)) {
|
|
103
|
+
const both = out(run(`${at} diff --name-only --diff-filter=U`)).split('\n').filter(Boolean);
|
|
104
|
+
run(`${at} cherry-pick --abort`);
|
|
105
|
+
if (both.length) return { ok: false, code: 'conflict', extra: `(changed on both sides: ${both.join(', ').slice(0, 200)})` };
|
|
106
|
+
return { ok: false, code: 'failed', extra: gitLine(picked) };
|
|
107
|
+
}
|
|
108
|
+
const nextR = run(`${at} rev-parse HEAD`);
|
|
109
|
+
const next = out(nextR);
|
|
110
|
+
if (failedRun(nextR) || !SHA_RE.test(next)) return { ok: false, code: 'failed', extra: '(the replayed version change could not be read back)' };
|
|
111
|
+
|
|
112
|
+
// 4. A plain fast-forward of GitHub's branch to the replayed commit. A second refusal
|
|
113
|
+
// (someone pushed in between) is reported, not chased.
|
|
114
|
+
const pushed = run(`${git} push ${remote} ${next}:refs/heads/${branch}`);
|
|
115
|
+
if (failedRun(pushed)) return { ok: false, code: pushFault(pushed), extra: gitLine(pushed) };
|
|
116
|
+
log(` ✓ GitHub had newer commits: the pin was added on top of them (${next.slice(0, 12)})`);
|
|
117
|
+
return { ok: true, replayed: true };
|
|
118
|
+
} finally {
|
|
119
|
+
// Not --force: the abort above leaves it clean, and the rm + prune catch anything left.
|
|
120
|
+
run(`${plain} worktree remove ${wt}`);
|
|
121
|
+
run(`rm -rf ${wt}`);
|
|
122
|
+
run(`${plain} worktree prune`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = { replayPinCommit };
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.21.
|
|
78
|
+
const CORE_VERSION = '1.21.117'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// tests/fitness_installed_core.mjs — fitness.js in a project that INSTALLS the core
|
|
2
|
+
// (task 1004737).
|
|
3
|
+
//
|
|
4
|
+
// There the core sits at <project>/node_modules/@bongos/core: not a git checkout of its
|
|
5
|
+
// own, just an ignored folder inside the project's repo. Every fitness check guards the
|
|
6
|
+
// core's own source and enumerates it with git, so each scan found nothing and either
|
|
7
|
+
// threw ("docs-entropy: scan defect — enumerated 0 tracked markdown files") or reported
|
|
8
|
+
// a scan defect — `bongos exec scripts/gds/fitness.js` crashed on toast, on 1.21.20 and
|
|
9
|
+
// again on 1.21.115. The core repo's CI already ran this gate before publishing, so in an
|
|
10
|
+
// installed core it steps aside, and says so.
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { test } from 'node:test';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
const lib = require(path.join(ROOT, 'scripts', 'gds', 'fitness-lib.js'));
|
|
23
|
+
|
|
24
|
+
const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
25
|
+
|
|
26
|
+
function tempDir(t) {
|
|
27
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fitness-installed-'));
|
|
28
|
+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
29
|
+
return dir;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
test('isInstalledCore: yes only for a core under node_modules that is not its own checkout', (t) => {
|
|
33
|
+
const base = tempDir(t);
|
|
34
|
+
const repo = path.join(base, 'project');
|
|
35
|
+
const nested = path.join(repo, 'node_modules', '@bongos', 'core');
|
|
36
|
+
const vendored = path.join(repo, 'vendor', 'core');
|
|
37
|
+
const looseInstalled = path.join(base, 'bare', 'node_modules', '@bongos', 'core');
|
|
38
|
+
const loose = path.join(base, 'loose');
|
|
39
|
+
for (const d of [nested, vendored, looseInstalled, loose]) fs.mkdirSync(d, { recursive: true });
|
|
40
|
+
git(repo, 'init', '-q');
|
|
41
|
+
assert.equal(lib.isInstalledCore(nested), true, 'an installed package inside a project repo is installed');
|
|
42
|
+
assert.equal(lib.isInstalledCore(looseInstalled), true, 'so is one under node_modules with no repo around it');
|
|
43
|
+
assert.equal(lib.isInstalledCore(ROOT), false, 'this checkout (main or a worktree) is not — the gate keeps running here');
|
|
44
|
+
assert.equal(lib.isInstalledCore(repo), false, 'nor is the root of any checkout');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// The grader's finding on the first cut: deciding "installed" from a git FAILURE let any git
|
|
48
|
+
// trouble in the core's own checkout (git off PATH, no .git, a misresolved root) skip the whole
|
|
49
|
+
// gate — rank guards and the core/host boundary included — and go green. Outside node_modules
|
|
50
|
+
// the answer is now no regardless of git, so those trees run every check and fail closed.
|
|
51
|
+
test('isInstalledCore: a git failure or a nested folder OUTSIDE node_modules never reads as installed (fail closed)', (t) => {
|
|
52
|
+
const base = tempDir(t);
|
|
53
|
+
const repo = path.join(base, 'project');
|
|
54
|
+
const vendored = path.join(repo, 'vendor', 'core');
|
|
55
|
+
const loose = path.join(base, 'loose');
|
|
56
|
+
fs.mkdirSync(vendored, { recursive: true });
|
|
57
|
+
fs.mkdirSync(loose);
|
|
58
|
+
git(repo, 'init', '-q');
|
|
59
|
+
assert.equal(lib.isInstalledCore(loose), false, 'no repo at all (git fails) outside node_modules: run the checks');
|
|
60
|
+
assert.equal(lib.isInstalledCore(vendored), false, 'a folder inside another repo but not under node_modules: run the checks');
|
|
61
|
+
assert.equal(lib.isInstalledCore(path.join(base, 'does-not-exist')), false, 'a misresolved root: run the checks');
|
|
62
|
+
const savedPath = process.env.PATH;
|
|
63
|
+
process.env.PATH = '';
|
|
64
|
+
try {
|
|
65
|
+
assert.equal(lib.isInstalledCore(ROOT), false, 'git missing from PATH in the core checkout: run the checks');
|
|
66
|
+
} finally {
|
|
67
|
+
process.env.PATH = savedPath;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('runChecks: in the core\'s own checkout every check runs, and a throw still crashes (fail closed)', () => {
|
|
72
|
+
const ran = [];
|
|
73
|
+
const out = lib.runChecks([() => { ran.push(1); return { hardFail: false }; }, () => { ran.push(2); return { hardFail: true }; }], { installed: false });
|
|
74
|
+
assert.deepEqual(ran, [1, 2]);
|
|
75
|
+
assert.equal(out.length, 2);
|
|
76
|
+
assert.throws(() => lib.runChecks([() => { throw new Error('scan defect'); }], { installed: false }), /scan defect/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('runChecks: in an installed core no check runs, and one non-failing result says why', () => {
|
|
80
|
+
let ran = 0;
|
|
81
|
+
const out = lib.runChecks([() => { ran++; throw new Error('scan defect'); }, () => { ran++; return { hardFail: true }; }], { installed: true });
|
|
82
|
+
assert.equal(ran, 0, 'no check is run against a tree with no core source in it');
|
|
83
|
+
assert.equal(out.length, 1);
|
|
84
|
+
assert.equal(out[0].hardFail, false);
|
|
85
|
+
assert.match(out[0].name, /skipped/);
|
|
86
|
+
assert.match(out[0].name, /installed package/);
|
|
87
|
+
assert.ok(out[0].warnings.length > 0, 'the skip surfaces as a warning, not a silent pass');
|
|
88
|
+
assert.match(out[0].note, /2 checks/, 'and counts what it did not run');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('end to end: fitness.js run from <project>/node_modules/@bongos/core exits 0 with the skip, instead of crashing', (t) => {
|
|
92
|
+
const project = tempDir(t);
|
|
93
|
+
const core = path.join(project, 'node_modules', '@bongos', 'core');
|
|
94
|
+
// The working tree as the package would ship it (tracked + new files), copied — not
|
|
95
|
+
// linked — so git inside it answers for the PROJECT, exactly as on toast.
|
|
96
|
+
const files = git(ROOT, 'ls-files', '-z', '--cached', '--others', '--exclude-standard').split('\0').filter(Boolean);
|
|
97
|
+
for (const f of files) {
|
|
98
|
+
const src = path.join(ROOT, f);
|
|
99
|
+
let st;
|
|
100
|
+
try { st = fs.lstatSync(src); } catch { continue; } // deleted in the working tree
|
|
101
|
+
if (!st.isFile()) continue;
|
|
102
|
+
fs.mkdirSync(path.dirname(path.join(core, f)), { recursive: true });
|
|
103
|
+
fs.copyFileSync(src, path.join(core, f));
|
|
104
|
+
}
|
|
105
|
+
fs.writeFileSync(path.join(project, '.gitignore'), 'node_modules\n');
|
|
106
|
+
fs.writeFileSync(path.join(project, 'CLAUDE.md'), '# project\n');
|
|
107
|
+
git(project, 'init', '-q');
|
|
108
|
+
git(project, 'add', '-A');
|
|
109
|
+
git(project, '-c', 'user.email=t@example.com', '-c', 'user.name=t', 'commit', '-qm', 'init');
|
|
110
|
+
|
|
111
|
+
let out = '';
|
|
112
|
+
let status = 0;
|
|
113
|
+
try {
|
|
114
|
+
out = execFileSync(process.execPath, [path.join(core, 'scripts', 'gds', 'fitness.js')], {
|
|
115
|
+
cwd: project,
|
|
116
|
+
encoding: 'utf8',
|
|
117
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
118
|
+
env: { ...process.env, NODE_PATH: path.join(ROOT, 'node_modules') },
|
|
119
|
+
});
|
|
120
|
+
} catch (e) {
|
|
121
|
+
status = e.status;
|
|
122
|
+
out = `${e.stdout || ''}${e.stderr || ''}`;
|
|
123
|
+
}
|
|
124
|
+
assert.equal(status, 0, `fitness must not fail an installed core's project:\n${out.slice(0, 2000)}`);
|
|
125
|
+
assert.match(out, /skipped: the core here is an installed package/);
|
|
126
|
+
assert.doesNotMatch(out, /scan defect/);
|
|
127
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// tests/pin_replay.mjs — a core move whose pin push is refused as "behind" replays the pin
|
|
2
|
+
// commit onto GitHub's newer history instead of leaving GitHub on the old core (task 1004733).
|
|
3
|
+
//
|
|
4
|
+
// The normal case for a project worked from a laptop: the owner has pushed commits the box
|
|
5
|
+
// does not have, so the box's pin commit cannot fast-forward GitHub. Before this task the move
|
|
6
|
+
// finished `done` with a `repo_not_updated:behind` note and GitHub kept naming the old core, so
|
|
7
|
+
// the next deploy from GitHub could quietly roll the core back. What these pin, against REAL
|
|
8
|
+
// git repositories (a bare "GitHub", the box's checkout, the owner's laptop clone):
|
|
9
|
+
// * origin ahead → only the pin commit is replayed on top of it and pushed; the owner's
|
|
10
|
+
// commits stay, nothing is forced, and the live box's checkout is not touched
|
|
11
|
+
// * the owner changed the lockfile too → reported in plain words, GitHub left as it was
|
|
12
|
+
// * origin already up to date → the ordinary push, no replay at all
|
|
13
|
+
//
|
|
14
|
+
// Run: node tests/pin_replay.mjs
|
|
15
|
+
|
|
16
|
+
import { strict as assert } from 'node:assert';
|
|
17
|
+
import { spawnSync } from 'node:child_process';
|
|
18
|
+
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { pushUpgradePin } from '../scripts/gds/provision-core-upgrade.js';
|
|
22
|
+
|
|
23
|
+
let passed = 0, failed = 0;
|
|
24
|
+
const t = async (name, fn) => {
|
|
25
|
+
try { await fn(); passed += 1; console.log(` PASS ${name}`); }
|
|
26
|
+
catch (e) { failed += 1; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Forward slashes: the commands run through bash, which would eat a Windows backslash.
|
|
30
|
+
const fwd = (p) => p.replace(/\\/g, '/');
|
|
31
|
+
const ID = '-c user.name=tester -c user.email=tester@example.invalid';
|
|
32
|
+
|
|
33
|
+
// The control plane's executor, for real: a shell command, the allowFail result shape
|
|
34
|
+
// provision-config.js softFailResult returns (output kept, softFailed set).
|
|
35
|
+
function realExec(ran) {
|
|
36
|
+
return (cmd, opts = {}) => {
|
|
37
|
+
ran.push(cmd);
|
|
38
|
+
const r = spawnSync('bash', ['-c', cmd], { encoding: 'utf8', env: { ...process.env, ...(opts.env || {}) } });
|
|
39
|
+
if (r.status === 0) return { ok: true, stdout: r.stdout || '', stderr: r.stderr || '' };
|
|
40
|
+
if (!opts.allowFail) throw new Error(`command failed: ${cmd}\n${r.stderr}`);
|
|
41
|
+
return { ok: true, stdout: r.stdout || '', stderr: r.stderr || '', softFailed: true };
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const sh = (cmd) => {
|
|
45
|
+
const r = spawnSync('bash', ['-c', cmd], { encoding: 'utf8' });
|
|
46
|
+
if (r.status !== 0) throw new Error(`setup failed: ${cmd}\n${r.stderr}`);
|
|
47
|
+
return String(r.stdout || '').trim();
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const pkg = (core, extra = {}) => `${JSON.stringify({ name: 'site', dependencies: { '@bongos/core': core, ...extra } }, null, 2)}\n`;
|
|
51
|
+
const lock = (core, extra = '') => `${JSON.stringify({ name: 'site', lockfileVersion: 3, packages: { 'node_modules/@bongos/core': { version: core } }, note: extra }, null, 2)}\n`;
|
|
52
|
+
|
|
53
|
+
// A bare "GitHub", the box's checkout (holding an unpushed pin commit, as upgrade.js
|
|
54
|
+
// --commit-pin --pin-no-push leaves it), and the owner's laptop clone.
|
|
55
|
+
function world({ ownerChanges = null } = {}) {
|
|
56
|
+
const root = fwd(mkdtempSync(join(tmpdir(), 'pin-replay-')));
|
|
57
|
+
const origin = `${root}/origin.git`, box = `${root}/box`, laptop = `${root}/laptop`, scratch = `${root}/scratch`;
|
|
58
|
+
sh(`git init -q --bare ${origin} && git -C ${origin} symbolic-ref HEAD refs/heads/main`);
|
|
59
|
+
sh(`git init -q ${laptop} && git -C ${laptop} checkout -q -b main`);
|
|
60
|
+
writeFileSync(`${laptop}/package.json`, pkg('1.0.0'));
|
|
61
|
+
writeFileSync(`${laptop}/package-lock.json`, lock('1.0.0'));
|
|
62
|
+
writeFileSync(`${laptop}/index.html`, '<p>v1</p>\n');
|
|
63
|
+
sh(`git -C ${laptop} add -A && git -C ${laptop} ${ID} commit -q -m init && git -C ${laptop} remote add origin ${origin} && git -C ${laptop} push -q origin main`);
|
|
64
|
+
sh(`git clone -q ${origin} ${box}`);
|
|
65
|
+
// The move's pin commit, on the box.
|
|
66
|
+
writeFileSync(`${box}/package.json`, pkg('1.1.0'));
|
|
67
|
+
writeFileSync(`${box}/package-lock.json`, lock('1.1.0'));
|
|
68
|
+
sh(`git -C ${box} add package.json package-lock.json && git -C ${box} ${ID} commit -q -m "pin @bongos/core 1.1.0 (was 1.0.0)"`);
|
|
69
|
+
if (ownerChanges) {
|
|
70
|
+
ownerChanges(laptop);
|
|
71
|
+
sh(`git -C ${laptop} add -A && git -C ${laptop} ${ID} commit -q -m "owner work" && git -C ${laptop} push -q origin main`);
|
|
72
|
+
}
|
|
73
|
+
return { root, origin, box, laptop, scratch, done: () => { try { rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ } } };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const inst = { id: 9, slug: 'acme-site', hosting_shape: 'cloud-host', status: 'active', owner_builder_id: 5, target_ref: 'acme/site' };
|
|
77
|
+
function run(w) {
|
|
78
|
+
const ran = [], logs = [];
|
|
79
|
+
const deps = {
|
|
80
|
+
controlExec: realExec(ran), log: (l) => logs.push(String(l)),
|
|
81
|
+
getGithubToken: async () => null, // no owner token: the box's own remote pushes
|
|
82
|
+
fsImpl: { existsSync: () => false }, // and no write key on this hub
|
|
83
|
+
pinKeyDir: `${w.root}/keys`, tmpDir: w.scratch,
|
|
84
|
+
};
|
|
85
|
+
sh(`mkdir -p ${w.scratch}`);
|
|
86
|
+
return pushUpgradePin(inst, deps, { instanceDir: w.box }).then((out) => ({ out, ran, logs }));
|
|
87
|
+
}
|
|
88
|
+
const at = (dir, ref) => sh(`git -C ${dir} rev-parse ${ref}`);
|
|
89
|
+
const show = (dir, ref, file) => sh(`git -C ${dir} show ${ref}:${file}`);
|
|
90
|
+
|
|
91
|
+
console.log('origin AHEAD of the box: the pin commit is replayed on top and pushed');
|
|
92
|
+
|
|
93
|
+
await t('the owner\'s commits stay, the pin lands on top of them, and nothing is forced', async () => {
|
|
94
|
+
const w = world({ ownerChanges: (d) => writeFileSync(`${d}/index.html`, '<p>v2 from the laptop</p>\n') });
|
|
95
|
+
try {
|
|
96
|
+
const ownerTip = at(w.origin, 'main');
|
|
97
|
+
const boxHead = at(w.box, 'HEAD');
|
|
98
|
+
const { out, ran } = await run(w);
|
|
99
|
+
assert.equal(out.pushed, true, `pushed — got ${JSON.stringify(out)}`);
|
|
100
|
+
assert.equal(out.replayed, true, 'by replaying the pin, not by the first push');
|
|
101
|
+
assert.equal(out.via, 'origin', 'over the same credential that was refused as behind');
|
|
102
|
+
const tip = at(w.origin, 'main');
|
|
103
|
+
assert.equal(at(w.origin, 'main^'), ownerTip, 'the replayed pin sits directly on the owner\'s newest commit');
|
|
104
|
+
assert.match(show(w.origin, 'main', 'package.json'), /"@bongos\/core": "1\.1\.0"/, 'GitHub now names the new core');
|
|
105
|
+
assert.match(show(w.origin, 'main', 'package-lock.json'), /"version": "1\.1\.0"/);
|
|
106
|
+
assert.match(show(w.origin, 'main', 'index.html'), /v2 from the laptop/, 'the owner\'s work is still there');
|
|
107
|
+
assert.equal(sh(`git -C ${w.origin} log -1 --format=%s main`), 'pin @bongos/core 1.1.0 (was 1.0.0)', 'the pin commit\'s own message');
|
|
108
|
+
assert.notEqual(tip, boxHead, 'a new commit, not the box\'s');
|
|
109
|
+
assert.equal(at(w.box, 'HEAD'), boxHead, 'the live box\'s checkout was NOT moved');
|
|
110
|
+
assert.match(readFileSync(`${w.box}/index.html`, 'utf8'), /v1/, 'nor its files changed under the running site');
|
|
111
|
+
assert.equal(sh(`git -C ${w.box} worktree list`).split('\n').length, 1, 'the scratch copy was cleaned up');
|
|
112
|
+
assert.ok(!ran.some((c) => /--force|\s\+\S|reset --hard|rebase/.test(c)), `never forced: ${ran.join('\n')}`);
|
|
113
|
+
} finally { w.done(); }
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await t('a conflicting LOCKFILE is reported in plain words, and GitHub is left exactly as it was', async () => {
|
|
117
|
+
const w = world({ ownerChanges: (d) => writeFileSync(`${d}/package-lock.json`, lock('1.0.1', 'the owner reinstalled')) });
|
|
118
|
+
try {
|
|
119
|
+
const ownerTip = at(w.origin, 'main');
|
|
120
|
+
const boxHead = at(w.box, 'HEAD');
|
|
121
|
+
const { out, ran } = await run(w);
|
|
122
|
+
assert.equal(out.pushed, false);
|
|
123
|
+
assert.equal(out.code, 'conflict');
|
|
124
|
+
assert.match(out.note, /^repo_not_updated:conflict: /, 'the note the web tier reads');
|
|
125
|
+
assert.match(out.note, /package-lock\.json/, 'it names the file');
|
|
126
|
+
assert.equal(at(w.origin, 'main'), ownerTip, 'nothing was pushed over the owner\'s commit');
|
|
127
|
+
assert.equal(at(w.box, 'HEAD'), boxHead, 'the box is untouched');
|
|
128
|
+
assert.equal(sh(`git -C ${w.box} status --porcelain`), '', 'and its tree is clean');
|
|
129
|
+
assert.equal(sh(`git -C ${w.box} worktree list`).split('\n').length, 1, 'the scratch copy was cleaned up');
|
|
130
|
+
assert.ok(!ran.some((c) => /--force|\s\+\S|reset --hard/.test(c)), 'never forced');
|
|
131
|
+
} finally { w.done(); }
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
await t('origin UP TO DATE: the ordinary push, no fetch and no replay', async () => {
|
|
135
|
+
const w = world();
|
|
136
|
+
try {
|
|
137
|
+
const boxHead = at(w.box, 'HEAD');
|
|
138
|
+
const { out, ran } = await run(w);
|
|
139
|
+
assert.equal(out.pushed, true);
|
|
140
|
+
assert.ok(!out.replayed, 'nothing to replay');
|
|
141
|
+
assert.equal(at(w.origin, 'main'), boxHead, 'GitHub fast-forwarded to the box\'s own pin commit');
|
|
142
|
+
assert.ok(!ran.some((c) => / fetch | worktree | cherry-pick /.test(c)), `no replay machinery ran: ${ran.join('\n')}`);
|
|
143
|
+
} finally { w.done(); }
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
147
|
+
if (failed) process.exit(1);
|
package/tests/pin_write_key.mjs
CHANGED
|
@@ -262,6 +262,32 @@ await t('GitHub AHEAD of the box (rejected non-fast-forward) on the write key: r
|
|
|
262
262
|
assert.ok(!call(r, /x-access-token@/) && !call(r, / push origin /));
|
|
263
263
|
});
|
|
264
264
|
|
|
265
|
+
await t('GitHub AHEAD on the write key: the pin is replayed and pushed over THAT key, never forced (task 1004733)', async () => {
|
|
266
|
+
const SHA = 'a'.repeat(40), UP = 'b'.repeat(40), NEXT = 'c'.repeat(40);
|
|
267
|
+
const r = pushRig();
|
|
268
|
+
let keyPushes = 0;
|
|
269
|
+
const inner = r.deps.exec;
|
|
270
|
+
r.deps.exec = (cmd, opts) => {
|
|
271
|
+
if (/core\.sshCommand=.* push /.test(cmd) && keyPushes++ === 0) { r.ran.push({ cmd, env: opts && opts.env }); return refused(' ! [rejected] HEAD -> main (fetch first)'); }
|
|
272
|
+
if (/log -1 --format=/.test(cmd)) { r.ran.push({ cmd }); return { ok: true, stdout: `${SHA}\n${'d'.repeat(40)}\nbox\nbox@x.invalid\npin @bongos/core 1.2.0 (was 1.1.0)\n` }; }
|
|
273
|
+
if (/rev-parse FETCH_HEAD/.test(cmd)) { r.ran.push({ cmd }); return { ok: true, stdout: `${UP}\n` }; }
|
|
274
|
+
if (/diff --quiet /.test(cmd)) { r.ran.push({ cmd }); return refused(''); }
|
|
275
|
+
if (/rev-parse HEAD/.test(cmd)) { r.ran.push({ cmd }); return { ok: true, stdout: `${NEXT}\n` }; }
|
|
276
|
+
return inner(cmd, opts);
|
|
277
|
+
};
|
|
278
|
+
const out = await push(r);
|
|
279
|
+
assert.equal(out.pushed, true);
|
|
280
|
+
assert.equal(out.via, 'write-key');
|
|
281
|
+
assert.equal(out.replayed, true);
|
|
282
|
+
const fetch = call(r, / fetch /);
|
|
283
|
+
assert.ok(fetch && fetch.cmd.includes('core.sshCommand=') && fetch.cmd.includes('git@github.com:acme/site.git main'), `fetched over the key: ${fetch && fetch.cmd}`);
|
|
284
|
+
const again = r.ran.filter((c) => /core\.sshCommand=.* push /.test(c.cmd)).pop();
|
|
285
|
+
assert.ok(again.cmd.includes(`push git@github.com:acme/site.git ${NEXT}:refs/heads/main`), `the replayed commit, over the key: ${again.cmd}`);
|
|
286
|
+
assert.ok(call(r, new RegExp(`cherry-pick ${SHA}`)), 'only the pin commit is replayed');
|
|
287
|
+
assert.ok(!r.ran.some((c) => /--force|\s\+\S|reset --hard|rebase/.test(c.cmd)), 'never forced');
|
|
288
|
+
assert.ok(!call(r, /x-access-token@/) && !call(r, / push origin /), 'no other credential');
|
|
289
|
+
});
|
|
290
|
+
|
|
265
291
|
await t('NO KEY yet but a FRESH TOKEN: the key is minted first (reconnecting is enough), then used', async () => {
|
|
266
292
|
const r = pushRig({ hasKey: false });
|
|
267
293
|
const out = await push(r);
|