forge-workflow 0.1.0-beta.6 → 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.
Files changed (179) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/bin/forge-gh-proxy.js +15 -0
  3. package/bin/forge-github-credential.js +21 -0
  4. package/bin/forge.js +16 -1
  5. package/docs/guides/MIGRATION.md +4 -3
  6. package/docs/reference/RELEASE.md +5 -3
  7. package/docs/reference/VALIDATION.md +14 -0
  8. package/docs/reference/github-accounts.md +114 -38
  9. package/lib/bun-workflow-pins.js +129 -3
  10. package/lib/commands/github.js +234 -49
  11. package/lib/commands/merge.js +2 -2
  12. package/lib/commands/push.js +308 -47
  13. package/lib/commands/release.js +18 -1
  14. package/lib/commands/shepherd.js +1 -1
  15. package/lib/commands/test.js +8 -0
  16. package/lib/commands/validate.js +219 -67
  17. package/lib/gh-proxy.js +309 -0
  18. package/lib/github-context.js +160 -25
  19. package/lib/github-credential.js +47 -0
  20. package/lib/github-router.js +774 -0
  21. package/lib/kernel/migrations.js +1 -1
  22. package/lib/kernel/sqlite-driver.js +1 -1
  23. package/lib/memory/router.js +1 -1
  24. package/lib/memory/usage-evidence.js +1 -1
  25. package/lib/native-gh.js +177 -0
  26. package/lib/npm-publish-workflow.js +5 -5
  27. package/lib/pr-monitor/flow-monitor.js +127 -28
  28. package/lib/pr-monitor/reconcile-executor.js +1 -1
  29. package/lib/project-memory.js +1 -1
  30. package/lib/protected-state-authority.js +214 -13
  31. package/lib/release-readiness.js +57 -41
  32. package/lib/shell-utils.js +41 -15
  33. package/lib/test-workflow.js +389 -0
  34. package/lib/validation-receipt.js +305 -0
  35. package/lib/workflow-templates/test.yml +654 -0
  36. package/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
  37. package/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
  38. package/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
  39. package/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
  40. package/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
  41. package/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
  42. package/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
  43. package/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
  44. package/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
  45. package/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
  46. package/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
  47. package/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
  48. package/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
  49. package/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
  50. package/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
  51. package/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
  52. package/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
  53. package/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
  54. package/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
  55. package/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
  56. package/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
  57. package/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
  58. package/node_modules/@forge/contracts/index.js +32 -0
  59. package/node_modules/@forge/contracts/package.json +35 -0
  60. package/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
  61. package/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
  62. package/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
  63. package/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
  64. package/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
  65. package/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
  66. package/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
  67. package/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
  68. package/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
  69. package/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
  70. package/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
  71. package/node_modules/@forge/contracts/src/baseline.js +23 -0
  72. package/node_modules/@forge/contracts/src/canonical.js +151 -0
  73. package/node_modules/@forge/contracts/src/definitions.js +176 -0
  74. package/node_modules/@forge/contracts/src/identity.js +42 -0
  75. package/node_modules/@forge/contracts/src/schema.js +72 -0
  76. package/node_modules/@forge/contracts/src/validate.js +305 -0
  77. package/node_modules/@forge/flow/index.js +114 -0
  78. package/node_modules/@forge/flow/package.json +34 -0
  79. package/node_modules/@forge/flow/src/bounded-loop.js +415 -0
  80. package/node_modules/@forge/flow/src/efficiency-supervisor.js +89 -0
  81. package/node_modules/@forge/flow/src/executor.js +279 -0
  82. package/node_modules/@forge/flow/src/monitor-durability.js +419 -0
  83. package/node_modules/@forge/flow/src/monitor-runtime.js +460 -0
  84. package/node_modules/@forge/flow/src/process-lifecycle.js +469 -0
  85. package/node_modules/@forge/flow/src/skill-runtime.js +343 -0
  86. package/node_modules/@forge/memory/index.js +331 -0
  87. package/node_modules/@forge/memory/package.json +34 -0
  88. package/node_modules/@forge/memory/src/authority-provider.js +67 -0
  89. package/node_modules/@forge/memory/src/backend-registry.js +166 -0
  90. package/node_modules/@forge/memory/src/feedback-intake.js +243 -0
  91. package/node_modules/@forge/memory/src/pr-lifecycle-authority.js +946 -0
  92. package/node_modules/@forge/memory/src/usage-evidence.js +205 -0
  93. package/package.json +12 -2
  94. package/packages/flow/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
  95. package/packages/flow/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
  96. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
  97. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
  98. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
  99. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
  100. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
  101. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
  102. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
  103. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
  104. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
  105. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
  106. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
  107. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
  108. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
  109. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
  110. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
  111. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
  112. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
  113. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
  114. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
  115. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
  116. package/packages/flow/node_modules/@forge/contracts/index.js +32 -0
  117. package/packages/flow/node_modules/@forge/contracts/package.json +35 -0
  118. package/packages/flow/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
  119. package/packages/flow/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
  120. package/packages/flow/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
  121. package/packages/flow/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
  122. package/packages/flow/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
  123. package/packages/flow/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
  124. package/packages/flow/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
  125. package/packages/flow/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
  126. package/packages/flow/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
  127. package/packages/flow/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
  128. package/packages/flow/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
  129. package/packages/flow/node_modules/@forge/contracts/src/baseline.js +23 -0
  130. package/packages/flow/node_modules/@forge/contracts/src/canonical.js +151 -0
  131. package/packages/flow/node_modules/@forge/contracts/src/definitions.js +176 -0
  132. package/packages/flow/node_modules/@forge/contracts/src/identity.js +42 -0
  133. package/packages/flow/node_modules/@forge/contracts/src/schema.js +72 -0
  134. package/packages/flow/node_modules/@forge/contracts/src/validate.js +305 -0
  135. package/packages/memory/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
  136. package/packages/memory/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
  137. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
  138. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
  139. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
  140. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
  141. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
  142. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
  143. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
  144. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
  145. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
  146. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
  147. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
  148. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
  149. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
  150. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
  151. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
  152. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
  153. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
  154. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
  155. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
  156. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
  157. package/packages/memory/node_modules/@forge/contracts/index.js +32 -0
  158. package/packages/memory/node_modules/@forge/contracts/package.json +35 -0
  159. package/packages/memory/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
  160. package/packages/memory/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
  161. package/packages/memory/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
  162. package/packages/memory/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
  163. package/packages/memory/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
  164. package/packages/memory/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
  165. package/packages/memory/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
  166. package/packages/memory/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
  167. package/packages/memory/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
  168. package/packages/memory/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
  169. package/packages/memory/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
  170. package/packages/memory/node_modules/@forge/contracts/src/baseline.js +23 -0
  171. package/packages/memory/node_modules/@forge/contracts/src/canonical.js +151 -0
  172. package/packages/memory/node_modules/@forge/contracts/src/definitions.js +176 -0
  173. package/packages/memory/node_modules/@forge/contracts/src/identity.js +42 -0
  174. package/packages/memory/node_modules/@forge/contracts/src/schema.js +72 -0
  175. package/packages/memory/node_modules/@forge/contracts/src/validate.js +305 -0
  176. package/scripts/check-forge-token.js +85 -67
  177. package/scripts/gen-embedded-assets.mjs +31 -5
  178. package/scripts/test-full-suite.js +69 -15
  179. package/scripts/test.js +11 -1
