@bongos/core 1.19.975 → 1.19.976
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 +38 -18
- package/docs/adr/0176-private-repo-deploy-keys.md +4 -0
- package/docs/module-api-changelog.md +2 -0
- package/modules/provisioning/migrations/provisioning_025_repo_private.sql +34 -0
- package/modules/provisioning/provisioning.js +3 -3
- package/modules/provisioning/repo-private.js +29 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/provision-repo-private.js +86 -0
- package/scripts/gds/provision.js +1 -1
- package/src/bongos/github-repos.js +22 -9
- package/src/module-api.js +1 -1
- package/tests/github_repos_helpers.mjs +55 -4
- package/tests/provision_repo_private.mjs +260 -0
- package/tests/provisioning_settings_apply.mjs +1 -1
- package/tests/provisioning_teardown_intent.mjs +2 -2
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.976",
|
|
6
|
+
"core_contract": "1.19.976",
|
|
7
|
+
"source_commit": "842af7dc231eb9ad6803f0de071d554622562402",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-23T15:01:32.683Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 516,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2382,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2924,
|
|
20
|
+
"tree_sha256": "6ad34cb8327cf6329010817bd052759a99e906fb785e2f370bb15ee113343cc1",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -1362,7 +1362,7 @@
|
|
|
1362
1362
|
{
|
|
1363
1363
|
"path": "docs/adr/0176-private-repo-deploy-keys.md",
|
|
1364
1364
|
"mode": "0000644",
|
|
1365
|
-
"sha256": "
|
|
1365
|
+
"sha256": "c10a9b3039835676e84c1cedac77b3897fc03f5db689ecc34b8b658c51210d5f"
|
|
1366
1366
|
},
|
|
1367
1367
|
{
|
|
1368
1368
|
"path": "docs/adr/0177-goal-membership-kind.md",
|
|
@@ -2702,7 +2702,7 @@
|
|
|
2702
2702
|
{
|
|
2703
2703
|
"path": "docs/module-api-changelog.md",
|
|
2704
2704
|
"mode": "0000644",
|
|
2705
|
-
"sha256": "
|
|
2705
|
+
"sha256": "9b3e01085f3f3648c51df5ecc0e6d6b4e6c687e90aceaa4e3c80689500767c88"
|
|
2706
2706
|
},
|
|
2707
2707
|
{
|
|
2708
2708
|
"path": "docs/modules-contract.md",
|
|
@@ -6719,6 +6719,11 @@
|
|
|
6719
6719
|
"mode": "0000644",
|
|
6720
6720
|
"sha256": "1287440b0088a6451dd4df234e578e06fc6f3e892915e64cd6c6e1f09dadd9d5"
|
|
6721
6721
|
},
|
|
6722
|
+
{
|
|
6723
|
+
"path": "modules/provisioning/migrations/provisioning_025_repo_private.sql",
|
|
6724
|
+
"mode": "0000644",
|
|
6725
|
+
"sha256": "949130ba321fc382c30fd7bfb76572e8415492b8641f644719cef3658a415cb9"
|
|
6726
|
+
},
|
|
6722
6727
|
{
|
|
6723
6728
|
"path": "modules/provisioning/module.json",
|
|
6724
6729
|
"mode": "0000644",
|
|
@@ -6757,7 +6762,7 @@
|
|
|
6757
6762
|
{
|
|
6758
6763
|
"path": "modules/provisioning/provisioning.js",
|
|
6759
6764
|
"mode": "0000644",
|
|
6760
|
-
"sha256": "
|
|
6765
|
+
"sha256": "a6c7b6cdaca50106bd59b86e0272a2e24fe269a43e4a7f6e3a63504e73fa74d5"
|
|
6761
6766
|
},
|
|
6762
6767
|
{
|
|
6763
6768
|
"path": "modules/provisioning/rate-limit.js",
|
|
@@ -6769,6 +6774,11 @@
|
|
|
6769
6774
|
"mode": "0000644",
|
|
6770
6775
|
"sha256": "4c933aad1307378496d47960e24c9d797a1f122f28aefa27b769f336e4cf2e75"
|
|
6771
6776
|
},
|
|
6777
|
+
{
|
|
6778
|
+
"path": "modules/provisioning/repo-private.js",
|
|
6779
|
+
"mode": "0000644",
|
|
6780
|
+
"sha256": "0adee6537b5f127ea3fa5589bc92acc5b6accf6f100f395fe802640ff74b018f"
|
|
6781
|
+
},
|
|
6772
6782
|
{
|
|
6773
6783
|
"path": "modules/provisioning/routes/body-validators.js",
|
|
6774
6784
|
"mode": "0000644",
|
|
@@ -8122,12 +8132,12 @@
|
|
|
8122
8132
|
{
|
|
8123
8133
|
"path": "package-lock.json",
|
|
8124
8134
|
"mode": "0000644",
|
|
8125
|
-
"sha256": "
|
|
8135
|
+
"sha256": "de02838b05a35599823a045bae0ed5e317ee334951d9dee697785c790bd90e66"
|
|
8126
8136
|
},
|
|
8127
8137
|
{
|
|
8128
8138
|
"path": "package.json",
|
|
8129
8139
|
"mode": "0000644",
|
|
8130
|
-
"sha256": "
|
|
8140
|
+
"sha256": "a93b66e4c048714c36e8c237004cc15061ea8776795535ddd7b179fe1b1b1bfc"
|
|
8131
8141
|
},
|
|
8132
8142
|
{
|
|
8133
8143
|
"path": "public-docs/index.html",
|
|
@@ -9104,6 +9114,11 @@
|
|
|
9104
9114
|
"mode": "0000644",
|
|
9105
9115
|
"sha256": "a113cdae4d6dcb3f8d968d41c2f1ce67b34d77229fe0a19d6bc5023f7ed469b5"
|
|
9106
9116
|
},
|
|
9117
|
+
{
|
|
9118
|
+
"path": "scripts/gds/provision-repo-private.js",
|
|
9119
|
+
"mode": "0000644",
|
|
9120
|
+
"sha256": "f2c31eaf1808ee19aaf7266a82509a05ae39d7f638ffaf3aeffcc33e304262aa"
|
|
9121
|
+
},
|
|
9107
9122
|
{
|
|
9108
9123
|
"path": "scripts/gds/provision-repo.js",
|
|
9109
9124
|
"mode": "0000644",
|
|
@@ -9122,7 +9137,7 @@
|
|
|
9122
9137
|
{
|
|
9123
9138
|
"path": "scripts/gds/provision.js",
|
|
9124
9139
|
"mode": "0000644",
|
|
9125
|
-
"sha256": "
|
|
9140
|
+
"sha256": "7747b46f241bddb4257a56461703f82603dd58e18d22bd2c547fd7137749d0e6"
|
|
9126
9141
|
},
|
|
9127
9142
|
{
|
|
9128
9143
|
"path": "scripts/gds/publish-credential-check.js",
|
|
@@ -9892,7 +9907,7 @@
|
|
|
9892
9907
|
{
|
|
9893
9908
|
"path": "src/bongos/github-repos.js",
|
|
9894
9909
|
"mode": "0000644",
|
|
9895
|
-
"sha256": "
|
|
9910
|
+
"sha256": "3e0087226edf1d877e56cf647b2af805430ececabf9b51ecf56df280f959b418"
|
|
9896
9911
|
},
|
|
9897
9912
|
{
|
|
9898
9913
|
"path": "src/bongos/goal-scope-check.js",
|
|
@@ -10147,7 +10162,7 @@
|
|
|
10147
10162
|
{
|
|
10148
10163
|
"path": "src/module-api.js",
|
|
10149
10164
|
"mode": "0000644",
|
|
10150
|
-
"sha256": "
|
|
10165
|
+
"sha256": "73c1013f0db275ad8c71b17973158d0ac150c99e9006a3d0aafac94758fa6f13"
|
|
10151
10166
|
},
|
|
10152
10167
|
{
|
|
10153
10168
|
"path": "src/module-loader/catalog.js",
|
|
@@ -11532,7 +11547,7 @@
|
|
|
11532
11547
|
{
|
|
11533
11548
|
"path": "tests/github_repos_helpers.mjs",
|
|
11534
11549
|
"mode": "0000644",
|
|
11535
|
-
"sha256": "
|
|
11550
|
+
"sha256": "66fd3d349445aa00e55fa77589958d6c98980e99f3cdaf4746ff55494202333a"
|
|
11536
11551
|
},
|
|
11537
11552
|
{
|
|
11538
11553
|
"path": "tests/github_token_store.mjs",
|
|
@@ -13274,6 +13289,11 @@
|
|
|
13274
13289
|
"mode": "0000644",
|
|
13275
13290
|
"sha256": "755e93592b581593c463af59bad68b7e5b753c8305b80ff759501d878a31b209"
|
|
13276
13291
|
},
|
|
13292
|
+
{
|
|
13293
|
+
"path": "tests/provision_repo_private.mjs",
|
|
13294
|
+
"mode": "0000644",
|
|
13295
|
+
"sha256": "9140b670c8ab76069c76238cf47bfdd539141198a40ef23f443813543947c4aa"
|
|
13296
|
+
},
|
|
13277
13297
|
{
|
|
13278
13298
|
"path": "tests/provision_restart.mjs",
|
|
13279
13299
|
"mode": "0000644",
|
|
@@ -13382,7 +13402,7 @@
|
|
|
13382
13402
|
{
|
|
13383
13403
|
"path": "tests/provisioning_settings_apply.mjs",
|
|
13384
13404
|
"mode": "0000644",
|
|
13385
|
-
"sha256": "
|
|
13405
|
+
"sha256": "ab01149d73158276a73464bc0feca3474ee2f054db03af378c9b43874a2056fb"
|
|
13386
13406
|
},
|
|
13387
13407
|
{
|
|
13388
13408
|
"path": "tests/provisioning_settings_env.mjs",
|
|
@@ -13402,7 +13422,7 @@
|
|
|
13402
13422
|
{
|
|
13403
13423
|
"path": "tests/provisioning_teardown_intent.mjs",
|
|
13404
13424
|
"mode": "0000644",
|
|
13405
|
-
"sha256": "
|
|
13425
|
+
"sha256": "b23049faa369d75b867a9c31237fe073424e694588627217a1105a5522c16fc6"
|
|
13406
13426
|
},
|
|
13407
13427
|
{
|
|
13408
13428
|
"path": "tests/provisioning_teardown_states.mjs",
|
|
@@ -51,6 +51,10 @@ best-effort revokes the key by title. Pushes keep preferring the owner-token HTT
|
|
|
51
51
|
- The flip-to-private refusal on the manage page (task 1003045) stays until existing
|
|
52
52
|
already-provisioned instances get the same wiring on flip — a follow-up task, since their
|
|
53
53
|
origin still rides the shared alias.
|
|
54
|
+
**Progress (2026-09-23):** the runner half has landed — task 1004167 let the wiring run
|
|
55
|
+
before a flip (`intendedPrivate`), and task 1004193 added the `repo-private` intent whose
|
|
56
|
+
runner leg wires the key and only then flips. The refusal stays until task 1003078 adds the
|
|
57
|
+
route and the manage-page control that enqueue it.
|
|
54
58
|
- Builder **dev boxes** of a private-repo project were parked here as out of scope.
|
|
55
59
|
**Answered on hardware 2026-08-25 (task 1003247): a box CAN read the private repo, and the
|
|
56
60
|
auth design needed no change.** The GDS-backed credential helper (`otb-git-credential`,
|
|
@@ -2424,5 +2424,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2424
2424
|
landed since 1.19.973 with no explicit bump. run 35816317024. (task 1002620)
|
|
2425
2425
|
1.19.975 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2426
2426
|
landed since 1.19.974 with no explicit bump. run 35871083707. (task 1002620)
|
|
2427
|
+
1.19.976 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2428
|
+
landed since 1.19.975 with no explicit bump. run 35878415178. (task 1002620)
|
|
2427
2429
|
---------------------------------------------------------------------------
|
|
2428
2430
|
```
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
-- provisioning_025_repo_private.sql — take an existing project's repository private
|
|
2
|
+
-- (task 1004193, split (b) of task 1003078; ADR 0176).
|
|
3
|
+
--
|
|
4
|
+
-- WHY THIS EXISTS. A project stood up while its repository was public deploys through the
|
|
5
|
+
-- platform's shared key, which cannot read a private repository. Flipping such a repository
|
|
6
|
+
-- private from GitHub breaks the project's next deploy pull (walk finding N4), so the manage
|
|
7
|
+
-- page has refused the flip since task 1003045. Standups after ADR 0176 give a private
|
|
8
|
+
-- repository its own read-only deploy key; this action gives an EXISTING project the same
|
|
9
|
+
-- wiring and only then flips it.
|
|
10
|
+
--
|
|
11
|
+
-- WHY AN INTENT. The wiring runs ssh-keygen and git config inside the project's checkout on
|
|
12
|
+
-- the control-plane box, with the owner's stored GitHub credential. The web tier holds
|
|
13
|
+
-- neither, so it may only ask; the runner (scripts/gds/provision-repo-private.js) does it —
|
|
14
|
+
-- deploy key FIRST, flip SECOND, so there is never a moment the repository is private and
|
|
15
|
+
-- the checkout still reads with the shared key.
|
|
16
|
+
--
|
|
17
|
+
-- WHY NO PAYLOAD. The action name IS the direction: it only ever means "make it private".
|
|
18
|
+
-- Going public needs no wiring and stays a direct call on the core route. So nothing new
|
|
19
|
+
-- is carried, and the target_version CHECK from provisioning_020 already holds for this
|
|
20
|
+
-- action as written (repo-private is not an upgrade action, and carries no target).
|
|
21
|
+
--
|
|
22
|
+
-- Additive and namespaced (ADR 0083): no down-migration, idempotent — safe to re-run.
|
|
23
|
+
|
|
24
|
+
BEGIN;
|
|
25
|
+
|
|
26
|
+
-- INTENT_ACTIONS in modules/provisioning/provisioning.js is the other spelling of this list;
|
|
27
|
+
-- tests/provisioning_restart.mjs and tests/core_upgrade_door.mjs fail if the two disagree.
|
|
28
|
+
ALTER TABLE provisioning_intents DROP CONSTRAINT IF EXISTS provisioning_intents_action_check;
|
|
29
|
+
ALTER TABLE provisioning_intents ADD CONSTRAINT provisioning_intents_action_check
|
|
30
|
+
CHECK (action IN ('provision', 'teardown', 'oauth-manifest', 'domain-attach',
|
|
31
|
+
'settings-apply', 'restart', 'disconnect', 'core-upgrade',
|
|
32
|
+
'core-upgrade-preflight', 'repo-private'));
|
|
33
|
+
|
|
34
|
+
COMMIT;
|
|
@@ -58,7 +58,7 @@ const PROJECT_TYPES = ['game', 'research', 'business', 'non-profit', 'not-sure']
|
|
|
58
58
|
// 'disconnect' (task 1003249, migration provisioning_016): the owner's true delete — ./disconnect.js.
|
|
59
59
|
// 'core-upgrade' (task 1003159, ADR 0293): move this instance's core pin — the owner's
|
|
60
60
|
// deploy door. The web tier only QUEUES it; the control-plane runner performs it.
|
|
61
|
-
const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart', 'disconnect', 'core-upgrade', 'core-upgrade-preflight'];
|
|
61
|
+
const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart', 'disconnect', 'core-upgrade', 'core-upgrade-preflight', 'repo-private'];
|
|
62
62
|
// Every state a teardown can be asked for — i.e. everything except the one
|
|
63
63
|
// state where there is provably nothing left to release. DERIVED from
|
|
64
64
|
// INSTANCE_STATES on purpose (task 1003042, walk finding N5): the old hand-kept
|
|
@@ -1158,7 +1158,7 @@ async function enqueueIntent(db, instanceId, action, requestedBy, targetVersion
|
|
|
1158
1158
|
// (the state CHECK has no 'superseded'; last_error says why) so the runner never
|
|
1159
1159
|
// stands it up. A PENDING settings push is superseded the same way (task 1003140):
|
|
1160
1160
|
// a push into a project about to be torn down is dead work, and letting it hold the
|
|
1161
|
-
// delete hostage would repeat 1002691. A pending 'restart' joins them for the same
|
|
1161
|
+
// delete hostage would repeat 1002691. A pending 'restart' or 'repo-private' (task 1004193) joins them for the same
|
|
1162
1162
|
// reason (task 1003238) — and more sharply, since the owner reaches for Restart
|
|
1163
1163
|
// precisely when a project looks broken, which is also when they give up and delete
|
|
1164
1164
|
// it. A RUNNING intent is mid-flight and never yanked —
|
|
@@ -1171,7 +1171,7 @@ async function enqueueTeardown(db, instanceId, requestedBy) {
|
|
|
1171
1171
|
`UPDATE provisioning_intents
|
|
1172
1172
|
SET state = 'error', last_error = 'superseded: teardown requested',
|
|
1173
1173
|
updated_at = now(), resolved_at = now()
|
|
1174
|
-
WHERE instance_id = $1 AND action IN ('provision', 'settings-apply', 'restart') AND state = 'pending'
|
|
1174
|
+
WHERE instance_id = $1 AND action IN ('provision', 'settings-apply', 'restart', 'repo-private') AND state = 'pending'
|
|
1175
1175
|
RETURNING id`,
|
|
1176
1176
|
[instanceId]
|
|
1177
1177
|
);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// modules/provisioning/repo-private.js — the queue half of "make this project's repository
|
|
2
|
+
// private" (task 1004193, split (b) of task 1003078; ADR 0176).
|
|
3
|
+
//
|
|
4
|
+
// A project stood up with a public repository reads its code with the platform's shared
|
|
5
|
+
// key, which cannot read a private one — so the flip must wait until the project has its
|
|
6
|
+
// own read-only deploy key. That wiring needs the box and the owner's stored credential, so
|
|
7
|
+
// the web tier only enqueues; scripts/gds/provision-repo-private.js is the runner leg that
|
|
8
|
+
// wires the key and THEN flips.
|
|
9
|
+
//
|
|
10
|
+
// Its own file because provisioning.js sits at the size ratchet. It reaches the queue
|
|
11
|
+
// through the provisioning module OBJECT, so a test can stub enqueueIntent.
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const provisioning = require('./provisioning');
|
|
15
|
+
|
|
16
|
+
// Queue a flip-to-private. Arbitration is the restart shape: an open repo-private in
|
|
17
|
+
// either state makes a repeat idempotent (the flip has no payload, so there is nothing a
|
|
18
|
+
// second request could change); any OTHER open intent refuses it with
|
|
19
|
+
// { conflict: 'open_intent' } rather than racing a standup, teardown or settings push over
|
|
20
|
+
// the same checkout. The partial unique index ux_provisioning_intents_open stays the
|
|
21
|
+
// arbiter if two requests interleave.
|
|
22
|
+
async function enqueueRepoPrivate(db, instanceId, requestedBy) {
|
|
23
|
+
const { intent, created } = await provisioning.enqueueIntent(db, instanceId, 'repo-private', requestedBy);
|
|
24
|
+
if (created) return { intent, created: true };
|
|
25
|
+
if (intent.action === 'repo-private') return { intent, created: false };
|
|
26
|
+
return { intent, created: false, conflict: 'open_intent' };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { enqueueRepoPrivate };
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.976",
|
|
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.976",
|
|
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.976",
|
|
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,86 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// provision-repo-private.js — "make this project's repository private": the runner leg
|
|
3
|
+
// (task 1004193, split (b) of task 1003078; ADR 0176).
|
|
4
|
+
//
|
|
5
|
+
// WHY. A project stood up while its repository was public reads its code with the
|
|
6
|
+
// platform's shared deploy key, which cannot read a private repository. Flip such a
|
|
7
|
+
// repository private and the project's next deploy pull fails (walk finding N4). Standups
|
|
8
|
+
// since ADR 0176 give a private repository the project's own read-only deploy key; this
|
|
9
|
+
// leg gives an EXISTING project the same wiring, and only then flips.
|
|
10
|
+
//
|
|
11
|
+
// ORDER IS THE SAFETY PROPERTY. Wire the key FIRST, flip SECOND. A deploy key authenticates
|
|
12
|
+
// against a still-public repository, so wiring first leaves no window in which the repo is
|
|
13
|
+
// private and the checkout still reads with the shared key. ensurePrivateRepoAccess would
|
|
14
|
+
// see the repo as public and do nothing, so it is told where the repo is going
|
|
15
|
+
// (`intendedPrivate`, task 1004167). If the flip then fails, the repository is public with
|
|
16
|
+
// a working key — harmless, and the retry re-runs both steps idempotently.
|
|
17
|
+
//
|
|
18
|
+
// Every expected failure returns { ok:false } rather than throwing: a throw makes the
|
|
19
|
+
// runner mark the whole project 'error', and a project that could not be made private is
|
|
20
|
+
// still running fine. The error text is the owner's sentence — it rides error_note onto
|
|
21
|
+
// the project card once the retries are spent.
|
|
22
|
+
//
|
|
23
|
+
// Its own file because provision.js sits at the size ratchet (the provision-disconnect.js
|
|
24
|
+
// precedent). The web tier only enqueues (modules/provisioning/repo-private.js).
|
|
25
|
+
|
|
26
|
+
const path = require('node:path');
|
|
27
|
+
const { REPO_ROOT } = require('./provision-config.js');
|
|
28
|
+
const { parseTargetRef, resolveOwnerGithubToken } = require('./provision-repo-auth.js');
|
|
29
|
+
const { ensurePrivateRepoAccess } = require('./provision-repo.js');
|
|
30
|
+
|
|
31
|
+
async function repoPrivateInstance(inst, deps) {
|
|
32
|
+
const { db, provisioning, log, apply } = deps;
|
|
33
|
+
log(`repo-private ${inst.slug} (shape=${inst.hosting_shape}, status=${inst.status})`);
|
|
34
|
+
|
|
35
|
+
// Only a standalone project has its own checkout to wire; any other shape reads from a
|
|
36
|
+
// checkout this project does not own, so a key minted here would change nothing.
|
|
37
|
+
if (inst.hosting_shape !== 'standalone') {
|
|
38
|
+
return { ok: false, error: 'This project shares its code checkout with others, so its repository cannot be made private from here.' };
|
|
39
|
+
}
|
|
40
|
+
if (inst.status !== 'active') {
|
|
41
|
+
return { ok: false, error: `This project is ${inst.status}, not running, so its repository was not changed. Try again once it is live.` };
|
|
42
|
+
}
|
|
43
|
+
const repo = parseTargetRef(inst.target_ref);
|
|
44
|
+
if (!repo) return { ok: false, error: 'This project has no GitHub repository on record, so there is nothing to make private.' };
|
|
45
|
+
|
|
46
|
+
let wired;
|
|
47
|
+
try {
|
|
48
|
+
wired = await ensurePrivateRepoAccess(inst, { ...deps, intendedPrivate: true });
|
|
49
|
+
} catch (e) {
|
|
50
|
+
return { ok: false, error: (e && e.message) || String(e) };
|
|
51
|
+
}
|
|
52
|
+
if (!apply) {
|
|
53
|
+
log(` [repo-private] would then make ${repo.owner}/${repo.name} private with the owner's credential — only after the deploy key above is wired (dry-run — nothing ran)`);
|
|
54
|
+
return { ok: true, planned: true };
|
|
55
|
+
}
|
|
56
|
+
if (!wired || wired.private !== true) {
|
|
57
|
+
// Defensive: with intendedPrivate set, a standalone row with a repo either wires or
|
|
58
|
+
// throws. Anything else means the checkout is unwired, and flipping would break it.
|
|
59
|
+
return { ok: false, error: `The deploy key for ${repo.owner}/${repo.name} could not be set up, so the repository was left public.` };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const token = await resolveOwnerGithubToken(inst, deps);
|
|
63
|
+
if (!token) {
|
|
64
|
+
return { ok: false, error: `${repo.owner}/${repo.name} was not made private: the project's GitHub credential could not be read. Its deploy key is already in place, so reconnect GitHub and try again.` };
|
|
65
|
+
}
|
|
66
|
+
const ghRepos = deps.githubRepos || require(path.join(REPO_ROOT, 'src/bongos/github-repos'));
|
|
67
|
+
try {
|
|
68
|
+
log(` [repo-private] making ${repo.owner}/${repo.name} private`);
|
|
69
|
+
await ghRepos.makeRepoPrivate(token, { owner: repo.owner, name: repo.name, fetchImpl: deps.fetchImpl });
|
|
70
|
+
} catch (e) {
|
|
71
|
+
const why = e && e.code === 'not_repo_admin' ? 'GitHub refused — the connected account cannot change this repository’s settings'
|
|
72
|
+
: e && e.code === 'repo_not_found' ? 'GitHub could not find the repository with the connected account'
|
|
73
|
+
: `GitHub could not make the change (${(e && e.message) || e})`;
|
|
74
|
+
return { ok: false, error: `${repo.owner}/${repo.name} is still public: ${why}. Its deploy key is already in place, so trying again is safe.` };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
await provisioning.recordEvent(db, {
|
|
78
|
+
instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'repo-private',
|
|
79
|
+
detail: `${repo.owner}/${repo.name} made private — the project now reads it with its own read-only deploy key${wired.minted ? ' (newly registered)' : ''}`,
|
|
80
|
+
actor: 'runner',
|
|
81
|
+
}).catch(() => {});
|
|
82
|
+
log(` ✓ ${repo.owner}/${repo.name} is private; ${inst.slug} reads it with its own deploy key`);
|
|
83
|
+
return { ok: true, minted: !!wired.minted };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { repoPrivateInstance };
|
package/scripts/gds/provision.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
|
-
//
|
|
4
3
|
// scripts/gds/provision.js — the INSTANCE-provisioning control-plane runner
|
|
5
4
|
// (ADR 0111 §2/§3, BONGOS-V1 goal 32, task #1944). A sibling of box.js.
|
|
6
5
|
//
|
|
@@ -1294,6 +1293,7 @@ async function cmdRunIntents(deps) {
|
|
|
1294
1293
|
else if (intent.action === 'disconnect') result = await disconnectInstance(inst, deps);
|
|
1295
1294
|
else if (intent.action === 'core-upgrade') result = await coreUpgradeInstance(inst, deps, intent); // also reads the INTENT: the version the owner previewed (ADR 0293 D4)
|
|
1296
1295
|
else if (intent.action === 'core-upgrade-preflight') result = await preflightCoreUpgrade(inst, deps, intent); // changes nothing; caches what the move WOULD say (task 1004121)
|
|
1296
|
+
else if (intent.action === 'repo-private') result = await require('./provision-repo-private.js').repoPrivateInstance(inst, deps); // wire the deploy key, THEN flip (task 1004193); required here — this file is at the size ratchet
|
|
1297
1297
|
else result = { ok: false, error: `unknown_action '${intent.action}' — runner core ${coreVersionSafe() || '?'} does not know it (runner too old?); refusing to guess` };
|
|
1298
1298
|
if (result && result.ok === false) {
|
|
1299
1299
|
if (intent.attempts >= MAX_INTENT_ATTEMPTS) {
|
|
@@ -129,17 +129,14 @@ async function getUserRepo(token, { owner, name, fetchImpl = fetch } = {}) {
|
|
|
129
129
|
};
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
// route refuses the request; this helper makes the refusal structural
|
|
137
|
-
// (task 1003045). Returns the same shape as getUserRepo.
|
|
138
|
-
async function makeRepoPublic(token, { owner, name, fetchImpl = fetch } = {}) {
|
|
132
|
+
// The visibility PATCH both directions share. Deliberately NOT exported: callers get
|
|
133
|
+
// one named function per direction, so the name stays the contract and no caller can
|
|
134
|
+
// pass a direction it did not mean (the createUserRepo private:false precedent).
|
|
135
|
+
async function setRepoPrivate(token, { owner, name, fetchImpl = fetch }, isPrivate) {
|
|
139
136
|
const res = await fetchImpl(`${GH_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`, {
|
|
140
137
|
method: 'PATCH',
|
|
141
138
|
headers: ghHeaders(token, { 'Content-Type': 'application/json' }),
|
|
142
|
-
body: JSON.stringify({ private:
|
|
139
|
+
body: JSON.stringify({ private: isPrivate }),
|
|
143
140
|
});
|
|
144
141
|
if (res.status === 401) {
|
|
145
142
|
throw new GithubApiError('GitHub rejected the stored token', { status: 401, code: 'github_unauthorized' });
|
|
@@ -162,6 +159,22 @@ async function makeRepoPublic(token, { owner, name, fetchImpl = fetch } = {}) {
|
|
|
162
159
|
};
|
|
163
160
|
}
|
|
164
161
|
|
|
162
|
+
// Flip one repo PUBLIC (task 1003045). Needs no deploy-key wiring — the shared key
|
|
163
|
+
// reads a public repo — so the core route may call it directly. Returns the same
|
|
164
|
+
// shape as getUserRepo.
|
|
165
|
+
async function makeRepoPublic(token, ref = {}) {
|
|
166
|
+
return setRepoPrivate(token, ref, false);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Flip one repo PRIVATE. Only the control-plane runner may call this, and only AFTER
|
|
170
|
+
// ensurePrivateRepoAccess has wired the project's own read-only deploy key: the
|
|
171
|
+
// shared key cannot read a private repo, so flipping first breaks the next deploy
|
|
172
|
+
// pull (walk finding N4, ADR 0176). scripts/gds/provision-repo-private.js is that
|
|
173
|
+
// caller (task 1004193); no web route calls it. Returns the same shape as getUserRepo.
|
|
174
|
+
async function makeRepoPrivate(token, ref = {}) {
|
|
175
|
+
return setRepoPrivate(token, ref, true);
|
|
176
|
+
}
|
|
177
|
+
|
|
165
178
|
// ---- deploy keys (task 1002735 / ADR 0176) ----
|
|
166
179
|
// The per-repo credential a PRIVATE repo's recurring deploy pull authenticates
|
|
167
180
|
// with: provisioning mints an ed25519 keypair, registers its public half here with
|
|
@@ -237,7 +250,7 @@ async function deleteDeployKey(token, { owner, name, keyId, fetchImpl = fetch }
|
|
|
237
250
|
}
|
|
238
251
|
|
|
239
252
|
module.exports = {
|
|
240
|
-
listUserRepos, createUserRepo, getUserRepo, makeRepoPublic,
|
|
253
|
+
listUserRepos, createUserRepo, getUserRepo, makeRepoPublic, makeRepoPrivate,
|
|
241
254
|
listDeployKeys, addDeployKey, deleteDeployKey,
|
|
242
255
|
GithubApiError, MAX_PAGES,
|
|
243
256
|
};
|
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.976'; // 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');
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { strict as assert } from 'node:assert';
|
|
9
9
|
import { test } from 'node:test';
|
|
10
10
|
import { createRequire } from 'node:module';
|
|
11
|
+
import fs from 'node:fs';
|
|
11
12
|
import path from 'node:path';
|
|
12
13
|
import { fileURLToPath } from 'node:url';
|
|
13
14
|
|
|
@@ -139,10 +140,9 @@ test('getUserRepo: 404 → typed repo_not_found (GitHub\'s own no-leak shape, fo
|
|
|
139
140
|
);
|
|
140
141
|
});
|
|
141
142
|
|
|
142
|
-
test('makeRepoPublic: PATCHes exactly {private:false} — the
|
|
143
|
-
// Like createUserRepo's private:false, the direction is
|
|
144
|
-
//
|
|
145
|
-
// private repo (N4), so nothing in the codebase may flip a repo private.
|
|
143
|
+
test('makeRepoPublic: PATCHes exactly {private:false} — the name is the direction', async () => {
|
|
144
|
+
// Like createUserRepo's private:false, the direction is not a parameter: each
|
|
145
|
+
// direction is its own named function, so no caller can send one it did not mean.
|
|
146
146
|
let sentMethod = '';
|
|
147
147
|
let sentBody = null;
|
|
148
148
|
const fetchImpl = async (url, init) => {
|
|
@@ -164,6 +164,57 @@ test('makeRepoPublic: 403 → typed not_repo_admin', async () => {
|
|
|
164
164
|
);
|
|
165
165
|
});
|
|
166
166
|
|
|
167
|
+
// ---- the private direction (task 1004193, split (b) of task 1003078) ----
|
|
168
|
+
|
|
169
|
+
test('makeRepoPrivate: PATCHes exactly {private:true} to the same endpoint', async () => {
|
|
170
|
+
// Arrange
|
|
171
|
+
let sentUrl = '';
|
|
172
|
+
let sentMethod = '';
|
|
173
|
+
let sentBody = null;
|
|
174
|
+
const fetchImpl = async (url, init) => {
|
|
175
|
+
sentUrl = url; sentMethod = init.method; sentBody = JSON.parse(init.body);
|
|
176
|
+
return res(200, { full_name: 'me/mercury', html_url: 'https://github.com/me/mercury', private: true, permissions: { admin: true } });
|
|
177
|
+
};
|
|
178
|
+
// Act
|
|
179
|
+
const out = await gh.makeRepoPrivate('tok', { owner: 'me', name: 'mercury', fetchImpl });
|
|
180
|
+
// Assert
|
|
181
|
+
assert.equal(sentMethod, 'PATCH');
|
|
182
|
+
assert.match(sentUrl, /\/repos\/me\/mercury$/);
|
|
183
|
+
assert.deepEqual(sentBody, { private: true });
|
|
184
|
+
assert.deepEqual(out, { full_name: 'me/mercury', html_url: 'https://github.com/me/mercury', private: true, admin: true });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('makeRepoPrivate: 403 → typed not_repo_admin, the same mapping as the public direction', async () => {
|
|
188
|
+
const fetchImpl = async () => res(403, { message: 'Must have admin rights' });
|
|
189
|
+
await assert.rejects(
|
|
190
|
+
() => gh.makeRepoPrivate('tok', { owner: 'other', name: 'repo', fetchImpl }),
|
|
191
|
+
(e) => e.code === 'not_repo_admin' && e.status === 403,
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('the shared visibility PATCH is not exported — only the two named directions are', () => {
|
|
196
|
+
assert.equal(typeof gh.makeRepoPublic, 'function');
|
|
197
|
+
assert.equal(typeof gh.makeRepoPrivate, 'function');
|
|
198
|
+
const visibilityExports = Object.keys(gh).filter((k) => /private|public|visibility/i.test(k)).sort();
|
|
199
|
+
assert.deepEqual(visibilityExports, ['makeRepoPrivate', 'makeRepoPublic'],
|
|
200
|
+
'a direction parameter on an export would let a caller flip private without the deploy-key wiring');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('no web route may call makeRepoPrivate — only the runner flips a repo private, after wiring its key', () => {
|
|
204
|
+
// ADR 0176 / walk finding N4: flipping before the project's own deploy key is wired
|
|
205
|
+
// breaks its next deploy pull, and only the control-plane runner can wire that key.
|
|
206
|
+
const routeDirs = ['src/bongos/routes', 'modules/provisioning/routes'];
|
|
207
|
+
const offenders = [];
|
|
208
|
+
for (const dir of routeDirs) {
|
|
209
|
+
const abs = path.join(ROOT, dir);
|
|
210
|
+
if (!fs.existsSync(abs)) continue;
|
|
211
|
+
for (const f of fs.readdirSync(abs).filter((n) => n.endsWith('.js'))) {
|
|
212
|
+
if (/makeRepoPrivate/.test(fs.readFileSync(path.join(abs, f), 'utf8'))) offenders.push(`${dir}/${f}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
assert.deepEqual(offenders, []);
|
|
216
|
+
});
|
|
217
|
+
|
|
167
218
|
// ---- deploy-key helpers (task 1002735 / ADR 0176) ----
|
|
168
219
|
|
|
169
220
|
test('listDeployKeys: GETs /repos/{owner}/{name}/keys, maps id/title/key/read_only', async () => {
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// tests/provision_repo_private.mjs
|
|
2
|
+
//
|
|
3
|
+
// Split (b) of task 1003078 (task 1004193, ADR 0176): the `repo-private` provisioning
|
|
4
|
+
// intent — its queue seam (modules/provisioning/repo-private.js), its runner leg
|
|
5
|
+
// (scripts/gds/provision-repo-private.js) and the run-intents dispatch that reaches it.
|
|
6
|
+
// Hermetic — exec, db and GitHub are recording fakes; no shell, no DB, no network.
|
|
7
|
+
//
|
|
8
|
+
// The load-bearing assertions:
|
|
9
|
+
// • ORDER: the project's own deploy key is registered and the checkout re-pointed at it
|
|
10
|
+
// BEFORE the repository is flipped private. Flip first and the next deploy pull fails
|
|
11
|
+
// with the shared key (walk finding N4) — this is the whole point of the task
|
|
12
|
+
// • the wiring is asked for as a PRE-flip wiring (intendedPrivate), because GitHub still
|
|
13
|
+
// answers "public" at that moment and the un-told wiring would do nothing
|
|
14
|
+
// • any failure BEFORE the flip leaves the repository untouched — no PATCH is sent
|
|
15
|
+
// • every expected failure is ok:false (retried), never a throw, so a running project is
|
|
16
|
+
// never relabelled 'error' by a flip that could not happen
|
|
17
|
+
// • non-standalone and not-yet-live projects are refused without touching GitHub
|
|
18
|
+
// • dry-run calls GitHub not at all
|
|
19
|
+
// • the queue seam: idempotent on its own kind, conflicts with any other open intent
|
|
20
|
+
//
|
|
21
|
+
// Run: node tests/provision_repo_private.mjs
|
|
22
|
+
|
|
23
|
+
import { strict as assert } from 'node:assert';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const P = require('../scripts/gds/provision.js');
|
|
28
|
+
const { repoPrivateInstance } = require('../scripts/gds/provision-repo-private.js');
|
|
29
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
30
|
+
const { enqueueRepoPrivate } = require('../modules/provisioning/repo-private.js');
|
|
31
|
+
|
|
32
|
+
let passed = 0, failed = 0;
|
|
33
|
+
async function ta(name, fn) {
|
|
34
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
35
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const liveStandalone = { id: 18, slug: 'demo', hosting_shape: 'standalone', status: 'active', owner_builder_id: 7, target_ref: 'example-owner/demo' };
|
|
39
|
+
|
|
40
|
+
// One ordered timeline across the shell AND GitHub, so the wire-before-flip order is
|
|
41
|
+
// observable as positions in a single list rather than inferred from two.
|
|
42
|
+
function recorder({ apply = true, token = 'gho_TOK', flipFails = null, getFails = null } = {}) {
|
|
43
|
+
const timeline = [], events = [], logs = [];
|
|
44
|
+
const deps = {
|
|
45
|
+
apply, log: (l) => logs.push(String(l)),
|
|
46
|
+
db: { query: async () => ({ rows: [] }) },
|
|
47
|
+
provisioning: { recordEvent: async (_db, ev) => { events.push(ev); } },
|
|
48
|
+
exec: (cmd) => { timeline.push(['exec', cmd]); return { ok: true, stdout: '' }; },
|
|
49
|
+
getGithubToken: async () => (token ? { token, scope: 'read:user,repo' } : null),
|
|
50
|
+
fsImpl: { existsSync: () => false, readFileSync: () => 'ssh-ed25519 AAAAlocal cloudbongos-deploy-demo\n' },
|
|
51
|
+
githubRepos: {
|
|
52
|
+
getUserRepo: async (_t, a) => {
|
|
53
|
+
timeline.push(['gh', 'get']);
|
|
54
|
+
if (getFails) throw getFails;
|
|
55
|
+
return { full_name: `${a.owner}/${a.name}`, private: false, admin: true };
|
|
56
|
+
},
|
|
57
|
+
listDeployKeys: async () => { timeline.push(['gh', 'list']); return []; },
|
|
58
|
+
addDeployKey: async (_t, a) => { timeline.push(['gh', 'add', a.readOnly]); return { id: 99, title: a.title, read_only: true }; },
|
|
59
|
+
deleteDeployKey: async () => { timeline.push(['gh', 'del']); return true; },
|
|
60
|
+
makeRepoPrivate: async (_t, a) => {
|
|
61
|
+
timeline.push(['gh', 'flip-private', `${a.owner}/${a.name}`]);
|
|
62
|
+
if (flipFails) throw flipFails;
|
|
63
|
+
return { full_name: `${a.owner}/${a.name}`, private: true, admin: true };
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
const at = (pred) => timeline.findIndex(pred);
|
|
68
|
+
return { deps, timeline, events, logs, at };
|
|
69
|
+
}
|
|
70
|
+
const isFlip = (e) => e[0] === 'gh' && e[1] === 'flip-private';
|
|
71
|
+
|
|
72
|
+
console.log('\nrunner leg — wire FIRST, flip SECOND:');
|
|
73
|
+
|
|
74
|
+
await ta('the deploy key is registered and the checkout re-pointed BEFORE the repository is flipped private', async () => {
|
|
75
|
+
// Arrange
|
|
76
|
+
const r = recorder();
|
|
77
|
+
// Act
|
|
78
|
+
const out = await repoPrivateInstance(liveStandalone, r.deps);
|
|
79
|
+
// Assert
|
|
80
|
+
assert.equal(out.ok, true, JSON.stringify(out));
|
|
81
|
+
const flip = r.at(isFlip);
|
|
82
|
+
const add = r.at((e) => e[0] === 'gh' && e[1] === 'add');
|
|
83
|
+
const sshCmd = r.at((e) => e[0] === 'exec' && /config core\.sshCommand/.test(e[1]));
|
|
84
|
+
const origin = r.at((e) => e[0] === 'exec' && /remote set-url origin git@github\.com:example-owner\/demo\.git/.test(e[1]));
|
|
85
|
+
assert.ok(flip >= 0, 'the repository is flipped');
|
|
86
|
+
assert.ok(add >= 0 && add < flip, 'the key is registered before the flip');
|
|
87
|
+
assert.ok(origin >= 0 && origin < flip, 'origin points at the plain GitHub URL before the flip');
|
|
88
|
+
assert.ok(sshCmd >= 0 && sshCmd < flip, 'core.sshCommand uses the project key before the flip');
|
|
89
|
+
assert.equal(r.timeline.filter(isFlip).length, 1, 'exactly one flip');
|
|
90
|
+
assert.equal(r.timeline[flip][2], 'example-owner/demo', 'the flip targets the project repository');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
await ta('the wiring is a PRE-flip wiring — it runs although GitHub still reports the repository public', async () => {
|
|
94
|
+
const r = recorder();
|
|
95
|
+
await repoPrivateInstance(liveStandalone, r.deps);
|
|
96
|
+
// Without intendedPrivate, ensurePrivateRepoAccess reads private:false and registers nothing.
|
|
97
|
+
const add = r.timeline.find((e) => e[0] === 'gh' && e[1] === 'add');
|
|
98
|
+
assert.ok(add, 'a key was registered for a repo GitHub still calls public');
|
|
99
|
+
assert.equal(add[2], true, 'and it is read-only');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await ta('the flip records an event on the project, after it happens', async () => {
|
|
103
|
+
const r = recorder();
|
|
104
|
+
await repoPrivateInstance(liveStandalone, r.deps);
|
|
105
|
+
assert.equal(r.events.length, 1);
|
|
106
|
+
assert.equal(r.events[0].event, 'repo-private');
|
|
107
|
+
assert.equal(r.events[0].instanceId, 18);
|
|
108
|
+
assert.equal(r.events[0].ownerBuilderId, 7);
|
|
109
|
+
assert.equal(r.events[0].actor, 'runner');
|
|
110
|
+
assert.match(r.events[0].detail, /example-owner\/demo made private/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
console.log('\nrunner leg — a failure before the flip leaves the repository alone:');
|
|
114
|
+
|
|
115
|
+
await ta('no stored credential: ok:false, no key and NO flip — the repository stays public', async () => {
|
|
116
|
+
const r = recorder({ token: null });
|
|
117
|
+
const out = await repoPrivateInstance(liveStandalone, r.deps);
|
|
118
|
+
assert.equal(out.ok, false);
|
|
119
|
+
assert.match(out.error, /cannot be made private[\s\S]*credential/i);
|
|
120
|
+
assert.equal(r.at(isFlip), -1, 'nothing was flipped');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await ta('GitHub unreachable during the wiring: ok:false (a retry), never a throw, and NO flip', async () => {
|
|
124
|
+
const r = recorder({ getFails: new Error('ECONNRESET') });
|
|
125
|
+
const out = await repoPrivateInstance(liveStandalone, r.deps);
|
|
126
|
+
assert.equal(out.ok, false);
|
|
127
|
+
assert.match(out.error, /could not be reached/);
|
|
128
|
+
assert.equal(r.at(isFlip), -1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await ta('the flip itself refused (403): ok:false, says the repository is STILL public and a retry is safe', async () => {
|
|
132
|
+
const e = Object.assign(new Error('GitHub refused the change for this account'), { code: 'not_repo_admin', status: 403 });
|
|
133
|
+
const r = recorder({ flipFails: e });
|
|
134
|
+
const out = await repoPrivateInstance(liveStandalone, r.deps);
|
|
135
|
+
assert.equal(out.ok, false);
|
|
136
|
+
assert.match(out.error, /still public/);
|
|
137
|
+
assert.match(out.error, /cannot change this repository/);
|
|
138
|
+
assert.match(out.error, /trying again is safe/);
|
|
139
|
+
assert.equal(r.events.length, 0, 'no "made private" event for a flip that did not happen');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
console.log('\nrunner leg — refusals that never reach GitHub:');
|
|
143
|
+
|
|
144
|
+
for (const [label, inst, why] of [
|
|
145
|
+
['a co-tenant project', { ...liveStandalone, hosting_shape: 'co-tenant' }, /shares its code checkout/],
|
|
146
|
+
['a project that is not live yet', { ...liveStandalone, status: 'provisioning' }, /is provisioning, not running/],
|
|
147
|
+
['a project with no repository on record', { ...liveStandalone, target_ref: null }, /no GitHub repository on record/],
|
|
148
|
+
]) {
|
|
149
|
+
await ta(`${label}: ok:false with a plain reason, and no shell or GitHub call at all`, async () => {
|
|
150
|
+
const r = recorder();
|
|
151
|
+
const out = await repoPrivateInstance(inst, r.deps);
|
|
152
|
+
assert.equal(out.ok, false);
|
|
153
|
+
assert.match(out.error, why);
|
|
154
|
+
assert.deepEqual(r.timeline, []);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
await ta('dry-run: plans the wire and the flip, and calls neither GitHub nor the shell', async () => {
|
|
159
|
+
const r = recorder({ apply: false });
|
|
160
|
+
const out = await repoPrivateInstance(liveStandalone, r.deps);
|
|
161
|
+
assert.equal(out.ok, true);
|
|
162
|
+
assert.equal(out.planned, true);
|
|
163
|
+
assert.deepEqual(r.timeline, []);
|
|
164
|
+
assert.ok(r.logs.some((l) => /ABOUT TO BE MADE PRIVATE/.test(l)), 'the plan names the pre-flip wiring');
|
|
165
|
+
assert.ok(r.logs.some((l) => /would then make example-owner\/demo private/.test(l)), 'and the flip after it');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
console.log('\nrun-intents dispatch:');
|
|
169
|
+
|
|
170
|
+
function drainDeps({ flipFails = null } = {}) {
|
|
171
|
+
let claims = 0; const resolved = [], statusWrites = [], updates = [];
|
|
172
|
+
const r = recorder({ flipFails });
|
|
173
|
+
const deps = {
|
|
174
|
+
...r.deps, sleep: async () => {},
|
|
175
|
+
db: { query: async (sql, params) => { if (/UPDATE provisioning_intents/.test(sql)) updates.push({ sql, params }); return { rows: [] }; } },
|
|
176
|
+
provisioning: {
|
|
177
|
+
claimNextIntent: async () => (claims++ === 0 ? { id: 31, instance_id: 18, action: 'repo-private', attempts: 1 } : null),
|
|
178
|
+
getInstanceById: async () => ({ ...liveStandalone }),
|
|
179
|
+
resolveIntent: async (_db, id, state, err) => { resolved.push({ id, state, err }); },
|
|
180
|
+
setInstanceStatus: async (_db, _id, s, patch) => { statusWrites.push({ s, patch }); },
|
|
181
|
+
recordEvent: async () => {},
|
|
182
|
+
claimNextOAuthManifest: async () => null, resolveOAuthManifest: async () => {},
|
|
183
|
+
},
|
|
184
|
+
writeFile: () => ({ ok: true }), doApi: null,
|
|
185
|
+
};
|
|
186
|
+
return { deps, r, resolved, statusWrites, updates };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
await ta("cmdRunIntents (--apply) routes 'repo-private' to the leg and resolves it done", async () => {
|
|
190
|
+
const d = drainDeps();
|
|
191
|
+
const out = await P.cmdRunIntents(d.deps);
|
|
192
|
+
assert.equal(out.errors, 0, JSON.stringify(out));
|
|
193
|
+
assert.deepEqual(d.resolved, [{ id: 31, state: 'done', err: null }]);
|
|
194
|
+
assert.equal(d.r.timeline.filter(isFlip).length, 1, 'it really reached the flip');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
await ta('cmdRunIntents: a flip that fails re-pends with the reason, and never marks the running project broken', async () => {
|
|
198
|
+
const d = drainDeps({ flipFails: Object.assign(new Error('boom'), { code: 'github_error' }) });
|
|
199
|
+
const out = await P.cmdRunIntents(d.deps);
|
|
200
|
+
assert.equal(out.errors, 1);
|
|
201
|
+
assert.equal(d.resolved.length, 0, 'not resolved — it re-pends for a later tick');
|
|
202
|
+
assert.match(d.updates[0].sql, /state='pending'/);
|
|
203
|
+
assert.match(d.updates[0].params[1], /still public/, 'the owner-facing reason rides last_error');
|
|
204
|
+
assert.equal(d.statusWrites.length, 0, 'a running project is not relabelled error');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
console.log('\nqueue seam — modules/provisioning/repo-private.js:');
|
|
208
|
+
|
|
209
|
+
async function withEnqueue(fake, fn) {
|
|
210
|
+
const orig = provisioning.enqueueIntent;
|
|
211
|
+
provisioning.enqueueIntent = fake;
|
|
212
|
+
try { await fn(); } finally { provisioning.enqueueIntent = orig; }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
await ta('a fresh request is queued as repo-private, carrying the actor', async () => {
|
|
216
|
+
const calls = [];
|
|
217
|
+
await withEnqueue(async (_db, id, action, by) => { calls.push({ id, action, by }); return { intent: { id: 1, action }, created: true }; }, async () => {
|
|
218
|
+
const out = await enqueueRepoPrivate({}, 18, 'api:self');
|
|
219
|
+
assert.equal(out.created, true);
|
|
220
|
+
assert.equal(out.conflict, undefined);
|
|
221
|
+
assert.deepEqual(calls, [{ id: 18, action: 'repo-private', by: 'api:self' }]);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
await ta('an open repo-private makes a repeat idempotent — not a conflict', async () => {
|
|
226
|
+
await withEnqueue(async () => ({ intent: { id: 1, action: 'repo-private', state: 'running' }, created: false }), async () => {
|
|
227
|
+
const out = await enqueueRepoPrivate({}, 18, 'api:self');
|
|
228
|
+
assert.equal(out.created, false);
|
|
229
|
+
assert.equal(out.conflict, undefined);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
await ta('any OTHER open intent refuses it with open_intent — it never races a teardown or standup', async () => {
|
|
234
|
+
for (const action of ['teardown', 'provision', 'settings-apply', 'disconnect']) {
|
|
235
|
+
await withEnqueue(async () => ({ intent: { id: 2, action, state: 'pending' }, created: false }), async () => {
|
|
236
|
+
const out = await enqueueRepoPrivate({}, 18, 'api:self');
|
|
237
|
+
assert.equal(out.conflict, 'open_intent', action);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
await ta('a teardown supersedes a PENDING repo-private — a queued flip must never hold the owner\'s delete hostage', async () => {
|
|
243
|
+
// Arrange — every intent shares one open slot per project, so an un-superseded pending
|
|
244
|
+
// flip would refuse the teardown until the runner worked through it (the 1002691 class).
|
|
245
|
+
const calls = [];
|
|
246
|
+
const db = { query: async (sql, params) => {
|
|
247
|
+
calls.push(sql);
|
|
248
|
+
if (/UPDATE provisioning_intents/.test(sql)) return { rows: [{ id: 5 }] };
|
|
249
|
+
return { rows: [{ id: 6, action: 'teardown', state: 'pending', instance_id: params[0] }] };
|
|
250
|
+
} };
|
|
251
|
+
// Act
|
|
252
|
+
const r = await provisioning.enqueueTeardown(db, 18, 'api:self');
|
|
253
|
+
// Assert
|
|
254
|
+
assert.equal(r.superseded, true);
|
|
255
|
+
assert.match(calls[0], /'repo-private'/, 'repo-private is on the supersedable list');
|
|
256
|
+
assert.match(calls[0], /state = 'pending'/, 'pending only — a RUNNING flip is mid-wiring and never yanked');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
console.log(`\nprovision_repo_private: ${passed} passed, ${failed} failed`);
|
|
260
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -131,7 +131,7 @@ await ta('enqueueTeardown supersedes a PENDING settings push (a push into a proj
|
|
|
131
131
|
const r = await provisioning.enqueueTeardown(db, 18, 'api:self');
|
|
132
132
|
assert.equal(r.created, true);
|
|
133
133
|
assert.equal(r.superseded, true, 'the queued push was cancelled so the teardown could take the slot');
|
|
134
|
-
assert.match(calls[0], /action IN \('provision', 'settings-apply', 'restart'\) AND state = 'pending'/, 'pending-only, and only the supersedable actions');
|
|
134
|
+
assert.match(calls[0], /action IN \('provision', 'settings-apply', 'restart', 'repo-private'\) AND state = 'pending'/, 'pending-only, and only the supersedable actions');
|
|
135
135
|
});
|
|
136
136
|
|
|
137
137
|
// ── the route, over a real mounted router (stubbed data access) ─────────────────
|
|
@@ -31,7 +31,7 @@ const require = createRequire(import.meta.url);
|
|
|
31
31
|
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
32
32
|
|
|
33
33
|
// The actions a teardown supersedes, mirroring enqueueTeardown's SQL.
|
|
34
|
-
const SUPERSEDABLE = new Set(['provision', 'settings-apply', 'restart']);
|
|
34
|
+
const SUPERSEDABLE = new Set(['provision', 'settings-apply', 'restart', 'repo-private']);
|
|
35
35
|
|
|
36
36
|
let passed = 0, failed = 0;
|
|
37
37
|
function t(name, fn) {
|
|
@@ -79,7 +79,7 @@ function fakeIntentDb(seed = []) {
|
|
|
79
79
|
// dead work in front of a teardown (task 1003140 widened it to the settings
|
|
80
80
|
// push, task 1003238 to the restart; an open teardown is left alone for the
|
|
81
81
|
// idempotent path).
|
|
82
|
-
assert.match(sql, /action IN \('provision', 'settings-apply', 'restart'\) AND state = 'pending'/);
|
|
82
|
+
assert.match(sql, /action IN \('provision', 'settings-apply', 'restart', 'repo-private'\) AND state = 'pending'/);
|
|
83
83
|
const hit = rows.filter((r) =>
|
|
84
84
|
String(r.instance_id) === String(params[0]) && SUPERSEDABLE.has(r.action) && r.state === 'pending');
|
|
85
85
|
for (const r of hit) { r.state = 'error'; r.last_error = 'superseded: teardown requested'; }
|