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.
- package/CHANGELOG.md +24 -0
- package/bin/forge-gh-proxy.js +15 -0
- package/bin/forge-github-credential.js +21 -0
- package/bin/forge.js +16 -1
- package/docs/guides/MIGRATION.md +4 -3
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/VALIDATION.md +14 -0
- package/docs/reference/github-accounts.md +114 -38
- package/lib/bun-workflow-pins.js +129 -3
- package/lib/commands/github.js +234 -49
- package/lib/commands/push.js +298 -46
- package/lib/commands/release.js +18 -1
- package/lib/commands/test.js +8 -0
- package/lib/commands/validate.js +209 -69
- package/lib/gh-proxy.js +309 -0
- package/lib/github-context.js +160 -25
- package/lib/github-credential.js +47 -0
- package/lib/github-router.js +774 -0
- package/lib/native-gh.js +177 -0
- package/lib/npm-publish-workflow.js +5 -5
- package/lib/pr-monitor/flow-monitor.js +125 -26
- package/lib/protected-state-authority.js +214 -13
- package/lib/release-readiness.js +57 -41
- package/lib/shell-utils.js +41 -15
- package/lib/test-workflow.js +389 -0
- package/lib/validation-receipt.js +127 -12
- package/lib/workflow-templates/test.yml +654 -0
- package/package.json +7 -5
- package/scripts/check-forge-token.js +85 -67
- package/scripts/gen-embedded-assets.mjs +31 -5
- package/scripts/test-full-suite.js +69 -15
- package/scripts/test.js +10 -0
package/lib/commands/push.js
CHANGED
|
@@ -1,12 +1,28 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const {
|
|
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 {
|
|
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');
|
|
9
|
-
const
|
|
22
|
+
const validationReceipt = require('../validation-receipt');
|
|
23
|
+
|
|
24
|
+
const { verifyValidationReceipt } = validationReceipt;
|
|
25
|
+
const PUSH_NONCE_ENV_VAR = forgeToken._internal.NONCE_ENV_VAR;
|
|
10
26
|
|
|
11
27
|
const isWindows = process.platform === 'win32';
|
|
12
28
|
|
|
@@ -25,6 +41,19 @@ function detectPackageManager(projectRoot, existsSync = fs.existsSync) {
|
|
|
25
41
|
return 'npm';
|
|
26
42
|
}
|
|
27
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
|
+
|
|
28
57
|
/**
|
|
29
58
|
* Get the current git branch name.
|
|
30
59
|
*
|
|
@@ -146,19 +175,26 @@ function maybeTriggerShepherdAfterPush({
|
|
|
146
175
|
* the tests from a `forge push`.
|
|
147
176
|
*
|
|
148
177
|
* @param {boolean} quickMode - Whether this is a --quick push
|
|
149
|
-
* @param {NodeJS.ProcessEnv} [baseEnv
|
|
178
|
+
* @param {NodeJS.ProcessEnv} [baseEnv] - Environment to derive from
|
|
179
|
+
* @param {string} [nonce] - Signed invocation proof locator for the Git child
|
|
150
180
|
* @returns {NodeJS.ProcessEnv} Environment for the git push child process
|
|
151
181
|
*/
|
|
152
|
-
function buildPushEnv(quickMode, baseEnv
|
|
153
|
-
const env = { ...baseEnv };
|
|
182
|
+
function buildPushEnv(quickMode, baseEnv, nonce) {
|
|
183
|
+
const env = { ...(baseEnv || process.env) };
|
|
184
|
+
delete env[PUSH_NONCE_ENV_VAR];
|
|
154
185
|
if (quickMode) {
|
|
155
186
|
env[QUICK_LANE_ENV_VAR] = QUICK_LANE_VALUE;
|
|
156
187
|
} else {
|
|
157
188
|
delete env[QUICK_LANE_ENV_VAR];
|
|
158
189
|
}
|
|
190
|
+
if (nonce) env[PUSH_NONCE_ENV_VAR] = nonce;
|
|
159
191
|
return env;
|
|
160
192
|
}
|
|
161
193
|
|
|
194
|
+
function buildGateEnv(baseEnv = process.env) {
|
|
195
|
+
return buildPushEnv(false, baseEnv);
|
|
196
|
+
}
|
|
197
|
+
|
|
162
198
|
/**
|
|
163
199
|
* Run branch protection check as a subprocess.
|
|
164
200
|
* @param {function} execFn - execFileSync or mock
|
|
@@ -166,9 +202,9 @@ function buildPushEnv(quickMode, baseEnv = process.env) {
|
|
|
166
202
|
* @param {function} log - Logger function
|
|
167
203
|
* @returns {boolean} True if branch protection passed
|
|
168
204
|
*/
|
|
169
|
-
function runBranchProtection(execFn, projectRoot, log) {
|
|
205
|
+
function runBranchProtection(execFn, projectRoot, log, env) {
|
|
170
206
|
try {
|
|
171
|
-
execFn('node', [path.join(projectRoot, 'scripts', 'branch-protection.js')]);
|
|
207
|
+
execFn('node', [path.join(projectRoot, 'scripts', 'branch-protection.js')], { env });
|
|
172
208
|
return true;
|
|
173
209
|
} catch (_err) { /* intentional: fallback to failure */ // NOSONAR S2486
|
|
174
210
|
log('Branch protection check failed — push aborted.');
|
|
@@ -183,11 +219,12 @@ function runBranchProtection(execFn, projectRoot, log) {
|
|
|
183
219
|
* @param {string} projectRoot - Absolute path to project root
|
|
184
220
|
* @returns {boolean} True if lint passed
|
|
185
221
|
*/
|
|
186
|
-
function runLint(spawnFn, pkgManager, projectRoot) {
|
|
222
|
+
function runLint(spawnFn, pkgManager, projectRoot, env) {
|
|
187
223
|
const lintResult = spawnFn(pkgManager, ['run', 'lint'], {
|
|
188
224
|
stdio: 'inherit',
|
|
189
225
|
shell: isWindows,
|
|
190
226
|
cwd: projectRoot,
|
|
227
|
+
env,
|
|
191
228
|
});
|
|
192
229
|
return lintResult.status === 0;
|
|
193
230
|
}
|
|
@@ -280,18 +317,104 @@ function reportTestRunTermination(testResult, timeoutMs, log) {
|
|
|
280
317
|
log('Test run ended without an exit status — push aborted.');
|
|
281
318
|
}
|
|
282
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
|
+
|
|
283
401
|
/**
|
|
284
402
|
* Run tests unless in quick mode, logging warnings for first push.
|
|
285
|
-
* @param {
|
|
286
|
-
* @
|
|
287
|
-
* @param {string} pkgManager - Package manager command
|
|
288
|
-
* @param {string} projectRoot - Absolute path to project root
|
|
289
|
-
* @param {boolean} quickMode - Whether to skip tests
|
|
290
|
-
* @param {function} log - Logger function
|
|
291
|
-
* @param {NodeJS.ProcessEnv} [env] - Environment used to resolve the test budget
|
|
292
|
-
* @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
|
|
293
405
|
*/
|
|
294
|
-
function runTests(
|
|
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
|
+
}) {
|
|
295
418
|
if (quickMode) {
|
|
296
419
|
log('Tests skipped (--quick) — CI will run full suite on GitHub');
|
|
297
420
|
const branch = getCurrentBranch(execFn);
|
|
@@ -301,7 +424,10 @@ function runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log, env
|
|
|
301
424
|
return undefined;
|
|
302
425
|
}
|
|
303
426
|
|
|
304
|
-
|
|
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
|
+
|
|
305
431
|
// Budget it from the shared full-suite budget in scripts/test.js — which is
|
|
306
432
|
// sized as ~2x the measured healthy full-suite runtime and overridable with
|
|
307
433
|
// FORGE_TEST_TIMEOUT_MS — instead of an arbitrary local cap: the old fixed
|
|
@@ -309,18 +435,108 @@ function runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log, env
|
|
|
309
435
|
// reintroduce a local constant here; change the shared budget with a fresh
|
|
310
436
|
// measurement instead.
|
|
311
437
|
const timeoutMs = resolveFullSuiteTimeoutMs(env);
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
438
|
+
return runSupervisedTestCommand({
|
|
439
|
+
testCommand,
|
|
440
|
+
testArgs,
|
|
441
|
+
projectRoot,
|
|
442
|
+
env,
|
|
443
|
+
timeoutMs,
|
|
444
|
+
log,
|
|
445
|
+
spawnAsyncFn,
|
|
446
|
+
createProcessTreeFn,
|
|
318
447
|
});
|
|
319
|
-
|
|
320
|
-
|
|
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.');
|
|
321
534
|
return false;
|
|
535
|
+
} finally {
|
|
536
|
+
if (authorization?.nonce) {
|
|
537
|
+
try { tokenConsume(worktreeRoot, { nonce: authorization.nonce }); } catch { /* best effort revocation */ }
|
|
538
|
+
}
|
|
322
539
|
}
|
|
323
|
-
return testResult.status === 0;
|
|
324
540
|
}
|
|
325
541
|
|
|
326
542
|
module.exports = {
|
|
@@ -344,59 +560,94 @@ module.exports = {
|
|
|
344
560
|
handler: async (args, flags, projectRoot, deps) => {
|
|
345
561
|
const execFn = deps?.execFileSync || execFileSync;
|
|
346
562
|
const spawnFn = deps?.spawnSync || spawnSync;
|
|
563
|
+
const spawnAsyncFn = deps?.spawn || spawn;
|
|
347
564
|
const existsFn = deps?.existsSync || fs.existsSync;
|
|
565
|
+
const readFileFn = deps?.readFileSync || fs.readFileSync;
|
|
348
566
|
const log = deps?.log || console.log;
|
|
349
567
|
const tokenWrite = deps?.writeForgeToken || forgeToken.write;
|
|
568
|
+
const tokenConsume = deps?.consumeForgeToken || forgeToken.consume;
|
|
569
|
+
const beginPushProof = deps?.beginPushProof || validationReceipt._pushProof.begin;
|
|
350
570
|
const verifyReceipt = deps?.verifyValidationReceipt || verifyValidationReceipt;
|
|
351
571
|
|
|
352
|
-
const quickMode
|
|
572
|
+
const { quickMode, gitArgs } = parsePushArguments(args, flags);
|
|
353
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 = [];
|
|
354
581
|
|
|
355
582
|
// Step 1: Branch protection
|
|
356
|
-
const branchOk = runBranchProtection(execFn, projectRoot, log);
|
|
583
|
+
const branchOk = runBranchProtection(execFn, projectRoot, log, gateEnv);
|
|
357
584
|
if (!branchOk) {
|
|
358
585
|
return { success: false, quickMode, lintPassed: false, pushed: false };
|
|
359
586
|
}
|
|
587
|
+
completedGates.push('branch-protection');
|
|
360
588
|
|
|
361
589
|
// Auto-file rail (non-blocking): ensure the branch being pushed has a backing
|
|
362
590
|
// Kernel issue so started work never goes unfiled. Never aborts the push.
|
|
363
591
|
await autoFileBackingIssueForPush(projectRoot, execFn, deps || {});
|
|
364
592
|
|
|
365
593
|
// Step 2: Lint
|
|
366
|
-
const lintPassed = runLint(spawnFn, pkgManager, projectRoot);
|
|
594
|
+
const lintPassed = runLint(spawnFn, pkgManager, projectRoot, gateEnv);
|
|
367
595
|
if (!lintPassed) {
|
|
368
596
|
log('Lint failed — push aborted.');
|
|
369
597
|
return { success: false, quickMode, lintPassed: false, pushed: false };
|
|
370
598
|
}
|
|
599
|
+
completedGates.push('lint');
|
|
371
600
|
|
|
372
601
|
// Step 3: Tests (skip in quick mode)
|
|
373
602
|
const receipt = quickMode
|
|
374
603
|
? { valid: false }
|
|
375
|
-
: verifyReceipt(
|
|
604
|
+
: verifyReceipt(worktreeRoot);
|
|
376
605
|
const validationReused = receipt.valid === true;
|
|
377
606
|
if (validationReused) log(`Tests reused from full validation at ${receipt.head || 'current HEAD'}`);
|
|
378
607
|
const testsPassed = validationReused
|
|
379
608
|
? true
|
|
380
|
-
: runTests(
|
|
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
|
+
});
|
|
381
621
|
if (!quickMode && !testsPassed) {
|
|
382
622
|
return { success: false, quickMode, lintPassed: true, testsPassed: false, validationReused, pushed: false };
|
|
383
623
|
}
|
|
624
|
+
if (!quickMode) completedGates.push('tests');
|
|
384
625
|
|
|
385
|
-
// Step 4: Write
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
+
});
|
|
393
638
|
|
|
394
639
|
// Step 5: git push with passthrough args
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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) {
|
|
400
651
|
return {
|
|
401
652
|
success: false,
|
|
402
653
|
quickMode,
|
|
@@ -424,6 +675,7 @@ module.exports = {
|
|
|
424
675
|
// Exposed for unit tests; not part of the CLI surface.
|
|
425
676
|
_internal: {
|
|
426
677
|
autoFileBackingIssueForPush,
|
|
678
|
+
buildGateEnv,
|
|
427
679
|
buildPushEnv,
|
|
428
680
|
maybeTriggerShepherdAfterPush,
|
|
429
681
|
},
|
package/lib/commands/release.js
CHANGED
|
@@ -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, {
|
package/lib/commands/test.js
CHANGED
|
@@ -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',
|