@@ -1,11 +1,28 @@
1
1
  'use strict';
2
2
 
3
- const { execFileSync, spawnSync } = require('node:child_process');
3
+ const { randomUUID } = require('node:crypto');
4
+ const { execFileSync, spawn, spawnSync } = require('node:child_process');
4
5
  const fs = require('node:fs');
5
6
  const path = require('node:path');
6
7
  const forgeToken = require('../../scripts/check-forge-token');
7
- const { QUICK_LANE_ENV_VAR, QUICK_LANE_VALUE, resolveFullSuiteTimeoutMs } = require('../../scripts/test');
8
+ const {
9
+ QUICK_LANE_ENV_VAR,
10
+ QUICK_LANE_VALUE,
11
+ TIMEOUT_EXIT_CODE,
12
+ resolveFullSuiteTimeoutMs,
13
+ runCommand,
14
+ } = require('../../scripts/test');
15
+ const {
16
+ INSTANCE_ENV,
17
+ MANIFEST_ENV,
18
+ TOKEN_ENV,
19
+ createProcessTree,
20
+ } = require('../../scripts/process-tree');
8
21
  const { fireAndForget } = require('../pr-monitor/reconcile-executor');
22
+ const validationReceipt = require('../validation-receipt');
23
+
24
+ const { verifyValidationReceipt } = validationReceipt;
25
+ const PUSH_NONCE_ENV_VAR = forgeToken._internal.NONCE_ENV_VAR;
9
26
 
