@bongos/core 1.19.1032 → 1.19.1033
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 +159 -54
- package/.claude/skills/grade-audit/SKILL.md +1 -1
- package/.claude/skills/grader-health/SKILL.md +1 -1
- package/docs/adr/0175-the-board-room-full-idea-ratification.md +3 -2
- package/docs/adr/0178-the-copy-desk-flag-and-queue.md +3 -3
- package/docs/adr/0188-patrol-runs-on-ci-not-the-server.md +2 -2
- package/docs/adr/0189-baseline-floors-and-the-freeze-detector.md +1 -1
- package/docs/adr/0233-a-copy-proposal-is-a-task-carrying-a-patch.md +1 -1
- package/docs/adr/0241-the-artist-gate-is-a-per-project-deploy-gate-that-reads-a-state.md +11 -3
- package/docs/adr/README.md +3 -3
- package/docs/architecture.md +1 -1
- package/docs/file-map.md +4 -3
- package/docs/module-api-changelog.md +2 -0
- package/docs/recipes/autobongos-windows-host.md +3 -1
- package/docs/recipes/packaging-the-core.md +2 -0
- package/docs/recipes/redteam-patrol.md +2 -2
- package/docs/recipes/upgrading-the-core.md +1 -1
- package/modules/autonomy/gauge.js +57 -9
- package/modules/hall-ui/records/builders-roster.md +10 -0
- package/modules/hall-ui/records/catalog.md +10 -0
- package/modules/hall-ui/records/collaboration.md +14 -0
- package/modules/hall-ui/records/goal-dependency-forest.md +11 -0
- package/modules/hall-ui/records/oversight-family.md +19 -0
- package/modules/hall-ui/records/panel-family.md +19 -0
- package/modules/hall-ui/records/reading-rooms.md +12 -0
- package/modules/hall-ui/records/record-family.md +13 -0
- package/modules/hall-ui/records/reference-room.md +9 -0
- package/modules/hall-ui/records/roster-and-ledger.md +9 -0
- package/modules/hall-ui/records/sky.md +23 -0
- package/modules/lifecycle/dependency-advisory.js +2 -1
- package/modules/lifecycle/ship-preflight.js +91 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +7411 -0
- package/scripts/gds/address-park-sweep.sh +3 -1
- package/scripts/gds/autobongos-run.js +81 -1
- package/scripts/gds/baseline-staleness.js +1 -1
- package/scripts/gds/db-backup-nightly.sh +9 -4
- package/scripts/gds/db-backup-offsite.sh +4 -2
- package/scripts/gds/package-core.js +72 -1
- package/scripts/gds/provision-core-upgrade.js +128 -77
- package/scripts/gds/redteam-patrol.js +4 -4
- package/scripts/gds/release-notes.js +271 -0
- package/scripts/gds/seed-board-room-tasks.js +1 -1
- package/scripts/gds/ship-flow.js +5 -3
- package/scripts/gds/ship-land.js +86 -7
- package/scripts/gds/ship-preflight-steps.js +56 -3
- package/scripts/gds/stray-unit-sweep.sh +3 -1
- package/scripts/gds/upgrade-migrate.js +67 -0
- package/scripts/gds/upgrade.js +6 -4
- package/src/module-api.js +1 -1
- package/tests/adr_0162_citation.mjs +184 -0
- package/tests/autobongos_loop.mjs +114 -0
- package/tests/autonomy_gauge.mjs +56 -0
- package/tests/core_upgrade_runner.mjs +89 -1
- package/tests/db_backup_nightly.mjs +21 -3
- package/tests/db_backup_offsite.mjs +81 -0
- package/tests/hall_records.mjs +85 -0
- package/tests/helpers.mjs +25 -0
- package/tests/instance_db_migrated_on_code_move.mjs +8 -1
- package/tests/plain_cards.mjs +1 -1
- package/tests/redteam_patrol.mjs +1 -1
- package/tests/release_notes.mjs +327 -0
- package/tests/shell_stat_portability.mjs +49 -0
- package/tests/ship_land_conflict.mjs +181 -0
- package/tests/ship_preflight.mjs +160 -3
- package/tests/sweep_app_account.mjs +15 -2
- package/tests/upgrade_migrate.mjs +195 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// scripts/gds/release-notes.js — what each core release brought in, as the summaries its
|
|
4
|
+
// ships wrote, generated when the core is PACKED and shipped inside the package as
|
|
5
|
+
// release-notes.json (task 1004218).
|
|
6
|
+
//
|
|
7
|
+
// WHY INSIDE THE PACKAGE. The Deploy page's "what this brings in" list used to be read out
|
|
8
|
+
// of a core git checkout on the machine asking (task 1004173), and no tenant box will ever
|
|
9
|
+
// have one: every project the wizard creates is a checkout of its OWN repo with the core
|
|
10
|
+
// vendored in at node_modules. The one machine that always has the full history is the one
|
|
11
|
+
// that builds the package — publish.yml checks out at fetch-depth 0 because package-core
|
|
12
|
+
// archives from git — so that is where the notes are written down, once, and every project
|
|
13
|
+
// that can download a core version can read what it carries.
|
|
14
|
+
//
|
|
15
|
+
// THREE PARTS, NO I/O OF ITS OWN. The PARSER (parseChangeRecords, moved here from
|
|
16
|
+
// provision-core-upgrade.js so there is exactly one), the GENERATOR (package-core.js hands
|
|
17
|
+
// it the ref's history), and the RANGE READER (the upgrade runner asks it for from→to).
|
|
18
|
+
// Git is injected, never required, so loading this costs package-core nothing.
|
|
19
|
+
//
|
|
20
|
+
// THE SHAPE. `{ "<version>": [ { id?, text }, … ] }`, ascending by version, one key per
|
|
21
|
+
// release — including a release whose merges were all housekeeping, which keeps an EMPTY
|
|
22
|
+
// list. That is the difference between "nothing landed" and "not covered": a range is
|
|
23
|
+
// answerable only when its target is a key, so a missing key can never be read as silence.
|
|
24
|
+
|
|
25
|
+
const CHANGE_TEXT_MAX = 400;
|
|
26
|
+
|
|
27
|
+
// The file's name at the package root, beside package.json.
|
|
28
|
+
const RELEASE_NOTES_FILE = 'release-notes.json';
|
|
29
|
+
|
|
30
|
+
// THE BOUND. The whole history to 1.19.1029 is ~400 KB pretty-printed (1,033 releases,
|
|
31
|
+
// 1,399 summaries, measured 2026-09-25), growing roughly 200 KB a month at 20–30 releases a
|
|
32
|
+
// day. Nothing needs trimming today; the bound exists so the file cannot grow unwatched.
|
|
33
|
+
// Past it, the OLDEST releases are dropped — the ranges an owner asks about are recent ones
|
|
34
|
+
// — and the reader then says those older ranges are not covered, rather than guessing.
|
|
35
|
+
const RELEASE_NOTES_MAX_BYTES = 1024 * 1024;
|
|
36
|
+
|
|
37
|
+
// A release commit's subject — the lane's own carrier AND a human release both begin this
|
|
38
|
+
// way. The same marker findCoreCheckout's probe uses to recognise core history.
|
|
39
|
+
const RELEASE_SUBJECT_RE = /^release: core (\d+\.\d+\.\d+) /;
|
|
40
|
+
const CORE_VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+$/;
|
|
41
|
+
|
|
42
|
+
// One record per commit: sha, parents, subject, body. `--topo-order` is load-bearing — see
|
|
43
|
+
// buildReleaseNotes for why the order releases are visited in must be topological.
|
|
44
|
+
const LOG_ARGS = ['log', '--topo-order', '--format=%H%x1f%P%x1f%s%x1f%b%x1e'];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The merge summaries in a `git log --merges --format=%s%x1f%b%x1e` dump. PURE.
|
|
48
|
+
*
|
|
49
|
+
* TWO MERGE SHAPES CARRY A SUMMARY AND THE SECOND IS THE COMMON ONE. A hand-landed ship
|
|
50
|
+
* writes `Merge <branch>: <summary>`; the CI auto-merge writes `Merge pull request #N
|
|
51
|
+
* from <owner>/<branch>` with `task <id>: <summary>` in the BODY. Measured over
|
|
52
|
+
* 1.19.881→1.19.972: 114 usable summaries, of which 98 were the second shape. A parser
|
|
53
|
+
* that read subjects only would have found eleven of them and quietly called that the
|
|
54
|
+
* release.
|
|
55
|
+
*
|
|
56
|
+
* EVERYTHING ELSE IS DROPPED, and the drops are the majority: `Merge origin/main into
|
|
57
|
+
* <branch> (pre-stage freshness…)`, `Merge remote-tracking branch…`, a regen chore. Those
|
|
58
|
+
* are the mechanics of landing work, not the work, and listing them would bury the
|
|
59
|
+
* eleven sentences the owner actually wants under a hundred they do not.
|
|
60
|
+
*/
|
|
61
|
+
function parseChangeRecords(stdout) {
|
|
62
|
+
const out = [];
|
|
63
|
+
const seen = new Set();
|
|
64
|
+
for (const rec of String(stdout || '').split('\x1e')) {
|
|
65
|
+
const r = rec.trim();
|
|
66
|
+
if (!r) continue;
|
|
67
|
+
const i = r.indexOf('\x1f');
|
|
68
|
+
const subject = (i < 0 ? r : r.slice(0, i)).trim();
|
|
69
|
+
const body = i < 0 ? '' : r.slice(i + 1);
|
|
70
|
+
let text = null;
|
|
71
|
+
let id = null;
|
|
72
|
+
// The negative lookahead is what keeps the two housekeeping shapes out: both begin
|
|
73
|
+
// "Merge " and both would otherwise match a `<token>:` somewhere later in the line.
|
|
74
|
+
let m = /^Merge\s+(?!pull request\b|remote-tracking\b)([^\s:]+):\s*(\S.*)$/.exec(subject);
|
|
75
|
+
if (m) {
|
|
76
|
+
text = m[2];
|
|
77
|
+
} else {
|
|
78
|
+
const first = body.split('\n').map((l) => l.trim()).find(Boolean) || '';
|
|
79
|
+
m = /^task\s+(\d+):\s*(\S.*)$/.exec(first);
|
|
80
|
+
if (m) { id = m[1]; text = m[2]; }
|
|
81
|
+
}
|
|
82
|
+
if (!text) continue;
|
|
83
|
+
// It reaches a DOM, and a ship summary is occasionally a paragraph.
|
|
84
|
+
text = boundText(text);
|
|
85
|
+
if (seen.has(text)) continue; // a branch merged twice says its sentence once
|
|
86
|
+
seen.add(text);
|
|
87
|
+
out.push(id ? { id, text } : { text });
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function boundText(text) {
|
|
93
|
+
return text.length > CHANGE_TEXT_MAX ? `${text.slice(0, CHANGE_TEXT_MAX - 1)}…` : text;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Order two three-number core versions. PURE; both must already match CORE_VERSION_RE. */
|
|
97
|
+
function compareCoreVersions(a, b) {
|
|
98
|
+
const x = String(a).split('.').map(Number);
|
|
99
|
+
const y = String(b).split('.').map(Number);
|
|
100
|
+
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] - y[i];
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Split a LOG_ARGS dump into `{ sha, parents, subject, body }` records, in log order. PURE. */
|
|
105
|
+
function parseLogRecords(stdout) {
|
|
106
|
+
const out = [];
|
|
107
|
+
for (const rec of String(stdout || '').split('\x1e')) {
|
|
108
|
+
const r = rec.replace(/^\n+/, ''); // git terminates each record with a newline
|
|
109
|
+
if (!r.trim()) continue;
|
|
110
|
+
const [sha, parents, subject, ...body] = r.split('\x1f');
|
|
111
|
+
out.push({
|
|
112
|
+
sha: sha.trim(),
|
|
113
|
+
parents: String(parents || '').split(' ').filter(Boolean),
|
|
114
|
+
subject: subject || '',
|
|
115
|
+
body: body.join('\x1f'),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function uniqueByText(entries) {
|
|
122
|
+
const seen = new Set();
|
|
123
|
+
return entries.filter((e) => (seen.has(e.text) ? false : seen.add(e.text)));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* What each release brought in, from a whole history. PURE.
|
|
128
|
+
*
|
|
129
|
+
* `commits` is every commit the packed ref reaches, NEWEST FIRST in topological order (what
|
|
130
|
+
* LOG_ARGS asks git for). Returns `{ notes, releases, tail }`.
|
|
131
|
+
*
|
|
132
|
+
* EXACTLY `git log --merges <previous release>..<release>`, FOR EVERY RELEASE AT ONCE — the
|
|
133
|
+
* range the checkout-based reader asks for one pair at a time. Splitting the log wherever a
|
|
134
|
+
* release subject appears would be simpler and is wrong: a PR branch's commits are not
|
|
135
|
+
* ordered against main's by the log, so a merge made on a branch before one release but
|
|
136
|
+
* landed after it would be credited to the earlier release. So each release, oldest first,
|
|
137
|
+
* claims everything it can reach that no earlier release reached. The visiting order has to
|
|
138
|
+
* be topological for that to be the previous-release range — hence `--topo-order`.
|
|
139
|
+
*
|
|
140
|
+
* THE UNRELEASED TAIL — commits the ref reaches that no release commit does — belongs to the
|
|
141
|
+
* version being packed when that version has no release commit of its own yet (a pack of a
|
|
142
|
+
* hand-declared version), and to nobody otherwise: on a work merge the tail is the NEXT
|
|
143
|
+
* release's, whose number does not exist yet.
|
|
144
|
+
*/
|
|
145
|
+
function buildReleaseNotes(commits, { version = null } = {}) {
|
|
146
|
+
const bySha = new Map(commits.map((c) => [c.sha, c]));
|
|
147
|
+
const position = new Map(commits.map((c, i) => [c.sha, i]));
|
|
148
|
+
const claimed = new Set();
|
|
149
|
+
const claim = (tip) => {
|
|
150
|
+
const mine = [];
|
|
151
|
+
const stack = [tip];
|
|
152
|
+
while (stack.length) {
|
|
153
|
+
const sha = stack.pop();
|
|
154
|
+
if (claimed.has(sha) || !bySha.has(sha)) continue;
|
|
155
|
+
claimed.add(sha);
|
|
156
|
+
const c = bySha.get(sha);
|
|
157
|
+
mine.push(c);
|
|
158
|
+
for (const p of c.parents) if (!claimed.has(p)) stack.push(p);
|
|
159
|
+
}
|
|
160
|
+
return mine;
|
|
161
|
+
};
|
|
162
|
+
// Newest first within a release, as `git log` itself would list them.
|
|
163
|
+
const summarize = (segment) => parseChangeRecords(segment
|
|
164
|
+
.filter((c) => c.parents.length > 1)
|
|
165
|
+
.sort((a, b) => position.get(a.sha) - position.get(b.sha))
|
|
166
|
+
.map((c) => `${c.subject}\x1f${c.body}\x1e`)
|
|
167
|
+
.join(''));
|
|
168
|
+
|
|
169
|
+
const byVersion = new Map();
|
|
170
|
+
let releases = 0;
|
|
171
|
+
for (let i = commits.length - 1; i >= 0; i--) {
|
|
172
|
+
const m = RELEASE_SUBJECT_RE.exec(commits[i].subject);
|
|
173
|
+
if (!m) continue;
|
|
174
|
+
releases++;
|
|
175
|
+
// A version released twice keeps both halves; the second claims only what the first
|
|
176
|
+
// could not reach.
|
|
177
|
+
byVersion.set(m[1], uniqueByText([...(byVersion.get(m[1]) || []), ...summarize(claim(commits[i].sha))]));
|
|
178
|
+
}
|
|
179
|
+
const tail = commits.filter((c) => !claimed.has(c.sha));
|
|
180
|
+
if (version && CORE_VERSION_RE.test(version) && !byVersion.has(version)
|
|
181
|
+
&& [...byVersion.keys()].every((v) => compareCoreVersions(v, version) < 0)) {
|
|
182
|
+
byVersion.set(version, summarize(tail));
|
|
183
|
+
}
|
|
184
|
+
const notes = {};
|
|
185
|
+
for (const v of [...byVersion.keys()].sort(compareCoreVersions)) notes[v] = byVersion.get(v);
|
|
186
|
+
return { notes, releases, tail: tail.length };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Read a ref's history through an injected `git(args) → stdout` and build its notes.
|
|
191
|
+
* Returns `{ notes, releases, tail }`, or `{ skipped }` saying why there are none — never
|
|
192
|
+
* a partial answer presented as a whole one.
|
|
193
|
+
*/
|
|
194
|
+
function generateReleaseNotes({ git, ref, version }) {
|
|
195
|
+
let shallow;
|
|
196
|
+
try { shallow = String(git(['rev-parse', '--is-shallow-repository'])).trim() === 'true'; }
|
|
197
|
+
catch (e) { return { skipped: `could not ask git about this clone (${firstLine(e)})` }; }
|
|
198
|
+
// A depth-limited clone reaches a handful of commits, and notes built from it would
|
|
199
|
+
// credit the newest release with everything the clone happens to hold.
|
|
200
|
+
if (shallow) return { skipped: 'this is a shallow clone, so it carries no release history' };
|
|
201
|
+
let out;
|
|
202
|
+
try { out = git([...LOG_ARGS, ref]); }
|
|
203
|
+
catch (e) { return { skipped: `could not read the history of ${ref} (${firstLine(e)})` }; }
|
|
204
|
+
const built = buildReleaseNotes(parseLogRecords(out), { version });
|
|
205
|
+
if (!built.releases) return { skipped: 'this history holds no release commits' };
|
|
206
|
+
return built;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function firstLine(e) {
|
|
210
|
+
return String((e && e.message) || e || '').trim().split('\n')[0].slice(0, 160);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The file's bytes: the same pretty-printed JSON the package's other generated files use. */
|
|
214
|
+
function serializeReleaseNotes(notes) {
|
|
215
|
+
return Buffer.from(`${JSON.stringify(notes, null, 2)}\n`, 'utf8');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Hold the file under `maxBytes` by dropping the OLDEST releases. PURE.
|
|
220
|
+
* Returns `{ notes, dropped }`; the newest release is always kept.
|
|
221
|
+
*/
|
|
222
|
+
function boundReleaseNotes(notes, maxBytes = RELEASE_NOTES_MAX_BYTES) {
|
|
223
|
+
const versions = Object.keys(notes);
|
|
224
|
+
const keep = (from) => Object.fromEntries(versions.slice(from).map((v) => [v, notes[v]]));
|
|
225
|
+
const fits = (from) => serializeReleaseNotes(keep(from)).length <= maxBytes;
|
|
226
|
+
if (versions.length <= 1 || fits(0)) return { notes, dropped: [] };
|
|
227
|
+
// The fewest drops that fit, by bisection — the size only shrinks as more are dropped.
|
|
228
|
+
let lo = 1;
|
|
229
|
+
let hi = versions.length - 1;
|
|
230
|
+
while (lo < hi) {
|
|
231
|
+
const mid = (lo + hi) >> 1;
|
|
232
|
+
if (fits(mid)) hi = mid; else lo = mid + 1;
|
|
233
|
+
}
|
|
234
|
+
return { notes: keep(lo), dropped: versions.slice(0, lo) };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The entries for every release in `(from, to]`, newest release first. PURE.
|
|
239
|
+
*
|
|
240
|
+
* Returns `{ entries }` or `{ uncovered }` — a phrase saying why these notes cannot answer,
|
|
241
|
+
* worded to follow "the release notes …". NEVER an empty list for a range the notes do not
|
|
242
|
+
* reach: an empty list reads as "nothing changed", which is exactly what a restart must not
|
|
243
|
+
* be sold as. They reach a range when they list its target and begin at or before its
|
|
244
|
+
* start: the oldest release they hold is where their knowledge begins.
|
|
245
|
+
*
|
|
246
|
+
* The file may have come off the network, so every entry is re-checked here, not trusted.
|
|
247
|
+
*/
|
|
248
|
+
function notesBetween(notes, from, to) {
|
|
249
|
+
if (!notes || typeof notes !== 'object' || Array.isArray(notes)) return { uncovered: 'are not a list of releases' };
|
|
250
|
+
const versions = Object.keys(notes)
|
|
251
|
+
.filter((v) => CORE_VERSION_RE.test(v) && Array.isArray(notes[v]))
|
|
252
|
+
.sort(compareCoreVersions);
|
|
253
|
+
if (!versions.includes(to)) return { uncovered: `do not list core ${to}` };
|
|
254
|
+
if (compareCoreVersions(versions[0], from) > 0) return { uncovered: `begin at core ${versions[0]}, after ${from}` };
|
|
255
|
+
const inRange = versions.filter((v) => compareCoreVersions(v, from) > 0 && compareCoreVersions(v, to) <= 0);
|
|
256
|
+
const entries = [];
|
|
257
|
+
for (const v of inRange.reverse()) {
|
|
258
|
+
for (const e of notes[v]) {
|
|
259
|
+
if (!e || typeof e.text !== 'string' || !e.text.trim()) continue;
|
|
260
|
+
const text = boundText(e.text.trim());
|
|
261
|
+
entries.push(/^\d+$/.test(String(e.id || '')) ? { id: String(e.id), text } : { text });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return { entries: uniqueByText(entries) };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
module.exports = {
|
|
268
|
+
RELEASE_NOTES_FILE, RELEASE_NOTES_MAX_BYTES,
|
|
269
|
+
parseChangeRecords, parseLogRecords, buildReleaseNotes,
|
|
270
|
+
generateReleaseNotes, serializeReleaseNotes, boundReleaseNotes, notesBetween,
|
|
271
|
+
};
|
|
@@ -43,7 +43,7 @@ const TASKS = [
|
|
|
43
43
|
// ---- Phase A — decide + foundation (P5) ----
|
|
44
44
|
{ ref:'R01', kind:'decision', disc:'engineer', prio:5, est:150, crit:[C1,C3,C4], deps:[],
|
|
45
45
|
title:'R01 · ADR: the Board Room government model',
|
|
46
|
-
desc:`Record the government model as one decision so the schema has something to encode.\n\nMUST STATE: the board's TWO subjects (a Full Idea, and a change to the constitution itself) and why nothing else is votable; membership as a configured predicate resolved per request and uncached; the timed SITTING (absent votes are nulled, not blocking) and why that replaces quorum — the clock IS the deadlock exit; CONSENT rather than majority, with silence as consent and one reasoned objection returning the idea; a zero-vote close PASSING; the OPEN ballot; Discord as an outbound notification mirror that casts nothing; and the amendment rule — a constitutional change is decided under whatever rule the CURRENT constitution names, so flipping out of monarchy is a legal monarchy act.\n\nAlso record the two owner decisions taken against recommendation with their trade-offs intact: vote-karma (yes-clicking bias, accepted because item volume is too low to farm) and automatic window-open on grade pass (author loses timing control; concurrent windows become normal).\n\nGrounding: idea 1000738, idea 1000754, the Six Variables of Governance synthesis, ADR 0151 (permissions-as-atoms), ADR 0157, ADR
|
|
46
|
+
desc:`Record the government model as one decision so the schema has something to encode.\n\nMUST STATE: the board's TWO subjects (a Full Idea, and a change to the constitution itself) and why nothing else is votable; membership as a configured predicate resolved per request and uncached; the timed SITTING (absent votes are nulled, not blocking) and why that replaces quorum — the clock IS the deadlock exit; CONSENT rather than majority, with silence as consent and one reasoned objection returning the idea; a zero-vote close PASSING; the OPEN ballot; Discord as an outbound notification mirror that casts nothing; and the amendment rule — a constitutional change is decided under whatever rule the CURRENT constitution names, so flipping out of monarchy is a legal monarchy act.\n\nAlso record the two owner decisions taken against recommendation with their trade-offs intact: vote-karma (yes-clicking bias, accepted because item volume is too low to farm) and automatic window-open on grade pass (author loses timing control; concurrent windows become normal).\n\nGrounding: idea 1000738, idea 1000754, the Six Variables of Governance synthesis, ADR 0151 (permissions-as-atoms), ADR 0157, ADR 0158 §2 (verdicts advisory), ADR 0162 (no human approval in the land path), ADR 0174 (government/ranks/roles). ${SPEC}` },
|
|
47
47
|
|
|
48
48
|
{ ref:'R02', kind:'decision', disc:'engineer', prio:5, est:90, crit:[C1], deps:['R01'],
|
|
49
49
|
title:'R02 · The pass-event contract the ideator credit lane consumes',
|
package/scripts/gds/ship-flow.js
CHANGED
|
@@ -18,7 +18,7 @@ const { captureSurpriseLearning, promptSurprise } = require('./ship-surprise.js'
|
|
|
18
18
|
// task 1003109: the optional `--visual` leg of a ship (goal 1000072).
|
|
19
19
|
const shipVisual = require('./ship-visual.js');
|
|
20
20
|
const { gitOk } = require('./ship-git.js');
|
|
21
|
-
const { autoLinkifyChangedDocs, mergeMainIntoBranch, preflight } = require('./ship-preflight-steps.js');
|
|
21
|
+
const { autoLinkifyChangedDocs, mergeMainIntoBranch, preflight, shipInputPaths } = require('./ship-preflight-steps.js');
|
|
22
22
|
const { applyBaseOverride, computeChangedFiles, computeGraderBaseline, runSmokeAsync } = require('./ship-grade.js');
|
|
23
23
|
const { buildPriorRoundContext, computeGradeResult, gradeAndRecord, printCreditsAndAchievements, printGradeOutcome } = require('./ship-grade-steps.js');
|
|
24
24
|
const { apiErrorLine, normalizeApiError, isUnknownFieldRejection } = require('./ship-io.js');
|
|
@@ -196,7 +196,9 @@ async function shipMain() {
|
|
|
196
196
|
// ---- Step 0: preflight (working tree + branch freshness) ----
|
|
197
197
|
console.log(`Verifying claim #${ac.claim_id} on task #${ac.task_id}: ${ac.title}`);
|
|
198
198
|
console.log('Preflight (working tree + branch freshness)...');
|
|
199
|
-
|
|
199
|
+
// task 1001436: inputFiles keeps --notes-file/--summary-file/--visual out of the
|
|
200
|
+
// preflight's untracked-file warning — they are this ship's input, not its artifact.
|
|
201
|
+
const pre = preflight(ac, { force, inputFiles: shipInputPaths(args) });
|
|
200
202
|
if (!pre.ok) {
|
|
201
203
|
console.error('');
|
|
202
204
|
console.error(`Ship aborted: ${pre.reason}. No claim resolved, no credits awarded.`);
|
|
@@ -929,7 +931,7 @@ async function regradeMain() {
|
|
|
929
931
|
// if the local ship/grade tooling is behind main (a stale grader silently
|
|
930
932
|
// fails the same way — the #662 lesson). --force overrides the freshness leg.
|
|
931
933
|
console.log('Preflight (working tree + branch freshness)...');
|
|
932
|
-
const pre = preflight(claimLike, { force });
|
|
934
|
+
const pre = preflight(claimLike, { force, inputFiles: shipInputPaths(args) });
|
|
933
935
|
if (!pre.ok) {
|
|
934
936
|
console.error('');
|
|
935
937
|
console.error(`Re-grade aborted: ${pre.reason}. Task stays at completed.`);
|
package/scripts/gds/ship-land.js
CHANGED
|
@@ -354,6 +354,59 @@ function redPrLines(md) {
|
|
|
354
354
|
return red ? [red, ...plainCardLines((md && md.reason) || 'failing_check')] : [];
|
|
355
355
|
}
|
|
356
356
|
|
|
357
|
+
// task 1003499 — a CONFLICTING PR is a land that cannot happen, however green it is.
|
|
358
|
+
// GitHub's auto-merge waits for a clean merge as well as for green checks, so a PR
|
|
359
|
+
// whose branch conflicts with main sits open under a fully green rollup and never
|
|
360
|
+
// merges. PR #497 sat like that for ~25 minutes while both polls below said nothing
|
|
361
|
+
// and then printed "auto-merge is armed ... NOT a failure", the banner a land that IS
|
|
362
|
+
// on its way gets. The server already knew (mergeBlockReason -> 'merge_conflict'); the
|
|
363
|
+
// ship never asked. Pure + exported. Takes both shapes it is handed: `gh pr view`'s
|
|
364
|
+
// camelCase (mergeable, mergeStateStatus) and publish-status's snake_case merge_driver
|
|
365
|
+
// (mergeable, merge_state, reason). A read that failed (null) is not a conflict.
|
|
366
|
+
function prConflicting(pr) {
|
|
367
|
+
if (!pr) return false;
|
|
368
|
+
const state = pr.mergeStateStatus ?? pr.merge_state;
|
|
369
|
+
return pr.mergeable === 'CONFLICTING' || state === 'DIRTY' || pr.reason === 'merge_conflict';
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// What the poll says the moment it sees the conflict. Pure + exported so the wording
|
|
373
|
+
// is pinned by tests/ship_land_conflict.mjs. The poll keeps waiting afterwards on
|
|
374
|
+
// purpose: a conflict only in generated files is healed by the server's own resolver
|
|
375
|
+
// on its next sweep (ADR 0082), and the PR then merges inside this same window.
|
|
376
|
+
function conflictPrLines(taskId) {
|
|
377
|
+
return [
|
|
378
|
+
' ci-land: the PR CONFLICTS with main, so auto-merge cannot fire until that is resolved, however green its checks are.',
|
|
379
|
+
' ci-land: a conflict only in generated files is healed by the server on its next sweep (~5 min), so this keeps waiting.',
|
|
380
|
+
' ci-land: anything hand-written waits for you. From this worktree:',
|
|
381
|
+
' git fetch -q origin && git merge origin/main # resolve the conflicts, commit',
|
|
382
|
+
` node scripts/gds/ship.js ${taskId} # resumes the land; it does not re-grade`,
|
|
383
|
+
...plainCardLines('merge_conflict'),
|
|
384
|
+
];
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// noteConflict — one poll's worth of conflict tracking, shared by both loops. Prints
|
|
388
|
+
// the lines above when the PR BECOMES conflicting (not on every 10s poll, and once
|
|
389
|
+
// more if main moves on and undoes a heal), and returns what the PR looks like now.
|
|
390
|
+
// A failed read keeps the last answer rather than inventing a new one.
|
|
391
|
+
function noteConflict(taskId, pr, wasConflicting) {
|
|
392
|
+
if (!pr) return wasConflicting;
|
|
393
|
+
const now = prConflicting(pr);
|
|
394
|
+
if (now && !wasConflicting) for (const line of conflictPrLines(taskId)) console.log(line);
|
|
395
|
+
return now;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// landConflicted — the verdict when the wait ends with the PR STILL conflicting. It is
|
|
399
|
+
// not landing on its own, so it must not print the "landing automatically" banner or
|
|
400
|
+
// the land_pending card ("Nothing to do. This is normal, not a failure."): task
|
|
401
|
+
// 1002541's rule that a land which is not happening must not announce itself as
|
|
402
|
+
// success. No bailId, so ship-merge never reroutes it: the PR already exists and is
|
|
403
|
+
// the problem. /strand-fix walks the cure (its case C: merge main in, resolve, re-ship).
|
|
404
|
+
function landConflicted(taskId) {
|
|
405
|
+
console.log('');
|
|
406
|
+
console.log(` ci-land: still CONFLICTING when the wait ended, so this will NOT land on its own. The task stays at 'confirmed' until the conflict is resolved (/strand-fix ${taskId} walks it).`);
|
|
407
|
+
return landBailed('the PR conflicts with main, so it cannot merge until the conflict is resolved', `/strand-fix ${taskId}`);
|
|
408
|
+
}
|
|
409
|
+
|
|
357
410
|
// One module-scope sleep command shared by both poll loops (task 1093 / C9). A
|
|
358
411
|
// child `node -e setTimeout` is used (not `await new Promise`) so it blocks
|
|
359
412
|
// identically in the SYNCHRONOUS pollCiDeploy and the async pollServerPublish —
|
|
@@ -366,8 +419,12 @@ const POLL_SLEEP_CMD = 'node -e "setTimeout(()=>{}, 10000)"';
|
|
|
366
419
|
// reconciles the task to shipped when it observes merge+deploy — task 1049) until
|
|
367
420
|
// the PR merges + deploy-prod succeeds. Returns true when prod is confirmed live;
|
|
368
421
|
// a timeout with an armed PR is success-PENDING (the publish worked), not failure.
|
|
369
|
-
|
|
422
|
+
// A timeout with a CONFLICTING PR is not (task 1003499): see landConflicted.
|
|
423
|
+
// `deps` exists for tests/ship_land_conflict.mjs; a real ship passes none.
|
|
424
|
+
async function pollServerPublish(taskId, branch, deps = {}) {
|
|
370
425
|
const TRIES = 54; // ~9 min at 10s — checks + auto-merge + Actions deploy
|
|
426
|
+
const readStatus = deps.readStatus || (async (q) => (await cliClient()).tasks.getTasksIdPublishStatus(q));
|
|
427
|
+
const pause = deps.pause || (() => shellOk(POLL_SLEEP_CMD));
|
|
371
428
|
console.log(' ci-land: waiting for auto-merge + prod deploy (polling the server)...');
|
|
372
429
|
// task 1002645: GitHub can report merged+deployed while the SERVER refused the
|
|
373
430
|
// confirmed→shipped flip (reconcile_blocked — the task-1547 land proof missed).
|
|
@@ -376,14 +433,16 @@ async function pollServerPublish(taskId, branch) {
|
|
|
376
433
|
// pushed tip, so the proof usually heals within the window.
|
|
377
434
|
let blockedReason = null;
|
|
378
435
|
let redLine = null;
|
|
436
|
+
let conflicting = false;
|
|
379
437
|
for (let i = 0; i < TRIES; i++) {
|
|
380
|
-
const r = await (
|
|
438
|
+
const r = await readStatus({ id: taskId, query: { branch } });
|
|
381
439
|
if (r.ok && r.data) {
|
|
382
440
|
const red = redPrLine(r.data.merge_driver);
|
|
383
441
|
if (red && red !== redLine) {
|
|
384
442
|
redLine = red;
|
|
385
443
|
for (const line of redPrLines(r.data.merge_driver)) console.log(line);
|
|
386
444
|
}
|
|
445
|
+
conflicting = noteConflict(taskId, r.data.merge_driver, conflicting);
|
|
387
446
|
const outcome = classifyLandOutcome(r.data);
|
|
388
447
|
if (outcome === 'shipped') {
|
|
389
448
|
if (r.data.reconcile_blocked) {
|
|
@@ -401,7 +460,7 @@ async function pollServerPublish(taskId, branch) {
|
|
|
401
460
|
return landBailed('the PR was closed without merging (a check failed, or it was closed manually)', `node scripts/gds/ship.js ${taskId}`);
|
|
402
461
|
}
|
|
403
462
|
}
|
|
404
|
-
|
|
463
|
+
pause();
|
|
405
464
|
}
|
|
406
465
|
if (blockedReason) {
|
|
407
466
|
console.log('');
|
|
@@ -409,6 +468,7 @@ async function pollServerPublish(taskId, branch) {
|
|
|
409
468
|
for (const line of plainCardLines('reconcile_blocked')) console.log(line);
|
|
410
469
|
return landBailed(`the PR merged but the server could not prove the land is this task's: ${blockedReason}`, `node scripts/gds/ship.js ${taskId}`);
|
|
411
470
|
}
|
|
471
|
+
if (conflicting) return landConflicted(taskId);
|
|
412
472
|
printLandPending(taskId);
|
|
413
473
|
return landPending();
|
|
414
474
|
}
|
|
@@ -723,14 +783,22 @@ async function ciLandLocal(taskId, branch, valueSummary) {
|
|
|
723
783
|
// 3. public edge healthz -> 200 (belt-and-braces, a different vantage).
|
|
724
784
|
// Times out conservatively; on any timeout the task stays at confirmed with a
|
|
725
785
|
// precise next step rather than a false "shipped".
|
|
726
|
-
|
|
786
|
+
// `deps` exists for tests/ship_land_conflict.mjs; a real ship passes none.
|
|
787
|
+
function pollCiDeploy(taskId, branch, headSha, deps = {}) {
|
|
727
788
|
const MERGE_TRIES = 30; // ~5 min at 10s — checks + auto-merge
|
|
728
789
|
const DEPLOY_TRIES = 24; // ~4 min at 10s — Actions deploy
|
|
790
|
+
const readJson = deps.ghJson || ghJson;
|
|
791
|
+
const pause = deps.pause || (() => shellOk(POLL_SLEEP_CMD));
|
|
729
792
|
|
|
730
793
|
console.log(' ci-land: waiting for auto-merge (checks + branch protection)...');
|
|
731
794
|
let mergeSha = null;
|
|
795
|
+
let conflicting = false;
|
|
732
796
|
for (let i = 0; i < MERGE_TRIES; i++) {
|
|
733
|
-
|
|
797
|
+
// task 1003499: mergeable + mergeStateStatus ride the same read, so a conflict is
|
|
798
|
+
// named on the poll it appears; the two-field read is the fallback for a gh that
|
|
799
|
+
// predates those fields, which keeps this no worse than before on any machine.
|
|
800
|
+
const pr = readJson(['pr', 'view', branch, '--json', 'state,mergeCommit,mergeable,mergeStateStatus'])
|
|
801
|
+
|| readJson(['pr', 'view', branch, '--json', 'state,mergeCommit']);
|
|
734
802
|
if (pr && pr.state === 'MERGED') {
|
|
735
803
|
mergeSha = pr.mergeCommit && pr.mergeCommit.oid;
|
|
736
804
|
break;
|
|
@@ -739,11 +807,14 @@ function pollCiDeploy(taskId, branch, headSha) {
|
|
|
739
807
|
console.log(` ci-land: PR was CLOSED without merging (a required check failed, or it was closed manually). Fix it and re-run \`node scripts/gds/ship.js ${taskId}\` to resume. Task stays at 'confirmed'.`);
|
|
740
808
|
return landBailed('the PR was closed without merging (a check failed, or it was closed manually)', `node scripts/gds/ship.js ${taskId}`);
|
|
741
809
|
}
|
|
742
|
-
|
|
810
|
+
conflicting = noteConflict(taskId, pr, conflicting);
|
|
811
|
+
pause();
|
|
743
812
|
}
|
|
744
813
|
if (!mergeSha) {
|
|
745
814
|
// task 1049: PR open + auto-merge armed but not merged in-window = the publish
|
|
746
|
-
// SUCCEEDED and is still in CI — success-pending, not a failure.
|
|
815
|
+
// SUCCEEDED and is still in CI — success-pending, not a failure. Unless it is
|
|
816
|
+
// CONFLICTING (task 1003499): then nothing is still in CI, and it never merges.
|
|
817
|
+
if (conflicting) return landConflicted(taskId);
|
|
747
818
|
printLandPending(taskId);
|
|
748
819
|
return landPending();
|
|
749
820
|
}
|
|
@@ -811,6 +882,14 @@ module.exports = {
|
|
|
811
882
|
prAssigneeLine,
|
|
812
883
|
redPrLine,
|
|
813
884
|
redPrLines,
|
|
885
|
+
// task 1003499: a CONFLICTING PR is named while the land waits, and a wait that
|
|
886
|
+
// ends conflicting is a bail, not "landing automatically". The polls are exported
|
|
887
|
+
// with their optional `deps` so the test drives the real loops.
|
|
888
|
+
prConflicting,
|
|
889
|
+
conflictPrLines,
|
|
890
|
+
landConflicted,
|
|
891
|
+
pollCiDeploy,
|
|
892
|
+
pollServerPublish,
|
|
814
893
|
reconciledCardStatus,
|
|
815
894
|
strandCommand,
|
|
816
895
|
strandNextStep,
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
const { execFileSync } = require('node:child_process');
|
|
12
12
|
const fs = require('node:fs');
|
|
13
13
|
const path = require('node:path');
|
|
14
|
-
const { trackedDirty, cleanupMacFinderDupRefs } = require('../../modules/lifecycle/ship-preflight');
|
|
14
|
+
const { trackedDirty, cleanupMacFinderDupRefs, untrackedLeftBehind, untrackedWarningLines } = require('../../modules/lifecycle/ship-preflight');
|
|
15
|
+
const { arg } = require('./cli-lib');
|
|
15
16
|
const mergeRegen = require('./git-merge-regen');
|
|
16
17
|
const { argvOut, gitArgvOk, gitOk } = require('./ship-git.js');
|
|
17
18
|
const { pushVia } = require('./ship-deploy-target.js');
|
|
@@ -40,6 +41,36 @@ function parseDeletedUpstreamPaths(rawLogOutput) {
|
|
|
40
41
|
return [...new Set(rawLogOutput.split('\n').map((s) => s.trim()).filter(Boolean))];
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
// shipInputPaths — the repo-relative paths of the files a ship was HANDED as input
|
|
45
|
+
// (task 1001436), so preflight's untracked-file warning never lists them. The
|
|
46
|
+
// builder-ship skill writes notes.md + summary.txt into the worktree and passes them
|
|
47
|
+
// here; left in, they would put the warning on nearly every ship and teach everyone
|
|
48
|
+
// to skip it. `-` (stdin), a missing value and a path outside the checkout add
|
|
49
|
+
// nothing — a file outside the tree cannot be an untracked file in it. Both sides go
|
|
50
|
+
// through realpath, so a /var vs /private/var spelling of one file still matches.
|
|
51
|
+
// `cwd`, `repoRoot` and `realpath` are injectable for the unit test.
|
|
52
|
+
const SHIP_INPUT_FLAGS = ['--notes-file', '--summary-file', '--response-file', '--visual'];
|
|
53
|
+
|
|
54
|
+
function safeRealpath(p) {
|
|
55
|
+
try { return fs.realpathSync.native(p); } catch (_) { return path.resolve(p); }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function shipInputPaths(args, { cwd = process.cwd(), repoRoot = null, realpath = safeRealpath } = {}) {
|
|
59
|
+
const root = realpath(repoRoot || gitOk('git rev-parse --show-toplevel') || cwd);
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const flag of SHIP_INPUT_FLAGS) {
|
|
62
|
+
const value = arg(flag, args || []);
|
|
63
|
+
if (typeof value !== 'string' || value === '' || value === '-' || value.startsWith('--')) continue;
|
|
64
|
+
const rel = path.relative(root, realpath(path.resolve(cwd, value)));
|
|
65
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) continue;
|
|
66
|
+
out.push(rel.split(path.sep).join('/'));
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// opts: { force, inputFiles } — inputFiles is shipInputPaths(args) from the caller;
|
|
72
|
+
// omitted (the resume path has no prose flags), nothing is excluded from the
|
|
73
|
+
// untracked-file warning.
|
|
43
74
|
function preflight(claim, opts = {}) {
|
|
44
75
|
// (a) Working-tree check — refuse if any TRACKED file carries an uncommitted
|
|
45
76
|
// modification or staged change. This fires post-commit, so a clean tree
|
|
@@ -145,8 +176,29 @@ function preflight(claim, opts = {}) {
|
|
|
145
176
|
}
|
|
146
177
|
}
|
|
147
178
|
|
|
148
|
-
|
|
149
|
-
|
|
179
|
+
// (e) Untracked-file warning (task 1001436) — WARN-ONLY, never blocks, never
|
|
180
|
+
// changes the exit code. Step (a) ignores untracked files so scratch cannot
|
|
181
|
+
// false-refuse a ship, which also meant a NEW deliverable left un-added beside
|
|
182
|
+
// committed work shipped without it, in silence (the residual of the task-1363
|
|
183
|
+
// phantom ship). This names those files while stopping is still free. It needs
|
|
184
|
+
// committed work to warn about — upstreamDelTouched is this branch's committed
|
|
185
|
+
// diff from step (d); with none, the empty-diff guard already refuses and lists
|
|
186
|
+
// them. Its own `git status`, on purpose: --untracked-files=all names each file
|
|
187
|
+
// rather than a collapsed `dir/`, so the ship's input files can be excluded
|
|
188
|
+
// exactly; and a failed or oversized read here stays a skipped warning, never a
|
|
189
|
+
// blank porcelain for the refusal in step (a).
|
|
190
|
+
const untrackedPorcelain = gitOk('git status --porcelain --untracked-files=all', { maxBuffer: 64 * 1024 * 1024 });
|
|
191
|
+
const leftBehind = untrackedLeftBehind(untrackedPorcelain, {
|
|
192
|
+
committedFileCount: upstreamDelTouched.length,
|
|
193
|
+
inputs: opts.inputFiles || [],
|
|
194
|
+
});
|
|
195
|
+
for (const line of untrackedWarningLines(leftBehind, { taskId: claim && claim.task_id })) console.error(line);
|
|
196
|
+
|
|
197
|
+
const treeState = leftBehind.length > 0
|
|
198
|
+
? `tracked files clean (${leftBehind.length} untracked file(s) will not ship — see the warning)`
|
|
199
|
+
: 'working tree clean';
|
|
200
|
+
console.log(` preflight: ${treeState}, branch ${behind} commits behind main, ship tooling current — OK`);
|
|
201
|
+
return { ok: true, untrackedLeftBehind: leftBehind };
|
|
150
202
|
}
|
|
151
203
|
|
|
152
204
|
// mergeMainIntoBranch — pull origin/main INTO the current feature branch BEFORE
|
|
@@ -320,4 +372,5 @@ module.exports = {
|
|
|
320
372
|
mergeMainIntoBranch,
|
|
321
373
|
parseDeletedUpstreamPaths,
|
|
322
374
|
preflight,
|
|
375
|
+
shipInputPaths,
|
|
323
376
|
};
|
|
@@ -31,7 +31,9 @@ ETC=/etc/systemd/system
|
|
|
31
31
|
CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
32
32
|
case "$CORE_DIR" in */node_modules/@bongos/core) INSTANCE_DIR="${INSTANCE_DIR:-${CORE_DIR%/node_modules/@bongos/core}}" ;; esac
|
|
33
33
|
[ -n "${PROVISION_APP_USER:-}${INSTANCE_DIR:-}" ] || { echo "set INSTANCE_DIR to the control plane's instance checkout (or PROVISION_APP_USER) — this sweep is not running from one"; exit 1; }
|
|
34
|
-
|
|
34
|
+
# Owner by name: GNU stat `-c %U`, else BSD/macOS stat `-f %Su` — each refuses the other's
|
|
35
|
+
# flag (task 1004170), the same lookup address-park-sweep.sh makes.
|
|
36
|
+
APP_USER="${PROVISION_APP_USER:-$(stat -c %U "$INSTANCE_DIR" 2>/dev/null || stat -f %Su "$INSTANCE_DIR" 2>/dev/null)}"
|
|
35
37
|
if [ "$APP_USER" = root ] || ! [[ "$APP_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]]; then
|
|
36
38
|
echo "cannot use '$APP_USER' as the app account — set PROVISION_APP_USER to the unprivileged account that owns ${INSTANCE_DIR:-the instance checkout}"; exit 1
|
|
37
39
|
fi
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// scripts/gds/upgrade-migrate.js — WHICH command `bongos upgrade` migrates an instance
|
|
4
|
+
// with (task 1004274, found preparing the task 1004047 catch-up pass). Kept out of
|
|
5
|
+
// upgrade.js, which sits at the 1500-line cap.
|
|
6
|
+
//
|
|
7
|
+
// THE GAP. upgrade.js migrated with `npm run migrate`, and that only works where the
|
|
8
|
+
// instance's package.json DECLARES a migrate script. A greenfield scaffold always does
|
|
9
|
+
// (init.js buildInstancePackageJson writes INSTANCE_MIGRATE_SCRIPT), but the ADOPT layer
|
|
10
|
+
// does not: init.js mergeCoreDependency adds the core dependency and nothing else, and
|
|
11
|
+
// for a repo that had no package.json of its own it mints `{ name, private,
|
|
12
|
+
// dependencies }`. So an adopted project that is not a JavaScript app has no scripts at
|
|
13
|
+
// all. There `npm run migrate` answers "Missing script" — AFTER the pin has moved and
|
|
14
|
+
// the new core is installed — and the upgrade rolls itself back, restarting someone's
|
|
15
|
+
// live app to change nothing. mercury (a Python/Flask project; its package.json is
|
|
16
|
+
// exactly name/private/dependencies) is that shape, and it is why the catch-up pass
|
|
17
|
+
// could not reach it. The dry run could not warn either, because it never looked.
|
|
18
|
+
//
|
|
19
|
+
// THE FALLBACK IS NOT A NEW MIGRATION PATH. It is the command a scaffold's own script
|
|
20
|
+
// IS, and the one the control plane ran to create that instance's database at stand-up
|
|
21
|
+
// (provision-repo.js standaloneMigrateCmd): the installed core's scripts/migrate.sh,
|
|
22
|
+
// run from the instance root. A declared script still wins, whatever it says — an
|
|
23
|
+
// owner who wrote their own migrate step meant it — and `--skip-migrate` stays the
|
|
24
|
+
// only way to run no migration at all, which on a real jump leaves the new code
|
|
25
|
+
// running against the old schema.
|
|
26
|
+
|
|
27
|
+
const fs = require('node:fs');
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
|
|
30
|
+
// Relative to the instance root, POSIX on purpose: it is an argument to bash, and it
|
|
31
|
+
// must stay byte-identical to the scaffold's script (tests/upgrade_migrate.mjs pins it
|
|
32
|
+
// against init.js INSTANCE_MIGRATE_SCRIPT).
|
|
33
|
+
const CORE_MIGRATE_SCRIPT = 'node_modules/@bongos/core/scripts/migrate.sh';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* How this instance is migrated: its own declared script, or the core's migrate.sh.
|
|
37
|
+
*
|
|
38
|
+
* Only a package.json we could READ, and that names no migrate script, earns the
|
|
39
|
+
* fallback. A missing or unparseable one keeps the old command, which then fails the
|
|
40
|
+
* way it always did — guessing at a file this could not parse would be worse.
|
|
41
|
+
*
|
|
42
|
+
* `env` exists only on the fallback, and only to hand migrate.sh what `npm run` would:
|
|
43
|
+
* migrate.sh finds the instance from INIT_CWD before its own cwd (task 2180), and npm
|
|
44
|
+
* sets it to the directory the script runs in. Launched directly, a stray INIT_CWD
|
|
45
|
+
* inherited from whoever started this upgrade would aim it at the wrong project.
|
|
46
|
+
*
|
|
47
|
+
* `note` is a pre-flight line, so the dry run says which command the move will run.
|
|
48
|
+
*/
|
|
49
|
+
function migratePlan(instanceDir, { fsImpl = fs, env = process.env } = {}) {
|
|
50
|
+
let pkg = null;
|
|
51
|
+
try { pkg = JSON.parse(fsImpl.readFileSync(path.join(instanceDir, 'package.json'), 'utf8')); } catch { /* absent or unreadable */ }
|
|
52
|
+
const scripts = pkg && typeof pkg.scripts === 'object' && pkg.scripts ? pkg.scripts : {};
|
|
53
|
+
const declared = typeof scripts.migrate === 'string' && scripts.migrate.trim() !== '';
|
|
54
|
+
if (!pkg || declared) {
|
|
55
|
+
return { via: 'npm-script', cmd: 'npm', args: ['run', 'migrate'], label: 'npm run migrate', env: null, note: null };
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
via: 'core-script',
|
|
59
|
+
cmd: 'bash',
|
|
60
|
+
args: [CORE_MIGRATE_SCRIPT],
|
|
61
|
+
label: `bash ${CORE_MIGRATE_SCRIPT}`,
|
|
62
|
+
env: { ...env, INIT_CWD: instanceDir },
|
|
63
|
+
note: 'migrate: this project declares no migrate script (an adopted repo), so the core\'s own scripts/migrate.sh will run — the command a scaffold\'s script runs',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { migratePlan, CORE_MIGRATE_SCRIPT };
|