greprag 5.80.0 → 5.82.0
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/dist/capture-manifest.js +2 -1
- package/dist/codex-fast-hook.js +6 -0
- package/dist/codex-steering.js +1 -1
- package/dist/commands/app-model.js +0 -1
- package/dist/commands/arm-reminder.js +9 -7
- package/dist/commands/collision-check.js +7 -6
- package/dist/commands/corpus/client.js +13 -3
- package/dist/commands/delivery-reminder.js +35 -14
- package/dist/commands/deploy-gate.js +55 -0
- package/dist/commands/deploy-lock.js +100 -0
- package/dist/commands/deploy-record.js +145 -0
- package/dist/commands/deploy-verify.js +111 -0
- package/dist/commands/inbox-primer-reminder.js +5 -5
- package/dist/commands/inbox-watch.js +2 -4
- package/dist/commands/init.js +82 -0
- package/dist/commands/load.js +40 -0
- package/dist/commands/loadout-reminder.js +1 -1
- package/dist/commands/merge-guard.js +419 -0
- package/dist/commands/merge-lock.js +176 -0
- package/dist/commands/parity-reminder.js +53 -0
- package/dist/commands/persona-reminder.js +11 -0
- package/dist/commands/persona.js +50 -0
- package/dist/commands/procedure.js +77 -6
- package/dist/commands/reminder-registry.js +21 -5
- package/dist/commands/repodoc.js +433 -0
- package/dist/commands/search.js +149 -0
- package/dist/commands/skillgain.js +33 -25
- package/dist/delivery-lifecycle.js +16 -1
- package/dist/deploy-gate.js +355 -0
- package/dist/deploy-locks.js +339 -0
- package/dist/deploy-verify.js +209 -0
- package/dist/env-redaction.js +157 -0
- package/dist/harness-limits.js +17 -0
- package/dist/hook-runtime.js +11 -1
- package/dist/hook.js +170 -88
- package/dist/index.js +593 -567
- package/dist/inline-atom-episode.js +15 -7
- package/dist/inline-atom.js +8 -2
- package/dist/native-skill-adoption.js +11 -0
- package/dist/native-skill-mirror.js +8 -1
- package/dist/node-identity.bundle.js +1166 -0
- package/dist/opencode-plugin.bundle.js +307 -119
- package/dist/procedure-enabled.js +55 -0
- package/dist/procedure-runtime.js +6 -0
- package/dist/procedure-scope.js +190 -0
- package/dist/procedure-watch.js +29 -16
- package/dist/procedure.js +111 -5
- package/dist/project-anchor.js +1 -14
- package/dist/reminder-injector.js +11 -10
- package/dist/repodoc-client.js +296 -0
- package/dist/session-id.js +7 -8
- package/dist/skill-landing.js +57 -2
- package/dist/skill-mirror-client.js +14 -0
- package/dist/skill-mirror-files.js +18 -0
- package/package.json +2 -2
- package/scripts/bundle-node-identity.mjs +47 -0
- package/skill/templates/chip-spawn.md +7 -1
- package/skill/templates/delivery.md +105 -0
- package/skill/templates/prompt-audit.md +196 -0
- package/skill/templates/skill-change.md +25 -2
- package/dist/assistant-doctrine.js +0 -85
- package/dist/commands/assistant-reminder.js +0 -19
- package/dist/commands/assistant.js +0 -95
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/** deploy-locks.ts — the lock primitives shared by the deploy gate and the merge lock.
|
|
3
|
+
*
|
|
4
|
+
* WHY A SEPARATE MODULE. `evaluateDeployGate` is read-only by construction, which
|
|
5
|
+
* is what makes it safe to run in shadow beside a live gate. A lock is a write.
|
|
6
|
+
* Keeping the primitives here lets the gate READ a lock without the gate module
|
|
7
|
+
* ever gaining a write path, and without the two implementations drifting.
|
|
8
|
+
*
|
|
9
|
+
* # adr: adr/deploy-gate-cli.md
|
|
10
|
+
*/
|
|
11
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
12
|
+
if (k2 === undefined) k2 = k;
|
|
13
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
14
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
15
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
16
|
+
}
|
|
17
|
+
Object.defineProperty(o, k2, desc);
|
|
18
|
+
}) : (function(o, m, k, k2) {
|
|
19
|
+
if (k2 === undefined) k2 = k;
|
|
20
|
+
o[k2] = m[k];
|
|
21
|
+
}));
|
|
22
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
23
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
24
|
+
}) : function(o, v) {
|
|
25
|
+
o["default"] = v;
|
|
26
|
+
});
|
|
27
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
28
|
+
var ownKeys = function(o) {
|
|
29
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
30
|
+
var ar = [];
|
|
31
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
32
|
+
return ar;
|
|
33
|
+
};
|
|
34
|
+
return ownKeys(o);
|
|
35
|
+
};
|
|
36
|
+
return function (mod) {
|
|
37
|
+
if (mod && mod.__esModule) return mod;
|
|
38
|
+
var result = {};
|
|
39
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
40
|
+
__setModuleDefault(result, mod);
|
|
41
|
+
return result;
|
|
42
|
+
};
|
|
43
|
+
})();
|
|
44
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
45
|
+
exports.CLAIM_MAX_AGE_MS = exports.MERGE_STALE_MS = exports.DEPLOY_STALE_MS = void 0;
|
|
46
|
+
exports.lockSession = lockSession;
|
|
47
|
+
exports.lockOwnership = lockOwnership;
|
|
48
|
+
exports.describeLockOwner = describeLockOwner;
|
|
49
|
+
exports.gitDir = gitDir;
|
|
50
|
+
exports.lockPath = lockPath;
|
|
51
|
+
exports.deployLockPath = deployLockPath;
|
|
52
|
+
exports.mergeLockPath = mergeLockPath;
|
|
53
|
+
exports.lockIsStale = lockIsStale;
|
|
54
|
+
exports.readLock = readLock;
|
|
55
|
+
exports.readMergeLock = readMergeLock;
|
|
56
|
+
exports.acquireLock = acquireLock;
|
|
57
|
+
exports.releaseLock = releaseLock;
|
|
58
|
+
exports.landedClaimsPath = landedClaimsPath;
|
|
59
|
+
exports.isAncestorOfHead = isAncestorOfHead;
|
|
60
|
+
exports.commitExists = commitExists;
|
|
61
|
+
exports.readLandedClaims = readLandedClaims;
|
|
62
|
+
exports.openClaims = openClaims;
|
|
63
|
+
exports.recordLandedClaim = recordLandedClaim;
|
|
64
|
+
exports.dropLandedClaim = dropLandedClaim;
|
|
65
|
+
exports.readMergeState = readMergeState;
|
|
66
|
+
const fs = __importStar(require("fs"));
|
|
67
|
+
const path = __importStar(require("path"));
|
|
68
|
+
const proc_1 = require("./proc");
|
|
69
|
+
const session_id_1 = require("./session-id");
|
|
70
|
+
/** The basename already on disk in the repos that have a deploy lock today. The
|
|
71
|
+
* CLI inspects the real file rather than a parallel one. */
|
|
72
|
+
const PREFIX = process.env.GREPRAG_DEPLOY_LOCK_PREFIX || 'sift';
|
|
73
|
+
/** A deploy is minutes. A merge with a human or an agent resolving conflicts can
|
|
74
|
+
* legitimately be much longer, so it gets its own ceiling rather than sharing
|
|
75
|
+
* the deploy's. Past it, the lock is treated as abandoned. */
|
|
76
|
+
exports.DEPLOY_STALE_MS = 15 * 60 * 1000;
|
|
77
|
+
exports.MERGE_STALE_MS = 60 * 60 * 1000;
|
|
78
|
+
/**
|
|
79
|
+
* THE identity a lock records, resolved through the CLI's single session resolver.
|
|
80
|
+
*
|
|
81
|
+
* WHY NOT AN INLINE ENV CHAIN. This used to read
|
|
82
|
+
* `GREPRAG_SESSION_ID || CLAUDE_SESSION_ID || 'unknown'` here and again in
|
|
83
|
+
* `recordLandedClaim`. Claude Code exports `CLAUDE_CODE_SESSION_ID`, so both sites
|
|
84
|
+
* fell straight through to the sentinel, and the merge guard then read a session's
|
|
85
|
+
* OWN lock as a stranger's and refused its merge — with no way out but releasing the
|
|
86
|
+
* lock. A copied chain cannot be kept correct; `readSessionEnv` is the one place that
|
|
87
|
+
* knows every harness's variable, and adding a harness there fixes locks for free.
|
|
88
|
+
*
|
|
89
|
+
* Returns the short form the guard compares, or null when no harness identifies us.
|
|
90
|
+
*/
|
|
91
|
+
function lockSession() {
|
|
92
|
+
return (0, session_id_1.truncateSessionId)((0, session_id_1.readSessionEnv)());
|
|
93
|
+
}
|
|
94
|
+
/** Pure. `sessionId` may be a full UUID (a hook payload) or a short id (a lock record);
|
|
95
|
+
* both are normalised through the shared helper rather than sliced by hand, so a
|
|
96
|
+
* UUIDv7 harness compares on the same 16 hex digits everywhere. */
|
|
97
|
+
function lockOwnership(held, sessionId) {
|
|
98
|
+
if (!held || held.stale)
|
|
99
|
+
return 'unlocked';
|
|
100
|
+
const owner = (0, session_id_1.truncateSessionId)(held.record.session);
|
|
101
|
+
const asker = (0, session_id_1.truncateSessionId)(sessionId);
|
|
102
|
+
// An owner that does not resolve covers locks written before identity was recorded
|
|
103
|
+
// properly, whose `session` is the literal string `unknown`.
|
|
104
|
+
if (!owner || !asker)
|
|
105
|
+
return 'unattributable';
|
|
106
|
+
return (0, session_id_1.sameSessionId)(owner, asker) ? 'mine' : 'peer';
|
|
107
|
+
}
|
|
108
|
+
/** For display only. Never let a missing owner print as `undefined`. */
|
|
109
|
+
function describeLockOwner(record) {
|
|
110
|
+
return (0, session_id_1.truncateSessionId)(record.session) || 'unidentified';
|
|
111
|
+
}
|
|
112
|
+
function gitDir(cwd) {
|
|
113
|
+
const result = (0, proc_1.safeSpawnSync)('git', ['rev-parse', '--absolute-git-dir'], {
|
|
114
|
+
encoding: 'utf8', cwd, stdio: ['ignore', 'pipe', 'pipe'],
|
|
115
|
+
});
|
|
116
|
+
if (result.status !== 0)
|
|
117
|
+
throw new Error('not a git checkout');
|
|
118
|
+
return String(result.stdout).trim();
|
|
119
|
+
}
|
|
120
|
+
/** Locks live inside `.git`, so they are never tree dirt and can never be committed. */
|
|
121
|
+
function lockPath(cwd, name) {
|
|
122
|
+
return path.join(gitDir(cwd), `${PREFIX}-${name}.lock`);
|
|
123
|
+
}
|
|
124
|
+
function deployLockPath(cwd, target) {
|
|
125
|
+
return lockPath(cwd, `deploy-${target}`);
|
|
126
|
+
}
|
|
127
|
+
/** A merge is repo-wide, not per-target: there is one working tree to conflict in. */
|
|
128
|
+
function mergeLockPath(cwd) {
|
|
129
|
+
return lockPath(cwd, 'merge');
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Pure: a lock whose owner died, or which outlived any plausible run, is not a lock.
|
|
133
|
+
*
|
|
134
|
+
* `checkPid` is the difference between the two kinds of holder, and getting it wrong
|
|
135
|
+
* makes a lock silently useless. A DEPLOY lock is held by a running script, so its pid
|
|
136
|
+
* is alive for the whole window and a dead pid means a crash. A MERGE lock is held by
|
|
137
|
+
* an agent session across many separate commands — the process that ran
|
|
138
|
+
* `merge-lock acquire` exited immediately, so its pid is always dead and pid-checking
|
|
139
|
+
* would mark every merge lock stale the instant it was taken. For that kind, elapsed
|
|
140
|
+
* time is the only honest signal.
|
|
141
|
+
*/
|
|
142
|
+
function lockIsStale(record, nowMs, staleMs = exports.DEPLOY_STALE_MS, { checkPid = true } = {}) {
|
|
143
|
+
if (!record || typeof record !== 'object')
|
|
144
|
+
return true;
|
|
145
|
+
const value = record;
|
|
146
|
+
const startedAt = Date.parse(String(value.startedAt ?? ''));
|
|
147
|
+
if (!Number.isFinite(startedAt))
|
|
148
|
+
return true;
|
|
149
|
+
if (nowMs - startedAt > staleMs)
|
|
150
|
+
return true;
|
|
151
|
+
if (!checkPid)
|
|
152
|
+
return false;
|
|
153
|
+
if (!Number.isInteger(value.pid))
|
|
154
|
+
return true;
|
|
155
|
+
try {
|
|
156
|
+
process.kill(value.pid, 0);
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
// ESRCH means the owner is gone. EPERM means it is alive under another user.
|
|
161
|
+
return error?.code === 'ESRCH';
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** Read-only. Returns null when no lock file exists or it cannot be parsed. */
|
|
165
|
+
function readLock(file, staleMs = exports.DEPLOY_STALE_MS, options = {}) {
|
|
166
|
+
let raw;
|
|
167
|
+
try {
|
|
168
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
let record;
|
|
174
|
+
try {
|
|
175
|
+
record = JSON.parse(raw);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// An unparseable lock is not a claim anyone can honour, and holding a deploy
|
|
179
|
+
// on it forever would be worse than reclaiming it.
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
const startedAt = Date.parse(record?.startedAt ?? '');
|
|
183
|
+
return {
|
|
184
|
+
record,
|
|
185
|
+
stale: lockIsStale(record, Date.now(), staleMs, options),
|
|
186
|
+
ageMs: Number.isFinite(startedAt) ? Date.now() - startedAt : Number.NaN,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/** The merge lock is session-held, so it never pid-checks. One helper so no caller
|
|
190
|
+
* has to remember that, which is exactly the mistake this shape prevents. */
|
|
191
|
+
function readMergeLock(cwd) {
|
|
192
|
+
return readLock(mergeLockPath(cwd), exports.MERGE_STALE_MS, { checkPid: false });
|
|
193
|
+
}
|
|
194
|
+
/** Exclusive create, then reclaim only if the existing lock is provably stale. */
|
|
195
|
+
function acquireLock(file, label, staleMs = exports.DEPLOY_STALE_MS, options = {}) {
|
|
196
|
+
const session = lockSession();
|
|
197
|
+
const record = {
|
|
198
|
+
// A process-held lock must record the process that is actually DEPLOYING, which for
|
|
199
|
+
// a script that shells out to this CLI is the caller, not this short-lived command.
|
|
200
|
+
// Recording our own pid there would make the lock read as stale the moment it exists.
|
|
201
|
+
pid: options.pid ?? process.pid,
|
|
202
|
+
startedAt: new Date().toISOString(),
|
|
203
|
+
// Omitted, never a placeholder: a lock that claims an owner it does not have is
|
|
204
|
+
// read as a stranger's by everyone, its holder included.
|
|
205
|
+
...(session ? { session } : {}),
|
|
206
|
+
label,
|
|
207
|
+
};
|
|
208
|
+
try {
|
|
209
|
+
const fd = fs.openSync(file, 'wx');
|
|
210
|
+
fs.writeSync(fd, JSON.stringify(record));
|
|
211
|
+
fs.closeSync(fd);
|
|
212
|
+
return { ok: true, path: file };
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
if (error?.code !== 'EEXIST')
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
const held = readLock(file, staleMs, options);
|
|
219
|
+
if (held && !held.stale)
|
|
220
|
+
return { ok: false, path: file, held };
|
|
221
|
+
fs.writeFileSync(file, JSON.stringify(record));
|
|
222
|
+
return { ok: true, path: file, ...(held ? { held } : {}) };
|
|
223
|
+
}
|
|
224
|
+
function releaseLock(file) {
|
|
225
|
+
try {
|
|
226
|
+
fs.rmSync(file, { force: true });
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
// A lock we cannot remove goes stale on its own; never fail completed work over it.
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// Landed claims — "I merged this; prove it is in what ships."
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
/** A claim outlives one deploy but must not block production forever. A session that
|
|
236
|
+
* recorded a claim and then vanished would otherwise wedge the checkout. */
|
|
237
|
+
exports.CLAIM_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
238
|
+
function landedClaimsPath(cwd) {
|
|
239
|
+
return path.join(gitDir(cwd), `${PREFIX}-landed.json`);
|
|
240
|
+
}
|
|
241
|
+
/** True when `sha` is reachable from HEAD. False for an orphan, a rewritten commit,
|
|
242
|
+
* or one that never existed — the caller distinguishes those with `commitExists`. */
|
|
243
|
+
function isAncestorOfHead(cwd, sha) {
|
|
244
|
+
const result = (0, proc_1.safeSpawnSync)('git', ['merge-base', '--is-ancestor', sha, 'HEAD'], {
|
|
245
|
+
encoding: 'utf8', cwd, stdio: ['ignore', 'pipe', 'pipe'],
|
|
246
|
+
});
|
|
247
|
+
return result.status === 0;
|
|
248
|
+
}
|
|
249
|
+
function commitExists(cwd, sha) {
|
|
250
|
+
const result = (0, proc_1.safeSpawnSync)('git', ['cat-file', '-e', `${sha}^{commit}`], {
|
|
251
|
+
encoding: 'utf8', cwd, stdio: ['ignore', 'pipe', 'pipe'],
|
|
252
|
+
});
|
|
253
|
+
return result.status === 0;
|
|
254
|
+
}
|
|
255
|
+
function readLandedClaims(cwd) {
|
|
256
|
+
try {
|
|
257
|
+
const raw = JSON.parse(fs.readFileSync(landedClaimsPath(cwd), 'utf8'));
|
|
258
|
+
return Array.isArray(raw) ? raw.filter(c => c && typeof c.sha === 'string') : [];
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/** Claims still worth checking: not yet shipped, and not older than the age cap.
|
|
265
|
+
* A claim that IS an ancestor is satisfied permanently, so dropping it is safe and
|
|
266
|
+
* keeps the file to exactly the open questions. */
|
|
267
|
+
function openClaims(cwd, claims, nowMs = Date.now()) {
|
|
268
|
+
return claims.filter(c => {
|
|
269
|
+
const at = Date.parse(c.at ?? '');
|
|
270
|
+
if (Number.isFinite(at) && nowMs - at > exports.CLAIM_MAX_AGE_MS)
|
|
271
|
+
return false;
|
|
272
|
+
return !isAncestorOfHead(cwd, c.sha);
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
function recordLandedClaim(cwd, sha, label) {
|
|
276
|
+
const session = lockSession();
|
|
277
|
+
const claim = {
|
|
278
|
+
sha,
|
|
279
|
+
label,
|
|
280
|
+
...(session ? { session } : {}),
|
|
281
|
+
at: new Date().toISOString(),
|
|
282
|
+
};
|
|
283
|
+
const kept = openClaims(cwd, readLandedClaims(cwd)).filter(c => c.sha !== sha);
|
|
284
|
+
try {
|
|
285
|
+
fs.writeFileSync(landedClaimsPath(cwd), JSON.stringify([...kept, claim], null, 2));
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
// A claim we cannot record is a claim nobody will check — say so rather than pretend.
|
|
289
|
+
throw new Error(`could not record the landed claim at ${landedClaimsPath(cwd)}`);
|
|
290
|
+
}
|
|
291
|
+
return claim;
|
|
292
|
+
}
|
|
293
|
+
function dropLandedClaim(cwd, sha) {
|
|
294
|
+
const claims = readLandedClaims(cwd);
|
|
295
|
+
const kept = claims.filter(c => !c.sha.startsWith(sha) && !sha.startsWith(c.sha));
|
|
296
|
+
if (kept.length === claims.length)
|
|
297
|
+
return false;
|
|
298
|
+
fs.writeFileSync(landedClaimsPath(cwd), JSON.stringify(kept, null, 2));
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Detect a half-finished merge, cherry-pick, revert or rebase.
|
|
303
|
+
*
|
|
304
|
+
* This is the check that was missing on 2026-09-04: a chip session died mid-merge
|
|
305
|
+
* and left the canonical checkout conflicted. Three separate sessions then
|
|
306
|
+
* discovered it independently, each reasoning from scratch about whether the owner
|
|
307
|
+
* was still alive, before one of them completed it. Git knows this state exactly
|
|
308
|
+
* and answers for free.
|
|
309
|
+
*/
|
|
310
|
+
function readMergeState(cwd) {
|
|
311
|
+
const dir = gitDir(cwd);
|
|
312
|
+
const markers = [
|
|
313
|
+
['MERGE_HEAD', 'merge'],
|
|
314
|
+
['CHERRY_PICK_HEAD', 'cherry-pick'],
|
|
315
|
+
['REVERT_HEAD', 'revert'],
|
|
316
|
+
['rebase-merge', 'rebase'],
|
|
317
|
+
['rebase-apply', 'rebase'],
|
|
318
|
+
];
|
|
319
|
+
for (const [name, kind] of markers) {
|
|
320
|
+
const marker = path.join(dir, name);
|
|
321
|
+
let stat;
|
|
322
|
+
try {
|
|
323
|
+
stat = fs.statSync(marker);
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
return { inProgress: true, kind, ageMs: Date.now() - stat.mtimeMs, unresolved: unresolvedPaths(cwd) };
|
|
329
|
+
}
|
|
330
|
+
return { inProgress: false, kind: null, ageMs: 0, unresolved: [] };
|
|
331
|
+
}
|
|
332
|
+
function unresolvedPaths(cwd) {
|
|
333
|
+
const result = (0, proc_1.safeSpawnSync)('git', ['diff', '--name-only', '--diff-filter=U'], {
|
|
334
|
+
encoding: 'utf8', cwd, stdio: ['ignore', 'pipe', 'pipe'],
|
|
335
|
+
});
|
|
336
|
+
if (result.status !== 0)
|
|
337
|
+
return [];
|
|
338
|
+
return String(result.stdout).split('\n').map(line => line.trim()).filter(Boolean);
|
|
339
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/** deploy-verify.ts — did production actually take the upload?
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS MOVED. Every other check describes an artifact on disk. This one asks
|
|
5
|
+
* production what it is serving, and it is the only correct answer to "is my change
|
|
6
|
+
* live?". Sift and the site each carried their own copy inside the shared block;
|
|
7
|
+
* onboarding greprag, paybot and discord would have meant three more. It depends on
|
|
8
|
+
* nothing repo-specific — an origin, a commit, and HTTP — so it belongs here.
|
|
9
|
+
*
|
|
10
|
+
* CACHE DISCIPLINE IS LOAD-BEARING, and a port that keeps only half of it looks
|
|
11
|
+
* correct while silently reintroducing the bug. On 2026-09-04 the gate was found
|
|
12
|
+
* confirming against a CDN copy: the site's build stamp was a plain static asset
|
|
13
|
+
* served with a cacheable default, so the edge answered the deploy's own liveness
|
|
14
|
+
* check with the PREVIOUS deploy's commit. Both halves are required — the origin
|
|
15
|
+
* returns `Cache-Control: no-store`, and every request here is sent uncacheable.
|
|
16
|
+
* `assertUncacheable` checks the origin half, because the request half alone cannot
|
|
17
|
+
* save you: static assets are cached without regard to the query string, so no
|
|
18
|
+
* caller can opt out from the client side.
|
|
19
|
+
*
|
|
20
|
+
* # adr: adr/deploy-gate-cli.md
|
|
21
|
+
*/
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.BUILD_STAMP_PATH = void 0;
|
|
24
|
+
exports.fetchHerokuReleases = fetchHerokuReleases;
|
|
25
|
+
exports.verifyHerokuRelease = verifyHerokuRelease;
|
|
26
|
+
exports.sameCommit = sameCommit;
|
|
27
|
+
exports.describeStampDrift = describeStampDrift;
|
|
28
|
+
exports.describeStampCacheability = describeStampCacheability;
|
|
29
|
+
exports.verifyDeployedBuildStamp = verifyDeployedBuildStamp;
|
|
30
|
+
exports.parseHerokuDeploySha = parseHerokuDeploySha;
|
|
31
|
+
exports.describeHerokuRelease = describeHerokuRelease;
|
|
32
|
+
exports.assertBuildStampUncacheable = assertBuildStampUncacheable;
|
|
33
|
+
const proc_1 = require("./proc");
|
|
34
|
+
exports.BUILD_STAMP_PATH = '/__build.json';
|
|
35
|
+
/** Read an app's releases via the Heroku CLI. Returns null when the CLI is missing,
|
|
36
|
+
* unauthenticated, or the app is unknown — the caller reports that as a failure to
|
|
37
|
+
* verify, never as a pass. */
|
|
38
|
+
function fetchHerokuReleases(app, num = 10) {
|
|
39
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(app))
|
|
40
|
+
return null;
|
|
41
|
+
// One command string through a shell: `heroku` is a `.cmd` shim on Windows, which
|
|
42
|
+
// Node refuses to spawn directly. `app` is validated above.
|
|
43
|
+
const result = (0, proc_1.safeSpawnSync)(`heroku releases --app ${app} --num ${num} --json`, [], {
|
|
44
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
45
|
+
shell: true, windowsHide: true, timeout: 30_000,
|
|
46
|
+
});
|
|
47
|
+
if (result.status !== 0)
|
|
48
|
+
return null;
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(String(result.stdout));
|
|
51
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Poll Heroku until the app is running `sha`. A push returns as soon as the slug is
|
|
58
|
+
* received; the build and release follow, so the first look is usually too early. */
|
|
59
|
+
async function verifyHerokuRelease({ app, sha, attempts = 12, intervalMs = 5000, fetchReleases = fetchHerokuReleases, sleep = (ms) => new Promise(resolve => { setTimeout(resolve, ms); }), }) {
|
|
60
|
+
let last = 'Heroku was never reached';
|
|
61
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
62
|
+
const releases = fetchReleases(app);
|
|
63
|
+
if (releases === null) {
|
|
64
|
+
last = `could not read Heroku releases for ${app} — is the Heroku CLI installed and logged in?`;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
const drift = describeHerokuRelease(releases, sha);
|
|
68
|
+
if (!drift)
|
|
69
|
+
return { ok: true, attempts: attempt };
|
|
70
|
+
last = drift;
|
|
71
|
+
}
|
|
72
|
+
if (attempt < attempts)
|
|
73
|
+
await sleep(intervalMs);
|
|
74
|
+
}
|
|
75
|
+
return { ok: false, attempts, reason: last };
|
|
76
|
+
}
|
|
77
|
+
/** Pure. Equal, or one an unambiguous abbreviation of the other. The two targets stamp
|
|
78
|
+
* at different widths — a worker can only report the short tag Cloudflare recorded,
|
|
79
|
+
* while a site writes the full sha into a file — so a short sha that prefixes the long
|
|
80
|
+
* one names the same commit. Seven characters is the floor; shorter is not a match. */
|
|
81
|
+
function sameCommit(a, b) {
|
|
82
|
+
if (!a || !b)
|
|
83
|
+
return false;
|
|
84
|
+
if (a === b)
|
|
85
|
+
return true;
|
|
86
|
+
const [short, long] = a.length <= b.length ? [a, b] : [b, a];
|
|
87
|
+
return short.length >= 7 && long.startsWith(short);
|
|
88
|
+
}
|
|
89
|
+
/** Pure: the whole decision, so this is testable without a network or a real deploy. */
|
|
90
|
+
function describeStampDrift(body, expectedSha) {
|
|
91
|
+
if (body == null)
|
|
92
|
+
return 'production did not return a build stamp';
|
|
93
|
+
let record;
|
|
94
|
+
try {
|
|
95
|
+
record = typeof body === 'string' ? JSON.parse(body) : body;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return 'production returned a build stamp that is not JSON';
|
|
99
|
+
}
|
|
100
|
+
const served = typeof record?.sha === 'string' ? record.sha : '';
|
|
101
|
+
// An empty sha — an unbound binding — reads as no answer, never as agreement.
|
|
102
|
+
if (!served)
|
|
103
|
+
return 'production returned a build stamp with no commit';
|
|
104
|
+
if (!sameCommit(served, expectedSha)) {
|
|
105
|
+
return `production is serving ${served.slice(0, 10)}, not the ${expectedSha.slice(0, 10)} just uploaded`;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
/** Pure: is this `Cache-Control` value one an edge may answer from cache? */
|
|
110
|
+
function describeStampCacheability(cacheControl) {
|
|
111
|
+
const value = (cacheControl || '').toLowerCase();
|
|
112
|
+
if (!value)
|
|
113
|
+
return 'production returned no Cache-Control on the build stamp';
|
|
114
|
+
if (/\bno-store\b/.test(value))
|
|
115
|
+
return null;
|
|
116
|
+
return `production serves the build stamp as \`${cacheControl}\`, which an edge may cache`;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Confirm the deployed origin is serving this commit. Retries because the edge takes a
|
|
120
|
+
* few seconds to pick up a new version, and a strict first-try check would fail every
|
|
121
|
+
* healthy deploy.
|
|
122
|
+
*/
|
|
123
|
+
async function verifyDeployedBuildStamp({ origin, sha, attempts = 8, intervalMs = 5000, fetchImpl = fetch, sleep = (ms) => new Promise(resolve => { setTimeout(resolve, ms); }), }) {
|
|
124
|
+
let last = 'production was never reached';
|
|
125
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
126
|
+
let body = null;
|
|
127
|
+
let cacheControl = null;
|
|
128
|
+
try {
|
|
129
|
+
// Three request-side defences, and the first is the only one that always works:
|
|
130
|
+
// a unique query string, an explicit no-cache header, and `cache: 'no-store'`.
|
|
131
|
+
// Node's fetch types do not declare `cache` and undici ignores it, so it is cast
|
|
132
|
+
// rather than dropped — it is honoured in browser-like runtimes and costs nothing
|
|
133
|
+
// here. None of these can save a target whose ORIGIN serves a cacheable stamp,
|
|
134
|
+
// which is why `assertBuildStampUncacheable` exists.
|
|
135
|
+
const init = {
|
|
136
|
+
cache: 'no-store',
|
|
137
|
+
headers: { 'Cache-Control': 'no-cache' },
|
|
138
|
+
};
|
|
139
|
+
const response = await fetchImpl(`${origin}${exports.BUILD_STAMP_PATH}?t=${Date.now()}`, init);
|
|
140
|
+
cacheControl = response.headers.get('cache-control');
|
|
141
|
+
body = response.ok ? await response.text() : null;
|
|
142
|
+
if (!response.ok)
|
|
143
|
+
last = `production answered ${response.status} for the build stamp`;
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
last = `could not reach production: ${error instanceof Error ? error.message : String(error)}`;
|
|
147
|
+
body = null;
|
|
148
|
+
}
|
|
149
|
+
if (body != null) {
|
|
150
|
+
const drift = describeStampDrift(body, sha);
|
|
151
|
+
if (!drift)
|
|
152
|
+
return { ok: true, attempts: attempt, cacheControl };
|
|
153
|
+
last = drift;
|
|
154
|
+
}
|
|
155
|
+
if (attempt < attempts)
|
|
156
|
+
await sleep(intervalMs);
|
|
157
|
+
}
|
|
158
|
+
return { ok: false, attempts, reason: last };
|
|
159
|
+
}
|
|
160
|
+
/** Pure: the commit a `git push heroku` release names, or null for any other release.
|
|
161
|
+
* Heroku writes `Deploy <short sha>` for a git push; everything else — config vars,
|
|
162
|
+
* add-on changes, rollbacks — has a different description and names no commit. */
|
|
163
|
+
function parseHerokuDeploySha(description) {
|
|
164
|
+
const match = /^Deploy\s+([0-9a-f]{7,40})\b/i.exec(String(description || '').trim());
|
|
165
|
+
return match ? match[1].toLowerCase() : null;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Pure: does this release list prove the app is running `expectedSha`?
|
|
169
|
+
*
|
|
170
|
+
* Two things make this more than a string compare on the newest entry.
|
|
171
|
+
*
|
|
172
|
+
* A config-var change creates a NEW current release whose description names no commit
|
|
173
|
+
* — the dyno restarts on the same slug, so the running code is still whatever the last
|
|
174
|
+
* actual deploy put there. Walking back to the most recent release that names a deploy
|
|
175
|
+
* is therefore correct, and comparing only the current release would fail spuriously
|
|
176
|
+
* every time someone touched a config var after deploying.
|
|
177
|
+
*
|
|
178
|
+
* And a release can be `pending` or `failed`: the push was accepted, the build was not.
|
|
179
|
+
* That is the case most worth catching, because the git push succeeded and looks fine.
|
|
180
|
+
*/
|
|
181
|
+
function describeHerokuRelease(releases, expectedSha) {
|
|
182
|
+
const list = [...(releases || [])].sort((a, b) => (b.version ?? 0) - (a.version ?? 0));
|
|
183
|
+
if (list.length === 0)
|
|
184
|
+
return 'Heroku reported no releases for this app';
|
|
185
|
+
const current = list.find(r => r.current) || list[0];
|
|
186
|
+
const status = String(current.status || '').toLowerCase();
|
|
187
|
+
if (status && status !== 'succeeded') {
|
|
188
|
+
return `the current Heroku release v${current.version} is ${status} — the push was accepted but the build was not`;
|
|
189
|
+
}
|
|
190
|
+
const deployed = list.find(r => parseHerokuDeploySha(r.description));
|
|
191
|
+
if (!deployed)
|
|
192
|
+
return 'no Heroku release names a deployed commit';
|
|
193
|
+
const sha = parseHerokuDeploySha(deployed.description);
|
|
194
|
+
if (!sameCommit(sha, expectedSha)) {
|
|
195
|
+
return `Heroku is running ${sha} (release v${deployed.version}), not the ${expectedSha.slice(0, 10)} just pushed`;
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
/** The origin half of the cache discipline. A cacheable stamp makes every liveness
|
|
200
|
+
* answer untrustworthy, including the ones that happen to be right today. */
|
|
201
|
+
async function assertBuildStampUncacheable(origin, fetchImpl = fetch) {
|
|
202
|
+
try {
|
|
203
|
+
const response = await fetchImpl(`${origin}${exports.BUILD_STAMP_PATH}`);
|
|
204
|
+
return describeStampCacheability(response.headers.get('cache-control'));
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
return `could not read the build stamp headers: ${error instanceof Error ? error.message : String(error)}`;
|
|
208
|
+
}
|
|
209
|
+
}
|