10
27
  const isWindows = process.platform === 'win32';
11
28
 
@@ -24,6 +41,19 @@ function detectPackageManager(projectRoot, existsSync = fs.existsSync) {
24
41
  return 'npm';
25
42
  }
26
43
 
44
+ function isForgeCheckout(projectRoot, readFileSync = fs.readFileSync, existsSync = fs.existsSync) {
45
+ try {
46
+ const manifest = JSON.parse(readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
47
+ return manifest.name === 'forge-workflow'
48
+ && manifest.bin?.forge === 'bin/forge.js'
49
+ && manifest.scripts?.['test:full:parallel'] === 'node scripts/test-full-suite.js'
50
+ && existsSync(path.join(projectRoot, 'scripts', 'test-full-suite.js'));
51
+ } catch (error) {
52
+ if (error instanceof SyntaxError || error.code === 'ENOENT' || error.code === 'ENOTDIR') return false;
53
+ throw error;
54
+ }
55
+ }
56
+
27
57
  /**
28
58
  * Get the current git branch name.
29
59
  *
@@ -145,19 +175,26 @@ function maybeTriggerShepherdAfterPush({
145
175
  * the tests from a `forge push`.
146
176
  *
147
177
  * @param {boolean} quickMode - Whether this is a --quick push
148
- * @param {NodeJS.ProcessEnv} [baseEnv=process.env] - Environment to derive from
178
+ * @param {NodeJS.ProcessEnv} [baseEnv] - Environment to derive from
179
+ * @param {string} [nonce] - Signed invocation proof locator for the Git child
149
180
  * @returns {NodeJS.ProcessEnv} Environment for the git push child process
150
181
  */
151
- function buildPushEnv(quickMode, baseEnv = process.env) {
152
- const env = { ...baseEnv };
182
+ function buildPushEnv(quickMode, baseEnv, nonce) {
183
+ const env = { ...(baseEnv || process.env) };
184
+ delete env[PUSH_NONCE_ENV_VAR];
153
185
  if (quickMode) {
154
186
  env[QUICK_LANE_ENV_VAR] = QUICK_LANE_VALUE;
155
187
  } else {
156
188
  delete env[QUICK_LANE_ENV_VAR];
157
189
  }
190
+ if (nonce) env[PUSH_NONCE_ENV_VAR] = nonce;
158
191
  return env;
159
192
  }
160
193
 
194
+ function buildGateEnv(baseEnv = process.env) {
195
+ return buildPushEnv(false, baseEnv);
196
+ }
197
+
161
198
  /**
162
199
  * Run branch protection check as a subprocess.
163
200
  * @param {function} execFn - execFileSync or mock
@@ -165,9 +202,9 @@ function buildPushEnv(quickMode, baseEnv = process.env) {
165
202
  * @param {function} log - Logger function
166
203
  * @returns {boolean} True if branch protection passed
167
204
  */
168
- function runBranchProtection(execFn, projectRoot, log) {
205
+ function runBranchProtection(execFn, projectRoot, log, env) {
169
206
  try {
170
- execFn('node', [path.join(projectRoot, 'scripts', 'branch-protection.js')]);
207
+ execFn('node', [path.join(projectRoot, 'scripts', 'branch-protection.js')], { env });
171
208
  return true;
172
209
  } catch (_err) { /* intentional: fallback to failure */ // NOSONAR S2486
173
210
  log('Branch protection check failed — push aborted.');
@@ -182,11 +219,12 @@ function runBranchProtection(execFn, projectRoot, log) {
182
219
  * @param {string} projectRoot - Absolute path to project root
183
220
  * @returns {boolean} True if lint passed
184
221
  */
185
- function runLint(spawnFn, pkgManager, projectRoot) {
222
+ function runLint(spawnFn, pkgManager, projectRoot, env) {
186
223
  const lintResult = spawnFn(pkgManager, ['run', 'lint'], {
187
224
  stdio: 'inherit',
188
225
  shell: isWindows,
189
226
  cwd: projectRoot,
227
+ env,
190
228
  });
191
229
  return lintResult.status === 0;
192
230
  }
@@ -279,18 +317,104 @@ function reportTestRunTermination(testResult, timeoutMs, log) {
279
317
  log('Test run ended without an exit status — push aborted.');
280
318
  }
281
319
 
320
+ async function runSupervisedTestCommand({
321
+ testCommand,
322
+ testArgs,
323
+ projectRoot,
324
+ env,
325
+ timeoutMs,
326
+ log,
327
+ spawnAsyncFn,
328
+ createProcessTreeFn,
329
+ }) {
330
+ const isolatedEnv = { ...env };
331
+ delete isolatedEnv[MANIFEST_ENV];
332
+ delete isolatedEnv[TOKEN_ENV];
333
+ delete isolatedEnv[INSTANCE_ENV];
334
+ // A unique manifest prevents timeout cleanup from touching an outer
335
+ // validation run while allInstances reaches this runner's nested shards.
336
+ const processTree = createProcessTreeFn({
337
+ env: isolatedEnv,
338
+ token: randomUUID(),
339
+ allInstances: true,
340
+ reconcile: false,
341
+ });
342
+ let supervisedEnv = null;
343
+ try {
344
+ supervisedEnv = processTree.envFor(isolatedEnv);
345
+ } catch { /* handled by the fail-closed check below */ }
346
+ const supervisionMarker = supervisedEnv?.[MANIFEST_ENV];
347
+ if (typeof supervisionMarker !== 'string' || supervisionMarker.length === 0) {
348
+ log('Test supervision unavailable — push aborted.');
349
+ processTree.cleanup('SIGKILL');
350
+ return false;
351
+ }
352
+ let cancellationSignal = null;
353
+ const removeSignalHandlers = processTree.installSignalHandlers((signal) => {
354
+ cancellationSignal = signal;
355
+ processTree.cleanup('SIGKILL');
356
+ });
357
+ let terminationSignal = null;
358
+ const observingSpawn = (...spawnArgs) => {
359
+ const child = spawnAsyncFn(...spawnArgs);
360
+ child.once?.('close', (_status, signal) => {
361
+ terminationSignal = signal;
362
+ });
363
+ return child;
364
+ };
365
+ try {
366
+ if (cancellationSignal) return false;
367
+ const status = await runCommand(testCommand, testArgs, {
368
+ cwd: projectRoot,
369
+ detached: !isWindows,
370
+ env: supervisedEnv,
371
+ killSignal: TEST_RUN_KILL_SIGNAL,
372
+ processTree,
373
+ timeout: timeoutMs,
374
+ windowsHide: true,
375
+ }, observingSpawn);
376
+ if (cancellationSignal) {
377
+ reportTestRunTermination({ status: null, signal: cancellationSignal }, timeoutMs, log);
378
+ return false;
379
+ }
380
+ if (status === TIMEOUT_EXIT_CODE) {
381
+ reportTestRunTermination({
382
+ status: null,
383
+ signal: TEST_RUN_KILL_SIGNAL,
384
+ error: Object.assign(new Error('test run timed out'), { code: 'ETIMEDOUT' }),
385
+ }, timeoutMs, log);
386
+ }
387
+ if (terminationSignal) {
388
+ reportTestRunTermination({ status: null, signal: terminationSignal }, timeoutMs, log);
389
+ return false;
390
+ }
391
+ return status === 0;
392
+ } catch (error) {
393
+ reportTestRunTermination({ status: null, signal: null, error }, timeoutMs, log);
394
+ return false;
395
+ } finally {
396
+ removeSignalHandlers();
397
+ processTree.cleanup(cancellationSignal ? 'SIGKILL' : 'SIGTERM');
398
+ }
399
+ }
400
+
282
401
  /**
283
402
  * Run tests unless in quick mode, logging warnings for first push.
284
- * @param {function} spawnFn - spawnSync or mock
285
- * @param {function} execFn - execFileSync or mock
286
- * @param {string} pkgManager - Package manager command
287
- * @param {string} projectRoot - Absolute path to project root
288
- * @param {boolean} quickMode - Whether to skip tests
289
- * @param {function} log - Logger function
290
- * @param {NodeJS.ProcessEnv} [env] - Environment used to resolve the test budget
291
- * @returns {boolean|undefined} True if tests passed, undefined if skipped
403
+ * @param {object} options - Test execution dependencies and state
404
+ * @returns {Promise<boolean|undefined>} True if tests passed, undefined if skipped
292
405
  */
293
- function runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log, env = process.env) {
406
+ async function runTests({
407
+ spawnAsyncFn,
408
+ execFn,
409
+ readFileFn,
410
+ existsFn,
411
+ pkgManager,
412
+ projectRoot,
413
+ quickMode,
414
+ log,
415
+ env = process.env,
416
+ createProcessTreeFn = createProcessTree,
417
+ }) {
294
418
  if (quickMode) {
295
419
  log('Tests skipped (--quick) — CI will run full suite on GitHub');
296
420
  const branch = getCurrentBranch(execFn);
@@ -300,7 +424,10 @@ function runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log, env
300
424
  return undefined;
301
425
  }
302
426
 
303
- // `forge push` runs the package-level `test` script, i.e. the whole suite.
427
+ const useForgeRunner = isForgeCheckout(projectRoot, readFileFn, existsFn);
428
+ const testCommand = useForgeRunner ? 'node' : pkgManager;
429
+ const testArgs = useForgeRunner ? ['scripts/test-full-suite.js'] : ['run', 'test'];
430
+
304
431
  // Budget it from the shared full-suite budget in scripts/test.js — which is
305
432
  // sized as ~2x the measured healthy full-suite runtime and overridable with
306
433
  // FORGE_TEST_TIMEOUT_MS — instead of an arbitrary local cap: the old fixed
@@ -308,18 +435,108 @@ function runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log, env
308
435
  // reintroduce a local constant here; change the shared budget with a fresh
309
436
  // measurement instead.
310
437
  const timeoutMs = resolveFullSuiteTimeoutMs(env);
311
- const testResult = spawnFn(pkgManager, ['run', 'test'], {
312
- stdio: 'inherit',
313
- shell: isWindows,
314
- cwd: projectRoot,
315
- timeout: timeoutMs,
316
- killSignal: TEST_RUN_KILL_SIGNAL,
438
+ return runSupervisedTestCommand({
439
+ testCommand,
440
+ testArgs,
441
+ projectRoot,
442
+ env,
443
+ timeoutMs,
444
+ log,
445
+ spawnAsyncFn,
446
+ createProcessTreeFn,
317
447
  });
318
- if (testResult.status === null || testResult.status === undefined) {
319
- reportTestRunTermination(testResult, timeoutMs, log);
448
+ }
449
+
450
+ /**
451
+ * Consume Forge-owned push arguments while preserving Git-owned arguments.
452
+ * Only the first `--` is a Forge delimiter; everything after it belongs to Git.
453
+ *
454
+ * @param {string[]} args - Raw command arguments
455
+ * @param {object} flags - Parsed Forge flags
456
+ * @returns {{ quickMode: boolean, gitArgs: string[] }} Parsed push arguments
457
+ */
458
+ function parsePushArguments(args, flags) {
459
+ const delimiterIndex = args.indexOf('--');
460
+ const forgeArgs = delimiterIndex === -1 ? args : args.slice(0, delimiterIndex);
461
+ const gitArgsAfterDelimiter = delimiterIndex === -1 ? [] : args.slice(delimiterIndex + 1);
462
+ const quickMode = delimiterIndex === -1
463
+ ? Boolean(flags && (flags['--quick'] || flags.quick))
464
+ : forgeArgs.some(arg => arg === '--quick' || arg === '-q');
465
+ return {
466
+ quickMode,
467
+ gitArgs: [
468
+ ...forgeArgs.filter(arg => arg !== '--quick' && arg !== '-q'),
469
+ ...gitArgsAfterDelimiter,
470
+ ],
471
+ };
472
+ }
473
+
474
+ /**
475
+ * Bind full-mode authorization to either the reused receipt or this fresh run.
476
+ * Quick mode has no test evidence and must carry no receipt identity.
477
+ */
478
+ function receiptIdentityForPush(quickMode, validationReused, receipt) {
479
+ if (quickMode) return null;
480
+ if (validationReused) return receipt.identity;
481
+ return 'fresh-tests';
482
+ }
483
+
484
+ /**
485
+ * Write the short-lived pre-push authorization after all required gates pass.
486
+ * Failure is non-fatal because Lefthook then executes its gates normally.
487
+ */
488
+ function writePushAuthorization({
489
+ tokenWrite,
490
+ worktreeRoot,
491
+ proofSnapshot,
492
+ quickMode,
493
+ completedGates,
494
+ validationReused,
495
+ receipt,
496
+ gateEnv,
497
+ log,
498
+ }) {
499
+ try {
500
+ return tokenWrite(worktreeRoot, {
501
+ snapshot: proofSnapshot,
502
+ mode: quickMode ? 'quick' : 'full',
503
+ gates: completedGates,
504
+ receiptIdentity: receiptIdentityForPush(quickMode, validationReused, receipt),
505
+ env: gateEnv,
506
+ });
507
+ } catch (_err) { /* intentional: non-fatal, lefthook will re-run hooks */ // NOSONAR S2486
508
+ log('Warning: Could not write forge push token — lefthook hooks will run again');
509
+ return null;
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Execute Git push and revoke this invocation's proof regardless of outcome.
515
+ */
516
+ function executeGitPush({
517
+ execFn,
518
+ gitArgs,
519
+ quickMode,
520
+ baseEnv,
521
+ authorization,
522
+ tokenConsume,
523
+ worktreeRoot,
524
+ log,
525
+ }) {
526
+ try {
527
+ execFn('git', ['push', ...gitArgs], {
528
+ stdio: 'inherit',
529
+ env: buildPushEnv(quickMode, baseEnv, authorization?.nonce),
530
+ });
531
+ return true;
532
+ } catch (_pushErr) { // NOSONAR S2486
533
+ log('git push failed.');
320
534
  return false;
535
+ } finally {
536
+ if (authorization?.nonce) {
537
+ try { tokenConsume(worktreeRoot, { nonce: authorization.nonce }); } catch { /* best effort revocation */ }
538
+ }
321
539
  }
322
- return testResult.status === 0;
323
540
  }
324
541
 
325
542
  module.exports = {
@@ -343,56 +560,99 @@ module.exports = {
343
560
  handler: async (args, flags, projectRoot, deps) => {
344
561
  const execFn = deps?.execFileSync || execFileSync;
345
562
  const spawnFn = deps?.spawnSync || spawnSync;
563
+ const spawnAsyncFn = deps?.spawn || spawn;
346
564
  const existsFn = deps?.existsSync || fs.existsSync;
565
+ const readFileFn = deps?.readFileSync || fs.readFileSync;
347
566
  const log = deps?.log || console.log;
348
567
  const tokenWrite = deps?.writeForgeToken || forgeToken.write;
568
+ const tokenConsume = deps?.consumeForgeToken || forgeToken.consume;
569
+ const beginPushProof = deps?.beginPushProof || validationReceipt._pushProof.begin;
570
+ const verifyReceipt = deps?.verifyValidationReceipt || verifyValidationReceipt;
349
571
 
350
- const quickMode = !!(flags && (flags['--quick'] || flags.quick));
572
+ const { quickMode, gitArgs } = parsePushArguments(args, flags);
351
573
  const pkgManager = detectPackageManager(projectRoot, existsFn);
574
+ const worktreeRoot = resolveGitToplevel(execFn, projectRoot);
575
+ const gateEnv = buildGateEnv(deps?.env || process.env);
576
+ let proofSnapshot = null;
577
+ try {
578
+ proofSnapshot = beginPushProof(worktreeRoot, { env: gateEnv });
579
+ } catch { /* invalid snapshot falls back to ordinary hooks */ }
580
+ const completedGates = [];
352
581
 
353
582
  // Step 1: Branch protection
354
- const branchOk = runBranchProtection(execFn, projectRoot, log);
583
+ const branchOk = runBranchProtection(execFn, projectRoot, log, gateEnv);
355
584
  if (!branchOk) {
356
585
  return { success: false, quickMode, lintPassed: false, pushed: false };
357
586
  }
587
+ completedGates.push('branch-protection');
358
588
 
359
589
  // Auto-file rail (non-blocking): ensure the branch being pushed has a backing
360
590
  // Kernel issue so started work never goes unfiled. Never aborts the push.
361
591
  await autoFileBackingIssueForPush(projectRoot, execFn, deps || {});
362
592
 
363
593
  // Step 2: Lint
364
- const lintPassed = runLint(spawnFn, pkgManager, projectRoot);
594
+ const lintPassed = runLint(spawnFn, pkgManager, projectRoot, gateEnv);
365
595
  if (!lintPassed) {
366
596
  log('Lint failed — push aborted.');
367
597
  return { success: false, quickMode, lintPassed: false, pushed: false };
368
598
  }
599
+ completedGates.push('lint');
369
600
 
370
601
  // Step 3: Tests (skip in quick mode)
371
- const testsPassed = runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log, deps?.env || process.env);
602
+ const receipt = quickMode
603
+ ? { valid: false }
604
+ : verifyReceipt(worktreeRoot);
605
+ const validationReused = receipt.valid === true;
606
+ if (validationReused) log(`Tests reused from full validation at ${receipt.head || 'current HEAD'}`);
607
+ const testsPassed = validationReused
608
+ ? true
609
+ : await runTests({
610
+ spawnAsyncFn,
611
+ execFn,
612
+ readFileFn,
613
+ existsFn,
614
+ pkgManager,
615
+ projectRoot,
616
+ quickMode,
617
+ log,
618
+ env: gateEnv,
619
+ createProcessTreeFn: deps?.createProcessTree || createProcessTree,
620
+ });
372
621
  if (!quickMode && !testsPassed) {
373
- return { success: false, quickMode, lintPassed: true, testsPassed: false, pushed: false };
622
+ return { success: false, quickMode, lintPassed: true, testsPassed: false, validationReused, pushed: false };
374
623
  }
624
+ if (!quickMode) completedGates.push('tests');
375
625
 
376
- // Step 4: Write nonce token so lefthook skips pre-push hooks.
377
- // Write to the git worktree top-level (where the hook checks), not the
378
- // inherited projectRoot, so the skip works from linked worktrees too.
379
- try {
380
- tokenWrite(resolveGitToplevel(execFn, projectRoot));
381
- } catch (_err) { /* intentional: non-fatal, lefthook will re-run hooks */ // NOSONAR S2486
382
- log('Warning: Could not write forge push token — lefthook hooks will run again');
383
- }
626
+ // Step 4: Write invocation proof under worktree-private Git metadata.
627
+ const authorization = writePushAuthorization({
628
+ tokenWrite,
629
+ worktreeRoot,
630
+ proofSnapshot,
631
+ quickMode,
632
+ completedGates,
633
+ validationReused,
634
+ receipt,
635
+ gateEnv,
636
+ log,
637
+ });
384
638
 
385
639
  // Step 5: git push with passthrough args
386
- const gitArgs = args.filter(a => a !== '--quick');
387
- try {
388
- execFn('git', ['push', ...gitArgs], { stdio: 'inherit', env: buildPushEnv(quickMode) });
389
- } catch (_pushErr) { // NOSONAR S2486
390
- log('git push failed.');
640
+ const pushed = executeGitPush({
641
+ execFn,
642
+ gitArgs,
643
+ quickMode,
644
+ baseEnv: deps?.env || process.env,
645
+ authorization,
646
+ tokenConsume,
647
+ worktreeRoot,
648
+ log,
649
+ });
650
+ if (!pushed) {
391
651
  return {
392
652
  success: false,
393
653
  quickMode,
394
654
  lintPassed: true,
395
- ...(quickMode ? {} : { testsPassed: true }),
655
+ ...(quickMode ? {} : { testsPassed: true, validationReused }),
396
656
  pushed: false,
397
657
  };
398
658
  }
@@ -407,7 +667,7 @@ module.exports = {
407
667
  success: true,
408
668
  quickMode,
409
669
  lintPassed: true,
410
- ...(quickMode ? {} : { testsPassed: true }),
670
+ ...(quickMode ? {} : { testsPassed: true, validationReused }),
411
671
  pushed: true,
412
672
  };
413
673
  },
@@ -415,6 +675,7 @@ module.exports = {
415
675
  // Exposed for unit tests; not part of the CLI surface.
416
676
  _internal: {
417
677
  autoFileBackingIssueForPush,
678
+ buildGateEnv,
418
679
  buildPushEnv,
419
680
  maybeTriggerShepherdAfterPush,
420
681
  },
@@ -9,13 +9,14 @@ const {
9
9
  const { runIssueOperation: defaultRunIssueOperation } = require('../forge-issues');
10
10
  const { normalizeArgs, normalizeIssueResult, withResolvedIssueBackend } = require('./_issue');
11
11
  const { generateNpmPublishWorkflow } = require('../npm-publish-workflow');
12
+ const { generateTestWorkflow } = require('../test-workflow');
12
13
  const { updateBunWorkflowPins } = require('../bun-workflow-pins');
13
14
 
14
15
  // `forge release <id>` releases a claimed issue; `forge release check` runs the
15
16
  // release-readiness gate. The two share the top-level verb, so this command
16
17
  // dispatches `check` to the gate and routes everything else through the shared
17
18
  // issue dispatch (resolve backend → runIssueOperation('release') → normalize).
18
- const usage = 'Usage: forge release <id> | forge release check --target 0.1.0 [--json] | forge release regen-audit | forge release generate-npm-workflow --expect-head <full-sha> | forge release update-bun-pins --expect-head <full-sha>';
19
+ const usage = 'Usage: forge release <id> | forge release check --target 0.1.0 [--json] | forge release regen-audit | forge release generate-npm-workflow --expect-head <full-sha> | forge release generate-test-workflow --expect-head <full-sha> | forge release update-bun-pins --expect-head <full-sha>';
19
20
 
20
21
  async function runReleaseIssue(args, projectRoot, opts = {}) {
21
22
  const resolved = withResolvedIssueBackend(projectRoot, opts);
@@ -59,6 +60,22 @@ function parseReleaseArgs(args = []) {
59
60
 
60
61
  async function handler(args, _flags, projectRoot, opts = {}) {
61
62
  const parsed = parseReleaseArgs(args);
63
+ if (parsed.subcommand === 'generate-test-workflow') {
64
+ const generator = opts.generateTestWorkflow || generateTestWorkflow;
65
+ const generated = await generator(projectRoot, {
66
+ env: opts.env,
67
+ kernelDeps: opts.kernelDeps,
68
+ expectedHead: parsed.expectedHead,
69
+ resolveHead: opts.resolveHead,
70
+ });
71
+ return generated.success
72
+ ? {
73
+ success: true,
74
+ generated,
75
+ output: `Generated ${generated.path} (${generated.contentHash}).\n`,
76
+ }
77
+ : generated;
78
+ }
62
79
  if (parsed.subcommand === 'update-bun-pins') {
63
80
  const updater = opts.updateBunWorkflowPins || updateBunWorkflowPins;
64
81
  const updated = await updater(projectRoot, {
@@ -313,7 +313,7 @@ async function buildMonitorContext(pr, projectRoot, deps) {
313
313
  const buildKernelDeps = deps.buildKernelDeps
314
314
  || require('../kernel/cli-broker-factory').buildMigratedKernelIssueDeps;
315
315
  const createMonitorStore = deps.createMonitorStore
316
- || require('../../packages/memory').createMonitorStore;
316
+ || require('@forge/memory').createMonitorStore;
317
317
  let kernel;
318
318
  try {
319
319
  kernel = await buildKernelDeps({ projectRoot: projectRoot || process.cwd(), gitCommonDir });
@@ -28,6 +28,8 @@ const LOCKFILE_MAP = [
28
28
 
29
29
  const DEFAULT_TIMEOUT = 120000;
30
30
  const DIRECT_TEST_CANDIDATES = Object.freeze({
31
+ 'lib/test-workflow.js': ['test/test-workflow.test.js'],
32
+ 'lib/workflow-templates/test.yml': ['test/test-workflow.test.js'],
31
33
  'bin/forge.js': [
32
34
  'test/cli-flags.test.js',
33
35
  'test/forge-cli-registry.test.js',
@@ -86,7 +88,13 @@ const DIRECT_TEST_CANDIDATES = Object.freeze({
86
88
  'test/github-launcher.test.js',
87
89
  'test/integration/github-account-context.test.js',
88
90
  ],
91
+ 'bin/forge-gh-proxy.js': ['test/bin/forge-gh-proxy.test.js', 'test/github-launcher.test.js'],
92
+ 'bin/forge-github-credential.js': ['test/bin/forge-github-credential.test.js', 'test/github-launcher.test.js'],
89
93
  'lib/github-context.js': ['test/github-context.test.js', 'test/integration/github-account-context.test.js'],
94
+ 'lib/github-credential.js': ['test/github-credential.test.js', 'test/github-launcher.test.js'],
95
+ 'lib/github-router.js': ['test/github-router.test.js', 'test/commands/github.test.js'],
96
+ 'lib/gh-proxy.js': ['test/gh-proxy.test.js', 'test/github-launcher.test.js'],
97
+ 'lib/native-gh.js': ['test/native-gh.test.js', 'test/integration/github-account-context.test.js'],
90
98
  'lib/commands/_registry.js': [
91
99
  'test/commands/_registry.test.js',
92
100
  'test/commands/_registry-github-context.test.js',