@bongos/core 1.19.319 → 1.19.321
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/dev-box/box-access.js +20 -0
- 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-ratchets.js +54 -6
- 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/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/status-probe.mjs +30 -2
- package/src/module-api.js +5 -1
- package/tests/box_access.mjs +46 -0
- package/tests/fitness_ratchets.mjs +82 -0
- 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
|
@@ -1,6 +1,17 @@
|
|
|
1
|
+
# TEMPLATE — not installable as-is (task 1002757). The __TOKENS__ below MUST be
|
|
2
|
+
# substituted at install time; a provisioned instance gets a correct per-instance
|
|
3
|
+
# unit pair generated by scripts/gds/provision.js (task 2213) and never needs this
|
|
4
|
+
# file. Substitute:
|
|
5
|
+
# __SERVICE_USER__ the unix user that owns the instance + its Postgres role
|
|
6
|
+
# __INSTANCE_ROOT__ the instance checkout root (docs live under it)
|
|
7
|
+
# __CORE_SCRIPTS_DIR__ the deployed core's scripts/gds dir:
|
|
8
|
+
# standalone: <instance-root>/node_modules/@bongos/core/scripts/gds
|
|
9
|
+
# full clone: <instance-root>/scripts/gds
|
|
10
|
+
# __CONFIG_DIR__ the instance config dir name (offsite unit only)
|
|
11
|
+
# One-liner: sed -e 's|__SERVICE_USER__|<user>|g' -e … gds-db-backup.service | sudo tee /etc/systemd/system/…
|
|
1
12
|
[Unit]
|
|
2
13
|
Description=Weekly timer for GDS DB offsite archive to Spaces (filed by #415)
|
|
3
|
-
Documentation=file
|
|
14
|
+
Documentation=file://__INSTANCE_ROOT__/docs/recipes/gds-db-backup.md
|
|
4
15
|
|
|
5
16
|
[Timer]
|
|
6
17
|
# Run weekly on Sunday at 05:00 UTC. That's one hour after the daily local
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// instance-apex.js — the instance's own registrable apex + the addresses derived
|
|
3
|
+
// from it, read from its branding pack (task 1002757).
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS EXISTS. Operator-plane defaults used to carry the founder's apex as a
|
|
6
|
+
// literal (box DNS zones, the Cloudflare zone, the provisioner's git identity),
|
|
7
|
+
// so another operator's fleet pointed at the founder's DNS and its commits were
|
|
8
|
+
// attributed to the hub. These resolvers return NULL rather than guessing when the
|
|
9
|
+
// pack names no public origin: every caller treats null as "not configured" and
|
|
10
|
+
// refuses, which fails loudly instead of minting an unreachable host.
|
|
11
|
+
function instanceApex() {
|
|
12
|
+
try {
|
|
13
|
+
const host = new URL(require('../../src/branding').branding().domains.publicOrigin).hostname;
|
|
14
|
+
return host && !/^localhost$/i.test(host) ? host : null;
|
|
15
|
+
} catch { return null; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The git identity a provisioner commits scaffold/pin changes as: explicit env →
|
|
19
|
+
// bot@<instance apex> → a neutral, deliberately-undeliverable platform address
|
|
20
|
+
// (never a live inbox by accident).
|
|
21
|
+
function provisionerBotEmail(env = process.env) {
|
|
22
|
+
const explicit = String(env.PROVISION_BOT_EMAIL || '').trim();
|
|
23
|
+
if (explicit) return explicit;
|
|
24
|
+
const apex = instanceApex();
|
|
25
|
+
return apex ? `bot@${apex}` : 'bot@cloud-bongos.invalid';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = { instanceApex, provisionerBotEmail };
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"est_minutes": 55,
|
|
50
50
|
"credits_reward": 15,
|
|
51
51
|
"title": "Generate one new in-world art asset of your choice (a prop, plant, animal, or accessory) via the pixel-art pipeline",
|
|
52
|
-
"description": "## What you're doing\n\
|
|
52
|
+
"description": "## What you're doing\n\nThis instance's world always needs more to look at. Your first ship as an artist is to **generate ONE new in-world asset of your own choosing** and add it to the world. You pick the subject — that's the fun part. Good candidates, all in-world (Mediterranean coast just south of Athens, mythic 'discovered secret' tone):\n- **Props**: amphora, fishing net, basket, crate, urn, torch, market awning, oar, clay pot.\n- **Plants**: olive tree, cypress, reeds, grapevine, shrub, wild herbs.\n- **Animals**: goat, chicken, duck, donkey, fish, gull, lizard.\n- **Accessories**: a sandal, a sun hat, a length of rope, a string of beads.\n\nIt is identical for every newcomer and **always valuable**, because a growing world has effectively unbounded demand for new props, flora, fauna, and detail — every newcomer picks a different subject, so two runs never collide.\n\n## How to find your way in\n\n1. Read `art/README.md` and `docs/recipes/pixel-art-pipeline.md`, plus `modules/art-pipeline/template/style_guide.md`, `modules/art-pipeline/template/rubric.json`, and `modules/art-pipeline/template/palette.json` — that's the look you must hit: 16-bit pixel art, early-Pokémon (FireRed) touchstone, top-down, 32×32, locked 64-colour Mediterranean palette (olive greens, warm tans, turquoise sea, weathered marble — NOT spring green, NOT navy, NOT pine).\n2. **Pick ONE subject.** Keep it to a SINGLE 32×32 tile/sprite — not a whole spritesheet or family (that's a bigger task).\n3. **Check the budget first**: `python3 modules/art-pipeline/pipeline/cost_ledger.py status` (the pipeline hard-stops at $80; one asset is ~$0.05–0.45).\n4. **Generate via the pipeline — do not hand-draw or freelance.** Use the `/otb-tile-generate` skill: it appends your new entry to `modules/art-pipeline/template/vocabulary.json`, runs the generate → post-process-to-32×32 → score-against-rubric → retry orchestrator, saves the final PNG to `modules/art-pipeline/generated/tiles/<id>.png`, and logs cost. (This needs the art pipeline's `GOOGLE_AI_STUDIO_KEY`, which resolves from `~/.config/otb/env`; most builder setups have it. If yours doesn't, claim the engineer or ideator newcomer chore instead, or ask an Archon to enable it.)\n5. **Confirm it PASSED** the rubric (`modules/art-pipeline/iterations/run_summary.json` shows `status: \"pass\"`). If it only reached best-effort, regenerate with the fix_hint — never paper over a fail.\n6. **See it in-game**: `node scripts/gds/art-stage.js` rebuilds the atlas and surfaces it on your sandbox preview (`sandbox-<login>.<instance-apex>`).\n7. Ship via `/builder-ship`; the ship notes name your asset and its rubric score.\n\n## Stay clear of (READ-ONLY)\n\nNever modify the locked palette (`modules/art-pipeline/template/palette.json`) or the rubric (`modules/art-pipeline/template/rubric.json`) — those are theme-defining and changing them needs an ADR (out of scope for a first ship). Touch only art assets/vocabulary under `art/` (and, only if your asset is a genuinely new base-tile TYPE, the `public/game/world/tilePalette.js` mapping). Do not touch any permission/pipeline-core/migration/infra file.",
|
|
53
53
|
"done_when": [
|
|
54
54
|
"Exactly one NEW in-world asset (your chosen subject) was generated via the pixel-art pipeline (`/otb-tile-generate` / orchestrator.py); the final PNG exists at modules/art-pipeline/generated/tiles/<id>.png.",
|
|
55
55
|
"It PASSED the rubric (modules/art-pipeline/iterations/run_summary.json status 'pass', or /otb-design-review numbers reported at/above the bar) and every pixel is on the locked palette (modules/art-pipeline/template/palette.json).",
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"est_minutes": 45,
|
|
71
71
|
"credits_reward": 12,
|
|
72
72
|
"title": "Idea-seed pass: file 5-8 fresh in-world ideas (names, lore, or builder-UX) into the idea inbox",
|
|
73
|
-
"description": "## What you'll do\n\nThe seed of almost everything built here is the **idea_inbox** — a queue anyone can drop ideas into, which trusted builders later triage into real tasks. Your first ship as an ideator is simple and concrete: **add a small batch of fresh ideas to that inbox.** You write no code, touch no game, change no system — you think, write a few short well-shaped ideas, and file them with one command each.\n\nIt is identical for every newcomer and **always valuable**: the inbox is never \"done\" — every triage pass consumes ideas (promote/discard/merge), so it is permanently hungry for fresh input, and a world
|
|
73
|
+
"description": "## What you'll do\n\nThe seed of almost everything built here is the **idea_inbox** — a queue anyone can drop ideas into, which trusted builders later triage into real tasks. Your first ship as an ideator is simple and concrete: **add a small batch of fresh ideas to that inbox.** You write no code, touch no game, change no system — you think, write a few short well-shaped ideas, and file them with one command each.\n\nIt is identical for every newcomer and **always valuable**: the inbox is never \"done\" — every triage pass consumes ideas (promote/discard/merge), so it is permanently hungry for fresh input, and a growing world always wants more names, more lore, more texture. Your batch is brand-new content, so it can never duplicate \"the work\" — the supply IS the work.\n\n## Pick ONE branch\n\n- **Branch A — PRODUCT flavour (default).** Ideas that grow the world players see: **names + one line of lore** for a character, structure, landmark, district, ship, or festival; or a world-background snippet (a custom, a superstition, a line a named NPC says). Tone: mythic, mysterious, a \"discovered secret\"; Mediterranean ancient-Greek coast (just south of Athens); NOT modern, NOT tongue-in-cheek-modern. Ground it in CLAUDE.md §3/§6/§7.\n- **Branch B — INTERNAL / builder-UX.** Small user-experience improvement ideas for the GDS / builders' hall (the surfaces builders use, not the game): a clearer label, a confusing onboarding message, a quality-of-life tweak to a status page or a slash-command's output. See `docs/TRACKS.md` for product vs internal.\n\n## How to find your way in\n\n1. (Branch A) Read CLAUDE.md §3/§6/§7 for tone. (Branch B) Skim `docs/TRACKS.md` and click around your instance's /builders hall for the rough edge you'll write up.\n2. See what's already filed so you don't repeat it: `node scripts/gds/api.js GET /api/gds/inbox`.\n3. File each idea with `node scripts/gds/capture.js \"<title>\" --body-file <path>` (filing an idea is the open-to-all path — a Xenos/Thetes session is allowed to do it; you just can't triage them). Put one line of detail + a `track: product` or `track: internal` line in each body file.",
|
|
74
74
|
"done_when": [
|
|
75
75
|
"You filed 5–8 fresh ideas into the idea_inbox via `node scripts/gds/capture.js` (each prints a `Captured idea #N` confirmation), all in ONE branch (A product, or B internal/UX).",
|
|
76
76
|
"Each idea has a clear title and a body with at least one sentence of detail — for Branch A, one line of in-world lore (who/what it is); for Branch B, what the rough edge is and what 'better' looks like.",
|
package/scripts/gds/provision.js
CHANGED
|
@@ -169,6 +169,10 @@ const CONFIG = {
|
|
|
169
169
|
sshOpts: process.env.PROVISION_SSH_OPTS || '-o BatchMode=yes -o StrictHostKeyChecking=accept-new',
|
|
170
170
|
};
|
|
171
171
|
|
|
172
|
+
// task 1002757 — the provisioner's git identity resolves from the instance's own
|
|
173
|
+
// apex (env → bot@<apex> → neutral), never the hub's address hardcoded.
|
|
174
|
+
const { provisionerBotEmail } = require('./instance-apex');
|
|
175
|
+
|
|
172
176
|
const MAX_INTENT_ATTEMPTS = numEnv('PROVISION_INTENT_MAX_ATTEMPTS', 3);
|
|
173
177
|
|
|
174
178
|
// Manifest-placement verification budget (task 1002720 / QA-walk F23): a child
|
|
@@ -709,7 +713,7 @@ async function scaffoldStandaloneRepo(inst, deps) {
|
|
|
709
713
|
writeFile(giPath, INSTANCE_GITIGNORE);
|
|
710
714
|
}
|
|
711
715
|
exec(`git -C ${root} config user.name "Cloud Bongos"`);
|
|
712
|
-
exec(`git -C ${root} config user.email "
|
|
716
|
+
exec(`git -C ${root} config user.email "${provisionerBotEmail()}"`);
|
|
713
717
|
exec(`git -C ${root} add -A`);
|
|
714
718
|
const commitMsg = mode === 'adopt'
|
|
715
719
|
? `chore: adopt Cloud Bongos onto ${inst.slug} on a pinned @bongos/core ${coreV} (provision.js, ADR 0121)`
|
|
@@ -939,7 +943,7 @@ function buildCorePinRefreshCommit({ root, fromVersion, toVersion, repo, ownerTo
|
|
|
939
943
|
return {
|
|
940
944
|
config: [
|
|
941
945
|
`git -C ${root} config user.name "Cloud Bongos"`,
|
|
942
|
-
`git -C ${root} config user.email "
|
|
946
|
+
`git -C ${root} config user.email "${provisionerBotEmail()}"`,
|
|
943
947
|
],
|
|
944
948
|
add: `git -C ${root} add -A`,
|
|
945
949
|
commit: `git -C ${root} commit -m "${msg}"`,
|
|
@@ -2477,7 +2481,7 @@ async function main() {
|
|
|
2477
2481
|
if (require.main === module) main();
|
|
2478
2482
|
|
|
2479
2483
|
module.exports = {
|
|
2480
|
-
CONFIG, MAX_INTENT_ATTEMPTS,
|
|
2484
|
+
CONFIG, MAX_INTENT_ATTEMPTS, provisionerBotEmail,
|
|
2481
2485
|
// pure step generators (tested directly)
|
|
2482
2486
|
dbName, dbCreateCmd, migrateCmd, seedFirstVersionCmd, serviceUnit, serviceUnitPath, webEnvBody, webEnvPath,
|
|
2483
2487
|
backupUnitName, backupServicePath, backupTimerPath, backupScriptPath, backupService, backupTimer,
|
|
@@ -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
|
+
};
|