@bongos/core 1.19.318 → 1.19.320
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 +125 -40
- package/.claude/skills/merge-mode/SKILL.md +3 -1
- package/.devcontainer/init-firewall.sh +41 -5
- package/clients/bongos-client/examples/hello-world.mjs +11 -2
- package/docs/copy-inventory.md +20 -20
- package/docs/copy-registry.json +22 -22
- package/modules/hall-ui/public/builders.js +0 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/audit-replay.sh +20 -3
- package/scripts/gds/box.js +79 -86
- package/scripts/gds/db-backup-nightly.sh +17 -4
- package/scripts/gds/db-backup-offsite.sh +23 -6
- package/scripts/gds/discipline-modes.json +1 -1
- package/scripts/gds/doc-cli-guard.js +34 -2
- package/scripts/gds/fitness.js +8 -4
- package/scripts/gds/gds-db-backup.service +15 -4
- package/scripts/gds/gds-db-backup.timer +12 -1
- package/scripts/gds/gds-db-offsite.service +16 -5
- package/scripts/gds/gds-db-offsite.timer +12 -1
- package/scripts/gds/go-live.js +142 -48
- package/scripts/gds/instance-apex.js +28 -0
- package/scripts/gds/newcomer-floor.templates.json +2 -2
- package/scripts/gds/provision.js +7 -3
- package/scripts/gds/ship-deploy-target.js +388 -0
- package/scripts/gds/ship-finish.js +377 -0
- package/scripts/gds/ship-flow.js +814 -0
- package/scripts/gds/ship-git.js +100 -0
- package/scripts/gds/ship-grade-steps.js +376 -0
- package/scripts/gds/ship-grade.js +319 -0
- package/scripts/gds/ship-io.js +100 -0
- package/scripts/gds/ship-land.js +539 -0
- package/scripts/gds/ship-memory-sync.js +192 -0
- package/scripts/gds/ship-merge.js +297 -0
- package/scripts/gds/ship-preflight-steps.js +323 -0
- package/scripts/gds/ship-regen.js +286 -0
- package/scripts/gds/ship-resume.js +151 -0
- package/scripts/gds/ship-session-upload.js +216 -0
- package/scripts/gds/ship-state.js +73 -0
- package/scripts/gds/ship-surprise.js +84 -0
- package/scripts/gds/ship.js +105 -4195
- package/scripts/gds/update-channel.js +12 -0
- package/scripts/status-probe.mjs +30 -2
- package/src/module-api.js +5 -1
- package/tests/go_live.mjs +65 -32
- package/tests/helpers.mjs +13 -0
- package/tests/provision.mjs +7 -1
- package/tests/regrade_eligibility.mjs +43 -0
- package/tests/ship_assurance.mjs +4 -2
- package/tests/ship_publish_error.mjs +43 -0
- package/tests/update_channel.mjs +33 -0
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
// scripts/gds/ship-land.js — the land outcome type and the ci-land paths (local, server-mediated) + their polling.
|
|
2
|
+
//
|
|
3
|
+
// Carved out of scripts/gds/ship.js by task 1003205 (goal 1000079, the R03
|
|
4
|
+
// de-monolith plan) — a STRUCTURAL move, not a rewrite: the code below is the
|
|
5
|
+
// original ship.js lines 990-1468 unchanged. ship.js remains the facade that
|
|
6
|
+
// re-exports every name and owns the CLI entry point, so the ship contract and
|
|
7
|
+
// every flag are untouched, and the other scripts that import from it are too.
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const os = require('node:os');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const { cliClient } = require('./cli-lib');
|
|
15
|
+
const { gitArgvOk, gitOk, sanitizeGitHubRefs, shellOk } = require('./ship-git.js');
|
|
16
|
+
const { buildPrCreateArgv, buildPrMergeArgv, deployRunSucceeded, deployedCommitStatus, fetchDeployedVersion, ghArgvOk, ghJson, pushVia, resolveInstanceBase } = require('./ship-deploy-target.js');
|
|
17
|
+
const { normalizeApiError } = require('./ship-io.js');
|
|
18
|
+
|
|
19
|
+
// ---------- the land outcome (task 1002541, part 1) ----------
|
|
20
|
+
//
|
|
21
|
+
// The land chain used to answer with a bare boolean. Every bail site already
|
|
22
|
+
// computes a precise reason and logs it, but `false` threw that reason away — so
|
|
23
|
+
// the completion card had only the task's STATUS to go on ('confirmed') and
|
|
24
|
+
// printed "Deploy landing automatically" even when nothing was landing and the
|
|
25
|
+
// builder was, under ADR 0120, unpaid. The failure announced itself as success.
|
|
26
|
+
//
|
|
27
|
+
// "Not shipped" is not one thing, so this is a three-way, not a boolean:
|
|
28
|
+
// landed() — merged + deployed; the task flips to shipped.
|
|
29
|
+
// landPending() — published and auto-merge armed: it DOES land on its own
|
|
30
|
+
// (CI / the server reconciler), so "landing automatically"
|
|
31
|
+
// is honest here. A poll timeout is this, not a failure
|
|
32
|
+
// (task 1049) — keep it out of the loud path.
|
|
33
|
+
// landBailed(r, n) — nothing is landing. `r` is the reason already logged,
|
|
34
|
+
// `n` the command that recovers it.
|
|
35
|
+
// Only landBailed makes the card say NOT landed. Purely additive: no bail site
|
|
36
|
+
// changes what it DOES, only what it now reports back.
|
|
37
|
+
// ADR 0184: an EMPTY ship has no commit to prove, so it must DECLARE itself.
|
|
38
|
+
function landed(o = {}) { return { ok: true, pending: false, reason: null, nextStep: null, noArtifact: o.noArtifact === true, noArtifactReason: o.noArtifactReason || null }; }
|
|
39
|
+
function landPending() { return { ok: false, pending: true, reason: null, nextStep: null }; }
|
|
40
|
+
function landBailed(reason, nextStep = null) { return { ok: false, pending: false, reason, nextStep }; }
|
|
41
|
+
|
|
42
|
+
// The short COMMAND form of strandNextStep (which is prose, sized for the log).
|
|
43
|
+
// The card has room for one command, not a sentence.
|
|
44
|
+
function strandCommand(taskId, via) {
|
|
45
|
+
return via === 'server' ? `node scripts/gds/ship.js ${taskId}` : '/merge-mode';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ciLand — dispatch the 'ci' confirmed->shipped land to the local or the
|
|
49
|
+
// server-mediated path. Async because the server path awaits GDS API calls.
|
|
50
|
+
async function ciLand(taskId, branch, valueSummary) {
|
|
51
|
+
console.log(' deploy mode: ci — landing via PR auto-merge (no droplet key needed)');
|
|
52
|
+
if (pushVia() === 'server') {
|
|
53
|
+
return ciLandServer(taskId, branch, valueSummary);
|
|
54
|
+
}
|
|
55
|
+
return await ciLandLocal(taskId, branch, valueSummary);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// task 1001916: render a FAILED POST /tasks/:id/publish-branch for the builder.
|
|
59
|
+
// The server has carried { step, reason } — which git step failed + the
|
|
60
|
+
// credential-scrubbed stderr — since task 1547, but the bail site printed only
|
|
61
|
+
// `data.message || data.error`: "[object Object]" on the ADR 0116 envelope, a
|
|
62
|
+
// bare `publish_failed` on the legacy flat shape. That hid a non-fast-forward
|
|
63
|
+
// rejection (the server had already freshened the branch itself) across several
|
|
64
|
+
// blind retry laps, so that case also gets its specific remedy. Pure + exported
|
|
65
|
+
// for tests/ship_publish_error.mjs (no git, no network).
|
|
66
|
+
const NON_FAST_FORWARD_RE = /fetch first|non-fast-forward/i;
|
|
67
|
+
|
|
68
|
+
// localHistoryRewritten — the discriminator formatPublishFailure needs (task 1003221).
|
|
69
|
+
// Is the remote-tracking tip an ancestor of HEAD? Yes = we only added commits, so a
|
|
70
|
+
// non-fast-forward means the SERVER moved. No = our history diverged, which is what a
|
|
71
|
+
// rebase or amend does. null = no ref to compare against, and null keeps the original
|
|
72
|
+
// wording so an unknown never invents a diagnosis.
|
|
73
|
+
function localHistoryRewritten(branch) {
|
|
74
|
+
const remoteRef = `origin/${branch}`;
|
|
75
|
+
if (!gitArgvOk(['rev-parse', '--verify', '--quiet', remoteRef], { stdio: 'ignore' })) return null;
|
|
76
|
+
// `merge-base --is-ancestor A B` exits 0 when A is an ancestor of B.
|
|
77
|
+
return !gitArgvOk(['merge-base', '--is-ancestor', remoteRef, 'HEAD'], { stdio: 'ignore' });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// task 1003221 — a non-fast-forward has TWO causes needing OPPOSITE recoveries. This
|
|
81
|
+
// gave one diagnosis ("the server already moved <branch>") and prescribed `reset`,
|
|
82
|
+
// which is right when the server is ahead and DESTRUCTIVE when the builder rebased or
|
|
83
|
+
// amended an already-published branch — it discards the commits they just made (the
|
|
84
|
+
// real 2026-08-21 case, 163 minutes). Server-mediated publish is APPEND-ONLY, as the
|
|
85
|
+
// learning claim.js prints says. opts.localHistoryRewritten discriminates; null means
|
|
86
|
+
// git could not tell and keeps the original wording rather than guessing.
|
|
87
|
+
function formatPublishFailure(data, status, branch, opts = {}) {
|
|
88
|
+
const { code, message } = normalizeApiError(data);
|
|
89
|
+
const envelopeDetails = (data && data.error && typeof data.error === 'object' && data.error.details) || null;
|
|
90
|
+
const details = envelopeDetails || data || {};
|
|
91
|
+
const label = message || code || (data && data.message) || `HTTP ${status}`;
|
|
92
|
+
// git stderr is multi-line; this is embedded mid-sentence in one log line.
|
|
93
|
+
const reason = details.reason ? String(details.reason).replace(/\s+/g, ' ').trim() : '';
|
|
94
|
+
const parts = [];
|
|
95
|
+
if (details.step) parts.push(`step: ${details.step}`);
|
|
96
|
+
if (reason) parts.push(`reason: ${reason}`);
|
|
97
|
+
let out = parts.length ? `${label} (${parts.join('; ')})` : label;
|
|
98
|
+
if (NON_FAST_FORWARD_RE.test(reason)) {
|
|
99
|
+
out += opts.localHistoryRewritten === true
|
|
100
|
+
? ` — your LOCAL history was rewritten (a rebase or amend), so ${branch} is no longer a `
|
|
101
|
+
+ `descendant of what the server already holds. Server-mediated publish is APPEND-ONLY: run `
|
|
102
|
+
+ `\`git fetch origin && git merge origin/${branch}\`, re-run the generators, commit the merge, `
|
|
103
|
+
+ 'and re-ship. Do NOT reset or re-rebase — that discards the commits you just made.'
|
|
104
|
+
: ` — the server already moved ${branch}: fetch origin, reset onto origin/${branch}, `
|
|
105
|
+
+ 're-run the generators, and re-ship so the push fast-forwards.';
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ciLandServer — the dev-box path: no GitHub push credential on this machine, so
|
|
111
|
+
// hand the branch to the GDS, which pushes it + opens the PR + auto-merges with
|
|
112
|
+
// the server-side credential. Requires a RE-AUTHED (non-box) cli session — a
|
|
113
|
+
// box-scoped session is 403'd by the endpoint (it must `/builder-reauth` first).
|
|
114
|
+
async function ciLandServer(taskId, branch, valueSummary) {
|
|
115
|
+
console.log(' ci-land: server-mediated publish (this machine has no GitHub push credential)');
|
|
116
|
+
const headSha = gitOk('git rev-parse HEAD');
|
|
117
|
+
// Make sure origin/main is current so the THIN bundle's `origin/main..HEAD`
|
|
118
|
+
// base is resolvable (a read-only fetch — works on a credential-less origin).
|
|
119
|
+
gitArgvOk(['fetch', 'origin', 'main'], { stdio: 'ignore' });
|
|
120
|
+
|
|
121
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-bundle-'));
|
|
122
|
+
const bundlePath = path.join(tmpDir, 'branch.bundle');
|
|
123
|
+
try {
|
|
124
|
+
if (!gitArgvOk(['bundle', 'create', bundlePath, `origin/main..${branch}`], { stdio: 'ignore' })) {
|
|
125
|
+
console.log(` ci-land: could not build the branch bundle (nothing new to push, or a git error); task stays at confirmed. ${strandNextStep(taskId, 'server')}`);
|
|
126
|
+
return landBailed('could not build the branch bundle', strandCommand(taskId, 'server'));
|
|
127
|
+
}
|
|
128
|
+
const bundleB64 = fs.readFileSync(bundlePath).toString('base64');
|
|
129
|
+
// allowBoxScope: handle the box_scope 403 locally (graceful "stays at
|
|
130
|
+
// confirmed, re-auth + re-ship") instead of cli-lib's central exit — task
|
|
131
|
+
// 1050. (In practice unreachable: a box session can't have claimed/confirmed
|
|
132
|
+
// to get here. Kept as a defensive, graceful path.)
|
|
133
|
+
const r = await (await cliClient({ allowBoxScope: true })).tasks.postTasksIdPublishBranch({
|
|
134
|
+
id: taskId,
|
|
135
|
+
body: { branch, head_sha: headSha, value_summary: valueSummary, bundle_b64: bundleB64 },
|
|
136
|
+
});
|
|
137
|
+
if (r.status === 403 && normalizeApiError(r.data).code === 'box_scope') {
|
|
138
|
+
console.log(' ci-land: this session is BOX-SCOPED — run /builder-reauth (browser, no terminal) to mint a real session, then re-ship. Task stays at confirmed.');
|
|
139
|
+
return landBailed('this CLI session is box-scoped', '/builder-reauth');
|
|
140
|
+
}
|
|
141
|
+
if (r.status === 503) {
|
|
142
|
+
console.log(' ci-land: server-mediated push is not configured on the server yet (no GitHub App nor GITHUB_PUSH_TOKEN — operator action, ADR 0055). Task stays at confirmed.');
|
|
143
|
+
return landBailed('server-mediated push is not configured (operator action)', strandCommand(taskId, 'server'));
|
|
144
|
+
}
|
|
145
|
+
if (!r.ok) {
|
|
146
|
+
const e = formatPublishFailure(r.data, r.status, branch, {
|
|
147
|
+
localHistoryRewritten: localHistoryRewritten(branch),
|
|
148
|
+
});
|
|
149
|
+
console.log(` ci-land: server publish failed — ${e}. Task stays at confirmed. ${strandNextStep(taskId, 'server')}`);
|
|
150
|
+
return landBailed(`server publish failed — ${e}`, strandCommand(taskId, 'server'));
|
|
151
|
+
}
|
|
152
|
+
console.log(` ci-land: server pushed ${branch} + PR ${r.data.pr_url} (auto-merge ${r.data.auto_merge ? 'enabled' : 'NOT enabled — merge it on GitHub'}).`);
|
|
153
|
+
return pollServerPublish(taskId, branch);
|
|
154
|
+
} finally {
|
|
155
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) { /* best-effort */ }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// task 1049 (B — async land semantics): classify a CI-land poll outcome so a
|
|
160
|
+
// SUCCESSFUL-but-still-running publish reads as success, not failure. The #1027
|
|
161
|
+
// retrospective: the ship's poll window is shorter than real CI, so a PR that was
|
|
162
|
+
// opened + auto-merge-armed (a SUCCESS) printed "did not merge — task stays at
|
|
163
|
+
// confirmed" and looked like a failure. Outcomes:
|
|
164
|
+
// 'shipped' — merged AND deploy-prod succeeded → flip the task to shipped
|
|
165
|
+
// 'failed' — PR closed without merging (a check failed / closed manually)
|
|
166
|
+
// 'pending' — PR still open with auto-merge armed (or merged, deploy not yet):
|
|
167
|
+
// NOT a failure — it lands on its own (the server reconciles the
|
|
168
|
+
// task to shipped, and a re-run resumes — task 941).
|
|
169
|
+
// Pure + exported for unit tests (no git/network). Accepts the publish-status
|
|
170
|
+
// response shape verbatim — getPublishStatus returns snake_case (pr_state,
|
|
171
|
+
// deploy_status); camelCase aliases are tolerated for test convenience.
|
|
172
|
+
// task 1002472: 'unconfigured' means the repo has no deploy-prod.yml to wait for
|
|
173
|
+
// (ADR 0108 pin-bump publishing), so the merge IS the land. Mirrors
|
|
174
|
+
// github-push.landConfirmed — kept in step by tests/ship_ci_deploy.mjs, since this
|
|
175
|
+
// CLI cannot import the server module.
|
|
176
|
+
function classifyLandOutcome(s) {
|
|
177
|
+
const merged = !!(s && s.merged);
|
|
178
|
+
const deployStatus = s && (s.deploy_status ?? s.deployStatus);
|
|
179
|
+
const prState = s && (s.pr_state ?? s.prState);
|
|
180
|
+
if (merged && (deployStatus === 'success' || deployStatus === 'unconfigured')) return 'shipped';
|
|
181
|
+
if (prState === 'closed' && !merged) return 'failed';
|
|
182
|
+
return 'pending';
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// task 1718: PURE — after ciLandLocal tries to arm GitHub auto-merge, decide which
|
|
186
|
+
// poll path lands + watches the PR. Arming auto-merge commonly FAILS when shipping
|
|
187
|
+
// from a worktree (`gh pr merge --auto` runs a git op that trips on `'main' is
|
|
188
|
+
// already used by worktree`) or with branch protection off — but the PR is open and
|
|
189
|
+
// this is NOT a dead-end: the server's merge driver + reconciler still land green
|
|
190
|
+
// PRs. So an enable-failure must NOT strand the builder at 'confirmed' (they'd never
|
|
191
|
+
// see the SHIPPED banner in-session, and the machine-global land-watch breadcrumb
|
|
192
|
+
// would surface it in some OTHER session). Instead:
|
|
193
|
+
// 'ci' → auto-merge armed: watch GitHub directly (pollCiDeploy).
|
|
194
|
+
// 'server' → auto-merge NOT armed: drive + watch the server land via publish-status
|
|
195
|
+
// (pollServerPublish) — reading it is reconcile-on-read, so it DRIVES the
|
|
196
|
+
// merge and observes the flip to 'shipped'.
|
|
197
|
+
// Both paths end at 'shipped' on a real land, so the banner renders in the shipping
|
|
198
|
+
// session and no 'confirmed' breadcrumb is written. Exported for unit tests.
|
|
199
|
+
function landPollAfterAutoMerge(autoMergeArmed) {
|
|
200
|
+
return autoMergeArmed ? 'ci' : 'server';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// task 1719: PURE — after mergeAndDeploy runs, the completion card + the pending-land
|
|
204
|
+
// breadcrumb must reflect the AUTHORITATIVE post-land status, not the pre-merge one
|
|
205
|
+
// finishShip was handed. If the task actually reached 'shipped' in-session (a laptop
|
|
206
|
+
// sync land, the enable-success poll, or the task-1718 enable-failure server poll),
|
|
207
|
+
// the card renders the SHIPPED banner and printCompletionCard writes NO breadcrumb;
|
|
208
|
+
// otherwise keep the pre-merge status (the async land is still pending → 'confirmed' →
|
|
209
|
+
// the next-turn land-watch breadcrumb is the intended fallback). Exported for tests.
|
|
210
|
+
function reconciledCardStatus(liveStatus, preMergeStatus) {
|
|
211
|
+
return liveStatus === 'shipped' ? 'shipped' : preMergeStatus;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// task 1052 (E): the box-appropriate next step when a ship lands the task at
|
|
215
|
+
// 'confirmed' without finishing. A dev box (pushVia 'server' — no authed gh)
|
|
216
|
+
// CANNOT run /merge-mode (it needs gh + a main checkout), so the old "for
|
|
217
|
+
// /merge-mode" advice was a dead end ON A BOX (the #1027 retrospective: the agent
|
|
218
|
+
// had to reverse-engineer recovery from source). Point a box at the resume
|
|
219
|
+
// instead (task 941); a laptop keeps /merge-mode (with resume as the alternative).
|
|
220
|
+
// Pure + exported for unit tests.
|
|
221
|
+
function strandNextStep(taskId, via) {
|
|
222
|
+
if (via === 'server') {
|
|
223
|
+
return `Re-run \`node scripts/gds/ship.js ${taskId}\` to resume — it picks up exactly where it stopped (task 941). ` +
|
|
224
|
+
`(/merge-mode is NOT available here — it needs gh + a main checkout this machine doesn't have.)`;
|
|
225
|
+
}
|
|
226
|
+
return `Run /merge-mode to land it — or re-run \`node scripts/gds/ship.js ${taskId}\`, which resumes from here (task 941).`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// The standard "published — it'll land on its own" success-pending banner
|
|
230
|
+
// (task 1049). Shared by both poll paths so the message is identical and never
|
|
231
|
+
// reads as a failure. The task legitimately stays at 'confirmed' until CI lands
|
|
232
|
+
// it; the server reconciles it to 'shipped' (publish-status reconcile-on-read)
|
|
233
|
+
// and a re-run resumes (task 941).
|
|
234
|
+
//
|
|
235
|
+
// task 1002606 (finding P-3): the banner used to end at "NOT a failure", so when
|
|
236
|
+
// a land DID fail (hand-written conflict, PR closed unmerged, persistent red
|
|
237
|
+
// check) the builder's last signal said everything was fine and the strand sat
|
|
238
|
+
// invisible until they happened to look. The banner now tells the failure story
|
|
239
|
+
// too: the server flags a strand needs_rebase (definitive shapes within a sweep,
|
|
240
|
+
// ambiguous ones at ~30 min), /builder-start warns, and new claims gate on it.
|
|
241
|
+
// Pure + exported so the wording is pinned by tests/ship_ci_deploy.mjs.
|
|
242
|
+
function landPendingLines(taskId, { merged } = {}) {
|
|
243
|
+
return [
|
|
244
|
+
` ci-land: ✓ Published — auto-merge is armed${merged ? ' and the PR has merged' : ''}. This is NOT a failure.`,
|
|
245
|
+
` CI is still finishing; GitHub will ${merged ? 'deploy' : 'merge + deploy'} automatically once the required checks pass,`,
|
|
246
|
+
` and the task flips to 'shipped' on its own. Re-run \`node scripts/gds/ship.js ${taskId}\` anytime to confirm`,
|
|
247
|
+
` it landed — it resumes from here (task 941). The task is at 'confirmed' until the deploy completes.`,
|
|
248
|
+
` If it CANNOT land on its own (a hand-written merge conflict, the PR closed without merging, a check`,
|
|
249
|
+
` that keeps failing), the server flags the task needs_rebase — /builder-start will warn you and new`,
|
|
250
|
+
` claims are gated until you fix + re-land it (re-run the ship after fixing), or release it.`,
|
|
251
|
+
];
|
|
252
|
+
}
|
|
253
|
+
function printLandPending(taskId, opts = {}) {
|
|
254
|
+
console.log('');
|
|
255
|
+
for (const line of landPendingLines(taskId, opts)) console.log(line);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// task 1002645 — the honest close-out when the PR MERGED but the server refused the
|
|
259
|
+
// confirmed→shipped flip (reconcile_blocked: the task-1547 land proof missed — a
|
|
260
|
+
// wrong/missing published tip, or the matched PR doesn't reference this task). The
|
|
261
|
+
// optimistic "NOT a failure" banner was a lie here: without proof the auto-land can
|
|
262
|
+
// NEVER finish, and the wave-5 ships sat at 'confirmed' with their last signal
|
|
263
|
+
// saying everything was fine. Name the refusal and the remedy instead. Pure +
|
|
264
|
+
// exported so the wording is pinned by tests/ship_ci_deploy.mjs.
|
|
265
|
+
function reconcileBlockedLines(taskId, reason) {
|
|
266
|
+
return [
|
|
267
|
+
` ci-land: the PR MERGED, but the task did NOT flip to 'shipped' — the server could not prove the land is`,
|
|
268
|
+
` THIS task's: ${reason}.`,
|
|
269
|
+
` The server reconciler retries the proof every ~5 min, so a late-recorded tip or PR reference still lands`,
|
|
270
|
+
` on its own. Re-run \`node scripts/gds/ship.js ${taskId}\` to re-drive it; if it stays blocked the server`,
|
|
271
|
+
` flags the task needs_rebase and /builder-start will show the remedy.`,
|
|
272
|
+
];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// One module-scope sleep command shared by both poll loops (task 1093 / C9). A
|
|
276
|
+
// child `node -e setTimeout` is used (not `await new Promise`) so it blocks
|
|
277
|
+
// identically in the SYNCHRONOUS pollCiDeploy and the async pollServerPublish —
|
|
278
|
+
// the loops keep their own structure (sync vs async, 1-stage vs 2-stage); only
|
|
279
|
+
// the ~10s cadence is shared.
|
|
280
|
+
const POLL_SLEEP_CMD = 'node -e "setTimeout(()=>{}, 10000)"';
|
|
281
|
+
|
|
282
|
+
// pollServerPublish — the server-mediated analogue of pollCiDeploy. Polls the
|
|
283
|
+
// GDS publish-status endpoint (which the server answers from GitHub, and which
|
|
284
|
+
// reconciles the task to shipped when it observes merge+deploy — task 1049) until
|
|
285
|
+
// the PR merges + deploy-prod succeeds. Returns true when prod is confirmed live;
|
|
286
|
+
// a timeout with an armed PR is success-PENDING (the publish worked), not failure.
|
|
287
|
+
async function pollServerPublish(taskId, branch) {
|
|
288
|
+
const TRIES = 54; // ~9 min at 10s — checks + auto-merge + Actions deploy
|
|
289
|
+
console.log(' ci-land: waiting for auto-merge + prod deploy (polling the server)...');
|
|
290
|
+
// task 1002645: GitHub can report merged+deployed while the SERVER refused the
|
|
291
|
+
// confirmed→shipped flip (reconcile_blocked — the task-1547 land proof missed).
|
|
292
|
+
// "Prod is live" + exit here was a lie that left the task stranded with no
|
|
293
|
+
// signal. Say the real reason and keep polling — attest-gate now records the
|
|
294
|
+
// pushed tip, so the proof usually heals within the window.
|
|
295
|
+
let blockedReason = null;
|
|
296
|
+
for (let i = 0; i < TRIES; i++) {
|
|
297
|
+
const r = await (await cliClient()).tasks.getTasksIdPublishStatus({ id: taskId, query: { branch } });
|
|
298
|
+
if (r.ok && r.data) {
|
|
299
|
+
const outcome = classifyLandOutcome(r.data);
|
|
300
|
+
if (outcome === 'shipped') {
|
|
301
|
+
if (r.data.reconcile_blocked) {
|
|
302
|
+
if (r.data.reconcile_blocked !== blockedReason) {
|
|
303
|
+
blockedReason = r.data.reconcile_blocked;
|
|
304
|
+
console.log(` ci-land: merged, but the server did NOT flip the task to shipped: ${blockedReason}. Still polling (the reconciler retries every sweep)...`);
|
|
305
|
+
}
|
|
306
|
+
} else {
|
|
307
|
+
console.log(` ci-land: merged (${String(r.data.merge_sha || '').slice(0, 8)}) + deploy-prod succeeded. Prod is live.`);
|
|
308
|
+
return landed();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (outcome === 'failed') {
|
|
312
|
+
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'.`);
|
|
313
|
+
return landBailed('the PR was closed without merging (a check failed, or it was closed manually)', `node scripts/gds/ship.js ${taskId}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
shellOk(POLL_SLEEP_CMD);
|
|
317
|
+
}
|
|
318
|
+
if (blockedReason) {
|
|
319
|
+
console.log('');
|
|
320
|
+
for (const line of reconcileBlockedLines(taskId, blockedReason)) console.log(line);
|
|
321
|
+
return landBailed(`the PR merged but the server could not prove the land is this task's: ${blockedReason}`, `node scripts/gds/ship.js ${taskId}`);
|
|
322
|
+
}
|
|
323
|
+
printLandPending(taskId);
|
|
324
|
+
return landPending();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// attestGateTrust — task 1169 (ADR 0058). Ask the SERVER to post the trustless
|
|
328
|
+
// gate-author-trust commit status for THIS builder's server-resolved rank on the
|
|
329
|
+
// pushed head SHA. ship.js's local path pushes + opens the PR with the laptop's
|
|
330
|
+
// OWN gh credential, but only the server can forge the rank attestation the
|
|
331
|
+
// ENFORCING gate-review check reads — without it a Metic+ author's gate-surface PR
|
|
332
|
+
// from a laptop is BLOCKED and they can't self-approve it (task 1162 / PR #131).
|
|
333
|
+
// Best-effort + never throws: a non-Metic author, an unconfigured server, or a
|
|
334
|
+
// transient failure just leaves the PR un-attested → it escalates (the safe
|
|
335
|
+
// direction). A non-gate PR is unaffected by the attestation either way.
|
|
336
|
+
async function attestGateTrust(taskId, headSha) {
|
|
337
|
+
if (!headSha) return;
|
|
338
|
+
try {
|
|
339
|
+
const r = await (await cliClient()).tasks.postTasksIdAttestGate({ id: taskId, body: { head_sha: headSha } });
|
|
340
|
+
if (r.ok && r.data) {
|
|
341
|
+
if (r.data.attested) {
|
|
342
|
+
console.log(' ci-land: gate-author-trust attested on the head SHA (Metic+) — a gate-surface PR auto-passes the ADR-0058 gatekeeper.');
|
|
343
|
+
} else {
|
|
344
|
+
console.log(` ci-land: gate-author-trust NOT attested (${r.data.reason || 'rank below Metic'}) — a gate-surface PR will need owner approval; a non-gate PR is unaffected.`);
|
|
345
|
+
}
|
|
346
|
+
} else if (r.status === 503) {
|
|
347
|
+
console.log(' ci-land: the server has no push credential to post the gate attestation (operator: GITHUB_APP_* / GITHUB_PUSH_TOKEN). A gate-surface PR will need owner approval.');
|
|
348
|
+
} else {
|
|
349
|
+
console.log(` ci-land: gate attestation request failed (HTTP ${r.status}); a gate-surface PR may need a manual re-run / owner approval.`);
|
|
350
|
+
}
|
|
351
|
+
} catch (_) {
|
|
352
|
+
/* best-effort — never block the ship on the attestation */
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// pickStaleGateReviewRun — PURE. Among `gh run list --json
|
|
357
|
+
// databaseId,headSha,status,conclusion` results for the gate-review workflow,
|
|
358
|
+
// find a run on THIS head SHA that has already COMPLETED without success — the
|
|
359
|
+
// one to re-run after the attestation is posted, because a new commit status does
|
|
360
|
+
// NOT re-trigger a pull_request-triggered check (the task 1162 / PR #131 gotcha).
|
|
361
|
+
// Returns the run's databaseId, or null when there's nothing stale to re-run (no
|
|
362
|
+
// run yet, still in flight, or already green). Exported for unit tests.
|
|
363
|
+
function pickStaleGateReviewRun(runs, headSha) {
|
|
364
|
+
if (!headSha || !Array.isArray(runs)) return null;
|
|
365
|
+
const hit = runs.find(
|
|
366
|
+
(r) => r && r.headSha === headSha && r.status === 'completed' && r.conclusion && r.conclusion !== 'success'
|
|
367
|
+
);
|
|
368
|
+
return hit ? (hit.databaseId ?? null) : null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// rerunGateReviewIfStale — best-effort gh re-run of a stale (completed-non-success)
|
|
372
|
+
// gate-review run on the current head, so it re-reads the freshly-posted
|
|
373
|
+
// gate-author-trust attestation. Only acts when such a run exists: a freshly-opened
|
|
374
|
+
// PR needs NO nudge (gate-review runs once, AFTER the attestation, and passes); the
|
|
375
|
+
// SHA-reuse-after-red resume case (and the rare push-then-attest race) needs one.
|
|
376
|
+
function rerunGateReviewIfStale(branch, headSha) {
|
|
377
|
+
const runs = ghJson([
|
|
378
|
+
'run', 'list', '--workflow', 'gate-review.yml', '--branch', branch,
|
|
379
|
+
'--json', 'databaseId,headSha,status,conclusion', '--limit', '15',
|
|
380
|
+
]);
|
|
381
|
+
const id = pickStaleGateReviewRun(runs, headSha);
|
|
382
|
+
if (id == null) return false;
|
|
383
|
+
console.log(` ci-land: re-running the stale gate-review check (run ${id}) so it re-reads the gate-author-trust attestation...`);
|
|
384
|
+
return ghArgvOk(['run', 'rerun', String(id)]);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ciLandLocal — today's local path: this machine pushes the branch + opens the PR
|
|
388
|
+
// + enables auto-merge via git + gh. async since task 1169 — it asks the server to
|
|
389
|
+
// post the gate-author-trust attestation between the push and the PR open.
|
|
390
|
+
async function ciLandLocal(taskId, branch, valueSummary) {
|
|
391
|
+
const headSha = gitOk('git rev-parse HEAD');
|
|
392
|
+
|
|
393
|
+
console.log(` pushing ${branch} to origin...`);
|
|
394
|
+
if (!gitArgvOk(['push', 'origin', branch])) {
|
|
395
|
+
console.log(' ci-land: push failed; task stays at confirmed. Fix the push and re-ship.');
|
|
396
|
+
return landBailed(`could not push ${branch} to origin`, `node scripts/gds/ship.js ${taskId}`);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// task 1169 (ADR 0058): attest the author's trust tier on the head SHA BEFORE
|
|
400
|
+
// opening the PR, so the gate-review check reads it on its first (and only) run
|
|
401
|
+
// and a Metic+ gate-surface PR auto-passes — race-free for a fresh PR, since the
|
|
402
|
+
// `gh pr create` below is what fires gate-review and it runs only after this
|
|
403
|
+
// returns. (A reused open PR is handled by the re-run nudge after auto-merge.)
|
|
404
|
+
await attestGateTrust(taskId, headSha);
|
|
405
|
+
|
|
406
|
+
// Ensure exactly one PR for this branch. gh pr create is idempotent-ish: it
|
|
407
|
+
// errors if a PR already exists, so check first and reuse it.
|
|
408
|
+
const existing = ghJson(['pr', 'view', branch, '--json', 'number,url,state']);
|
|
409
|
+
// Reusing an already-open PR means gate-review may have ALREADY run (red, before
|
|
410
|
+
// the attestation existed) on this same head SHA — nudge a re-run below.
|
|
411
|
+
const reusingOpenPr = !!(existing && existing.state === 'OPEN');
|
|
412
|
+
// PR titles + bodies are GitHub-rendered: a bare "#NNN" autolinks into
|
|
413
|
+
// GitHub's PR/issue number space (dead links for GDS task ids — task 916),
|
|
414
|
+
// so the task ref is written as "task NNN" and the summary is de-#-ed.
|
|
415
|
+
const title = `task ${taskId}: ${sanitizeGitHubRefs(valueSummary) || branch}`.slice(0, 240);
|
|
416
|
+
const deployTargetNote = resolveInstanceBase() || 'the configured instance';
|
|
417
|
+
const body =
|
|
418
|
+
`Auto-opened by ship.js (deploy mode: ci) for task ${taskId}.\n\n` +
|
|
419
|
+
`${sanitizeGitHubRefs(valueSummary) || '(no value summary)'}\n\n` +
|
|
420
|
+
`Merging this PR triggers .github/workflows/deploy-prod.yml to deploy to ` +
|
|
421
|
+
`${deployTargetNote}. Auto-merge is enabled — GitHub lands it once the ` +
|
|
422
|
+
`required checks pass.`;
|
|
423
|
+
if (!existing || existing.state === 'CLOSED' || existing.state === 'MERGED') {
|
|
424
|
+
console.log(' ci-land: opening PR -> main...');
|
|
425
|
+
if (!ghArgvOk(buildPrCreateArgv(branch, title, body))) {
|
|
426
|
+
console.log(' ci-land: `gh pr create` failed. Is the GitHub CLI installed + authenticated (`gh auth status`)? Open the PR manually; task stays at confirmed.');
|
|
427
|
+
return landBailed('`gh pr create` failed (is gh installed + authenticated?)', `node scripts/gds/ship.js ${taskId}`);
|
|
428
|
+
}
|
|
429
|
+
} else {
|
|
430
|
+
console.log(` ci-land: reusing open PR ${existing.url}`);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
console.log(' ci-land: enabling auto-merge (lands when required checks pass)...');
|
|
434
|
+
// task 1718: an enable-failure is NOT a dead-end — the PR is open and the server
|
|
435
|
+
// still lands green PRs. Don't return false (which stranded the builder at
|
|
436
|
+
// 'confirmed' with no in-session SHIPPED banner); fall through to the server poll,
|
|
437
|
+
// which drives + observes the land. See landPollAfterAutoMerge.
|
|
438
|
+
const autoMergeArmed = ghArgvOk(buildPrMergeArgv(branch));
|
|
439
|
+
if (!autoMergeArmed) {
|
|
440
|
+
console.log(' ci-land: GitHub auto-merge could not be armed from here (normal when shipping from a worktree, or with branch protection off). The PR is open; the server lands it — waiting for the land instead of stranding at confirmed...');
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// task 1169: if we REUSED an open PR, a stale red gate-review run may sit on this
|
|
444
|
+
// same head SHA from before the attestation existed; a new commit status does not
|
|
445
|
+
// re-trigger a pull_request check, so re-run it (best-effort). A freshly-opened PR
|
|
446
|
+
// is left alone — its single gate-review run reads the attestation and passes.
|
|
447
|
+
if (reusingOpenPr) rerunGateReviewIfStale(branch, headSha);
|
|
448
|
+
|
|
449
|
+
// enable-success watches GitHub directly; enable-failure drives + watches the
|
|
450
|
+
// server land (publish-status reconcile-on-read). Both flip to 'shipped' on a real
|
|
451
|
+
// land, so the banner renders in-session and no 'confirmed' breadcrumb is written.
|
|
452
|
+
return landPollAfterAutoMerge(autoMergeArmed) === 'ci'
|
|
453
|
+
? pollCiDeploy(taskId, branch, headSha)
|
|
454
|
+
: pollServerPublish(taskId, branch);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Confirm the CI land actually reached production. Polls in stages:
|
|
458
|
+
// 1. PR state -> MERGED (checks passing + branch protection satisfied); grabs
|
|
459
|
+
// the merge commit oid.
|
|
460
|
+
// 2. the deploy-prod workflow run for that commit -> success.
|
|
461
|
+
// 3. public edge healthz -> 200 (belt-and-braces, a different vantage).
|
|
462
|
+
// Times out conservatively; on any timeout the task stays at confirmed with a
|
|
463
|
+
// precise next step rather than a false "shipped".
|
|
464
|
+
function pollCiDeploy(taskId, branch, headSha) {
|
|
465
|
+
const MERGE_TRIES = 30; // ~5 min at 10s — checks + auto-merge
|
|
466
|
+
const DEPLOY_TRIES = 24; // ~4 min at 10s — Actions deploy
|
|
467
|
+
|
|
468
|
+
console.log(' ci-land: waiting for auto-merge (checks + branch protection)...');
|
|
469
|
+
let mergeSha = null;
|
|
470
|
+
for (let i = 0; i < MERGE_TRIES; i++) {
|
|
471
|
+
const pr = ghJson(['pr', 'view', branch, '--json', 'state,mergeCommit']);
|
|
472
|
+
if (pr && pr.state === 'MERGED') {
|
|
473
|
+
mergeSha = pr.mergeCommit && pr.mergeCommit.oid;
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
if (pr && pr.state === 'CLOSED') {
|
|
477
|
+
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'.`);
|
|
478
|
+
return landBailed('the PR was closed without merging (a check failed, or it was closed manually)', `node scripts/gds/ship.js ${taskId}`);
|
|
479
|
+
}
|
|
480
|
+
shellOk(POLL_SLEEP_CMD);
|
|
481
|
+
}
|
|
482
|
+
if (!mergeSha) {
|
|
483
|
+
// task 1049: PR open + auto-merge armed but not merged in-window = the publish
|
|
484
|
+
// SUCCEEDED and is still in CI — success-pending, not a failure.
|
|
485
|
+
printLandPending(taskId);
|
|
486
|
+
return landPending();
|
|
487
|
+
}
|
|
488
|
+
console.log(` ci-land: merged to main (${mergeSha.slice(0, 8)}). Waiting for the prod deploy...`);
|
|
489
|
+
|
|
490
|
+
for (let i = 0; i < DEPLOY_TRIES; i++) {
|
|
491
|
+
const runs = ghJson([
|
|
492
|
+
'run', 'list', '--workflow', 'deploy-prod.yml', '--branch', 'main',
|
|
493
|
+
'--json', 'headSha,status,conclusion', '--limit', '15',
|
|
494
|
+
]);
|
|
495
|
+
if (deployRunSucceeded(runs, mergeSha)) {
|
|
496
|
+
console.log(' ci-land: deploy-prod workflow succeeded for the merge commit.');
|
|
497
|
+
// Commit-pin the confirmation against /version (#858) — stronger than a
|
|
498
|
+
// liveness probe. The deploy run succeeding for OUR mergeSha is already
|
|
499
|
+
// authoritative, so /version only refines the message (and never turns a
|
|
500
|
+
// real deploy into a false negative on a transient miss or a concurrent
|
|
501
|
+
// later deploy).
|
|
502
|
+
const status = deployedCommitStatus(fetchDeployedVersion(), mergeSha);
|
|
503
|
+
const instanceBase = resolveInstanceBase();
|
|
504
|
+
const healthzUrl = instanceBase ? `${instanceBase}/healthz` : null;
|
|
505
|
+
if (status === 'match') {
|
|
506
|
+
console.log(' ci-land: /version confirms prod is live on the merge commit (commit-pinned).');
|
|
507
|
+
} else if (status === 'mismatch') {
|
|
508
|
+
console.log(' ci-land: deploy run succeeded; /version shows a different commit (a later deploy has since superseded it — fine, our change is in main).');
|
|
509
|
+
} else if (healthzUrl && shellOk(`curl -fsS --max-time 10 ${healthzUrl} -o /dev/null`)) {
|
|
510
|
+
console.log(` ci-land: /version unavailable; ${healthzUrl} 200 — treating the successful deploy run as authoritative.`);
|
|
511
|
+
} else {
|
|
512
|
+
console.log(' ci-land: deploy run succeeded but /version + healthz probes both missed (likely transient). Treating the successful deploy run as authoritative.');
|
|
513
|
+
}
|
|
514
|
+
return landed();
|
|
515
|
+
}
|
|
516
|
+
shellOk(POLL_SLEEP_CMD);
|
|
517
|
+
}
|
|
518
|
+
// task 1049: merged but the deploy run hasn't confirmed in-window — still
|
|
519
|
+
// success-pending (it deploys on its own), not a failure.
|
|
520
|
+
printLandPending(taskId, { merged: true });
|
|
521
|
+
return landPending();
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
module.exports = {
|
|
525
|
+
localHistoryRewritten, // task 1003221 — the append-only discriminator
|
|
526
|
+
ciLand,
|
|
527
|
+
classifyLandOutcome,
|
|
528
|
+
formatPublishFailure,
|
|
529
|
+
landBailed,
|
|
530
|
+
landPending,
|
|
531
|
+
landPendingLines,
|
|
532
|
+
landPollAfterAutoMerge,
|
|
533
|
+
landed,
|
|
534
|
+
pickStaleGateReviewRun,
|
|
535
|
+
reconcileBlockedLines,
|
|
536
|
+
reconciledCardStatus,
|
|
537
|
+
strandCommand,
|
|
538
|
+
strandNextStep,
|
|
539
|
+
};
|