forge-workflow 0.1.0-beta.7 → 0.1.0-beta.8

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.
@@ -0,0 +1,389 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { randomUUID } = require('node:crypto');
6
+ const { secureExecFileSync } = require('./shell-utils');
7
+ const {
8
+ createProtectedStateAuditRecord,
9
+ recordProtectedStateAuditEvent,
10
+ removeProtectedFile,
11
+ writeProtectedFile,
12
+ } = require('./protected-state-surfaces');
13
+ const {
14
+ issueTestWorkflowAuthorization,
15
+ completeTestWorkflowAuthorization,
16
+ } = require('./protected-state-authority');
17
+ const {
18
+ readIndexedPackageManifest,
19
+ readPinnedBunVersion,
20
+ readSourcePackageManifest,
21
+ resolveCurrentHead,
22
+ } = require('./bun-workflow-pins');
23
+
24
+ const TEST_WORKFLOW_PATH = '.github/workflows/test.yml';
25
+ const TEST_WORKFLOW_TEMPLATE_PATH = 'lib/workflow-templates/test.yml';
26
+ const BUN_VERSION_PLACEHOLDER = '__BUN_VERSION__';
27
+ const BUN_VERSION_PLACEHOLDER_COUNT = 8;
28
+
29
+ /**
30
+ * Renders the canonical test workflow as exact bytes for one stable Bun version.
31
+ * The template must contain exactly eight version placeholders so every substitution is deterministic.
32
+ *
33
+ * @param {Buffer|string} template canonical workflow template bytes
34
+ * @param {string} bunVersion exact stable Bun version
35
+ * @returns {Buffer} rendered workflow bytes
36
+ * @throws {Error} when the version is not exact or the template placeholder count is not eight
37
+ */
38
+ function renderTestWorkflow(template, bunVersion) {
39
+ if (!/^\d+\.\d+\.\d+$/.test(bunVersion)) {
40
+ throw new Error('Test workflow requires an exact stable Bun version.');
41
+ }
42
+ const source = Buffer.from(template).toString('utf8');
43
+ const count = source.split(BUN_VERSION_PLACEHOLDER).length - 1;
44
+ if (count !== BUN_VERSION_PLACEHOLDER_COUNT) {
45
+ throw new Error(`Test workflow template must contain exactly ${BUN_VERSION_PLACEHOLDER_COUNT} ${BUN_VERSION_PLACEHOLDER} placeholders; found ${count}.`);
46
+ }
47
+ return Buffer.from(source.replaceAll(BUN_VERSION_PLACEHOLDER, bunVersion));
48
+ }
49
+
50
+ function readGitFile(projectRoot, revision, filePath, execGit = secureExecFileSync) {
51
+ const indexed = revision === ':';
52
+ const options = {
53
+ cwd: projectRoot,
54
+ encoding: 'utf8',
55
+ stdio: ['ignore', 'pipe', 'pipe'],
56
+ windowsHide: true,
57
+ };
58
+ const entry = execGit('git', indexed
59
+ ? ['ls-files', '--stage', '--', filePath]
60
+ : ['ls-tree', revision, '--', filePath], options).trim();
61
+ const suffix = filePath.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
62
+ const expected = indexed
63
+ ? new RegExp(String.raw`^100(?:644|755) [0-9a-f]+ 0\t${suffix}$`)
64
+ : new RegExp(String.raw`^100(?:644|755) blob [0-9a-f]+\t${suffix}$`);
65
+ if (!expected.test(entry)) {
66
+ const location = indexed ? 'in the Git index' : `at ${revision}`;
67
+ throw new Error(`${filePath} is not one regular file ${location}.`);
68
+ }
69
+ return Buffer.from(execGit('git', ['show', `${revision}${indexed ? '' : ':'}${filePath}`], {
70
+ ...options,
71
+ encoding: 'buffer',
72
+ }));
73
+ }
74
+
75
+ function projectionObjectId(raw, filePath, indexed, sourceHead) {
76
+ const suffix = filePath.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
77
+ const matching = raw.trim().split(/\r?\n/).filter(line => line.endsWith(`\t${filePath}`));
78
+ if (matching.length === 0) return null;
79
+ const pattern = indexed
80
+ ? new RegExp(String.raw`^100(?:644|755) ([0-9a-f]+) 0\t${suffix}$`)
81
+ : new RegExp(String.raw`^100(?:644|755) blob ([0-9a-f]+)\t${suffix}$`);
82
+ if (matching.length !== 1 || !pattern.test(matching[0])) {
83
+ const location = indexed ? 'in the Git index' : `at ${sourceHead}`;
84
+ throw new Error(`${filePath} is not one regular file ${location}.`);
85
+ }
86
+ return pattern.exec(matching[0])[1];
87
+ }
88
+
89
+ /**
90
+ * Captures the committed package/template and stage-0 package/template/target by Git blob ID.
91
+ * A missing template remains null for legacy/bootstrap handling; nonregular or conflicted entries fail closed.
92
+ *
93
+ * @param {string} projectRoot repository root
94
+ * @param {string} sourceHead immutable source commit
95
+ * @param {Function} execGit Git execution seam
96
+ * @returns {{sourcePackage: Buffer, indexedPackage: Buffer, sourceTemplate: Buffer|null, indexedTemplate: Buffer|null, indexedWorkflow: Buffer|null}}
97
+ * @throws {Error} when required package blobs or present workflow entries are not one regular file
98
+ */
99
+ function readTestWorkflowProjection(projectRoot, sourceHead, execGit = secureExecFileSync) {
100
+ const options = {
101
+ cwd: projectRoot,
102
+ encoding: 'utf8',
103
+ stdio: ['ignore', 'pipe', 'pipe'],
104
+ windowsHide: true,
105
+ };
106
+ const sourceEntries = execGit('git', [
107
+ 'ls-tree', sourceHead, '--', 'package.json', TEST_WORKFLOW_TEMPLATE_PATH,
108
+ ], options);
109
+ const indexedEntries = execGit('git', [
110
+ 'ls-files', '--stage', '--', 'package.json', TEST_WORKFLOW_TEMPLATE_PATH, TEST_WORKFLOW_PATH,
111
+ ], options);
112
+ const sourcePackageId = projectionObjectId(sourceEntries, 'package.json', false, sourceHead);
113
+ const indexedPackageId = projectionObjectId(indexedEntries, 'package.json', true, sourceHead);
114
+ if (sourcePackageId === null) throw new Error(`package.json is not a regular file at ${sourceHead}.`);
115
+ if (indexedPackageId === null) throw new Error('package.json is not one regular file in the Git index.');
116
+ const readObject = objectId => Buffer.from(execGit('git', ['cat-file', 'blob', objectId], {
117
+ ...options,
118
+ encoding: 'buffer',
119
+ }));
120
+ const readOptionalObject = objectId => objectId === null ? null : readObject(objectId);
121
+ const sourceTemplateId = projectionObjectId(sourceEntries, TEST_WORKFLOW_TEMPLATE_PATH, false, sourceHead);
122
+ const indexedTemplateId = projectionObjectId(indexedEntries, TEST_WORKFLOW_TEMPLATE_PATH, true, sourceHead);
123
+ return {
124
+ sourcePackage: readObject(sourcePackageId),
125
+ indexedPackage: readObject(indexedPackageId),
126
+ sourceTemplate: readOptionalObject(sourceTemplateId),
127
+ indexedTemplate: readOptionalObject(indexedTemplateId),
128
+ indexedWorkflow: sourceTemplateId === null && indexedTemplateId === null
129
+ ? null
130
+ : readOptionalObject(projectionObjectId(indexedEntries, TEST_WORKFLOW_PATH, true, sourceHead)),
131
+ };
132
+ }
133
+
134
+ function readRegularFile(projectRoot, filePath) {
135
+ const fullPath = path.join(projectRoot, filePath);
136
+ const stat = fs.lstatSync(fullPath);
137
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`${filePath} must be a regular file.`);
138
+ return fs.readFileSync(fullPath);
139
+ }
140
+
141
+ function snapshotWorkflow(projectRoot) {
142
+ const fullPath = path.join(projectRoot, TEST_WORKFLOW_PATH);
143
+ try {
144
+ const stat = fs.lstatSync(fullPath);
145
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`${TEST_WORKFLOW_PATH} must be a regular file.`);
146
+ return { existed: true, content: fs.readFileSync(fullPath) };
147
+ } catch (error) {
148
+ if (error.code === 'ENOENT') return { existed: false, content: null };
149
+ throw error;
150
+ }
151
+ }
152
+
153
+ function sameSnapshot(left, right) {
154
+ return left?.existed === right?.existed
155
+ && (!left?.existed || (Buffer.isBuffer(left.content) && left.content.equals(right.content)));
156
+ }
157
+
158
+ /**
159
+ * Resolves one exact workflow update after checking HEAD, source baseline, index, and working-tree consistency.
160
+ * The result includes exact new bytes and the compare-and-swap snapshot used for recovery.
161
+ *
162
+ * @param {string} projectRoot repository root
163
+ * @param {string} sourceHead immutable source commit
164
+ * @param {object} options dependency seams and staged-state readers
165
+ * @returns {object} validated update, compare-and-swap snapshot, and exact output bytes
166
+ * @throws {Error} when the source baseline is noncanonical or staged/working bytes have drifted
167
+ */
168
+ function resolveTestWorkflowUpdate(projectRoot, sourceHead, options = {}) {
169
+ const currentHead = (options.resolveHead || resolveCurrentHead)(projectRoot, options.execGit);
170
+ if (currentHead !== sourceHead) {
171
+ throw new Error(`Protected test workflow source HEAD changed from ${sourceHead} to ${currentHead}.`);
172
+ }
173
+
174
+ const sourcePackage = Buffer.from((options.readSourcePackageManifest || readSourcePackageManifest)(
175
+ projectRoot,
176
+ sourceHead,
177
+ options.execGit,
178
+ ));
179
+ const indexedPackage = Buffer.from((options.readIndexedPackageManifest || readIndexedPackageManifest)(
180
+ projectRoot,
181
+ options.execGit,
182
+ ));
183
+ const workingPackage = readRegularFile(projectRoot, 'package.json');
184
+ if (!workingPackage.equals(indexedPackage)) {
185
+ throw new Error('package.json differs from the Git index; stage the intended manifest before generating test.yml.');
186
+ }
187
+
188
+ const sourceTemplate = Buffer.from((options.readSourceTemplate || readGitFile)(
189
+ projectRoot,
190
+ sourceHead,
191
+ TEST_WORKFLOW_TEMPLATE_PATH,
192
+ options.execGit,
193
+ ));
194
+ const indexedTemplate = Buffer.from((options.readIndexedTemplate || readGitFile)(
195
+ projectRoot,
196
+ ':',
197
+ TEST_WORKFLOW_TEMPLATE_PATH,
198
+ options.execGit,
199
+ ));
200
+ const workingTemplate = readRegularFile(projectRoot, TEST_WORKFLOW_TEMPLATE_PATH);
201
+ if (!workingTemplate.equals(indexedTemplate)) {
202
+ throw new Error(`${TEST_WORKFLOW_TEMPLATE_PATH} differs from the Git index; stage the intended template before generating test.yml.`);
203
+ }
204
+
205
+ const sourceWorkflow = Buffer.from((options.readSourceWorkflow || readGitFile)(
206
+ projectRoot,
207
+ sourceHead,
208
+ TEST_WORKFLOW_PATH,
209
+ options.execGit,
210
+ ));
211
+ const sourceVersion = readPinnedBunVersion(sourcePackage);
212
+ const baseline = renderTestWorkflow(sourceTemplate, sourceVersion);
213
+ if (!sourceWorkflow.equals(baseline)) {
214
+ throw new Error(`${TEST_WORKFLOW_PATH} at ${sourceHead} differs from its canonical template output.`);
215
+ }
216
+
217
+ const version = readPinnedBunVersion(indexedPackage);
218
+ const content = renderTestWorkflow(indexedTemplate, version);
219
+ const snapshot = snapshotWorkflow(projectRoot);
220
+ if (!snapshot.existed || (!snapshot.content.equals(baseline) && !snapshot.content.equals(content))) {
221
+ throw new Error(`${TEST_WORKFLOW_PATH} has unrelated local edits; restore or commit them before generation.`);
222
+ }
223
+ const indexedWorkflow = Buffer.from((options.readIndexedWorkflow || readGitFile)(
224
+ projectRoot,
225
+ ':',
226
+ TEST_WORKFLOW_PATH,
227
+ options.execGit,
228
+ ));
229
+ if (!indexedWorkflow.equals(baseline) && !indexedWorkflow.equals(content)) {
230
+ throw new Error(`${TEST_WORKFLOW_PATH} has unrelated staged edits; restore or commit them before generation.`);
231
+ }
232
+
233
+ return { path: TEST_WORKFLOW_PATH, sourceHead, version, baseline, content, indexedWorkflow, snapshot };
234
+ }
235
+
236
+ function recoverWorkflow(snapshot, projectRoot, generatedContent, protectedWriter, protectedRemover, writeOptions) {
237
+ const recoveryOptions = {
238
+ ...writeOptions,
239
+ operation: 'recover_test_workflow',
240
+ expectedContent: generatedContent,
241
+ };
242
+ return snapshot.existed
243
+ ? protectedWriter(projectRoot, TEST_WORKFLOW_PATH, snapshot.content, recoveryOptions)
244
+ : protectedRemover(projectRoot, TEST_WORKFLOW_PATH, recoveryOptions);
245
+ }
246
+
247
+ function attemptRecovery(snapshot, projectRoot, generatedContent, protectedWriter, protectedRemover, writeOptions) {
248
+ try {
249
+ return recoverWorkflow(snapshot, projectRoot, generatedContent, protectedWriter, protectedRemover, writeOptions);
250
+ } catch (error) {
251
+ return { allowed: false, reason: error.message };
252
+ }
253
+ }
254
+
255
+ function matchesExpectedUpdate(expectedUpdate, update) {
256
+ if (!expectedUpdate) return true;
257
+ try {
258
+ return expectedUpdate.version === update.version
259
+ && Buffer.from(expectedUpdate.content).equals(update.content);
260
+ } catch {
261
+ return false;
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Generates test.yml by validating HEAD/preflight bindings, authorizing exact bytes, writing atomically,
267
+ * auditing, and recording completion. Audit or completion failures conditionally restore the prior snapshot.
268
+ *
269
+ * @param {string} projectRoot repository root
270
+ * @param {object} options expected HEAD, actor, staged-state seams, and protected writer dependencies
271
+ * @returns {Promise<object>} success evidence or a handled failure with recovery evidence when applicable
272
+ * @throws {Error} when snapshot inspection itself cannot read the target filesystem state
273
+ */
274
+ async function generateTestWorkflow(projectRoot, options = {}) {
275
+ const { verifyExpectedHead } = require('./npm-publish-workflow');
276
+ const head = verifyExpectedHead(projectRoot, options.expectedHead, options.resolveHead);
277
+ if (!head.success) return head;
278
+ const sourceHead = head.sourceHead;
279
+ if (options.expectedSnapshot && !sameSnapshot(options.expectedSnapshot, snapshotWorkflow(projectRoot))) {
280
+ return { success: false, error: `${TEST_WORKFLOW_PATH} changed after Bun pin batch preflight.` };
281
+ }
282
+ let update;
283
+ try {
284
+ update = resolveTestWorkflowUpdate(projectRoot, sourceHead, options);
285
+ } catch (error) {
286
+ return { success: false, error: error.message };
287
+ }
288
+ if (!matchesExpectedUpdate(options.expectedUpdate, update)) {
289
+ return { success: false, error: `${TEST_WORKFLOW_PATH} changed after Bun pin batch preflight.` };
290
+ }
291
+
292
+ const env = options.env || process.env;
293
+ const actor = options.actor || env.FORGE_PROTECTED_STATE_ACTOR || env.FORGE_ACTOR || env.USER || env.USERNAME || 'unknown';
294
+ const capabilityId = (options.createCapabilityId || randomUUID)();
295
+ const issueAuthorization = options.issueAuthorization || issueTestWorkflowAuthorization;
296
+ const completeAuthorization = options.completeAuthorization || completeTestWorkflowAuthorization;
297
+ const protectedWriter = options.writeProtectedFile || writeProtectedFile;
298
+ const protectedRemover = options.removeProtectedFile || removeProtectedFile;
299
+ const auditWriter = options.recordProtectedStateAuditEvent || recordProtectedStateAuditEvent;
300
+ let authorization;
301
+ try {
302
+ authorization = await issueAuthorization(projectRoot, {
303
+ actor,
304
+ sourceHead,
305
+ targetBunVersion: update.version,
306
+ }, { deps: options.kernelDeps, capabilityId });
307
+ } catch (error) {
308
+ authorization = { success: false, error: error.message };
309
+ }
310
+ if (!authorization.success || authorization.capabilityId !== capabilityId) {
311
+ return { success: false, error: `Could not authorize ${TEST_WORKFLOW_PATH}: ${authorization.error || 'authority returned a different capability id.'}` };
312
+ }
313
+
314
+ const writeOptions = {
315
+ actor,
316
+ operation: 'generate_test_workflow',
317
+ viaForgeApi: true,
318
+ surface: 'workflows',
319
+ expectedContent: update.snapshot.content,
320
+ ...(options.beforeAtomicCommit ? { beforeAtomicCommit: options.beforeAtomicCommit } : {}),
321
+ };
322
+ let write;
323
+ try {
324
+ write = protectedWriter(projectRoot, TEST_WORKFLOW_PATH, update.content, writeOptions);
325
+ } catch (error) {
326
+ return { success: false, error: error.message };
327
+ }
328
+ if (!write.allowed) return { success: false, error: write.reason, write };
329
+
330
+ const audit = (() => {
331
+ try {
332
+ return auditWriter(createProtectedStateAuditRecord({
333
+ actor,
334
+ surface: 'workflows',
335
+ path: TEST_WORKFLOW_PATH,
336
+ content: update.content,
337
+ operation: 'generate_test_workflow',
338
+ viaForgeApi: true,
339
+ sourceHead,
340
+ }), { cwd: projectRoot });
341
+ } catch (error) {
342
+ return { success: false, error: error.message };
343
+ }
344
+ })();
345
+ if (!audit.success) {
346
+ const recovery = attemptRecovery(update.snapshot, projectRoot, update.content, protectedWriter, protectedRemover, writeOptions);
347
+ return { success: false, error: `Could not record ${TEST_WORKFLOW_PATH}: ${audit.error}`, audit, recovery };
348
+ }
349
+
350
+ let completion;
351
+ try {
352
+ completion = await completeAuthorization(projectRoot, {
353
+ actor,
354
+ sourceHead,
355
+ capabilityId,
356
+ targetBunVersion: update.version,
357
+ }, { deps: options.kernelDeps });
358
+ } catch (error) {
359
+ completion = { success: false, error: error.message };
360
+ }
361
+ if (!completion.success) {
362
+ const recovery = attemptRecovery(update.snapshot, projectRoot, update.content, protectedWriter, protectedRemover, writeOptions);
363
+ return { success: false, error: `Could not complete ${TEST_WORKFLOW_PATH}: ${completion.error}`, audit, completion, recovery };
364
+ }
365
+
366
+ return {
367
+ success: true,
368
+ path: TEST_WORKFLOW_PATH,
369
+ sourceHead,
370
+ bunVersion: update.version,
371
+ contentHash: write.contentHash,
372
+ write,
373
+ audit,
374
+ authorization,
375
+ completion,
376
+ };
377
+ }
378
+
379
+ module.exports = {
380
+ BUN_VERSION_PLACEHOLDER,
381
+ // Preserve the existing deep-import name while canonical callers use template terminology.
382
+ BUN_VERSION_TOKEN: BUN_VERSION_PLACEHOLDER,
383
+ TEST_WORKFLOW_PATH,
384
+ TEST_WORKFLOW_TEMPLATE_PATH,
385
+ renderTestWorkflow,
386
+ readTestWorkflowProjection,
387
+ resolveTestWorkflowUpdate,
388
+ generateTestWorkflow,
389
+ };
@@ -6,11 +6,17 @@ const fs = require('node:fs');
6
6
  const os = require('node:os');
7
7
  const path = require('node:path');
8
8
  const { resolveWorktreeScope } = require('./protected-state-authority');
9
+ const { QUICK_LANE_ENV_VAR, QUICK_LANE_VALUE } = require('../scripts/test');
9
10
 
10
11
  const SCHEMA_VERSION = 1;
11
12
  const RECEIPT_TTL_MS = 60 * 60 * 1000;
12
13
  const FUTURE_SKEW_MS = 60 * 1000;
13
14
  const RUNNER_ID = 'forge-full-suite-v1';
15
+ const PUSH_PROOF_SCHEMA_VERSION = 1;
16
+ const PUSH_MODES = Object.freeze({
17
+ full: Object.freeze(['branch-protection', 'lint', 'tests']),
18
+ quick: Object.freeze(['branch-protection', 'lint']),
19
+ });
14
20
 
15
21
  function git(projectRoot, args, deps = {}) {
16
22
  return (deps.execFileSync || execFileSync)('git', args, {
@@ -39,13 +45,19 @@ function runtimeIdentity(deps = {}) {
39
45
  || [process.platform, process.arch, process.versions.node, process.versions.bun || 'node'].join(':');
40
46
  }
41
47
 
42
- function commandIdentity(command, projectRoot, env, run) {
43
- const version = run(command, ['--version'], {
44
- cwd: projectRoot,
45
- encoding: 'utf8',
46
- env,
47
- stdio: ['ignore', 'pipe', 'pipe'],
48
- }).trim();
48
+ function commandIdentity(command, projectRoot, env, run, options = {}) {
49
+ let version;
50
+ try {
51
+ version = run(command, ['--version'], {
52
+ cwd: projectRoot,
53
+ encoding: 'utf8',
54
+ env,
55
+ stdio: ['ignore', 'pipe', 'pipe'],
56
+ }).trim();
57
+ } catch (error) {
58
+ if (options.missingIdentity && error?.code === 'ENOENT') return options.missingIdentity;
59
+ throw error;
60
+ }
49
61
  const located = path.isAbsolute(command)
50
62
  ? fs.realpathSync.native(command)
51
63
  : run(process.platform === 'win32' ? 'where.exe' : 'which', [command], {
@@ -56,13 +68,19 @@ function commandIdentity(command, projectRoot, env, run) {
56
68
  return `${process.platform === 'win32' ? located.toLowerCase() : located}:${version}`;
57
69
  }
58
70
 
59
- function testRuntimeIdentity(projectRoot, deps = {}) {
71
+ function testRuntimeIdentity(projectRoot, deps = {}, options = {}) {
60
72
  if (deps.testRuntimeIdentity) return deps.testRuntimeIdentity;
61
73
  const env = deps.env || process.env;
62
74
  const run = deps.runtimeExecFileSync || execFileSync;
63
- return [env.BUN_EXE || 'bun', 'node']
64
- .map(command => commandIdentity(command, projectRoot, env, run))
65
- .join('|');
75
+ const bunCommand = env.BUN_EXE || 'bun';
76
+ return [
77
+ commandIdentity(bunCommand, projectRoot, env, run, {
78
+ missingIdentity: options.allowMissingDefaultBun && !env.BUN_EXE
79
+ ? 'missing:default-bun'
80
+ : null,
81
+ }),
82
+ commandIdentity('node', projectRoot, env, run),
83
+ ].join('|');
66
84
  }
67
85
 
68
86
  function captureState(projectRoot, deps = {}) {
@@ -87,6 +105,98 @@ function sameState(left, right) {
87
105
  && left.runner === right.runner;
88
106
  }
89
107
 
108
+ function capturePushState(projectRoot, deps = {}) {
109
+ if (deps.capturePushState) return deps.capturePushState(projectRoot);
110
+ const root = resolveGitRoot(projectRoot, deps);
111
+ const state = captureState(root, {
112
+ ...deps,
113
+ testRuntimeIdentity: testRuntimeIdentity(root, deps, { allowMissingDefaultBun: true }),
114
+ });
115
+ delete state.runtime;
116
+ return {
117
+ ...state,
118
+ branch: git(root, ['symbolic-ref', '--quiet', 'HEAD'], deps),
119
+ };
120
+ }
121
+
122
+ function samePushState(left, right) {
123
+ return left?.clean === true
124
+ && right?.clean === true
125
+ && left.worktree === right.worktree
126
+ && left.head === right.head
127
+ && left.testRuntime === right.testRuntime
128
+ && left.runner === right.runner
129
+ && left.branch === right.branch;
130
+ }
131
+
132
+ function hasExactGates(mode, gates) {
133
+ if (mode !== 'full' && mode !== 'quick') return false;
134
+ const expected = PUSH_MODES[mode];
135
+ return Boolean(expected)
136
+ && Array.isArray(gates)
137
+ && gates.length === expected.length
138
+ && gates.every((gate, index) => gate === expected[index]);
139
+ }
140
+
141
+ function beginPushProof(projectRoot, deps = {}) {
142
+ try {
143
+ const state = capturePushState(projectRoot, deps);
144
+ return state.clean ? state : null;
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ function createPushProof(projectRoot, snapshot, claims, deps = {}) {
151
+ if (!snapshot) throw new Error('push proof requires a clean pre-gate snapshot');
152
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(claims?.nonce || '')) {
153
+ throw new Error('push proof requires a valid nonce');
154
+ }
155
+ if (!hasExactGates(claims?.mode, claims?.gates)) throw new Error('push proof gates do not match mode');
156
+ if (!Number.isInteger(claims?.owner?.pid) || claims.owner.pid <= 0 || typeof claims.owner.identity !== 'string' || !claims.owner.identity) {
157
+ throw new Error('push proof requires an exact process owner');
158
+ }
159
+ if (claims.mode === 'full' && (typeof claims.receiptIdentity !== 'string' || !claims.receiptIdentity)) {
160
+ throw new Error('full push proof requires test evidence identity');
161
+ }
162
+ const current = capturePushState(projectRoot, deps);
163
+ if (!samePushState(snapshot, current)) throw new Error('push proof state changed during gates');
164
+ const payload = {
165
+ schemaVersion: PUSH_PROOF_SCHEMA_VERSION,
166
+ type: 'forge-push-invocation',
167
+ nonce: claims.nonce,
168
+ state: current,
169
+ mode: claims.mode,
170
+ gates: [...claims.gates],
171
+ owner: { ...claims.owner },
172
+ receiptIdentity: claims.receiptIdentity || null,
173
+ };
174
+ return { payload, signature: sign(payload, deps) };
175
+ }
176
+
177
+ function verifyPushProof(projectRoot, proof, expected, deps = {}) {
178
+ try {
179
+ const payload = proof?.payload;
180
+ if (payload?.schemaVersion !== PUSH_PROOF_SCHEMA_VERSION || payload.type !== 'forge-push-invocation') {
181
+ return { valid: false, reason: 'schema' };
182
+ }
183
+ if (payload.nonce !== expected?.nonce) return { valid: false, reason: 'nonce' };
184
+ if (!signatureMatches(payload, proof.signature, deps)) return { valid: false, reason: 'signature' };
185
+ if (!hasExactGates(payload.mode, payload.gates)) return { valid: false, reason: 'gates' };
186
+ const quickLane = expected?.env?.[QUICK_LANE_ENV_VAR] === QUICK_LANE_VALUE;
187
+ if ((payload.mode === 'quick') !== quickLane) return { valid: false, reason: 'mode' };
188
+ if (payload.mode === 'full' && (typeof payload.receiptIdentity !== 'string' || !payload.receiptIdentity)) {
189
+ return { valid: false, reason: 'test-evidence' };
190
+ }
191
+ if (!samePushState(payload.state, capturePushState(projectRoot, deps))) {
192
+ return { valid: false, reason: 'state' };
193
+ }
194
+ return { valid: true, payload };
195
+ } catch {
196
+ return { valid: false, reason: 'missing-or-invalid' };
197
+ }
198
+ }
199
+
90
200
  function validationIsComplete(result) {
91
201
  const checks = result?.checks || {};
92
202
  const tests = checks.tests || {};
@@ -175,7 +285,7 @@ function verifyValidationReceipt(projectRoot, deps = {}) {
175
285
  if (receipt.payload.issuedAt > now + FUTURE_SKEW_MS) return { valid: false, reason: 'future' };
176
286
  if (receipt.payload.expiresAt < now) return { valid: false, reason: 'expired' };
177
287
  if (!sameState(receipt.payload, captureState(projectRoot, deps))) return { valid: false, reason: 'state' };
178
- return { valid: true, head: receipt.payload.head };
288
+ return { valid: true, head: receipt.payload.head, identity: receipt.signature };
179
289
  } catch {
180
290
  return { valid: false, reason: 'missing-or-invalid' };
181
291
  }
@@ -187,4 +297,9 @@ module.exports = {
187
297
  resolveReceiptPath,
188
298
  validationIsComplete,
189
299
  verifyValidationReceipt,
300
+ _pushProof: {
301
+ begin: beginPushProof,
302
+ create: createPushProof,
303
+ verify: verifyPushProof,
304
+ },
190
305
  };