@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,192 @@
|
|
|
1
|
+
// scripts/gds/ship-memory-sync.js — memory sync-on-ship + transcript/model resolution (task #274, V3.R55).
|
|
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 197-365 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 { REPO_ROOT } = require('./ship-state.js');
|
|
16
|
+
|
|
17
|
+
// ---------- Memory sync-on-ship (Task #274 / V3.R55) ----------
|
|
18
|
+
//
|
|
19
|
+
// After a successful ship, push the builder's local Claude Code memory dir to
|
|
20
|
+
// the server (POST /api/gds/memory/sync), so a builder's MEMORY.md +
|
|
21
|
+
// feedback_*.md follow them across machines and survive a dead laptop. This is
|
|
22
|
+
// the push half of ADR 0024's cloneable-memory chain; #275 wires the pull side.
|
|
23
|
+
//
|
|
24
|
+
// Convention for locating the memory dir (NO hardcoded identity — CLAUDE.md §10):
|
|
25
|
+
// Claude Code stores per-project memory under
|
|
26
|
+
// <homedir>/.claude/projects/<encoded-project-path>/memory/
|
|
27
|
+
// where <encoded-project-path> is the project's ABSOLUTE path with every '/'
|
|
28
|
+
// and '.' replaced by '-' (e.g. /Users/x/Desktop/Claude-OTB →
|
|
29
|
+
// -Users-x-Desktop-Claude-OTB). We derive the project path from the MAIN
|
|
30
|
+
// worktree root (REPO_ROOT), not this ephemeral feature worktree, because the
|
|
31
|
+
// memory dir is keyed to the canonical project checkout. CLAUDE_CONFIG_DIR
|
|
32
|
+
// overrides the ~/.claude base when set (matches Claude Code's own behavior).
|
|
33
|
+
//
|
|
34
|
+
// Conflict policy: storeMemoryFile is an unconditional UPSERT (last-write-wins,
|
|
35
|
+
// server clock — ADR 0024 §3), so we push every file and let the server
|
|
36
|
+
// overwrite. Smarter mtime-diffing (skip files the server already has newer) is
|
|
37
|
+
// a possible follow-up; for #274 a full push is correct and cheap (KB of md).
|
|
38
|
+
//
|
|
39
|
+
// BEST-EFFORT: any failure here is logged as a warning and swallowed — a memory
|
|
40
|
+
// sync hiccup (dir missing, API down, quota) must NEVER fail or undo a ship.
|
|
41
|
+
|
|
42
|
+
function claudeConfigBase() {
|
|
43
|
+
// Claude Code honors CLAUDE_CONFIG_DIR as the base for its state dir; default
|
|
44
|
+
// is ~/.claude. Resolve via os.homedir() so this works for any user.
|
|
45
|
+
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function encodeProjectPath(absProjectPath) {
|
|
49
|
+
// Claude Code's encoding: every NON-ALPHANUMERIC char becomes '-'. The old
|
|
50
|
+
// `[/.]` class silently no-op'd on Windows (separator '\', drive ':', spaces),
|
|
51
|
+
// so the transcript was never found, no session_record was written, and the
|
|
52
|
+
// cost-plus reward — the only stream a cost-plus-only instance pays (ADR 0146)
|
|
53
|
+
// — could never book: every Windows ship paid 0 (task 1002471). Same rule as
|
|
54
|
+
// scripts/gds/onboarding-config.js, which already had it right. POSIX output is
|
|
55
|
+
// unchanged for paths whose only specials are '/' and '.'.
|
|
56
|
+
return String(absProjectPath).replace(/[^a-zA-Z0-9]/g, '-');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// resolveTranscriptPath — find this session's transcript .jsonl. Claude Code
|
|
60
|
+
// keys the transcript to the project dir of the CWD where the session RUNS, so
|
|
61
|
+
// a session in a git worktree stores it under the WORKTREE-encoded dir, not the
|
|
62
|
+
// main checkout. (6D.1 #540 shipped resolving only REPO_ROOT, which silently
|
|
63
|
+
// missed every worktree session — #551.) We try the session-local dir
|
|
64
|
+
// (process.cwd()) first, then REPO_ROOT as a fallback. Pure (exists + base
|
|
65
|
+
// injectable for tests). Returns the first existing path, or null.
|
|
66
|
+
function resolveTranscriptPath(sessionId, projectDirs, opts = {}) {
|
|
67
|
+
const exists = opts.existsFn || fs.existsSync;
|
|
68
|
+
const base = opts.base || claudeConfigBase();
|
|
69
|
+
for (const dir of projectDirs) {
|
|
70
|
+
if (!dir) continue;
|
|
71
|
+
const p = path.join(base, 'projects', encodeProjectPath(path.resolve(dir)), `${sessionId}.jsonl`);
|
|
72
|
+
if (exists(p)) return p;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// resolveBuilderModel — the REAL model that authored this session's work, read
|
|
78
|
+
// from the session transcript (dominant by output tokens, synthetic turns
|
|
79
|
+
// skipped), so selectGraderModel gets a true cross-model input instead of the
|
|
80
|
+
// ASSUMED_BUILDER_MODEL fallback (task 1002659 — everything graded on the
|
|
81
|
+
// assumed-builder default before this). Best-effort: no session id, no
|
|
82
|
+
// transcript, or any read error → null → the grader assumes, as before.
|
|
83
|
+
function resolveBuilderModel(opts = {}) {
|
|
84
|
+
try {
|
|
85
|
+
const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
|
|
86
|
+
// Same path-traversal guard as uploadSessionOnShip: the id lands in a path.
|
|
87
|
+
if (!sessionId || !/^[A-Za-z0-9_-]+$/.test(sessionId)) return null;
|
|
88
|
+
const transcriptPath = resolveTranscriptPath(sessionId, [process.cwd(), REPO_ROOT].filter(Boolean), opts);
|
|
89
|
+
if (!transcriptPath) return null;
|
|
90
|
+
const perModel = new Map();
|
|
91
|
+
for (const line of fs.readFileSync(transcriptPath, 'utf8').split('\n')) {
|
|
92
|
+
if (!line.trim()) continue;
|
|
93
|
+
let o;
|
|
94
|
+
try { o = JSON.parse(line); } catch (_) { continue; }
|
|
95
|
+
const msg = o && o.message;
|
|
96
|
+
if (!msg || !msg.model || msg.model === '<synthetic>' || !msg.usage) continue;
|
|
97
|
+
perModel.set(msg.model, (perModel.get(msg.model) || 0) + (Number(msg.usage.output_tokens) || 0));
|
|
98
|
+
}
|
|
99
|
+
let best = null;
|
|
100
|
+
let bestTokens = 0;
|
|
101
|
+
for (const [model, tokens] of perModel) {
|
|
102
|
+
if (tokens > bestTokens) { best = model; bestTokens = tokens; }
|
|
103
|
+
}
|
|
104
|
+
return best;
|
|
105
|
+
} catch (_) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function resolveMemoryDir() {
|
|
111
|
+
// REPO_ROOT is the main worktree (canonical project checkout). The memory dir
|
|
112
|
+
// is keyed to that path, not the per-task feature worktree we're shipping from.
|
|
113
|
+
const encoded = encodeProjectPath(path.resolve(REPO_ROOT));
|
|
114
|
+
return path.join(claudeConfigBase(), 'projects', encoded, 'memory');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Enumerate the memory dir into [{ path, content, mtime }]. `path` is RELATIVE
|
|
118
|
+
// to the memory dir (matches the server's (builder_id, relative-path) key).
|
|
119
|
+
// Recurses so nested memory files (if Claude Code ever nests them) are caught.
|
|
120
|
+
// Returns [] if the dir doesn't exist (not all builders have memory).
|
|
121
|
+
function collectMemoryFiles(memDir) {
|
|
122
|
+
if (!fs.existsSync(memDir)) return [];
|
|
123
|
+
const out = [];
|
|
124
|
+
const walk = (absDir, relPrefix) => {
|
|
125
|
+
let entries;
|
|
126
|
+
try {
|
|
127
|
+
entries = fs.readdirSync(absDir, { withFileTypes: true });
|
|
128
|
+
} catch (_) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
for (const ent of entries) {
|
|
132
|
+
const abs = path.join(absDir, ent.name);
|
|
133
|
+
const rel = relPrefix ? `${relPrefix}/${ent.name}` : ent.name;
|
|
134
|
+
if (ent.isDirectory()) {
|
|
135
|
+
walk(abs, rel);
|
|
136
|
+
} else if (ent.isFile()) {
|
|
137
|
+
try {
|
|
138
|
+
const content = fs.readFileSync(abs, 'utf8');
|
|
139
|
+
const mtime = fs.statSync(abs).mtime.toISOString();
|
|
140
|
+
// Use POSIX-style relative paths regardless of OS so the server key
|
|
141
|
+
// is stable across machines.
|
|
142
|
+
out.push({ path: rel.split(path.sep).join('/'), content, mtime });
|
|
143
|
+
} catch (_) {
|
|
144
|
+
/* skip unreadable file — best-effort */
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
walk(memDir, '');
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function syncMemoryOnShip() {
|
|
154
|
+
try {
|
|
155
|
+
const memDir = resolveMemoryDir();
|
|
156
|
+
const files = collectMemoryFiles(memDir);
|
|
157
|
+
if (files.length === 0) {
|
|
158
|
+
// Silent-ish: a builder with no memory dir is normal. One quiet line so
|
|
159
|
+
// the absence is visible in the ship log without looking like an error.
|
|
160
|
+
console.log(' memory sync: no local memory dir found — skipping (this is fine).');
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
console.log(` syncing ${files.length} memory file${files.length === 1 ? '' : 's'} to the server...`);
|
|
164
|
+
const r = await (await cliClient({ allowUnauthenticated: true })).memory.postMemorySync({ body: { files } });
|
|
165
|
+
if (r.status === 401) {
|
|
166
|
+
console.log(' memory sync: skipped (session not valid for sync; non-blocking).');
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const d = r.data || {};
|
|
170
|
+
if (r.ok) {
|
|
171
|
+
const errN = Array.isArray(d.errors) ? d.errors.length : 0;
|
|
172
|
+
console.log(
|
|
173
|
+
` memory sync: ${d.stored ?? 0} stored, ${d.skipped ?? 0} skipped${errN ? `, ${errN} error(s)` : ''}.`
|
|
174
|
+
);
|
|
175
|
+
} else if (r.status === 413) {
|
|
176
|
+
console.log(` memory sync: quota/size limit hit (${d.error || 'quota'}) — prune memory and re-sync. Non-blocking; ship is complete.`);
|
|
177
|
+
} else {
|
|
178
|
+
console.log(` memory sync: server returned ${r.status} (non-blocking):`, d.error || d);
|
|
179
|
+
}
|
|
180
|
+
} catch (e) {
|
|
181
|
+
// Any error at all — network, fs, anything — must not fail the ship.
|
|
182
|
+
console.log(' memory sync failed (non-blocking):', e && e.message ? e.message : e);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = {
|
|
187
|
+
claudeConfigBase,
|
|
188
|
+
encodeProjectPath,
|
|
189
|
+
resolveBuilderModel,
|
|
190
|
+
resolveTranscriptPath,
|
|
191
|
+
syncMemoryOnShip,
|
|
192
|
+
};
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// scripts/gds/ship-merge.js — autoMerge — the merge/regen/push/land chain that puts the branch on main.
|
|
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 2307-2579 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 path = require('node:path');
|
|
12
|
+
const { cleanupMacFinderDupRefs } = require('../../modules/lifecycle/ship-preflight');
|
|
13
|
+
const mergeLock = require('../../modules/lifecycle/merge-lock');
|
|
14
|
+
const { buildMergeArgv, gitArgvOk, gitOk, shellOk } = require('./ship-git.js');
|
|
15
|
+
const { mainIsProtected, pushVia, readDeployConfigRaw, resolveDeployMode, resolveDeployTarget, safeBranding, shouldBailOnFetchFailure } = require('./ship-deploy-target.js');
|
|
16
|
+
const { ciLand, landBailed, landed } = require('./ship-land.js');
|
|
17
|
+
const { runSmoke } = require('./ship-grade.js');
|
|
18
|
+
const { currentBranch, mainWorktreeBusy, regenerateApiClient, regenerateApiDocs, regenerateDiagrams, regenerateFileMap, regenerateRepoMap, regenerateSessionIndex } = require('./ship-regen.js');
|
|
19
|
+
const { REPO_ROOT } = require('./ship-state.js');
|
|
20
|
+
|
|
21
|
+
function zeroCommitVerdict(commitsAhead, dbOnly) {
|
|
22
|
+
if (commitsAhead !== '0') return 'has-commits';
|
|
23
|
+
return dbOnly ? 'db-only' : 'refuse';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function autoMerge(taskId, valueSummary, { dbOnly = false } = {}) {
|
|
27
|
+
const branch = currentBranch();
|
|
28
|
+
if (!branch || branch === 'main' || branch === 'HEAD') {
|
|
29
|
+
// task 1002723: an --allow-empty / --db-only ship legitimately runs FROM the
|
|
30
|
+
// main checkout (nothing was ever committed), but this bail fired BEFORE the
|
|
31
|
+
// deliberate-empty landing below could — the task parked at confirmed with no
|
|
32
|
+
// branch and no published tip, a shape the server sweep can never prove (the
|
|
33
|
+
// task-1002716 strand). Land it here iff the checkout has zero commits ahead
|
|
34
|
+
// of origin/main; anything else keeps the safe bail.
|
|
35
|
+
if (dbOnly && shellOk('git fetch origin main')
|
|
36
|
+
&& zeroCommitVerdict(gitOk('git rev-list origin/main..HEAD --count'), true) === 'db-only') {
|
|
37
|
+
console.log(' auto-merge: deliberate empty ship (--db-only / --allow-empty) from the main checkout - nothing to merge; advancing to shipped without merge/deploy.');
|
|
38
|
+
return landed({ noArtifact: true, noArtifactReason: 'empty ship from the main checkout — no commits to merge' });
|
|
39
|
+
}
|
|
40
|
+
console.log(` auto-merge: skipped (current branch is ${branch})`);
|
|
41
|
+
// The merge source IS the current branch, so on main there is nothing to
|
|
42
|
+
// merge. Half of the single-checkout catch-22 this task documents: the other
|
|
43
|
+
// half is the "main worktree is on <branch>, not main" bail below.
|
|
44
|
+
return landBailed(`nothing to merge — this checkout is on ${branch}, not a task branch`, '/merge-mode');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Refresh the data-driven diagrams before computing commits-ahead, so a
|
|
48
|
+
// diagram-only refresh still counts as something to deploy.
|
|
49
|
+
regenerateDiagrams();
|
|
50
|
+
// Refresh the generated code symbol skeleton (Path B / task 1224) on the same beat.
|
|
51
|
+
regenerateRepoMap();
|
|
52
|
+
// Refresh the generated session-log index + the bounded CLAUDE.md §13 snippet (ADR 0062 §8 / task 1226).
|
|
53
|
+
regenerateSessionIndex();
|
|
54
|
+
// Refresh the generated file-map sections (skills + scheduled-tasks) on the same beat (ADR 0066 / task 1276).
|
|
55
|
+
regenerateFileMap();
|
|
56
|
+
// Refresh the generated OpenAPI spec + API reference from the shipped route tree (task 1918).
|
|
57
|
+
regenerateApiDocs();
|
|
58
|
+
// ...then the typed client FROM that spec (+ the devbox vendored copy) on the same beat (task 2051).
|
|
59
|
+
regenerateApiClient();
|
|
60
|
+
|
|
61
|
+
// Resolve the deploy mode ONCE (task 1093 / C9) — it reads config/deploy.json;
|
|
62
|
+
// reused at the fetch-bail check + the ci-land branch below.
|
|
63
|
+
const deployMode = resolveDeployMode();
|
|
64
|
+
|
|
65
|
+
// DB-only / no-op ship detection (task #219). Some legitimate ships have
|
|
66
|
+
// no commits to merge — e.g. a task whose work was a direct SQL write to
|
|
67
|
+
// the production DB. The change is already live; the merge step has
|
|
68
|
+
// nothing to push, merge, or deploy. Short-circuit so the caller's POST
|
|
69
|
+
// /tasks/:id/ship still flips status to shipped instead of stranding the
|
|
70
|
+
// task at confirmed forever.
|
|
71
|
+
// A credential-less dev box can't `git fetch origin main` (private origin, no
|
|
72
|
+
// creds — ADR 0053), but that's only fatal OFF the server-publish path: the
|
|
73
|
+
// box's ADR-0055 path bundles against its LOCAL origin/main and the server tops
|
|
74
|
+
// up main. Without this, the box dead-ends here and strands every ship at
|
|
75
|
+
// confirmed (task 1027). The `!fetchedMain &&` short-circuit keeps the gh-probe
|
|
76
|
+
// (pushVia) off the happy laptop path, so laptop behavior is byte-for-byte.
|
|
77
|
+
const fetchedMain = shellOk('git fetch origin main');
|
|
78
|
+
if (!fetchedMain && shouldBailOnFetchFailure(fetchedMain, deployMode, pushVia())) {
|
|
79
|
+
console.log(' auto-merge: fetch failed; task stays at confirmed for /merge-mode');
|
|
80
|
+
return landBailed('could not fetch origin/main', '/merge-mode');
|
|
81
|
+
}
|
|
82
|
+
const commitsAhead = gitOk('git rev-list origin/main..HEAD --count');
|
|
83
|
+
const verdict = zeroCommitVerdict(commitsAhead, dbOnly);
|
|
84
|
+
if (verdict === 'refuse') {
|
|
85
|
+
// task 391 (idea #37): the old code short-circuited ANY zero-commit branch
|
|
86
|
+
// straight to shipped — silently advancing a claim with empty/wrong touches[]
|
|
87
|
+
// or simply uncommitted work, awarding credit for no change. Now a zero-commit
|
|
88
|
+
// ship must be DELIBERATELY declared with --db-only. Fail-safe: the claim is
|
|
89
|
+
// already resolved by this point, so the task stays at confirmed and a plain
|
|
90
|
+
// re-run resumes the merge leg (task 941) once they commit or pass --db-only.
|
|
91
|
+
console.error('');
|
|
92
|
+
console.error(' Ship refused: no commits ahead of main, and --db-only was not passed.');
|
|
93
|
+
console.error(' A zero-commit ship is almost always a mistake (uncommitted work, wrong branch, or empty/incorrect touches[]).');
|
|
94
|
+
console.error(' • If you have local changes, commit them, then re-ship.');
|
|
95
|
+
console.error(' • If this is a DELIBERATE direct-SQL / DB-only ship (the work was a write straight to the prod');
|
|
96
|
+
console.error(' DB — nothing to merge or deploy), re-run with --db-only:');
|
|
97
|
+
console.error(` node scripts/gds/ship.js ${taskId} --db-only`);
|
|
98
|
+
console.error(' The task stays at confirmed; nothing was merged or deployed.');
|
|
99
|
+
return landBailed('no commits ahead of main, and --db-only was not passed', `node scripts/gds/ship.js ${taskId} --db-only`);
|
|
100
|
+
}
|
|
101
|
+
if (verdict === 'db-only') {
|
|
102
|
+
console.log(' auto-merge: deliberate empty ship (--db-only / --allow-empty) - no commits ahead of main; advancing to shipped without merge/deploy.');
|
|
103
|
+
return landed({ noArtifact: true, noArtifactReason: 'empty ship — no commits ahead of main' });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// #679 / ADR 0042: in 'ci' deploy mode the operator's machine never merges or
|
|
107
|
+
// SSHes the droplet — ship lands via PR auto-merge and CI deploys. The laptop
|
|
108
|
+
// block below stays the default ('laptop' mode) so this is inert until the
|
|
109
|
+
// deliberate cutover. The DB-only short-circuit above is shared by both modes.
|
|
110
|
+
if (deployMode === 'ci') {
|
|
111
|
+
return await ciLand(taskId, branch, valueSummary);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
console.log(` pushing ${branch} to origin...`);
|
|
115
|
+
if (!gitArgvOk(['push', 'origin', branch])) {
|
|
116
|
+
console.log(' auto-merge: push failed; task stays at confirmed for /merge-mode');
|
|
117
|
+
return landBailed(`could not push ${branch} to origin`, '/merge-mode');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Serialize the shared-main-worktree merge (#462 C4; #269 C6). Concurrent
|
|
121
|
+
// ships AND manual /merge-mode runs queue on this one lock; if a live holder
|
|
122
|
+
// is still merging past the wait window we bail cleanly to /merge-mode rather
|
|
123
|
+
// than racing its working tree.
|
|
124
|
+
if (!mergeLock.acquire(REPO_ROOT)) {
|
|
125
|
+
console.log(' auto-merge: another active ship or /merge-mode run holds the merge lock; task stays at confirmed for /merge-mode');
|
|
126
|
+
return landBailed('another ship or /merge-mode run holds the merge lock', '/merge-mode');
|
|
127
|
+
}
|
|
128
|
+
let mergedClean = false;
|
|
129
|
+
try {
|
|
130
|
+
// Never touch REPO_ROOT if a parallel session left it mid-merge or dirty —
|
|
131
|
+
// this is the exact collision that previously bailed with the misleading
|
|
132
|
+
// "main is ahead of origin somehow" (#462).
|
|
133
|
+
const busy = mainWorktreeBusy();
|
|
134
|
+
if (busy) {
|
|
135
|
+
console.log(` auto-merge: main worktree busy (${busy}); task stays at confirmed for /merge-mode`);
|
|
136
|
+
return landBailed(`the main worktree is busy (${busy})`, '/merge-mode');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
console.log(` switching to main worktree (${REPO_ROOT}) for merge...`);
|
|
140
|
+
// Same cleanup as preflight: drop macOS Finder/iCloud `* 2` ref duplicates
|
|
141
|
+
// before fetching, otherwise `git fetch origin` fails with "bad object
|
|
142
|
+
// refs/heads/<name> 2" and the auto-merge bails. Was a recurring 2026-05-10
|
|
143
|
+
// failure mode — every ship of the day hit this and required /merge-mode.
|
|
144
|
+
const dupRefs = cleanupMacFinderDupRefs(REPO_ROOT);
|
|
145
|
+
if (dupRefs.length > 0) {
|
|
146
|
+
console.log(` auto-merge: removed ${dupRefs.length} stray refs (macOS Finder duplicates)`);
|
|
147
|
+
}
|
|
148
|
+
if (!shellOk(`git fetch origin`, { cwd: REPO_ROOT })) {
|
|
149
|
+
console.log(' auto-merge: fetch failed; task stays at confirmed for /merge-mode');
|
|
150
|
+
return landBailed('could not fetch origin in the main worktree', '/merge-mode');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// The main worktree must currently have main checked out.
|
|
154
|
+
const onMain = gitOk('git rev-parse --abbrev-ref HEAD', { cwd: REPO_ROOT });
|
|
155
|
+
if (onMain !== 'main') {
|
|
156
|
+
console.log(` auto-merge: main worktree is on ${onMain}, not main; task stays at confirmed`);
|
|
157
|
+
// The other half of the single-checkout catch-22: the merge target tree is
|
|
158
|
+
// the one holding the feature branch, so there is nowhere to merge INTO.
|
|
159
|
+
// Name the cure, not just the symptom — this is the bail that used to be
|
|
160
|
+
// invisible behind "landing automatically".
|
|
161
|
+
return landBailed(
|
|
162
|
+
`the main checkout is on ${onMain}, not main — work a task in its own worktree so the main checkout stays on main`,
|
|
163
|
+
'/merge-mode',
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!gitArgvOk(['merge', '--ff-only', 'origin/main'], { cwd: REPO_ROOT })) {
|
|
168
|
+
console.log(' auto-merge: main is ahead of origin somehow; task stays at confirmed');
|
|
169
|
+
return landBailed('the local main is ahead of origin/main', '/merge-mode');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// #681: build the merge argv and run it WITHOUT a shell. valueSummary is
|
|
173
|
+
// DB-sourced (untrusted) — passing it as a literal argv element means
|
|
174
|
+
// `$(...)`/backticks in it can never be interpreted as a command.
|
|
175
|
+
if (!gitArgvOk(buildMergeArgv(branch, valueSummary), { cwd: REPO_ROOT })) {
|
|
176
|
+
console.log(' auto-merge: merge failed (conflicts?); task stays at confirmed for manual /merge-mode');
|
|
177
|
+
return landBailed(`merging ${branch} into main failed (conflicts?)`, '/merge-mode');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
console.log(' smoke against post-merge state...');
|
|
181
|
+
if (!runSmoke().clean) {
|
|
182
|
+
console.log(' auto-merge: post-merge smoke failed; task stays at confirmed; investigate');
|
|
183
|
+
return landBailed('post-merge smoke tests failed', '/merge-mode');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
console.log(' pushing merged main to origin...');
|
|
187
|
+
// SEC #863: route through push-main.js, which carries the
|
|
188
|
+
// OTB_ALLOW_MAIN_PUSH override so the pre-push permission gate lets THIS
|
|
189
|
+
// graded+confirmed pipeline push through while still blocking ad-hoc
|
|
190
|
+
// `git push origin main`. process.execPath (quoted) for the same
|
|
191
|
+
// cross-platform reason as the other node spawns here (#660).
|
|
192
|
+
if (!shellOk(`"${process.execPath}" ${JSON.stringify(path.join(__dirname, 'push-main.js'))}`, { cwd: REPO_ROOT })) {
|
|
193
|
+
console.log(' auto-merge: push failed; task stays at confirmed');
|
|
194
|
+
// task 1002629: a branch-protected main refuses EVERY direct push, so
|
|
195
|
+
// retrying here can never work — resolveDeployMode normally reroutes to
|
|
196
|
+
// the ci PR path before this point; reaching this with protection on
|
|
197
|
+
// means an env-forced 'laptop' or a failed protection probe.
|
|
198
|
+
if (mainIsProtected() === true) {
|
|
199
|
+
return landBailed('main is branch-protected — the direct push can never land; unset OTB_DEPLOY_MODE (the ci PR path lands it)', `node scripts/gds/ship.js ${taskId}`);
|
|
200
|
+
}
|
|
201
|
+
return landBailed('could not push the merged main to origin', '/merge-mode');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Deploy step. The core carries NO deploy target of its own — under the
|
|
205
|
+
// standalone model a core change reaches a live instance via package-core +
|
|
206
|
+
// `bongos upgrade` (pin-bump), NOT an SSH deploy (ADR 0108). So unless an
|
|
207
|
+
// instance whose own checkout IS its live server has configured a target
|
|
208
|
+
// (config/deploy.json "sshTarget" or <PREFIX>_DEPLOY_SSH_TARGET), the land is
|
|
209
|
+
// complete at "merged + pushed to main" and the SSH deploy is skipped. (This
|
|
210
|
+
// used to SSH a hardcoded target — the founder instance's old game box, i.e. the
|
|
211
|
+
// WRONG server post-migration. task 1002202.)
|
|
212
|
+
const { sshTarget, healthzUrl } = resolveDeployTarget({
|
|
213
|
+
configRaw: readDeployConfigRaw(),
|
|
214
|
+
branding: safeBranding(),
|
|
215
|
+
});
|
|
216
|
+
if (!sshTarget) {
|
|
217
|
+
console.log(' merged + pushed to main. No deploy target configured, so no SSH deploy —');
|
|
218
|
+
console.log(' expected for the core. To publish to a live instance run the standalone release');
|
|
219
|
+
console.log(' flow: package-core → bongos upgrade (pin-bump), per ADR 0108. (Not an SSH deploy.)');
|
|
220
|
+
} else {
|
|
221
|
+
console.log(` triggering deploy on ${sshTarget}...`);
|
|
222
|
+
// Quote ~/deploy.sh so the LOCAL shell doesn't tilde-expand it before ssh
|
|
223
|
+
// transmits the command; with the quotes ssh sends the literal `~/deploy.sh`
|
|
224
|
+
// and the REMOTE shell expands it. (Matches /merge-mode SKILL.md:81.)
|
|
225
|
+
if (!shellOk(`ssh -o ConnectTimeout=10 ${sshTarget} "~/deploy.sh"`)) {
|
|
226
|
+
// task 984 / idea 180: ~/deploy.sh's internal /healthz curl can race the
|
|
227
|
+
// service-restart window (Node not yet bound) and exit non-zero even though
|
|
228
|
+
// the deploy succeeded — which used to false-fail the ship and force a
|
|
229
|
+
// needless /merge-mode. Before concluding failure, re-probe the CONFIGURED
|
|
230
|
+
// /healthz with backoff (curl's own --retry; cross-platform, no sleep) —
|
|
231
|
+
// never a hardcoded host. --retry-all-errors retries connection-refused
|
|
232
|
+
// during the bind window; -f makes a non-200 an error so a 502/503 also
|
|
233
|
+
// retries. If it comes up within the window the deploy really succeeded.
|
|
234
|
+
if (healthzUrl) {
|
|
235
|
+
console.log(' auto-merge: ~/deploy.sh returned non-zero — re-probing /healthz with backoff before concluding...');
|
|
236
|
+
if (shellOk(`curl -fsS --max-time 10 --retry 5 --retry-delay 3 --retry-all-errors ${JSON.stringify(healthzUrl)} -o /dev/null`)) {
|
|
237
|
+
console.log(' auto-merge: /healthz 200 on re-probe — deploy actually succeeded (deploy.sh raced the restart window). Proceeding.');
|
|
238
|
+
} else {
|
|
239
|
+
console.log(' auto-merge: deploy failed (/healthz never returned 200 after retries); task stays at confirmed');
|
|
240
|
+
return landBailed('merged to main, but the deploy failed (/healthz never returned 200)', '/merge-mode');
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
console.log(' auto-merge: ~/deploy.sh returned non-zero and no healthzUrl configured to re-probe; task stays at confirmed');
|
|
244
|
+
return landBailed('merged to main, but ~/deploy.sh returned non-zero (no healthz to re-probe)', '/merge-mode');
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Merged + deployed — the feature branch is permanently in main's history
|
|
250
|
+
// (--no-ff merge commit) and no longer needed on origin. Deleting it keeps
|
|
251
|
+
// GitHub's "Create PR" affordance from offering empty PRs against zero-diff
|
|
252
|
+
// branches and prevents builder confusion about whether a branch landed.
|
|
253
|
+
// Local refs/heads ref stays so the worktree on the Mac isn't disturbed
|
|
254
|
+
// (cleanup of stale local refs happens next `git fetch --prune`).
|
|
255
|
+
// Non-blocking: a deploy that succeeded shouldn't be marked failed just
|
|
256
|
+
// because the cleanup push fails (e.g. branch already deleted manually,
|
|
257
|
+
// permission glitch, network blip).
|
|
258
|
+
console.log(` cleaning up: deleting ${branch} on origin...`);
|
|
259
|
+
if (!gitArgvOk(['push', 'origin', '--delete', branch], { cwd: REPO_ROOT })) {
|
|
260
|
+
console.log(` warn: could not delete origin/${branch} — may already be gone (non-blocking)`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
console.log(' flipping task to shipped...');
|
|
264
|
+
mergedClean = true;
|
|
265
|
+
return landed();
|
|
266
|
+
} finally {
|
|
267
|
+
// Leave REPO_ROOT clean for the NEXT ship/merge no matter how we exited.
|
|
268
|
+
// A failed merge here wedges everyone else: a conflict leaves the tree
|
|
269
|
+
// mid-merge (MERGE_HEAD + conflicted index → mainWorktreeBusy() rejects all
|
|
270
|
+
// future ships), and a failure AFTER the local merge commit but BEFORE push
|
|
271
|
+
// (post-merge smoke/push/deploy) leaves main ahead-but-unpushed (the next
|
|
272
|
+
// `git merge --ff-only origin/main` then fails with the misleading "main is
|
|
273
|
+
// ahead of origin somehow"). Only normalize on the failure path — on the
|
|
274
|
+
// success path we already pushed, so touching the tree is needless risk.
|
|
275
|
+
// Best-effort: cleanup must never mask the real outcome. This is the
|
|
276
|
+
// clean-failure twin of merge-lock.recoverWorktree() (which handles the
|
|
277
|
+
// crash/stale-reclaim case where we never reach this finally at all).
|
|
278
|
+
if (!mergedClean) {
|
|
279
|
+
try {
|
|
280
|
+
if (gitOk('git rev-parse -q --verify MERGE_HEAD', { cwd: REPO_ROOT })) {
|
|
281
|
+
shellOk('git merge --abort', { cwd: REPO_ROOT });
|
|
282
|
+
}
|
|
283
|
+
if (gitOk('git rev-parse --abbrev-ref HEAD', { cwd: REPO_ROOT }) === 'main') {
|
|
284
|
+
shellOk('git reset --hard origin/main', { cwd: REPO_ROOT });
|
|
285
|
+
}
|
|
286
|
+
} catch (_) {
|
|
287
|
+
/* best-effort cleanup; the lock release below is what matters most */
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
mergeLock.release(REPO_ROOT);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
module.exports = {
|
|
295
|
+
autoMerge,
|
|
296
|
+
zeroCommitVerdict,
|
|
297
|
+
};
|