@bongos/core 1.19.674 → 1.19.676
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 -27
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +61 -3
- package/docs/api-reference.md +3 -2
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/core-release-pipeline.md +12 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/discord/discord-channels.js +107 -0
- package/modules/discord/routes/discord.js +26 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/discord-channels.js +14 -3
- package/scripts/gds/go-live.js +118 -25
- package/scripts/gds/upgrade.js +28 -4
- package/src/module-api.js +1 -1
- package/tests/discord_channels_snapshot.mjs +198 -0
- package/tests/go_live.mjs +174 -2
- package/tests/upgrade_persist_pin.mjs +61 -0
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
// directly — it asks the deployed bot (via the API) to do the work.
|
|
11
11
|
//
|
|
12
12
|
// Usage:
|
|
13
|
-
// node scripts/gds/discord-channels.js plan
|
|
13
|
+
// node scripts/gds/discord-channels.js plan # dry run: what WOULD change
|
|
14
14
|
// node scripts/gds/discord-channels.js apply # apply docs/discord/channels.json to Discord
|
|
15
|
+
// node scripts/gds/discord-channels.js snapshot # print the guild AS channels.json (seed the file)
|
|
15
16
|
//
|
|
16
17
|
// The channel layout itself is edited in docs/discord/channels.json and merged
|
|
17
18
|
// (rank-gated) — this command just triggers the reconcile.
|
|
@@ -20,8 +21,8 @@ const { cliClient } = require('./cli-lib');
|
|
|
20
21
|
|
|
21
22
|
async function main() {
|
|
22
23
|
const cmd = (process.argv[2] || '').toLowerCase();
|
|
23
|
-
if (cmd !== 'plan' && cmd !== 'apply') {
|
|
24
|
-
console.error('usage: node scripts/gds/discord-channels.js <plan|apply>');
|
|
24
|
+
if (cmd !== 'plan' && cmd !== 'apply' && cmd !== 'snapshot') {
|
|
25
|
+
console.error('usage: node scripts/gds/discord-channels.js <plan|apply|snapshot>');
|
|
25
26
|
process.exit(2);
|
|
26
27
|
}
|
|
27
28
|
|
|
@@ -29,6 +30,8 @@ async function main() {
|
|
|
29
30
|
let res;
|
|
30
31
|
if (cmd === 'plan') {
|
|
31
32
|
res = await api.discord.getDiscordChannelsPlan();
|
|
33
|
+
} else if (cmd === 'snapshot') {
|
|
34
|
+
res = await api.discord.getDiscordChannelsSnapshot();
|
|
32
35
|
} else {
|
|
33
36
|
res = await api.discord.postDiscordChannelsReconcile({ body: {} });
|
|
34
37
|
}
|
|
@@ -47,7 +50,15 @@ async function main() {
|
|
|
47
50
|
process.exit(1);
|
|
48
51
|
}
|
|
49
52
|
|
|
53
|
+
// Warnings go to STDERR so `snapshot > channels.json` writes clean JSON.
|
|
50
54
|
for (const w of body.warnings || []) console.warn(`warning: ${w}`);
|
|
55
|
+
if (cmd === 'snapshot') {
|
|
56
|
+
// STDOUT is the file body and nothing else — this command exists to be
|
|
57
|
+
// redirected. Review the diff before committing: it is the guild's real
|
|
58
|
+
// state, which is not necessarily the state anyone intended.
|
|
59
|
+
console.log(JSON.stringify(body.config, null, 2));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
51
62
|
if (cmd === 'plan') {
|
|
52
63
|
const ops = body.ops || [];
|
|
53
64
|
if (!ops.length) { console.log('In sync — no channel changes needed.'); return; }
|
package/scripts/gds/go-live.js
CHANGED
|
@@ -77,8 +77,32 @@ function isSafeToken(v) {
|
|
|
77
77
|
// rather than ignored: a typo'd `healthURL` that silently disables the health gate
|
|
78
78
|
// is precisely the class of quiet failure this pipeline keeps being bitten by.
|
|
79
79
|
// `ssh` is NOT required: a target with no ssh host is LOCAL (the box driving itself).
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
//
|
|
81
|
+
// `deployTimer` is NOT required either — that is CO-TENANT MODE (task 1003521). A
|
|
82
|
+
// provisioned instance under /srv/<host>/<slug> has no pull-deploy timer at all (only
|
|
83
|
+
// its <slug>-db-backup.timer), so requiring the field forced an operator to invent a
|
|
84
|
+
// unit name that the script would then try to `systemctl stop`. Absent, the stop and
|
|
85
|
+
// the restart are simply skipped: there is no `reset --hard` to race.
|
|
86
|
+
const REQUIRED_FIELDS = ['instanceDir', 'service', 'healthUrl', 'versionUrl'];
|
|
87
|
+
const OPTIONAL_FIELDS = ['ssh', 'local', 'deployTimer', 'deployService', 'npmEnvFile', 'runAs', 'backupDb', 'backupDir', 'env', 'registryPackage', 'pinMode'];
|
|
88
|
+
|
|
89
|
+
// How the bump is made durable — the other half of co-tenant mode (task 1003521).
|
|
90
|
+
//
|
|
91
|
+
// On the PLATFORM instance the repo of record is ours, and a pull-deploy
|
|
92
|
+
// `git reset --hard origin/main` reverts any pin that was not PUSHED (task 1002712 —
|
|
93
|
+
// thirteen releases were lost exactly that way). So the default is `push`.
|
|
94
|
+
//
|
|
95
|
+
// On a provisioned co-tenant the repo of record is the CUSTOMER's own GitHub repo, so
|
|
96
|
+
// that push is both wrong and, in practice, uncredentialed. With no pull-deploy timer
|
|
97
|
+
// nothing will revert the pin, so a local commit is enough to leave the tree clean:
|
|
98
|
+
// local-commit — commit the pin, do not push it
|
|
99
|
+
// leave-dirty — do not commit at all; the bump lives in the working tree by design
|
|
100
|
+
const PIN_MODES = ['push', 'local-commit', 'leave-dirty'];
|
|
101
|
+
const DEFAULT_PIN_MODE = 'push';
|
|
102
|
+
|
|
103
|
+
function pinModeOf(target) {
|
|
104
|
+
return (target && target.pinMode) || DEFAULT_PIN_MODE;
|
|
105
|
+
}
|
|
82
106
|
|
|
83
107
|
function validateTarget(name, t) {
|
|
84
108
|
const errors = [];
|
|
@@ -105,6 +129,10 @@ function validateTarget(name, t) {
|
|
|
105
129
|
if (typeof v !== 'boolean') errors.push(`target "${name}".local must be true or false`);
|
|
106
130
|
continue;
|
|
107
131
|
}
|
|
132
|
+
if (k === 'pinMode') { // an enum, never interpolated into a command
|
|
133
|
+
if (!PIN_MODES.includes(v)) errors.push(`target "${name}".pinMode must be one of ${PIN_MODES.join(', ')} (got ${JSON.stringify(v)})`);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
108
136
|
if (!isSafeToken(v)) errors.push(`target "${name}".${k} contains characters that are unsafe in a shell (got ${JSON.stringify(v)})`);
|
|
109
137
|
}
|
|
110
138
|
return errors;
|
|
@@ -123,14 +151,27 @@ const CONFIG_TEMPLATE = `{
|
|
|
123
151
|
"service": "my-instance.service", // systemd unit to restart
|
|
124
152
|
"healthUrl": "http://127.0.0.1:3002/healthz", // box-side, NOT the apex
|
|
125
153
|
"versionUrl": "http://127.0.0.1:3002/version", // box-side truth read back after
|
|
126
|
-
"deployTimer": "my-deploy.timer", // pull-deploy timer (its reset --hard is the hazard)
|
|
154
|
+
"deployTimer": "my-deploy.timer", // optional: pull-deploy timer (its reset --hard is the hazard)
|
|
127
155
|
"deployService": "my-deploy.service", // optional: forced tick proves the pin stuck
|
|
128
156
|
"npmEnvFile": "/etc/my/npm.env", // optional: root-only NPM_TOKEN for a PRIVATE package
|
|
129
157
|
"runAs": "me", // optional: unprivileged owner of the instance
|
|
130
158
|
"backupDb": "mydb", // optional: pg_dump before the bump
|
|
131
159
|
"backupDir": "~/backups",
|
|
132
160
|
"env": { "PGDATABASE": "mydb" }, // optional: load-bearing for migrate
|
|
133
|
-
"registryPackage": "@bongos/core"
|
|
161
|
+
"registryPackage": "@bongos/core",
|
|
162
|
+
"pinMode": "push" // optional: push (default) | local-commit | leave-dirty
|
|
163
|
+
},
|
|
164
|
+
"a-co-tenant": {
|
|
165
|
+
// A PROVISIONED instance: no pull-deploy timer to stop, and the repo of record
|
|
166
|
+
// is the customer's own GitHub repo — so the pin is committed, never pushed.
|
|
167
|
+
"ssh": "my-box",
|
|
168
|
+
"instanceDir": "/srv/my-host/their-slug",
|
|
169
|
+
"service": "their-slug.service",
|
|
170
|
+
"healthUrl": "http://127.0.0.1:3101/healthz",
|
|
171
|
+
"versionUrl": "http://127.0.0.1:3101/version",
|
|
172
|
+
"runAs": "their-slug",
|
|
173
|
+
"env": { "PGDATABASE": "their_slug" },
|
|
174
|
+
"pinMode": "local-commit"
|
|
134
175
|
}
|
|
135
176
|
}
|
|
136
177
|
}`;
|
|
@@ -455,7 +496,10 @@ function renderReport(r) {
|
|
|
455
496
|
|
|
456
497
|
L.push('');
|
|
457
498
|
L.push(` live reads back ${r.liveVersion || '(unread)'}`);
|
|
458
|
-
|
|
499
|
+
const mode = r.pinMode || DEFAULT_PIN_MODE;
|
|
500
|
+
if (mode === 'leave-dirty') L.push(' pin left in the working tree (pinMode leave-dirty)');
|
|
501
|
+
else if (r.pinCommitted) L.push(` pin committed yes${mode === 'local-commit' ? ' (local commit — this target\'s remote is not ours to push)' : ''}`);
|
|
502
|
+
else L.push(' pin committed NO — the next pull-deploy reset will revert this bump');
|
|
459
503
|
L.push('');
|
|
460
504
|
return L.join('\n');
|
|
461
505
|
}
|
|
@@ -506,36 +550,78 @@ echo "##END##"
|
|
|
506
550
|
};
|
|
507
551
|
}
|
|
508
552
|
|
|
553
|
+
// The `bongos upgrade` invocation, as its own function so the pin-mode wiring is
|
|
554
|
+
// assertable without a box.
|
|
555
|
+
//
|
|
556
|
+
// Both non-default modes pass --pin-no-push, which is what tells upgrade.js the repo of
|
|
557
|
+
// record is not ours: `local-commit` pairs it with --commit-pin (commit, no push), and
|
|
558
|
+
// `leave-dirty` sends it alone, which downgrades upgrade.js's "PIN NOT COMMITTED" alarm
|
|
559
|
+
// to a note. That alarm is correct on a pull-deploy instance and misleading here, and an
|
|
560
|
+
// alarm an operator learns to ignore is worse than no alarm.
|
|
561
|
+
function upgradeCommand(target, targetVersion) {
|
|
562
|
+
const mode = pinModeOf(target);
|
|
563
|
+
return [
|
|
564
|
+
`./node_modules/.bin/bongos upgrade`,
|
|
565
|
+
`--to ${targetVersion}`, '--registry',
|
|
566
|
+
`--instance ${target.instanceDir}`,
|
|
567
|
+
`--service ${target.service}`,
|
|
568
|
+
`--health-url ${target.healthUrl}`,
|
|
569
|
+
...(mode === 'leave-dirty' ? [] : ['--commit-pin']),
|
|
570
|
+
...(mode === 'push' ? [] : ['--pin-no-push']),
|
|
571
|
+
'--force',
|
|
572
|
+
].join(' ');
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Whether the post-bump pin state is what this target ASKED for. Under `leave-dirty` a
|
|
576
|
+
// modified working tree is the configured outcome, so reporting it as the durability
|
|
577
|
+
// failure it is on a pull-deploy instance would make a correct co-tenant run read as
|
|
578
|
+
// broken — the one place the three modes must not share a verdict.
|
|
579
|
+
function pinStep(mode, pinDirty, head) {
|
|
580
|
+
if (mode === 'leave-dirty') {
|
|
581
|
+
return {
|
|
582
|
+
name: 'pin left in the working tree (pinMode leave-dirty)',
|
|
583
|
+
ok: true,
|
|
584
|
+
detail: pinDirty ? 'package.json/-lock MODIFIED by design — this target has no pull-deploy to revert it' : (head || null),
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
return {
|
|
588
|
+
name: mode === 'local-commit' ? 'pin committed locally (not pushed)' : 'pin committed + pushed',
|
|
589
|
+
ok: !pinDirty,
|
|
590
|
+
detail: pinDirty ? 'package.json/-lock still MODIFIED — the next reset --hard reverts this bump' : (head || null),
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
509
594
|
function applyGoLive(target, targetName, targetVersion, from, opts = {}) {
|
|
510
595
|
const pkg = target.registryPackage || '@bongos/core';
|
|
596
|
+
const pinMode = pinModeOf(target);
|
|
511
597
|
const steps = [];
|
|
512
598
|
const src = target.npmEnvFile ? `set -a; . ${target.npmEnvFile}; set +a` : 'true';
|
|
513
599
|
|
|
514
600
|
// Phase A — stop the pull-deploy timer, then back up. The timer's `reset --hard`
|
|
515
|
-
// is the hazard: it would revert an uncommitted pin mid-flight.
|
|
601
|
+
// is the hazard: it would revert an uncommitted pin mid-flight. A co-tenant has no
|
|
602
|
+
// such timer (see REQUIRED_FIELDS), so there is nothing to stop and no hazard.
|
|
516
603
|
const backup = target.backupDb
|
|
517
604
|
? asOwner(target, `bash -c "mkdir -p ${target.backupDir || '~/backups'} && pg_dump ${target.backupDb} | gzip > ${target.backupDir || '~/backups'}/pre-${targetVersion}-$(date -u +%Y%m%dT%H%M%SZ).sql.gz"`)
|
|
518
605
|
: 'echo "(no backupDb configured — skipping)"';
|
|
519
606
|
const phaseA = remote(target, `
|
|
520
607
|
set -euo pipefail
|
|
521
608
|
${SUDO_PREAMBLE}
|
|
522
|
-
echo "##TIMER##"; $
|
|
609
|
+
echo "##TIMER##"; ${target.deployTimer
|
|
610
|
+
? `$SUDO systemctl stop ${target.deployTimer} && $SUDO systemctl is-active ${target.deployTimer} || true`
|
|
611
|
+
: 'echo "(no deployTimer configured — no pull-deploy to stop)"'}
|
|
523
612
|
echo "##BACKUP##"; ${backup}
|
|
524
613
|
echo "##END##"
|
|
525
614
|
`, opts);
|
|
526
|
-
steps.push({
|
|
527
|
-
|
|
615
|
+
steps.push({
|
|
616
|
+
name: target.deployTimer ? `stop ${target.deployTimer} + backup` : 'backup (no pull-deploy timer)',
|
|
617
|
+
ok: phaseA.ok,
|
|
618
|
+
detail: phaseA.ok ? null : (phaseA.stderr || '').trim().split('\n').pop(),
|
|
619
|
+
});
|
|
620
|
+
if (!phaseA.ok) return { ok: false, steps, from, to: targetVersion, targetName, pinMode, failedAt: 'PREPARE', raw: phaseA };
|
|
528
621
|
|
|
529
622
|
// Phase B — the upgrade itself. --commit-pin makes the bump durable against the
|
|
530
623
|
// pull-deploy reset; auto-rollback stays ON (do NOT pass --no-rollback-on-failure).
|
|
531
|
-
const upgrade =
|
|
532
|
-
`./node_modules/.bin/bongos upgrade`,
|
|
533
|
-
`--to ${targetVersion}`, '--registry',
|
|
534
|
-
`--instance ${target.instanceDir}`,
|
|
535
|
-
`--service ${target.service}`,
|
|
536
|
-
`--health-url ${target.healthUrl}`,
|
|
537
|
-
'--commit-pin', '--force',
|
|
538
|
-
].join(' ');
|
|
624
|
+
const upgrade = upgradeCommand(target, targetVersion);
|
|
539
625
|
const phaseB = remote(target, `
|
|
540
626
|
set -uo pipefail
|
|
541
627
|
${src}
|
|
@@ -549,9 +635,10 @@ ${asOwner(target, `bash -c "cd ${target.instanceDir} && ${upgrade}"`, { needsNpm
|
|
|
549
635
|
});
|
|
550
636
|
if (!phaseB.ok || rolledBack) {
|
|
551
637
|
// Restart the timer even on failure — leaving it stopped silently disables
|
|
552
|
-
// the instance's pull-deploy until someone notices.
|
|
553
|
-
|
|
554
|
-
|
|
638
|
+
// the instance's pull-deploy until someone notices. Nothing was stopped on a
|
|
639
|
+
// co-tenant, so there is nothing to put back.
|
|
640
|
+
if (target.deployTimer) remote(target, `${SUDO_PREAMBLE}\n$SUDO systemctl start ${target.deployTimer} || true`, opts);
|
|
641
|
+
return { ok: false, steps, from, to: targetVersion, targetName, pinMode, failedAt: 'UPGRADE', rolledBack, raw: phaseB };
|
|
555
642
|
}
|
|
556
643
|
|
|
557
644
|
// Phase C — restart the timer, force one tick, and read back the BOX-SIDE truth.
|
|
@@ -560,7 +647,7 @@ ${asOwner(target, `bash -c "cd ${target.instanceDir} && ${upgrade}"`, { needsNpm
|
|
|
560
647
|
const phaseC = remote(target, `
|
|
561
648
|
set -uo pipefail
|
|
562
649
|
${SUDO_PREAMBLE}
|
|
563
|
-
echo "##TIMERSTART##"; $SUDO systemctl start ${target.deployTimer}; $SUDO systemctl is-active ${target.deployTimer}
|
|
650
|
+
${target.deployTimer ? `echo "##TIMERSTART##"; $SUDO systemctl start ${target.deployTimer}; $SUDO systemctl is-active ${target.deployTimer}` : ''}
|
|
564
651
|
echo "##PIN##"; ( cd ${target.instanceDir} && ${target.runAs ? `sudo -u ${target.runAs} -H ` : ''}git status --porcelain -- package.json package-lock.json )
|
|
565
652
|
echo "##HEAD##"; ( cd ${target.instanceDir} && ${target.runAs ? `sudo -u ${target.runAs} -H ` : ''}git log -1 --format='%h %s' )
|
|
566
653
|
${target.deployService ? `echo "##TICK##"; $SUDO systemctl start ${target.deployService} || true; $SUDO journalctl -u ${target.deployService} -n 20 --no-pager | tail -8` : ''}
|
|
@@ -578,13 +665,13 @@ echo "##END##"
|
|
|
578
665
|
let liveVersion = null;
|
|
579
666
|
try { liveVersion = JSON.parse(section('VERSION') || '{}').coreVersion || null; } catch { liveVersion = null; }
|
|
580
667
|
|
|
581
|
-
steps.push({ name: `restart ${target.deployTimer}`, ok: (section('TIMERSTART') || '').includes('active') });
|
|
582
|
-
steps.push(
|
|
668
|
+
if (target.deployTimer) steps.push({ name: `restart ${target.deployTimer}`, ok: (section('TIMERSTART') || '').includes('active') });
|
|
669
|
+
steps.push(pinStep(pinMode, pinDirty, section('HEAD')));
|
|
583
670
|
steps.push({ name: 'installed package version', ok: installed === targetVersion, detail: installed === targetVersion ? installed : `reports ${installed}, expected ${targetVersion}` });
|
|
584
671
|
steps.push({ name: 'live reads back (box-side)', ok: liveVersion === targetVersion, detail: liveVersion === targetVersion ? liveVersion : `reports ${liveVersion}, expected ${targetVersion}` });
|
|
585
672
|
|
|
586
673
|
const ok = steps.every((s) => s.ok);
|
|
587
|
-
return { ok, steps, from, to: targetVersion, targetName, liveVersion, installed, pinCommitted: !pinDirty, raw: phaseC };
|
|
674
|
+
return { ok, steps, from, to: targetVersion, targetName, pinMode, liveVersion, installed, pinCommitted: !pinDirty, raw: phaseC };
|
|
588
675
|
}
|
|
589
676
|
|
|
590
677
|
// ---------------------------------------------------------------------------
|
|
@@ -608,6 +695,11 @@ go-live — move an enrolled instance's core pin, as one command.
|
|
|
608
695
|
node scripts/gds/go-live.js --no-divergence skip the git-derived checks (needs --to)
|
|
609
696
|
node scripts/gds/go-live.js --json machine-readable
|
|
610
697
|
|
|
698
|
+
CO-TENANT TARGETS. A provisioned instance has no pull-deploy timer and its repo of
|
|
699
|
+
record is the customer's own, so omit "deployTimer" (the stop/restart is skipped) and
|
|
700
|
+
set "pinMode": "local-commit" (the pin is committed, never pushed). "leave-dirty"
|
|
701
|
+
leaves the bump in the working tree; "push" (the default) is the platform instance.
|
|
702
|
+
|
|
611
703
|
Host topology is NOT baked into the core. ${DEFAULT_CONFIG} (host-owned, not published):
|
|
612
704
|
|
|
613
705
|
${CONFIG_TEMPLATE}
|
|
@@ -770,5 +862,6 @@ if (require.main === module) {
|
|
|
770
862
|
module.exports = {
|
|
771
863
|
loadConfig, selectTarget, validateTarget, planGoLive, parseMarker, parseMarkerHits,
|
|
772
864
|
renderPlan, renderReport, failureExcerpt, asOwner, isSafeToken, NPM_AUTH_PREAMBLE, refreshMain, isLocalTarget, gitRefExists, SUDO_PREAMBLE,
|
|
773
|
-
|
|
865
|
+
applyGoLive, upgradeCommand, pinStep, pinModeOf,
|
|
866
|
+
CONFIG_TEMPLATE, HALTS, SEMVER_RE, PIN_MODES, DEFAULT_PIN_MODE,
|
|
774
867
|
};
|
package/scripts/gds/upgrade.js
CHANGED
|
@@ -160,7 +160,12 @@ function dirtyPinFiles(instanceDir, run = spawnSync) {
|
|
|
160
160
|
return (r.stdout || '').split('\n').map((l) => l.slice(3).trim()).filter(Boolean);
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
|
|
163
|
+
// `push: false` is CO-TENANT MODE (task 1003521). The reasoning above assumes the repo
|
|
164
|
+
// of record is OURS; on a provisioned instance it is the customer's own GitHub repo, so
|
|
165
|
+
// pushing there is both wrong and uncredentialed. Such an instance also has no
|
|
166
|
+
// pull-deploy timer, so nothing will `reset --hard` the pin away — the commit alone
|
|
167
|
+
// makes it durable, and leaves the tree clean for the customer's own pulls.
|
|
168
|
+
function persistPin({ instanceDir, fromVersion, toVersion, commitPin, push = true, remote = 'origin', dryRun = false },
|
|
164
169
|
run = spawnSync, log = console.log, err = console.error) {
|
|
165
170
|
const dirty = dirtyPinFiles(instanceDir, run);
|
|
166
171
|
if (dirty === null) return { applicable: false, reason: 'not a git repo' };
|
|
@@ -169,6 +174,15 @@ function persistPin({ instanceDir, fromVersion, toVersion, commitPin, remote = '
|
|
|
169
174
|
const branch = gitCurrentBranch(instanceDir, run);
|
|
170
175
|
|
|
171
176
|
if (!commitPin) {
|
|
177
|
+
if (!push) {
|
|
178
|
+
// No pull-deploy of ours reaches this instance, so an uncommitted pin is untidy
|
|
179
|
+
// rather than doomed. Crying wolf here would teach an operator to ignore the
|
|
180
|
+
// warning that DOES matter on the platform instance.
|
|
181
|
+
err(` ! pin not committed — ${dirty.join(', ')} changed in the working tree only.`);
|
|
182
|
+
err(' This target pulls from a repo that is not ours, so nothing here will revert it —');
|
|
183
|
+
err(' but the tree stays dirty until it is committed. Re-run with --commit-pin to commit it.');
|
|
184
|
+
return { applicable: true, committed: false, pushed: false, dirty, warned: true };
|
|
185
|
+
}
|
|
172
186
|
// LOUD — this is the whole point. A silent uncommitted pin is the bug.
|
|
173
187
|
err(` ! PIN NOT COMMITTED — ${dirty.join(', ')} changed in the working tree only.`);
|
|
174
188
|
err(' On a pull-deploy instance the next push to the repo of record makes deploy.sh');
|
|
@@ -184,7 +198,9 @@ function persistPin({ instanceDir, fromVersion, toVersion, commitPin, remote = '
|
|
|
184
198
|
}
|
|
185
199
|
|
|
186
200
|
if (dryRun) {
|
|
187
|
-
log(
|
|
201
|
+
log(push
|
|
202
|
+
? ` [dry-run] would commit + push ${dirty.join(', ')} to ${remote}/${branch || 'main'}`
|
|
203
|
+
: ` [dry-run] would commit ${dirty.join(', ')} locally (no push — this remote is not ours)`);
|
|
188
204
|
return { applicable: true, committed: false, pushed: false, dirty, dryRun: true };
|
|
189
205
|
}
|
|
190
206
|
|
|
@@ -201,8 +217,13 @@ function persistPin({ instanceDir, fromVersion, toVersion, commitPin, remote = '
|
|
|
201
217
|
}
|
|
202
218
|
log(` ✓ committed the pin (${dirty.join(', ')})`);
|
|
203
219
|
|
|
204
|
-
|
|
205
|
-
|
|
220
|
+
if (!push) {
|
|
221
|
+
log(' ✓ not pushed, by request — this instance\'s repo of record is not ours, and no pull-deploy of ours can revert the commit');
|
|
222
|
+
return { applicable: true, committed: true, pushed: false, dirty, pushSkipped: true };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const pushed = run('git', ['push', remote, `HEAD:${branch || 'main'}`], { cwd: instanceDir, encoding: 'utf8' });
|
|
226
|
+
if (!pushed || pushed.error || pushed.status !== 0) {
|
|
206
227
|
// Committed but not pushed is still doomed: deploy.sh resets to the REMOTE.
|
|
207
228
|
err(` ! commit landed locally but the push to ${remote} FAILED — this bump is still not durable.`);
|
|
208
229
|
err(` Push it: git -C ${instanceDir} push ${remote} HEAD:${branch || 'main'}`);
|
|
@@ -984,6 +1005,7 @@ async function runUpgrade(opts, deps = {}) {
|
|
|
984
1005
|
fromVersion,
|
|
985
1006
|
toVersion: targetVersion,
|
|
986
1007
|
commitPin: opts.commitPin,
|
|
1008
|
+
push: !opts.pinNoPush,
|
|
987
1009
|
remote: opts.gitRemote || 'origin',
|
|
988
1010
|
dryRun: opts.dryRun,
|
|
989
1011
|
}, run, log, err);
|
|
@@ -1006,6 +1028,7 @@ function parseArgs(argv) {
|
|
|
1006
1028
|
pinPath: arg('--pin-path', argv),
|
|
1007
1029
|
registry: hasFlag('--registry', argv), // ADR 0134: pull @bongos/core@<--to> from the npm registry instead of vendoring
|
|
1008
1030
|
commitPin: hasFlag('--commit-pin', argv), // task 1002712: commit + push package.json/-lock so a pull-deploy reset cannot revert the bump
|
|
1031
|
+
pinNoPush: hasFlag('--pin-no-push', argv), // task 1003521 co-tenant mode: commit the pin but leave the customer's own remote alone
|
|
1009
1032
|
gitRemote: arg('--git-remote', argv),
|
|
1010
1033
|
dryRun: hasFlag('--dry-run', argv),
|
|
1011
1034
|
force: hasFlag('--force', argv),
|
|
@@ -1042,6 +1065,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
1042
1065
|
' --skip-pin-verify do not verify the integrity pin (tree_sha256 + tarball.sha256) after install',
|
|
1043
1066
|
' --require-pin treat a MISSING reference manifest as fatal (default: warn — a MISMATCH is always fatal)',
|
|
1044
1067
|
' --no-rollback-on-failure do NOT auto-revert on a failed install/pin-verify/migrate/health check (default: auto-rollback ON)',
|
|
1068
|
+
' --pin-no-push with --commit-pin: commit the pin but do NOT push it — for an instance whose repo of record is not ours (a provisioned co-tenant), where the push is wrong and no pull-deploy can revert the commit',
|
|
1045
1069
|
' --dry-run show the plan; change nothing',
|
|
1046
1070
|
' --force skip the clean-tree + same-version guards',
|
|
1047
1071
|
'',
|
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.676'; // 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');
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// tests/discord_channels_snapshot.mjs
|
|
2
|
+
//
|
|
3
|
+
// Seeding docs/discord/channels.json from the LIVE guild (task 1003510).
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS EXISTS. ADR 0037 §1 calls channels.json "the single source of truth"
|
|
6
|
+
// and rests its security argument on it — changing channels means editing a repo
|
|
7
|
+
// file and merging it, and merging is rank-gated, so the layout is Archon-gated
|
|
8
|
+
// by construction. That property only holds for channels the file actually
|
|
9
|
+
// describes. It described one (board-room, task 1003033); the rest of the guild
|
|
10
|
+
// was configured somewhere untracked. Reconstructing them by hand from the ADR's
|
|
11
|
+
// access table was rejected on purpose: the table lists `view` but no names and
|
|
12
|
+
// no topics, so guessing would make the bot EDIT live channels to match an
|
|
13
|
+
// invention. `configFromSnapshot` reads the real values instead.
|
|
14
|
+
//
|
|
15
|
+
// THE PROPERTY THAT MAKES IT TRUSTWORTHY — and the whole point of this file:
|
|
16
|
+
//
|
|
17
|
+
// planChannelReconcile(snapshot, configFromSnapshot(snapshot).config) === no ops
|
|
18
|
+
//
|
|
19
|
+
// Feeding the derived config straight back at the guild it came from must ask for
|
|
20
|
+
// NOTHING. Any field inverted wrongly — a view, a name, a topic, a parent — makes
|
|
21
|
+
// the planner emit an op, so this one assertion tests the whole function instead
|
|
22
|
+
// of restating it field by field. A config that round-trips is one an operator can
|
|
23
|
+
// commit without the next reconcile rewriting the guild.
|
|
24
|
+
//
|
|
25
|
+
// Pure: no Discord, no network, no DB.
|
|
26
|
+
//
|
|
27
|
+
// Run: node tests/discord_channels_snapshot.mjs
|
|
28
|
+
|
|
29
|
+
import { strict as assert } from 'node:assert';
|
|
30
|
+
import { createRequire } from 'node:module';
|
|
31
|
+
|
|
32
|
+
const require = createRequire(import.meta.url);
|
|
33
|
+
const ch = require('../modules/discord/discord-channels.js');
|
|
34
|
+
const { configFromSnapshot, viewFromOverwrites, planChannelReconcile, mergeViewOverwrites, resolveViewRoles } = ch;
|
|
35
|
+
|
|
36
|
+
const EVERYONE = '100';
|
|
37
|
+
const ROLES = { xenos: '201', thetes: '202', metic: '203', archon: '204' };
|
|
38
|
+
const BOT = '999';
|
|
39
|
+
const ctx = { everyoneId: EVERYONE, roleMap: ROLES, botId: BOT };
|
|
40
|
+
|
|
41
|
+
let passed = 0, failed = 0;
|
|
42
|
+
function test(name, fn) {
|
|
43
|
+
try { fn(); console.log(` ok ${name}`); passed++; }
|
|
44
|
+
catch (e) { console.error(` FAIL ${name}\n ${e.message}`); failed++; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Build the overwrites a REAL reconciled channel carries for a given view, using
|
|
48
|
+
// the production forward path — so the fixtures are what the bot actually writes,
|
|
49
|
+
// not a hand-rolled guess at it.
|
|
50
|
+
const owFor = (view) => mergeViewOverwrites([], resolveViewRoles(view, ROLES), ctx);
|
|
51
|
+
|
|
52
|
+
// ---- the inverse, on its own ----------------------------------------------
|
|
53
|
+
|
|
54
|
+
test('everyone-visible reads back as "everyone"', () => {
|
|
55
|
+
assert.equal(viewFromOverwrites(owFor('everyone'), ctx), 'everyone');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('each rank round-trips through the overwrites the bot writes', () => {
|
|
59
|
+
for (const rank of ['xenos', 'thetes', 'metic', 'archon']) {
|
|
60
|
+
assert.equal(viewFromOverwrites(owFor(rank), ctx), rank, `view=${rank}`);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('the LOWEST allowed rank wins — view grants that rank and every rank above', () => {
|
|
65
|
+
// thetes' overwrites allow thetes+metic+archon. The answer must be the floor,
|
|
66
|
+
// not whichever happens to be first in the array.
|
|
67
|
+
const ow = owFor('thetes');
|
|
68
|
+
assert.ok(ow.some((o) => o.id === ROLES.archon), 'fixture really does allow archon too');
|
|
69
|
+
assert.equal(viewFromOverwrites(ow, ctx), 'thetes');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('no managed overwrite at all reads back as null, never "everyone"', () => {
|
|
73
|
+
// A channel the bot has never touched. Claiming "everyone" here would write a
|
|
74
|
+
// claim the guild never made, and the next reconcile would act on it.
|
|
75
|
+
assert.equal(viewFromOverwrites([], ctx), null);
|
|
76
|
+
assert.equal(viewFromOverwrites([{ id: '777', type: 0, allow: '0', deny: '0' }], ctx), null);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('@everyone denied with no rank allowed fails safe to archon', () => {
|
|
80
|
+
const ow = [{ id: EVERYONE, type: 0, allow: '0', deny: '1024' }];
|
|
81
|
+
assert.equal(viewFromOverwrites(ow, ctx), 'archon', 'mirrors resolveViewRoles fail-safe');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ---- the round trip --------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
// A guild shaped like the real one: a public category, a restricted one, emoji in
|
|
87
|
+
// the names, and a channel whose view is TIGHTER than its category's.
|
|
88
|
+
function guild() {
|
|
89
|
+
return {
|
|
90
|
+
categories: [
|
|
91
|
+
{ id: '1', name: 'COMMONS', overwrites: owFor('everyone') },
|
|
92
|
+
{ id: '2', name: 'BUILDERS', overwrites: owFor('xenos') },
|
|
93
|
+
],
|
|
94
|
+
channels: [
|
|
95
|
+
{ id: '10', name: '👋-welcome', type: 0, parentId: '1', topic: 'Start here.', overwrites: owFor('everyone') },
|
|
96
|
+
{ id: '11', name: '📯-ship-news', type: 0, parentId: '1', topic: 'What shipped.', overwrites: owFor('everyone') },
|
|
97
|
+
{ id: '12', name: '🏛️-board-room', type: 0, parentId: '2', topic: 'Ratification votes.', overwrites: owFor('xenos') },
|
|
98
|
+
{ id: '13', name: '🔒-archon-only', type: 0, parentId: '2', topic: 'Archon business.', overwrites: owFor('archon') },
|
|
99
|
+
],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
test('ROUND TRIP: the derived config asks the planner for nothing', () => {
|
|
104
|
+
const snapshot = guild();
|
|
105
|
+
const { config, warnings } = configFromSnapshot(snapshot, ctx);
|
|
106
|
+
assert.deepEqual(warnings, [], 'a fully-reconciled guild produces no warnings');
|
|
107
|
+
const { ops } = planChannelReconcile(snapshot, config, ctx);
|
|
108
|
+
assert.deepEqual(ops, [], `expected zero ops, got: ${JSON.stringify(ops.map((o) => [o.op, o.name]))}`);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('the real names survive, emoji and all — they are what slug matching needs', () => {
|
|
112
|
+
const { config } = configFromSnapshot(guild(), ctx);
|
|
113
|
+
const names = config.categories.flatMap((c) => c.channels.map((x) => x.name));
|
|
114
|
+
assert.deepEqual(names, ['👋-welcome', '📯-ship-news', '🏛️-board-room', '🔒-archon-only']);
|
|
115
|
+
const keys = config.categories.flatMap((c) => c.channels.map((x) => x.key));
|
|
116
|
+
assert.deepEqual(keys, ['welcome', 'ship-news', 'board-room', 'archon-only'], 'key is the slug');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('topics are carried verbatim — the ADR table has none, which is why guessing was refused', () => {
|
|
120
|
+
const { config } = configFromSnapshot(guild(), ctx);
|
|
121
|
+
const commons = config.categories.find((c) => c.name === 'COMMONS');
|
|
122
|
+
assert.equal(commons.channels.find((c) => c.key === 'ship-news').topic, 'What shipped.');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('a channel view EQUAL to its category is omitted; a tighter one is stated', () => {
|
|
126
|
+
const { config } = configFromSnapshot(guild(), ctx);
|
|
127
|
+
const builders = config.categories.find((c) => c.name === 'BUILDERS');
|
|
128
|
+
assert.equal(builders.view, 'xenos');
|
|
129
|
+
// board-room matches its category → inherits, so no `view` key to drift later.
|
|
130
|
+
assert.equal('view' in builders.channels.find((c) => c.key === 'board-room'), false);
|
|
131
|
+
// archon-only is tighter → must be stated or the config would widen it.
|
|
132
|
+
assert.equal(builders.channels.find((c) => c.key === 'archon-only').view, 'archon');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('a tighter channel view is not merely recorded — it survives the round trip', () => {
|
|
136
|
+
// Guard against "stated but wrong": drop the view and the planner must object.
|
|
137
|
+
const snapshot = guild();
|
|
138
|
+
const { config } = configFromSnapshot(snapshot, ctx);
|
|
139
|
+
const builders = config.categories.find((c) => c.name === 'BUILDERS');
|
|
140
|
+
delete builders.channels.find((c) => c.key === 'archon-only').view;
|
|
141
|
+
const { ops } = planChannelReconcile(snapshot, config, ctx);
|
|
142
|
+
assert.ok(ops.length > 0, 'removing the tighter view must make the planner want to widen it');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ---- the honest edges ------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
test('an uncategorised channel is REPORTED and omitted, never invented into a category', () => {
|
|
148
|
+
const snapshot = guild();
|
|
149
|
+
snapshot.channels.push({ id: '14', name: 'stray', type: 0, parentId: null, topic: '', overwrites: owFor('everyone') });
|
|
150
|
+
const { config, warnings } = configFromSnapshot(snapshot, ctx);
|
|
151
|
+
const all = config.categories.flatMap((c) => c.channels.map((x) => x.key));
|
|
152
|
+
assert.ok(!all.includes('stray'), 'omitted — channels.json cannot express it');
|
|
153
|
+
assert.ok(warnings.some((w) => /stray/.test(w) && /no category/.test(w)), 'and said so');
|
|
154
|
+
// Omitted means UNTOUCHED, which is the file header's own rule.
|
|
155
|
+
const { ops } = planChannelReconcile(snapshot, config, ctx);
|
|
156
|
+
assert.deepEqual(ops, [], 'an omitted channel still produces no op');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('a never-reconciled channel warns instead of silently claiming a view', () => {
|
|
160
|
+
const snapshot = guild();
|
|
161
|
+
snapshot.channels.push({ id: '15', name: 'untouched', type: 0, parentId: '1', topic: '', overwrites: [] });
|
|
162
|
+
const { config, warnings } = configFromSnapshot(snapshot, ctx);
|
|
163
|
+
const entry = config.categories.find((c) => c.name === 'COMMONS').channels.find((c) => c.key === 'untouched');
|
|
164
|
+
assert.equal('view' in entry, false, 'inherits the category, as it does in the guild');
|
|
165
|
+
assert.ok(warnings.some((w) => /untouched/.test(w)), 'the operator is told before they apply');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('a CATEGORY with no managed overwrite is recorded as everyone, and warns', () => {
|
|
169
|
+
// The category-side twin of the channel null path above. A guild can carry a
|
|
170
|
+
// category the bot has never restricted; channels.json has no way to say
|
|
171
|
+
// "inherits" for a category (it is the top of the tree), so 'everyone' is the
|
|
172
|
+
// only expressible answer — and it is a WIDER claim than the guild made, which
|
|
173
|
+
// is exactly why it has to warn rather than pass silently.
|
|
174
|
+
const snapshot = {
|
|
175
|
+
categories: [{ id: '1', name: 'UNMANAGED', overwrites: [] }],
|
|
176
|
+
channels: [{ id: '10', name: 'chat', type: 0, parentId: '1', topic: '', overwrites: owFor('everyone') }],
|
|
177
|
+
};
|
|
178
|
+
const { config, warnings } = configFromSnapshot(snapshot, ctx);
|
|
179
|
+
assert.equal(config.categories[0].view, 'everyone');
|
|
180
|
+
assert.ok(
|
|
181
|
+
warnings.some((w) => /UNMANAGED/.test(w) && /confirm/i.test(w)),
|
|
182
|
+
'the operator is told the category view was assumed, not read'
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('the output is the shape channels.json actually declares', () => {
|
|
187
|
+
const { config } = configFromSnapshot(guild(), ctx);
|
|
188
|
+
assert.deepEqual(Object.keys(config), ['categories']);
|
|
189
|
+
for (const c of config.categories) {
|
|
190
|
+
assert.deepEqual(Object.keys(c).sort(), ['channels', 'name', 'view']);
|
|
191
|
+
for (const x of c.channels) {
|
|
192
|
+
for (const k of Object.keys(x)) assert.ok(['key', 'name', 'topic', 'view'].includes(k), `unexpected key ${k}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
console.log(`\ndiscord_channels_snapshot: ${passed} passed, ${failed} failed`);
|
|
198
|
+
process.exit(failed === 0 ? 0 : 1);
|