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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forge-workflow",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.8",
|
|
4
4
|
"packageManager": "bun@1.4.2",
|
|
5
5
|
"description": "Local runtime control plane for AI-assisted engineering workflows, gates, evidence, and all AI agents",
|
|
6
6
|
"bin": {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"prebuild": "node scripts/gen-command-manifest.js",
|
|
24
24
|
"lint": "eslint . --max-warnings 0",
|
|
25
25
|
"check": "node scripts/validate.js",
|
|
26
|
-
"prepare": "lefthook install || echo 'Note: lefthook not installed. Git hooks disabled. Run: bun add -d lefthook'",
|
|
26
|
+
"prepare": "node -e \"process.exit(process.env.npm_command === 'pack' ? 0 : 1)\" || lefthook install || echo 'Note: lefthook not installed. Git hooks disabled. Run: bun add -d lefthook'",
|
|
27
27
|
"setup": "node ./bin/forge.js setup",
|
|
28
28
|
"help": "node ./bin/forge.js --help",
|
|
29
29
|
"gen:assets": "bun scripts/gen-embedded-assets.mjs",
|
|
@@ -52,6 +52,9 @@
|
|
|
52
52
|
"@commitlint/cli": "^21.0.0",
|
|
53
53
|
"@commitlint/config-conventional": "^21.0.0",
|
|
54
54
|
"@eslint/js": "^10.0.1",
|
|
55
|
+
"@forge/contracts": "0.1.0-beta.6",
|
|
56
|
+
"@forge/flow": "0.1.0-beta.6",
|
|
57
|
+
"@forge/memory": "0.1.0-beta.6",
|
|
55
58
|
"@microsoft/eslint-formatter-sarif": "^3.1.0",
|
|
56
59
|
"@stryker-mutator/core": "^9.6.0",
|
|
57
60
|
"c8": "^12.0.0",
|
|
@@ -89,6 +92,8 @@
|
|
|
89
92
|
"files": [
|
|
90
93
|
"bin/",
|
|
91
94
|
"lib/",
|
|
95
|
+
"!lib/embedded-assets.generated.mjs",
|
|
96
|
+
"!lib/embedded-assets.generated-assets/",
|
|
92
97
|
"rules/",
|
|
93
98
|
"skills/",
|
|
94
99
|
".claude/rules/",
|
|
@@ -116,9 +121,6 @@
|
|
|
116
121
|
".mcp.json.example"
|
|
117
122
|
],
|
|
118
123
|
"dependencies": {
|
|
119
|
-
"@forge/flow": "0.1.0-beta.6",
|
|
120
|
-
"@forge/memory": "0.1.0-beta.6",
|
|
121
|
-
"@forge/contracts": "0.1.0-beta.6",
|
|
122
124
|
"@babel/parser": "^8.0.0",
|
|
123
125
|
"cross-spawn": "^7.0.6",
|
|
124
126
|
"fastest-levenshtein": "^1.0.16",
|
|
@@ -1,97 +1,115 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const
|
|
3
|
+
const { execFileSync } = require('node:child_process');
|
|
4
4
|
const crypto = require('node:crypto');
|
|
5
|
+
const fs = require('node:fs');
|
|
5
6
|
const path = require('node:path');
|
|
7
|
+
const { _pushProof } = require('../lib/validation-receipt');
|
|
8
|
+
const { defaultGetProcessIdentity } = require('./process-tree');
|
|
6
9
|
|
|
7
|
-
const
|
|
8
|
-
const
|
|
10
|
+
const NONCE_ENV_VAR = 'FORGE_PUSH_NONCE';
|
|
11
|
+
const NONCE_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
9
12
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
function tokenPath(projectRoot) {
|
|
17
|
-
return path.join(projectRoot, TOKEN_FILENAME);
|
|
13
|
+
function git(projectRoot, args, deps = {}) {
|
|
14
|
+
return (deps.execFileSync || execFileSync)('git', args, {
|
|
15
|
+
cwd: projectRoot,
|
|
16
|
+
encoding: 'utf8',
|
|
17
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
18
|
+
}).trim();
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
21
|
+
function resolveProofPath(projectRoot, nonce, deps = {}) {
|
|
22
|
+
if (!NONCE_PATTERN.test(nonce || '')) throw new Error('invalid push proof nonce');
|
|
23
|
+
if (deps.resolvePushProofPath) return path.resolve(deps.resolvePushProofPath(projectRoot, nonce));
|
|
24
|
+
const root = path.resolve(git(projectRoot, ['rev-parse', '--show-toplevel'], deps));
|
|
25
|
+
return path.resolve(root, git(root, ['rev-parse', '--git-path', `forge/push-tokens/${nonce}.json`], deps));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writeAtomic(filePath, content) {
|
|
29
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
30
|
+
const temporary = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
31
|
+
try {
|
|
32
|
+
fs.writeFileSync(temporary, content, { flag: 'wx', mode: 0o600 });
|
|
33
|
+
fs.renameSync(temporary, filePath);
|
|
34
|
+
} finally {
|
|
35
|
+
fs.rmSync(temporary, { force: true });
|
|
36
|
+
}
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
/**
|
|
38
|
-
*
|
|
39
|
-
* Does NOT delete the token — use `consume()` for one-time validation.
|
|
40
|
+
* Write signed authority for one active `forge push` invocation.
|
|
40
41
|
*
|
|
41
|
-
* @param {string} projectRoot
|
|
42
|
-
* @
|
|
42
|
+
* @param {string} projectRoot
|
|
43
|
+
* @param {object} options
|
|
44
|
+
* @returns {{nonce: string}}
|
|
43
45
|
*/
|
|
44
|
-
function
|
|
46
|
+
function write(projectRoot, options = {}) {
|
|
47
|
+
const nonce = (options.randomUUID || crypto.randomUUID)();
|
|
48
|
+
const ownerPid = options.processPid || process.pid;
|
|
49
|
+
const getProcessIdentity = options.getProcessIdentity || defaultGetProcessIdentity;
|
|
50
|
+
const ownerIdentity = getProcessIdentity(ownerPid);
|
|
51
|
+
if (!ownerIdentity) throw new Error('push proof owner identity unavailable');
|
|
52
|
+
const proof = _pushProof.create(projectRoot, options.snapshot, {
|
|
53
|
+
nonce,
|
|
54
|
+
mode: options.mode,
|
|
55
|
+
gates: options.gates,
|
|
56
|
+
owner: { pid: ownerPid, identity: ownerIdentity },
|
|
57
|
+
receiptIdentity: options.receiptIdentity,
|
|
58
|
+
}, options);
|
|
59
|
+
writeAtomic(resolveProofPath(projectRoot, nonce, options), JSON.stringify(proof));
|
|
60
|
+
return { nonce };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function validate(projectRoot, options = {}) {
|
|
45
64
|
try {
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
if (!
|
|
49
|
-
|
|
65
|
+
const env = options.env || process.env;
|
|
66
|
+
const nonce = env[NONCE_ENV_VAR];
|
|
67
|
+
if (!NONCE_PATTERN.test(nonce || '')) return { valid: false };
|
|
68
|
+
const proof = JSON.parse(fs.readFileSync(resolveProofPath(projectRoot, nonce, options), 'utf8'));
|
|
69
|
+
const verified = _pushProof.verify(projectRoot, proof, { nonce, env }, options);
|
|
70
|
+
if (!verified.valid) return verified;
|
|
71
|
+
const getProcessIdentity = options.getProcessIdentity || defaultGetProcessIdentity;
|
|
72
|
+
const owner = verified.payload.owner;
|
|
73
|
+
if (!owner || getProcessIdentity(owner.pid) !== owner.identity) {
|
|
74
|
+
return { valid: false, reason: 'owner' };
|
|
50
75
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
return false;
|
|
76
|
+
return verified;
|
|
77
|
+
} catch {
|
|
78
|
+
return { valid: false };
|
|
55
79
|
}
|
|
56
80
|
}
|
|
57
81
|
|
|
58
82
|
/**
|
|
59
|
-
*
|
|
60
|
-
* Always attempts to delete the file, even if the token is stale or invalid.
|
|
61
|
-
*
|
|
62
|
-
* @param {string} projectRoot - Absolute path to project root
|
|
63
|
-
* @returns {boolean} True if the token was valid before deletion
|
|
83
|
+
* Check signed authority without consuming it so all three hook predicates can read it.
|
|
64
84
|
*/
|
|
65
|
-
function
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const raw = fs.readFileSync(tp, 'utf-8');
|
|
69
|
-
// Always delete after reading — whether valid or not
|
|
70
|
-
try { fs.unlinkSync(tp); } catch (_e) { /* already gone */ }
|
|
85
|
+
function isValid(projectRoot, options = {}) {
|
|
86
|
+
return validate(projectRoot, options).valid === true;
|
|
87
|
+
}
|
|
71
88
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Revoke only the proof named by this invocation's nonce.
|
|
91
|
+
*/
|
|
92
|
+
function consume(projectRoot, options = {}) {
|
|
93
|
+
try {
|
|
94
|
+
const nonce = options.nonce || (options.env || process.env)[NONCE_ENV_VAR];
|
|
95
|
+
fs.unlinkSync(resolveProofPath(projectRoot, nonce, options));
|
|
96
|
+
return true;
|
|
97
|
+
} catch {
|
|
81
98
|
return false;
|
|
82
99
|
}
|
|
83
100
|
}
|
|
84
101
|
|
|
85
|
-
module.exports = {
|
|
102
|
+
module.exports = {
|
|
103
|
+
write,
|
|
104
|
+
isValid,
|
|
105
|
+
consume,
|
|
106
|
+
_internal: { NONCE_ENV_VAR, resolveProofPath, validate },
|
|
107
|
+
};
|
|
86
108
|
|
|
87
|
-
// When run directly as a script (e.g., from lefthook skip check):
|
|
88
|
-
// Exit 0 = token valid (skip hooks), Exit 1 = run hooks normally.
|
|
89
|
-
// Uses isValid() (non-destructive) so multiple lefthook commands can
|
|
90
|
-
// each check the same token. The token expires after 30s automatically.
|
|
91
109
|
if (require.main === module) {
|
|
92
|
-
const
|
|
93
|
-
if (
|
|
94
|
-
console.log(
|
|
110
|
+
const result = validate(process.cwd());
|
|
111
|
+
if (result.valid) {
|
|
112
|
+
console.log(`forge push ${result.payload.mode} authorization valid — skipping pre-push hooks`);
|
|
95
113
|
process.exit(0);
|
|
96
114
|
}
|
|
97
115
|
process.exit(1);
|
|
@@ -25,6 +25,8 @@ const { ASSET_ROOTS } = pkgRoot;
|
|
|
25
25
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
26
26
|
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
27
27
|
const OUT_FILE = path.join(REPO_ROOT, 'lib', 'embedded-assets.generated.mjs');
|
|
28
|
+
const STAGING_DIR_NAME = 'embedded-assets.generated-assets';
|
|
29
|
+
const STAGING_DIR = path.join(REPO_ROOT, 'lib', STAGING_DIR_NAME);
|
|
28
30
|
|
|
29
31
|
// Deterministic, locale-INDEPENDENT codepoint comparator. Locale-default sort would
|
|
30
32
|
// diverge by machine locale and produce different embed bytes across the step-3
|
|
@@ -83,14 +85,37 @@ export function assetFingerprint(repoRoot = REPO_ROOT, files = collectAssetFiles
|
|
|
83
85
|
return h.digest('hex');
|
|
84
86
|
}
|
|
85
87
|
|
|
86
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Copy source assets to deterministic non-code paths before Bun imports them as
|
|
90
|
+
* raw files. This keeps a JavaScript source path from also becoming a raw asset
|
|
91
|
+
* module in the same compiled graph.
|
|
92
|
+
*/
|
|
93
|
+
export function stageAssetFiles(repoRoot, files, stagingDir = path.join(repoRoot, 'lib', STAGING_DIR_NAME)) {
|
|
94
|
+
const expectedDir = path.resolve(repoRoot, 'lib', STAGING_DIR_NAME);
|
|
95
|
+
const resolvedDir = path.resolve(stagingDir);
|
|
96
|
+
if (resolvedDir !== expectedDir) {
|
|
97
|
+
throw new Error(`Refusing to replace unexpected embedded-asset staging path: ${resolvedDir}`);
|
|
98
|
+
}
|
|
99
|
+
fs.rmSync(resolvedDir, { recursive: true, force: true });
|
|
100
|
+
fs.mkdirSync(resolvedDir, { recursive: true });
|
|
101
|
+
return files.map((rel, index) => {
|
|
102
|
+
const filename = `a${index}.asset`;
|
|
103
|
+
fs.copyFileSync(
|
|
104
|
+
path.join(repoRoot, ...rel.split('/')),
|
|
105
|
+
path.join(resolvedDir, filename),
|
|
106
|
+
);
|
|
107
|
+
return { rel, filename };
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function renderManifest(stagedFiles, fingerprint) {
|
|
87
112
|
const imports = [];
|
|
88
113
|
const entries = [];
|
|
89
114
|
const execEntries = [];
|
|
90
|
-
|
|
115
|
+
stagedFiles.forEach(({ rel, filename }, i) => {
|
|
91
116
|
const ident = `a${i}`;
|
|
92
|
-
// Specifier is relative to
|
|
93
|
-
const specifier = JSON.stringify(
|
|
117
|
+
// Specifier is relative to the generated manifest in lib/.
|
|
118
|
+
const specifier = JSON.stringify(`./${STAGING_DIR_NAME}/${filename}`);
|
|
94
119
|
imports.push(`import ${ident} from ${specifier} with { type: "file" };`);
|
|
95
120
|
entries.push(` ${JSON.stringify(rel)}: ${ident},`);
|
|
96
121
|
if (isExecutableAsset(rel)) execEntries.push(` ${JSON.stringify(rel)},`);
|
|
@@ -116,8 +141,9 @@ function render(files, fingerprint) {
|
|
|
116
141
|
function main() {
|
|
117
142
|
const files = collectAssetFiles();
|
|
118
143
|
const fingerprint = assetFingerprint(REPO_ROOT, files);
|
|
144
|
+
const stagedFiles = stageAssetFiles(REPO_ROOT, files, STAGING_DIR);
|
|
119
145
|
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
|
|
120
|
-
fs.writeFileSync(OUT_FILE,
|
|
146
|
+
fs.writeFileSync(OUT_FILE, renderManifest(stagedFiles, fingerprint), 'utf8');
|
|
121
147
|
const execCount = files.filter(isExecutableAsset).length;
|
|
122
148
|
console.log(
|
|
123
149
|
`Embedded ${files.length} asset file(s) (${execCount} executable), fingerprint ${fingerprint.slice(0, 16)} → ${path.relative(REPO_ROOT, OUT_FILE)}`
|
|
@@ -64,13 +64,24 @@ function parseArgs(argv) {
|
|
|
64
64
|
const next = argv[index + 1];
|
|
65
65
|
|
|
66
66
|
if (current === '--label-prefix') args.labelPrefix = next;
|
|
67
|
-
if (current === '--shards') args.shards =
|
|
67
|
+
if (current === '--shards') args.shards = parseResourceBudget(next);
|
|
68
68
|
if (current === '--timeout') args.timeoutMs = parseTimeoutMs(next);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
return args;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
function parseResourceBudget(value) {
|
|
75
|
+
if (!/^[1-9]\d*$/.test(String(value ?? ''))) {
|
|
76
|
+
throw new Error('--shards must be a positive integer resource budget');
|
|
77
|
+
}
|
|
78
|
+
const budget = Number(value);
|
|
79
|
+
if (!Number.isSafeInteger(budget)) {
|
|
80
|
+
throw new Error('--shards must be a positive integer resource budget');
|
|
81
|
+
}
|
|
82
|
+
return budget;
|
|
83
|
+
}
|
|
84
|
+
|
|
74
85
|
function parseTimeoutMs(value) {
|
|
75
86
|
const timeoutMs = Number(value);
|
|
76
87
|
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
|
|
@@ -479,12 +490,21 @@ function buildResourceLanePlan(allTests, shardTotal, durationMap = new Map(), op
|
|
|
479
490
|
// A subprocess-lane worker owns two OS processes on Windows: the shard's own bun
|
|
480
491
|
// runtime plus the bun.exe grandchild its tests spawn. Counting such a worker as
|
|
481
492
|
// one budget unit oversubscribes small runners (3 workers is ~6 processes on 4
|
|
482
|
-
// vCPU), so weight the grant by
|
|
493
|
+
// vCPU), so weight the grant by the declared worker cost instead of worker count.
|
|
483
494
|
function laneWorkerCost(laneName, platform = process.platform) {
|
|
484
495
|
if (platform !== 'win32') return 1;
|
|
485
496
|
return laneName === 'unit' ? 1 : 2;
|
|
486
497
|
}
|
|
487
498
|
|
|
499
|
+
function minimumResourceBudget(lanes, platform = process.platform) {
|
|
500
|
+
return lanes
|
|
501
|
+
.filter((lane) => lane.shards.length > 0)
|
|
502
|
+
.reduce(
|
|
503
|
+
(minimum, lane) => Math.max(minimum, laneWorkerCost(lane.name, platform)),
|
|
504
|
+
0,
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
488
508
|
function computeLaneGrants(lanes, options = {}) {
|
|
489
509
|
const platform = options.platform || process.platform;
|
|
490
510
|
const workerBudget = Number.isInteger(options.workerBudget) && options.workerBudget > 0
|
|
@@ -492,6 +512,7 @@ function computeLaneGrants(lanes, options = {}) {
|
|
|
492
512
|
: null;
|
|
493
513
|
const sharedLanes = lanes.filter((lane) => lane.name !== 'exclusive');
|
|
494
514
|
const overlapsSubprocess = sharedLanes.some((lane) => lane.name === 'subprocess');
|
|
515
|
+
const deferWindowsUnitLane = platform === 'win32' && overlapsSubprocess;
|
|
495
516
|
const grants = new Map();
|
|
496
517
|
for (const lane of lanes) {
|
|
497
518
|
grants.set(lane, {
|
|
@@ -503,13 +524,29 @@ function computeLaneGrants(lanes, options = {}) {
|
|
|
503
524
|
}
|
|
504
525
|
if (workerBudget === null) {
|
|
505
526
|
for (const lane of sharedLanes) {
|
|
506
|
-
|
|
507
|
-
|
|
527
|
+
const entry = grants.get(lane);
|
|
528
|
+
if (deferWindowsUnitLane && lane.name === 'unit') {
|
|
529
|
+
entry.granted = 0;
|
|
530
|
+
entry.deferred = true;
|
|
531
|
+
entry.deferredConcurrency = lane.concurrency;
|
|
532
|
+
} else {
|
|
533
|
+
entry.granted = overlapsSubprocess && lane.name === 'unit' ? 1 : lane.concurrency;
|
|
534
|
+
}
|
|
508
535
|
}
|
|
509
536
|
return grants;
|
|
510
537
|
}
|
|
511
|
-
|
|
512
|
-
|
|
538
|
+
const minimumBudget = minimumResourceBudget(lanes, platform);
|
|
539
|
+
if (workerBudget < minimumBudget) {
|
|
540
|
+
throw new Error(
|
|
541
|
+
`Full suite resource budget: requested=${workerBudget} minimum=${minimumBudget} outcome=rejected`,
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
for (const lane of lanes.filter((candidate) => candidate.name === 'exclusive')) {
|
|
545
|
+
const entry = grants.get(lane);
|
|
546
|
+
entry.granted = Math.min(lane.concurrency, Math.floor(workerBudget / entry.cost));
|
|
547
|
+
}
|
|
548
|
+
// An explicit shard count is an operator-imposed weighted worker budget;
|
|
549
|
+
// reserve it for heavier subprocess workers first and
|
|
513
550
|
// defer leftover lanes until capacity frees instead of exceeding it.
|
|
514
551
|
const ordered = [...sharedLanes].sort(
|
|
515
552
|
(left, right) => (left.name === 'subprocess' ? 0 : 1) - (right.name === 'subprocess' ? 0 : 1),
|
|
@@ -517,7 +554,9 @@ function computeLaneGrants(lanes, options = {}) {
|
|
|
517
554
|
let reservedCost = 0;
|
|
518
555
|
for (const [position, lane] of ordered.entries()) {
|
|
519
556
|
const entry = grants.get(lane);
|
|
520
|
-
const want = overlapsSubprocess && lane.name === 'unit'
|
|
557
|
+
const want = overlapsSubprocess && lane.name === 'unit'
|
|
558
|
+
? (deferWindowsUnitLane ? 0 : 1)
|
|
559
|
+
: lane.concurrency;
|
|
521
560
|
const affordable = Math.floor(Math.max(0, workerBudget - reservedCost) / entry.cost);
|
|
522
561
|
// The strongest lane always keeps one worker so the suite can never stall at zero.
|
|
523
562
|
const granted = position === 0
|
|
@@ -587,7 +626,7 @@ async function runLaneSchedule(lanes, execute, cancel = () => {}, options = {})
|
|
|
587
626
|
if (deferredFailure) throw deferredFailure.reason;
|
|
588
627
|
|
|
589
628
|
for (const lane of lanes.filter((candidate) => candidate.name === 'exclusive')) {
|
|
590
|
-
resultsByLane.set(lane, await runLane(lane));
|
|
629
|
+
resultsByLane.set(lane, await runLane(lane, grants.get(lane).granted));
|
|
591
630
|
}
|
|
592
631
|
return lanes.flatMap((lane) => {
|
|
593
632
|
const laneResults = resultsByLane.get(lane);
|
|
@@ -920,10 +959,11 @@ async function runFullSuiteInParallel(args = {}, deps = {}) {
|
|
|
920
959
|
|
|
921
960
|
try {
|
|
922
961
|
const allTests = deps.allTests || listAllFullSuiteTests();
|
|
923
|
-
const
|
|
924
|
-
?
|
|
925
|
-
:
|
|
926
|
-
const
|
|
962
|
+
const requestedResourceBudget = args.shards === null || args.shards === undefined
|
|
963
|
+
? null
|
|
964
|
+
: parseResourceBudget(args.shards);
|
|
965
|
+
const shardTotal = requestedResourceBudget ?? getDefaultShardCount(deps.cpuCount);
|
|
966
|
+
const subprocessShardTotal = requestedResourceBudget !== null
|
|
927
967
|
? shardTotal
|
|
928
968
|
: Math.max(6, shardTotal);
|
|
929
969
|
const profile = deps.profile || readNewestProfile(reportDir);
|
|
@@ -940,6 +980,21 @@ async function runFullSuiteInParallel(args = {}, deps = {}) {
|
|
|
940
980
|
subprocessShardTotal,
|
|
941
981
|
});
|
|
942
982
|
const shardSpecs = lanePlan.flatMap((lane) => lane.shards);
|
|
983
|
+
const minimumBudget = minimumResourceBudget(lanePlan, platform);
|
|
984
|
+
const effectiveResourceBudget = requestedResourceBudget === null
|
|
985
|
+
? Math.max(shardTotal, minimumBudget)
|
|
986
|
+
: shardTotal;
|
|
987
|
+
if (requestedResourceBudget !== null && requestedResourceBudget < minimumBudget) {
|
|
988
|
+
console.log(
|
|
989
|
+
`Full suite resource budget: requested=${requestedResourceBudget} minimum=${minimumBudget} outcome=rejected`,
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
const laneGrants = computeLaneGrants(lanePlan, {
|
|
993
|
+
platform,
|
|
994
|
+
workerBudget: effectiveResourceBudget,
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
console.log(`Full suite resource budget: requested=${requestedResourceBudget ?? 'default'} effective=${effectiveResourceBudget}`);
|
|
943
998
|
|
|
944
999
|
if (shardSpecs.length === 0) {
|
|
945
1000
|
const exitCode = signal ? signalExitCode(signal) : 1;
|
|
@@ -953,12 +1008,11 @@ async function runFullSuiteInParallel(args = {}, deps = {}) {
|
|
|
953
1008
|
const runReportDir = fs.mkdtempSync(path.join(reportDir, 'full-suite-'));
|
|
954
1009
|
|
|
955
1010
|
console.log(`Running local full suite in ${shardSpecs.length} shard(s)`);
|
|
956
|
-
const laneGrants = computeLaneGrants(lanePlan, { platform, workerBudget: shardTotal });
|
|
957
1011
|
for (const lane of lanePlan) {
|
|
958
1012
|
const grant = laneGrants.get(lane);
|
|
959
1013
|
const granted = grant.deferred ? grant.deferredConcurrency : grant.granted;
|
|
960
1014
|
const files = lane.shards.reduce((total, shard) => total + shard.files.length, 0);
|
|
961
|
-
console.log(`Resource lane ${lane.name}: files=${files} shards=${lane.shards.length} concurrency=${granted} (nominal=${lane.concurrency} cost=${grant.cost} budget=${
|
|
1015
|
+
console.log(`Resource lane ${lane.name}: files=${files} shards=${lane.shards.length} concurrency=${granted} (nominal=${lane.concurrency} cost=${grant.cost} budget=${effectiveResourceBudget}${grant.deferred ? ' deferred' : ''})`);
|
|
962
1016
|
}
|
|
963
1017
|
const childEnv = stripFullSuiteChildEnv(
|
|
964
1018
|
typeof processTree.envFor === 'function' ? processTree.envFor(env) : env,
|
|
@@ -992,7 +1046,7 @@ async function runFullSuiteInParallel(args = {}, deps = {}) {
|
|
|
992
1046
|
}, {
|
|
993
1047
|
grants: laneGrants,
|
|
994
1048
|
platform,
|
|
995
|
-
workerBudget:
|
|
1049
|
+
workerBudget: effectiveResourceBudget,
|
|
996
1050
|
});
|
|
997
1051
|
} catch (error) {
|
|
998
1052
|
console.error('Full suite shard execution failed:', error);
|
package/scripts/test.js
CHANGED
|
@@ -62,9 +62,17 @@ const KNOWN_TARGETABLE_FILES = new Set([
|
|
|
62
62
|
'lib/kernel/schema.js',
|
|
63
63
|
'lib/kernel/migrations.js',
|
|
64
64
|
'lib/kernel/sqlite-driver.js',
|
|
65
|
+
'lib/test-workflow.js',
|
|
66
|
+
'lib/workflow-templates/test.yml',
|
|
65
67
|
'lib/commands/shepherd.js',
|
|
66
68
|
'lib/commands/github.js',
|
|
69
|
+
'bin/forge-gh-proxy.js',
|
|
70
|
+
'bin/forge-github-credential.js',
|
|
67
71
|
'lib/github-context.js',
|
|
72
|
+
'lib/github-credential.js',
|
|
73
|
+
'lib/github-router.js',
|
|
74
|
+
'lib/gh-proxy.js',
|
|
75
|
+
'lib/native-gh.js',
|
|
68
76
|
'lib/commands/_registry.js',
|
|
69
77
|
'lib/commands/pr.js',
|
|
70
78
|
'lib/commands/ship.js',
|
|
@@ -649,12 +657,14 @@ module.exports = {
|
|
|
649
657
|
OBSERVED_FULL_SUITE_RUNTIME_MS,
|
|
650
658
|
QUICK_LANE_ENV_VAR,
|
|
651
659
|
QUICK_LANE_VALUE,
|
|
660
|
+
TIMEOUT_EXIT_CODE,
|
|
652
661
|
buildTestExecutionPlan,
|
|
653
662
|
classifyPushTests,
|
|
654
663
|
detectPackageManager,
|
|
655
664
|
isQuickPushLane,
|
|
656
665
|
resolveCommandTimeoutMs,
|
|
657
666
|
resolveFullSuiteTimeoutMs,
|
|
667
|
+
runCommand,
|
|
658
668
|
runLocalValidationTests,
|
|
659
669
|
runPrePushTests,
|
|
660
670
|
runTestExecutionPlan,
|