@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,388 @@
|
|
|
1
|
+
// scripts/gds/ship-deploy-target.js — deploy-mode + deploy-target resolution, the gh helpers and push-path selection.
|
|
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 639-989 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 { spawnSync } = require('node:child_process');
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const { loadSessionSync } = require('./cli-lib');
|
|
15
|
+
const { argvOk, argvOut } = require('./ship-git.js');
|
|
16
|
+
const { REPO_ROOT } = require('./ship-state.js');
|
|
17
|
+
|
|
18
|
+
// ---------- CI deploy mode (#679, ADR 0042) ----------
|
|
19
|
+
//
|
|
20
|
+
// Two ways the confirmed→shipped land can run, chosen by a SINGLE committed
|
|
21
|
+
// switch (config/deploy.json `mode`, env OTB_DEPLOY_MODE overrides):
|
|
22
|
+
//
|
|
23
|
+
// 'laptop' (default) — the operator's machine merges to main locally and, IFF a
|
|
24
|
+
// deploy target is configured (config/deploy.json "sshTarget" or
|
|
25
|
+
// <PREFIX>_DEPLOY_SSH_TARGET, resolveDeployTarget()), SSHes `~/deploy.sh` on it.
|
|
26
|
+
// The core carries NO target of its own, so by default this is a merge-to-main
|
|
27
|
+
// with no SSH deploy — a change reaches a live instance via package-core +
|
|
28
|
+
// `bongos upgrade` (pin-bump), NOT an SSH deploy (ADR 0108). When a target IS
|
|
29
|
+
// set it requires that host's SSH key, so only the operator can land.
|
|
30
|
+
//
|
|
31
|
+
// 'ci' — NO builder box holds the prod key. ship pushes the branch, opens a
|
|
32
|
+
// PR, and enables GitHub auto-merge; once required checks pass, GitHub
|
|
33
|
+
// merges to main and .github/workflows/deploy-prod.yml deploys from
|
|
34
|
+
// Actions. ship then CONFIRMS the deploy landed (PR merged + the
|
|
35
|
+
// deploy-prod workflow run succeeded for the merge commit + public
|
|
36
|
+
// healthz 200) so the synchronous "deploy-confirmed" UX is preserved
|
|
37
|
+
// without the key. This is what lets a Windows/Chromebook builder with no
|
|
38
|
+
// main checkout and no droplet key land their own work.
|
|
39
|
+
//
|
|
40
|
+
// parseDeployMode is pure (env string + raw config file contents → mode) and
|
|
41
|
+
// exported so the test can assert the precedence + the safe default without
|
|
42
|
+
// touching fs/env. The default is 'laptop' precisely so this code is INERT
|
|
43
|
+
// until the deliberate, atomic cutover (config flip + GitHub arming together).
|
|
44
|
+
function parseDeployMode(envVal, configRaw) {
|
|
45
|
+
const fromEnv = String(envVal || '').trim().toLowerCase();
|
|
46
|
+
if (fromEnv === 'ci' || fromEnv === 'laptop') return fromEnv;
|
|
47
|
+
if (configRaw) {
|
|
48
|
+
try {
|
|
49
|
+
const m = String(JSON.parse(configRaw).mode || '').trim().toLowerCase();
|
|
50
|
+
if (m === 'ci' || m === 'laptop') return m;
|
|
51
|
+
} catch (_) {
|
|
52
|
+
/* malformed config → fall through to the safe default */
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return 'laptop';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// effectiveDeployMode — credential- and protection-aware adjustment of the parsed
|
|
59
|
+
// mode (pure; exported for the regression test). The 'laptop' land path pushes
|
|
60
|
+
// main + SSHes the droplet FROM THIS MACHINE, so it requires a local GitHub push
|
|
61
|
+
// credential. A machine without one — a dev box, or a fresh/stale local clone that
|
|
62
|
+
// can't even `git pull` (token-less origin, ADR 0053) — physically cannot run it;
|
|
63
|
+
// it must ship via the 'ci' server-mediated publish path instead
|
|
64
|
+
// (pushVia()==='server', ADR 0055), which lets the GDS server push with its own
|
|
65
|
+
// credential. So when the parsed mode is 'laptop' but the machine can't push
|
|
66
|
+
// locally AND the mode was NOT pinned by an explicit OTB_DEPLOY_MODE, fall back to
|
|
67
|
+
// 'ci' rather than strand every ship at merge. This is the scootermack 2026-06-22
|
|
68
|
+
// case: a checkout left at the pre-cutover 'laptop' default that can't pull
|
|
69
|
+
// forward to 'ci', so it kept attempting a push it had no credential for. An
|
|
70
|
+
// explicit env override always wins (tests/staging may force 'laptop'). Mirrors
|
|
71
|
+
// pushVia()'s existing credential-awareness — the asymmetry (pushVia smart,
|
|
72
|
+
// resolveDeployMode blind) WAS the bug. (task 1449)
|
|
73
|
+
//
|
|
74
|
+
// Second input, same shape (task 1002629): a machine WITH push credentials but a
|
|
75
|
+
// branch-PROTECTED main also cannot run the laptop land — GitHub refuses the
|
|
76
|
+
// direct `git push origin main` no matter whose credential it is. When task
|
|
77
|
+
// 1002619 armed protection (required `unit` check), every ship resolved 'laptop'
|
|
78
|
+
// and pushed into the wall, stranding graded-PASS tasks at confirmed (tasks
|
|
79
|
+
// 1002608/1002609; learning 1000177). mainProtected must be STRICTLY true to
|
|
80
|
+
// trigger — null/undefined means the probe couldn't answer (no gh, offline, no
|
|
81
|
+
// permission) and keeps today's behavior rather than rerouting on a guess.
|
|
82
|
+
function effectiveDeployMode(mode, { canPushLocally, envForced, mainProtected } = {}) {
|
|
83
|
+
if (mode === 'laptop' && !envForced && !canPushLocally) return 'ci';
|
|
84
|
+
if (mode === 'laptop' && !envForced && mainProtected === true) return 'ci';
|
|
85
|
+
return mode;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// mainIsProtected — true/false when GitHub answers, null when it can't. The
|
|
89
|
+
// `gh api` resolves {owner}/{repo} from the checkout's origin; any failure
|
|
90
|
+
// (gh missing, offline, non-200) is null = unknown, which effectiveDeployMode
|
|
91
|
+
// treats as "no change". Same spawn discipline as ghAvailableAndAuthed().
|
|
92
|
+
function mainIsProtected() {
|
|
93
|
+
try {
|
|
94
|
+
const r = spawnSync('gh', ['api', 'repos/{owner}/{repo}/branches/main', '--jq', '.protected'], {
|
|
95
|
+
encoding: 'utf8',
|
|
96
|
+
shell: process.platform === 'win32',
|
|
97
|
+
windowsHide: true,
|
|
98
|
+
timeout: 15000,
|
|
99
|
+
});
|
|
100
|
+
if (!r || r.error || r.status !== 0) return null;
|
|
101
|
+
const out = String(r.stdout || '').trim();
|
|
102
|
+
if (out === 'true') return true;
|
|
103
|
+
if (out === 'false') return false;
|
|
104
|
+
return null;
|
|
105
|
+
} catch (_) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function resolveDeployMode() {
|
|
111
|
+
const configRaw = readDeployConfigRaw();
|
|
112
|
+
const mode = parseDeployMode(process.env.OTB_DEPLOY_MODE, configRaw);
|
|
113
|
+
const envForced = ['ci', 'laptop'].includes(
|
|
114
|
+
String(process.env.OTB_DEPLOY_MODE || '').trim().toLowerCase()
|
|
115
|
+
);
|
|
116
|
+
// Probe `gh` ONLY when it can change the answer (resolved 'laptop', no env
|
|
117
|
+
// override) — the happy 'ci' path and an env-pinned mode spawn no extra gh,
|
|
118
|
+
// keeping the documented spawn discipline (the line-1019 note). The protection
|
|
119
|
+
// probe runs only when the credential check kept us on 'laptop' (a
|
|
120
|
+
// credential-less machine already falls back without it).
|
|
121
|
+
if (mode === 'laptop' && !envForced) {
|
|
122
|
+
const canPushLocally = ghAvailableAndAuthed();
|
|
123
|
+
const mainProtected = canPushLocally ? mainIsProtected() : null;
|
|
124
|
+
const effective = effectiveDeployMode(mode, { canPushLocally, envForced, mainProtected });
|
|
125
|
+
if (effective === 'ci' && mainProtected === true) {
|
|
126
|
+
console.log(' deploy mode: main is branch-protected (direct pushes refused) — landing via the ci PR path');
|
|
127
|
+
}
|
|
128
|
+
return effective;
|
|
129
|
+
}
|
|
130
|
+
return mode;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// readDeployConfigRaw — raw contents of config/deploy.json (or null if absent /
|
|
134
|
+
// unreadable). One reader shared by resolveDeployMode (the mode switch) and
|
|
135
|
+
// resolveDeployTarget (the SSH target) so they never diverge on where the file is.
|
|
136
|
+
function readDeployConfigRaw() {
|
|
137
|
+
try {
|
|
138
|
+
return fs.readFileSync(path.join(REPO_ROOT, 'config', 'deploy.json'), 'utf8');
|
|
139
|
+
} catch (_) {
|
|
140
|
+
return null; // no config file → callers apply their safe default
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// safeBranding — the instance brand pack, or null if it can't be read. Guarded the
|
|
145
|
+
// same way ship.js guards the sound player (line ~91): a deploy must never crash
|
|
146
|
+
// because the brand file is missing on a fresh / re-seeded checkout.
|
|
147
|
+
function safeBranding() {
|
|
148
|
+
try {
|
|
149
|
+
return require('../../src/branding').branding();
|
|
150
|
+
} catch (_) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// readSessionApiBase — the signed-in session's persisted api_base (the instance the
|
|
156
|
+
// CLI is actually pointed at), or null when not signed in / unreadable. This is the
|
|
157
|
+
// session's OWN value only — deliberately NOT readSessionToken().apiBase, which
|
|
158
|
+
// already folds in the env override + branding default; resolveInstanceBase orders
|
|
159
|
+
// that precedence itself so it stays pure over its injected inputs.
|
|
160
|
+
function readSessionApiBase() {
|
|
161
|
+
try {
|
|
162
|
+
const s = loadSessionSync();
|
|
163
|
+
return (s && typeof s.api_base === 'string' && s.api_base.trim()) ? s.api_base.trim() : null;
|
|
164
|
+
} catch (_) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// resolveInstanceBase — the ORIGIN of the instance this ship is landing on, used by
|
|
170
|
+
// the ci-land deployed-version probe (/version), the /healthz re-probe, the ci
|
|
171
|
+
// PR-body deploy note, and the manual-confirm curl guidance. Precedence mirrors the
|
|
172
|
+
// CLI's own base resolution: <PREFIX>_API_BASE env → the signed-in session's
|
|
173
|
+
// api_base → branding publicOrigin → null (unknowable). There is NO hardcoded
|
|
174
|
+
// founder host — 5 sites here baked in `https://amazonprimea.com`, so a ci-land /
|
|
175
|
+
// manual-confirm on a migrated instance pointed the builder at the WRONG box; the
|
|
176
|
+
// ci-land/guidance sibling of resolveDeployTarget()'s laptop-mode fix (task 1002232
|
|
177
|
+
// ⊃ task 1002202). Pure over its injected inputs (env / sessionApiBase / branding)
|
|
178
|
+
// so the precedence is unit-tested without fs/env/network; the defaults read the
|
|
179
|
+
// real session file + brand pack.
|
|
180
|
+
function resolveInstanceBase({
|
|
181
|
+
env = process.env,
|
|
182
|
+
sessionApiBase = readSessionApiBase(),
|
|
183
|
+
branding = safeBranding(),
|
|
184
|
+
} = {}) {
|
|
185
|
+
const ic = require('../../src/instance-config');
|
|
186
|
+
const trim = (s) => (typeof s === 'string' && s.trim() ? s.trim().replace(/\/+$/, '') : null);
|
|
187
|
+
const envBase = trim(ic.resolveEnv('API_BASE', { env }));
|
|
188
|
+
if (envBase) return envBase;
|
|
189
|
+
const sess = trim(sessionApiBase);
|
|
190
|
+
if (sess) return sess;
|
|
191
|
+
const origin = branding && branding.domains && branding.domains.publicOrigin;
|
|
192
|
+
return trim(origin);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// deployedVersionUrl — the instance's /version endpoint, or null when no instance
|
|
196
|
+
// origin is resolvable (then fetchDeployedVersion is a no-op). Derived from
|
|
197
|
+
// resolveInstanceBase — no hardcoded host (task 1002232).
|
|
198
|
+
function deployedVersionUrl() {
|
|
199
|
+
const base = resolveInstanceBase();
|
|
200
|
+
return base ? `${base}/version` : null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// resolveDeployTarget — WHERE a laptop-mode ship SSHes `~/deploy.sh`, and which
|
|
204
|
+
// /healthz it re-probes when deploy.sh's own health check races the restart window.
|
|
205
|
+
// Pure (env + raw config + branding → target) and exported so the precedence and the
|
|
206
|
+
// safe "no target" default are unit-tested without touching fs/env/network.
|
|
207
|
+
//
|
|
208
|
+
// There is deliberately NO hardcoded default. The founder instance's old game-box
|
|
209
|
+
// SSH target was baked in here, so a laptop-mode ship from a migrated
|
|
210
|
+
// checkout SSHed the WRONG server (task 1002202). The core carries no deploy target
|
|
211
|
+
// of its own — under the standalone model a core change reaches a live instance via
|
|
212
|
+
// package-core + `bongos upgrade` (pin-bump), not an SSH deploy (ADR 0108). An
|
|
213
|
+
// instance whose OWN checkout is its live server opts back into laptop-SSH deploy by
|
|
214
|
+
// setting the target:
|
|
215
|
+
// config/deploy.json → { "sshTarget": "user@host", "healthzUrl": "https://…/healthz" }
|
|
216
|
+
// env → <PREFIX>_DEPLOY_SSH_TARGET, <PREFIX>_DEPLOY_HEALTHZ_URL
|
|
217
|
+
// healthzUrl, when unset, falls back to the instance's own publicOrigin + /healthz
|
|
218
|
+
// (never a hardcoded host); null when even that is unknown (re-probe then skipped).
|
|
219
|
+
function resolveDeployTarget({ env = process.env, configRaw = null, branding = null } = {}) {
|
|
220
|
+
const ic = require('../../src/instance-config');
|
|
221
|
+
let cfg = {};
|
|
222
|
+
if (configRaw) {
|
|
223
|
+
try { cfg = JSON.parse(configRaw) || {}; } catch (_) { cfg = {}; }
|
|
224
|
+
}
|
|
225
|
+
const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
|
|
226
|
+
|
|
227
|
+
const sshTarget = ic.resolveEnv('DEPLOY_SSH_TARGET', { env }) || str(cfg.sshTarget) || null;
|
|
228
|
+
|
|
229
|
+
let healthzUrl = ic.resolveEnv('DEPLOY_HEALTHZ_URL', { env }) || str(cfg.healthzUrl) || null;
|
|
230
|
+
const publicOrigin = branding && branding.domains && branding.domains.publicOrigin;
|
|
231
|
+
if (!healthzUrl && str(publicOrigin)) {
|
|
232
|
+
try { healthzUrl = new URL('/healthz', publicOrigin).toString(); }
|
|
233
|
+
catch (_) { /* malformed origin → leave healthzUrl null */ }
|
|
234
|
+
}
|
|
235
|
+
return { sshTarget, healthzUrl };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// gh runners — mirror gitArgvOk/gitOk but for the GitHub CLI. No shell, ever:
|
|
239
|
+
// branch names + DB-sourced value_summary reach `gh` as literal argv elements
|
|
240
|
+
// (same injection discipline as #681). ghArgvOk = boolean side-effect; ghJson =
|
|
241
|
+
// parse `gh ... --json` stdout to an object/array (null on any failure).
|
|
242
|
+
function ghArgvOk(args, opts = {}) {
|
|
243
|
+
return argvOk('gh', args, opts);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function ghJson(args, opts = {}) {
|
|
247
|
+
const out = argvOut('gh', args, opts);
|
|
248
|
+
if (out == null) return null;
|
|
249
|
+
try { return JSON.parse(out); } catch (_) { return null; }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Pure argv builders for the PR land path — exported + tested so the untrusted
|
|
253
|
+
// value_summary is proven to ride as ONE literal argv element (never a shell
|
|
254
|
+
// string), exactly like buildMergeArgv.
|
|
255
|
+
function buildPrCreateArgv(branch, title, body) {
|
|
256
|
+
return ['pr', 'create', '--head', branch, '--base', 'main', '--title', title, '--body', body];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// --merge keeps the project's --no-ff merge-commit convention (architecture.md
|
|
260
|
+
// branch-cleanup note); --auto arms GitHub to merge the instant required checks
|
|
261
|
+
// pass + branch protection is satisfied; --delete-branch mirrors the laptop
|
|
262
|
+
// path's origin-branch cleanup.
|
|
263
|
+
function buildPrMergeArgv(branch) {
|
|
264
|
+
return ['pr', 'merge', '--auto', '--merge', '--delete-branch', branch];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Pure: did the prod-deploy workflow finish successfully for the merge commit?
|
|
268
|
+
// `runs` is the parsed `gh run list --json headSha,status,conclusion` array.
|
|
269
|
+
function deployRunSucceeded(runs, mergeSha) {
|
|
270
|
+
if (!mergeSha) return false;
|
|
271
|
+
return (runs || []).some(
|
|
272
|
+
(r) => r && r.headSha === mergeSha && r.status === 'completed' && r.conclusion === 'success'
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Pure: does the prod /version endpoint (#858) report it's running the commit we
|
|
277
|
+
// just merged? 'match' = commit-pinned confirmation; 'mismatch' = prod is on a
|
|
278
|
+
// different commit (e.g. a later deploy already superseded ours — still fine, our
|
|
279
|
+
// run succeeded); 'unknown' = /version absent or unparseable (older prod / blip)
|
|
280
|
+
// → caller falls back to the deploy-run + healthz signal. Compares on the shorter
|
|
281
|
+
// length so a short vs full sha still matches. Exported + tested.
|
|
282
|
+
function deployedCommitStatus(versionBody, mergeSha) {
|
|
283
|
+
if (!versionBody || !mergeSha) return 'unknown';
|
|
284
|
+
const c = String(versionBody.commit || '');
|
|
285
|
+
if (!/^[0-9a-f]{7,40}$/i.test(c)) return 'unknown';
|
|
286
|
+
const n = Math.min(c.length, String(mergeSha).length);
|
|
287
|
+
return c.slice(0, n).toLowerCase() === String(mergeSha).slice(0, n).toLowerCase()
|
|
288
|
+
? 'match'
|
|
289
|
+
: 'mismatch';
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Fetch + parse the prod /version JSON (#858). null on any failure — never throws.
|
|
293
|
+
// Default target is the resolved instance origin (no hardcoded host — task 1002232);
|
|
294
|
+
// null when none is resolvable, in which case the probe is skipped.
|
|
295
|
+
function fetchDeployedVersion(url = deployedVersionUrl()) {
|
|
296
|
+
if (!url) return null; // no resolvable instance origin → nothing to probe
|
|
297
|
+
// stdio override DISCARDS stderr (a curl progress/error line must not pollute
|
|
298
|
+
// the JSON parse) — the documented per-caller difference from ghJson.
|
|
299
|
+
const out = argvOut('curl', ['-fsS', '--max-time', '10', url], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
300
|
+
if (out == null) return null;
|
|
301
|
+
try { return JSON.parse(out); } catch (_) { return null; }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ciLand — the 'ci' deploy-mode analogue of the laptop merge+SSH block. Pushes
|
|
305
|
+
// the branch, ensures a PR exists, enables auto-merge, then confirms the deploy
|
|
306
|
+
// actually landed. Returns true ONLY when prod is confirmed live on the merge
|
|
307
|
+
// commit — so the caller flips the task to shipped exactly as the laptop path
|
|
308
|
+
// does. Any earlier exit leaves the task at confirmed with a clear next step.
|
|
309
|
+
// pushVia — which publish path ship uses in 'ci' mode (ADR 0055 / task 1025):
|
|
310
|
+
// 'local' — this machine pushes the branch + opens the PR with git + gh
|
|
311
|
+
// (laptop: gh present + authenticated). Today's behaviour.
|
|
312
|
+
// 'server' — this machine has NO GitHub push credential (a dev box: no gh, a
|
|
313
|
+
// credential-less origin). ship uploads a thin branch bundle to the
|
|
314
|
+
// GDS, which pushes + opens the PR + auto-merges with the SERVER's
|
|
315
|
+
// credential. The box holds nothing (#919 model preserved).
|
|
316
|
+
// Override with OTB_PUSH_VIA_SERVER=1 (force server) / =0 (force local) for tests.
|
|
317
|
+
function ghAvailableAndAuthed() {
|
|
318
|
+
try {
|
|
319
|
+
const a = spawnSync('gh', ['auth', 'status'], {
|
|
320
|
+
stdio: 'ignore',
|
|
321
|
+
shell: process.platform === 'win32',
|
|
322
|
+
windowsHide: true,
|
|
323
|
+
timeout: 15000,
|
|
324
|
+
});
|
|
325
|
+
return !!a && !a.error && a.status === 0;
|
|
326
|
+
} catch (_) {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function pushVia() {
|
|
332
|
+
const o = String(process.env.OTB_PUSH_VIA_SERVER || '').trim();
|
|
333
|
+
if (o === '1') return 'server';
|
|
334
|
+
if (o === '0') return 'local';
|
|
335
|
+
// No explicit override: a machine that can run an authenticated gh (laptop)
|
|
336
|
+
// takes the local path; everything else (a dev box) goes server-mediated.
|
|
337
|
+
return ghAvailableAndAuthed() ? 'local' : 'server';
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// broadcastHandledByServer — task 1045: on the server-mediated publish path the
|
|
341
|
+
// SERVER posts the Discord ship-broadcast after it confirms the deploy (it holds
|
|
342
|
+
// the webhook secrets; the box does NOT — ADR 0053). So ship.js must NOT also post
|
|
343
|
+
// locally there, or the box would either double-post (impossible — no secret) or,
|
|
344
|
+
// as before, fail silently. True only when the publish actually goes server-side:
|
|
345
|
+
// ci deploy mode AND this machine has no GitHub push credential (pushVia 'server').
|
|
346
|
+
// The laptop path (pushVia 'local') keeps posting locally, exactly as today. Pure
|
|
347
|
+
// w.r.t. its inputs (env + committed deploy.json) + exported so the gate is tested.
|
|
348
|
+
function broadcastHandledByServer() {
|
|
349
|
+
return resolveDeployMode() === 'ci' && pushVia() === 'server';
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// shouldBailOnFetchFailure — autoMerge's decision when `git fetch origin main`
|
|
353
|
+
// fails. A credential-less dev box (private origin, scrubbed clone token —
|
|
354
|
+
// ADR 0053) CANNOT fetch origin, and that is FINE for the server-mediated
|
|
355
|
+
// publish path (ADR 0055): ciLandServer bundles against the box's LOCAL
|
|
356
|
+
// origin/main and the SERVER tops up main with its own credential. So a fetch
|
|
357
|
+
// failure is fatal ONLY when we are NOT taking that path — otherwise the box can
|
|
358
|
+
// never reach ciLand and strands every ship at confirmed (the bug task 1027
|
|
359
|
+
// surfaced: ADR 0055 shipped the server path but left this fetch guard in front
|
|
360
|
+
// of it). Pure + exported for the regression test (no git, no network).
|
|
361
|
+
// NOTE: callers must still guard the pushVia() gh-probe behind `!fetchedMain` so
|
|
362
|
+
// the happy path (fetch ok) spawns no extra gh and laptop behavior is unchanged.
|
|
363
|
+
function shouldBailOnFetchFailure(fetchedMain, deployMode, via) {
|
|
364
|
+
if (fetchedMain) return false;
|
|
365
|
+
const serverPublish = deployMode === 'ci' && via === 'server';
|
|
366
|
+
return !serverPublish;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
module.exports = {
|
|
370
|
+
broadcastHandledByServer,
|
|
371
|
+
buildPrCreateArgv,
|
|
372
|
+
buildPrMergeArgv,
|
|
373
|
+
deployRunSucceeded,
|
|
374
|
+
deployedCommitStatus,
|
|
375
|
+
effectiveDeployMode,
|
|
376
|
+
fetchDeployedVersion,
|
|
377
|
+
ghArgvOk,
|
|
378
|
+
ghJson,
|
|
379
|
+
mainIsProtected,
|
|
380
|
+
parseDeployMode,
|
|
381
|
+
pushVia,
|
|
382
|
+
readDeployConfigRaw,
|
|
383
|
+
resolveDeployMode,
|
|
384
|
+
resolveDeployTarget,
|
|
385
|
+
resolveInstanceBase,
|
|
386
|
+
safeBranding,
|
|
387
|
+
shouldBailOnFetchFailure,
|
|
388
|
+
};
|