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
|
@@ -11,6 +11,7 @@ const { hashProtectedContent, normalizeRepoPath } = require('./protected-state-s
|
|
|
11
11
|
const {
|
|
12
12
|
BUN_WORKFLOW_SPECS,
|
|
13
13
|
deriveBunWorkflowUpdate,
|
|
14
|
+
readCompleteBunPinBatch,
|
|
14
15
|
readIndexedPackageManifest,
|
|
15
16
|
readIndexedWorkflow,
|
|
16
17
|
readPinnedBunVersion,
|
|
@@ -28,6 +29,7 @@ const PROTECTED_STATE_AUTHORIZATION_CONSUMED = 'protected_state.authorization.co
|
|
|
28
29
|
const PROTECTED_STATE_AUTHORIZATION_VERSION = 1;
|
|
29
30
|
const PROTECTED_STATE_AUTHORIZATION_ORIGIN = 'cli';
|
|
30
31
|
const NPM_WORKFLOW_SOURCE_COMMAND = 'forge release generate-npm-workflow';
|
|
32
|
+
const TEST_WORKFLOW_SOURCE_COMMAND = 'forge release generate-test-workflow';
|
|
31
33
|
const BUN_WORKFLOW_SOURCE_COMMAND = 'forge release update-bun-pins';
|
|
32
34
|
const BUN_WORKFLOW_PATHS = new Set(BUN_WORKFLOW_SPECS.map(spec => spec.path));
|
|
33
35
|
const SKILL_MIRROR_SOURCE_COMMAND = 'scripts/sync-agent-skills.js';
|
|
@@ -52,6 +54,13 @@ function owningWriter(pathname, surface) {
|
|
|
52
54
|
completionOperation: 'generate_npm_workflow_completed',
|
|
53
55
|
};
|
|
54
56
|
}
|
|
57
|
+
if (surface === 'workflows' && pathname === '.github/workflows/test.yml') {
|
|
58
|
+
return {
|
|
59
|
+
sourceCommand: TEST_WORKFLOW_SOURCE_COMMAND,
|
|
60
|
+
issueOperation: 'generate_test_workflow',
|
|
61
|
+
completionOperation: 'generate_test_workflow_completed',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
55
64
|
if (surface === 'generated_harness' && /^\.agents\/skills\/[^/]+\/.+/.test(pathname)) {
|
|
56
65
|
return {
|
|
57
66
|
sourceCommand: SKILL_MIRROR_SOURCE_COMMAND,
|
|
@@ -165,7 +174,8 @@ function evaluateAuthorization(request, rows = []) {
|
|
|
165
174
|
const latest = active[0];
|
|
166
175
|
const writer = owningWriter(latest.path, latest.surface);
|
|
167
176
|
|
|
168
|
-
const bunPinWriter = [BUN_WORKFLOW_SOURCE_COMMAND, NPM_WORKFLOW_SOURCE_COMMAND]
|
|
177
|
+
const bunPinWriter = [BUN_WORKFLOW_SOURCE_COMMAND, NPM_WORKFLOW_SOURCE_COMMAND, TEST_WORKFLOW_SOURCE_COMMAND]
|
|
178
|
+
.includes(writer?.sourceCommand);
|
|
169
179
|
const structurallyValid =
|
|
170
180
|
writer !== null &&
|
|
171
181
|
latest.version === PROTECTED_STATE_AUTHORIZATION_VERSION &&
|
|
@@ -444,6 +454,57 @@ async function issueBunWorkflowAuthorization(projectRoot, params = {}, options =
|
|
|
444
454
|
}, options);
|
|
445
455
|
}
|
|
446
456
|
|
|
457
|
+
function resolveCurrentTestWorkflowTarget(projectRoot, params, requireWritten = false) {
|
|
458
|
+
let update;
|
|
459
|
+
try {
|
|
460
|
+
update = require('./test-workflow').resolveTestWorkflowUpdate(projectRoot, params.sourceHead);
|
|
461
|
+
if (update.version !== params.targetBunVersion) {
|
|
462
|
+
return { success: false, error: 'Protected test workflow target does not match the staged package.json Bun pin.' };
|
|
463
|
+
}
|
|
464
|
+
if (requireWritten) {
|
|
465
|
+
const fullPath = path.join(projectRoot, update.path);
|
|
466
|
+
const stat = fs.lstatSync(fullPath);
|
|
467
|
+
if (!stat.isFile() || stat.isSymbolicLink() || !fs.readFileSync(fullPath).equals(update.content)) {
|
|
468
|
+
return { success: false, error: 'Protected test workflow completion requires the exact generated workflow bytes.' };
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
} catch (error) {
|
|
472
|
+
return { success: false, error: error.message };
|
|
473
|
+
}
|
|
474
|
+
return { success: true, update };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Authorizes the canonical test workflow derived from the current staged package and template.
|
|
479
|
+
* The target and exact generated bytes are resolved again before the capability is inserted.
|
|
480
|
+
*/
|
|
481
|
+
async function issueTestWorkflowAuthorization(projectRoot, params = {}, options = {}) {
|
|
482
|
+
if (!isValidGitObjectId(params.sourceHead)) {
|
|
483
|
+
return { success: false, error: 'Protected test workflow authorization requires a full 40- or 64-character lowercase source HEAD.' };
|
|
484
|
+
}
|
|
485
|
+
const target = resolveCurrentTestWorkflowTarget(projectRoot, params);
|
|
486
|
+
if (!target.success) return target;
|
|
487
|
+
const expectedHash = hashProtectedContent(target.update.content);
|
|
488
|
+
return issueProtectedStateAuthorization(projectRoot, {
|
|
489
|
+
actor: params.actor,
|
|
490
|
+
surface: 'workflows',
|
|
491
|
+
path: target.update.path,
|
|
492
|
+
content: target.update.content,
|
|
493
|
+
operation: 'generate_test_workflow',
|
|
494
|
+
viaForgeApi: true,
|
|
495
|
+
sourceCommand: TEST_WORKFLOW_SOURCE_COMMAND,
|
|
496
|
+
sourceHead: params.sourceHead,
|
|
497
|
+
targetBunVersion: params.targetBunVersion,
|
|
498
|
+
verifyBeforeInsert: () => {
|
|
499
|
+
const fresh = resolveCurrentTestWorkflowTarget(projectRoot, params);
|
|
500
|
+
if (!fresh.success) return fresh;
|
|
501
|
+
return hashProtectedContent(fresh.update.content) === expectedHash
|
|
502
|
+
? { success: true }
|
|
503
|
+
: { success: false, error: 'Protected test workflow target changed during authorization.' };
|
|
504
|
+
},
|
|
505
|
+
}, options);
|
|
506
|
+
}
|
|
507
|
+
|
|
447
508
|
function findExactAuthorization(rows, params, expected) {
|
|
448
509
|
const issued = (rows || [])
|
|
449
510
|
.filter(row => row?.event_type === PROTECTED_STATE_AUTHORIZATION_ISSUED)
|
|
@@ -645,6 +706,74 @@ async function completeBunWorkflowAuthorization(projectRoot, params = {}, option
|
|
|
645
706
|
}
|
|
646
707
|
}
|
|
647
708
|
|
|
709
|
+
/**
|
|
710
|
+
* Completes only an active capability that matches the canonical target and exact written bytes.
|
|
711
|
+
* The staged target and generated file are rechecked before the completion event is inserted.
|
|
712
|
+
*/
|
|
713
|
+
async function completeTestWorkflowAuthorization(projectRoot, params = {}, options = {}) {
|
|
714
|
+
if (!isValidGitObjectId(params.sourceHead) || !params.capabilityId) {
|
|
715
|
+
return { success: false, error: 'Protected test workflow completion requires a capability id and full 40- or 64-character source HEAD.' };
|
|
716
|
+
}
|
|
717
|
+
const target = resolveCurrentTestWorkflowTarget(projectRoot, params, true);
|
|
718
|
+
if (!target.success) return target;
|
|
719
|
+
const actor = params.actor;
|
|
720
|
+
const normalizedPath = normalizeRepoPath(target.update.path);
|
|
721
|
+
const contentHash = hashProtectedContent(target.update.content);
|
|
722
|
+
const worktreeScope = options.worktreeScope || resolveWorktreeScope(projectRoot, options.deps);
|
|
723
|
+
const entityId = authorizationEntityId(worktreeScope, normalizedPath);
|
|
724
|
+
const kernel = await resolveOwnedKernel(projectRoot, options.deps);
|
|
725
|
+
try {
|
|
726
|
+
const rows = await kernel.driver.listKernelEvents(PROTECTED_STATE_ENTITY_TYPE, entityId, {}, kernel.config);
|
|
727
|
+
const { exactIssued, consumed } = findExactAuthorization(rows, params, {
|
|
728
|
+
actor,
|
|
729
|
+
entityId,
|
|
730
|
+
path: normalizedPath,
|
|
731
|
+
surface: 'workflows',
|
|
732
|
+
contentHash,
|
|
733
|
+
worktreeScope,
|
|
734
|
+
operation: 'generate_test_workflow',
|
|
735
|
+
writeIntent: 'update',
|
|
736
|
+
sourceCommand: TEST_WORKFLOW_SOURCE_COMMAND,
|
|
737
|
+
targetBunVersion: params.targetBunVersion,
|
|
738
|
+
});
|
|
739
|
+
if (!exactIssued || consumed) {
|
|
740
|
+
return { success: false, error: 'Protected test workflow completion does not match one active exact authorization.' };
|
|
741
|
+
}
|
|
742
|
+
const fresh = resolveCurrentTestWorkflowTarget(projectRoot, params, true);
|
|
743
|
+
if (!fresh.success || hashProtectedContent(fresh.update.content) !== contentHash) {
|
|
744
|
+
return fresh.success ? { success: false, error: 'Protected test workflow target changed during completion.' } : fresh;
|
|
745
|
+
}
|
|
746
|
+
const inserted = await kernel.driver.insertKernelEvent({
|
|
747
|
+
entity_type: PROTECTED_STATE_ENTITY_TYPE,
|
|
748
|
+
entity_id: entityId,
|
|
749
|
+
event_type: PROTECTED_STATE_WRITE_COMPLETED,
|
|
750
|
+
idempotency_key: `${PROTECTED_STATE_WRITE_COMPLETED}:${params.capabilityId}`,
|
|
751
|
+
expected_revision: 0,
|
|
752
|
+
actor,
|
|
753
|
+
origin: PROTECTED_STATE_AUTHORIZATION_ORIGIN,
|
|
754
|
+
payload: {
|
|
755
|
+
version: PROTECTED_STATE_AUTHORIZATION_VERSION,
|
|
756
|
+
capabilityId: params.capabilityId,
|
|
757
|
+
actor,
|
|
758
|
+
path: normalizedPath,
|
|
759
|
+
surface: 'workflows',
|
|
760
|
+
contentHash,
|
|
761
|
+
sourceHead: params.sourceHead,
|
|
762
|
+
worktreeScope,
|
|
763
|
+
writeIntent: 'update',
|
|
764
|
+
operation: 'generate_test_workflow_completed',
|
|
765
|
+
viaForgeApi: true,
|
|
766
|
+
sourceCommand: TEST_WORKFLOW_SOURCE_COMMAND,
|
|
767
|
+
targetBunVersion: params.targetBunVersion,
|
|
768
|
+
},
|
|
769
|
+
created_at: options.now || new Date().toISOString(),
|
|
770
|
+
}, {}, kernel.config);
|
|
771
|
+
return { success: true, capabilityId: params.capabilityId, event: parseAuthorizationEvent(inserted) };
|
|
772
|
+
} finally {
|
|
773
|
+
closeIfOwned(kernel);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
648
777
|
async function cancelBunWorkflowBatchAuthorizations(projectRoot, requests = [], options = {}) {
|
|
649
778
|
if (requests.length === 0) return { success: true, results: [] };
|
|
650
779
|
const worktreeScope = options.worktreeScope || resolveWorktreeScope(projectRoot, options.deps);
|
|
@@ -655,7 +784,7 @@ async function cancelBunWorkflowBatchAuthorizations(projectRoot, requests = [],
|
|
|
655
784
|
const normalizedPath = normalizeRepoPath(request.path);
|
|
656
785
|
const writer = owningWriter(normalizedPath, 'workflows');
|
|
657
786
|
if (!request.actor || !isValidGitObjectId(request.sourceHead) || !request.capabilityId ||
|
|
658
|
-
!writer || ![BUN_WORKFLOW_SOURCE_COMMAND, NPM_WORKFLOW_SOURCE_COMMAND].includes(writer.sourceCommand)) {
|
|
787
|
+
!writer || ![BUN_WORKFLOW_SOURCE_COMMAND, NPM_WORKFLOW_SOURCE_COMMAND, TEST_WORKFLOW_SOURCE_COMMAND].includes(writer.sourceCommand)) {
|
|
659
788
|
results.push({ success: false, path: normalizedPath, capabilityId: request.capabilityId, error: 'Invalid Bun workflow batch cancellation request.' });
|
|
660
789
|
continue;
|
|
661
790
|
}
|
|
@@ -707,7 +836,7 @@ async function cancelBunWorkflowBatchAuthorizations(projectRoot, requests = [],
|
|
|
707
836
|
writeIntent: 'update',
|
|
708
837
|
operation: 'batch_rollback',
|
|
709
838
|
viaForgeApi: true,
|
|
710
|
-
sourceCommand:
|
|
839
|
+
sourceCommand: writer.sourceCommand,
|
|
711
840
|
},
|
|
712
841
|
created_at: options.now || new Date().toISOString(),
|
|
713
842
|
}, {}, kernel.config);
|
|
@@ -954,41 +1083,111 @@ function validateBunDecisionTargets(projectRoot, decisions) {
|
|
|
954
1083
|
}
|
|
955
1084
|
}
|
|
956
1085
|
|
|
1086
|
+
/**
|
|
1087
|
+
* Validates the complete staged Bun workflow projection, including canonical test workflow output
|
|
1088
|
+
* when the pin is unchanged. Repositories with neither source nor staged template retain the legacy
|
|
1089
|
+
* path; bootstrap must add the template and output together, and established deletion is incomplete.
|
|
1090
|
+
*/
|
|
957
1091
|
function validateCompleteBunPinBatch(projectRoot, options = {}) {
|
|
958
1092
|
if (options.sourceHead === null) return { success: true };
|
|
959
1093
|
let sourceHead;
|
|
960
1094
|
let sourcePackage;
|
|
1095
|
+
let snapshot;
|
|
961
1096
|
try {
|
|
962
|
-
sourceHead = options.sourceHead || resolveCurrentHead(projectRoot);
|
|
963
|
-
sourcePackage = Buffer.from(
|
|
1097
|
+
sourceHead = options.sourceHead || resolveCurrentHead(projectRoot, options.execGit);
|
|
1098
|
+
sourcePackage = Buffer.from(
|
|
1099
|
+
(options.readSourcePackageManifest || readSourcePackageManifest)(projectRoot, sourceHead),
|
|
1100
|
+
);
|
|
1101
|
+
if (JSON.parse(sourcePackage.toString('utf8')).name !== 'forge-workflow') return { success: true };
|
|
964
1102
|
} catch (error) {
|
|
965
1103
|
if (error.message.includes('package.json is not a regular file at')) return { success: true };
|
|
966
1104
|
return { success: false, error: `Could not verify the complete staged Bun pin update: ${error.message}` };
|
|
967
1105
|
}
|
|
968
1106
|
try {
|
|
969
|
-
if (JSON.parse(sourcePackage.toString('utf8')).name !== 'forge-workflow') return { success: true };
|
|
970
1107
|
const sourceVersion = readPinnedBunVersion(sourcePackage);
|
|
971
|
-
const indexedPackage = Buffer.from(
|
|
1108
|
+
const indexedPackage = Buffer.from(
|
|
1109
|
+
(options.readIndexedPackageManifest || readIndexedPackageManifest)(projectRoot),
|
|
1110
|
+
);
|
|
972
1111
|
const targetVersion = readPinnedBunVersion(indexedPackage);
|
|
973
|
-
|
|
974
|
-
if (
|
|
1112
|
+
const testWorkflow = require('./test-workflow');
|
|
1113
|
+
if (targetVersion === sourceVersion) {
|
|
1114
|
+
const projection = (options.readTestWorkflowProjection || testWorkflow.readTestWorkflowProjection)(
|
|
1115
|
+
projectRoot,
|
|
1116
|
+
sourceHead,
|
|
1117
|
+
options.execGit,
|
|
1118
|
+
);
|
|
1119
|
+
if (!Buffer.from(projection.sourcePackage).equals(sourcePackage)
|
|
1120
|
+
|| !Buffer.from(projection.indexedPackage).equals(indexedPackage)) {
|
|
1121
|
+
throw new Error('package.json changed while reading the staged test workflow projection');
|
|
1122
|
+
}
|
|
1123
|
+
if (projection.sourceTemplate === null && projection.indexedTemplate === null) return { success: true };
|
|
1124
|
+
if (projection.indexedTemplate === null) {
|
|
1125
|
+
return { success: false, error: `${testWorkflow.TEST_WORKFLOW_TEMPLATE_PATH} is not one regular file in the Git index.` };
|
|
1126
|
+
}
|
|
1127
|
+
if (projection.indexedWorkflow === null) {
|
|
1128
|
+
return { success: false, error: `${testWorkflow.TEST_WORKFLOW_PATH} is not one regular file in the Git index.` };
|
|
1129
|
+
}
|
|
1130
|
+
const expectedTest = testWorkflow.renderTestWorkflow(projection.indexedTemplate, targetVersion);
|
|
1131
|
+
if (!Buffer.from(projection.indexedWorkflow).equals(expectedTest)) {
|
|
1132
|
+
return { success: false, error: `${testWorkflow.TEST_WORKFLOW_PATH} does not match the staged canonical template for Bun ${targetVersion}.` };
|
|
1133
|
+
}
|
|
1134
|
+
return { success: true };
|
|
1135
|
+
}
|
|
1136
|
+
const npmWorkflow = require('./npm-publish-workflow');
|
|
1137
|
+
const hasLegacyReaders = options.readSourcePackageManifest || options.readIndexedPackageManifest
|
|
1138
|
+
|| options.readSourceWorkflow || options.readIndexedWorkflow || options.readIndexedNpmPublishWorkflow;
|
|
1139
|
+
if (options.readCompleteBunPinBatch || !hasLegacyReaders) {
|
|
1140
|
+
snapshot = (options.readCompleteBunPinBatch || readCompleteBunPinBatch)(
|
|
1141
|
+
projectRoot,
|
|
1142
|
+
sourceHead,
|
|
1143
|
+
[npmWorkflow.NPM_PUBLISH_WORKFLOW_PATH, testWorkflow.TEST_WORKFLOW_PATH],
|
|
1144
|
+
options.execGit,
|
|
1145
|
+
);
|
|
1146
|
+
if (!Buffer.from(snapshot.source.get('package.json')).equals(sourcePackage)
|
|
1147
|
+
|| !Buffer.from(snapshot.indexed.get('package.json')).equals(indexedPackage)) {
|
|
1148
|
+
throw new Error('package.json changed while reading the complete Bun pin snapshot');
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
if (resolveBunVersionTarget(projectRoot, {
|
|
1152
|
+
...options,
|
|
1153
|
+
readIndexedPackageManifest: () => indexedPackage,
|
|
1154
|
+
}).version !== targetVersion) {
|
|
975
1155
|
return { success: false, error: 'The working package.json Bun pin differs from the staged pin.' };
|
|
976
1156
|
}
|
|
977
1157
|
|
|
978
1158
|
for (const spec of BUN_WORKFLOW_SPECS) {
|
|
979
|
-
const baseline =
|
|
1159
|
+
const baseline = snapshot
|
|
1160
|
+
? snapshot.source.get(spec.path)
|
|
1161
|
+
: (options.readSourceWorkflow || readSourceWorkflow)(projectRoot, sourceHead, spec.path);
|
|
980
1162
|
const expected = Buffer.from(renderBunWorkflowPin(spec.path, baseline, targetVersion));
|
|
981
|
-
const indexed = Buffer.from(
|
|
1163
|
+
const indexed = Buffer.from(snapshot
|
|
1164
|
+
? snapshot.indexed.get(spec.path)
|
|
1165
|
+
: (options.readIndexedWorkflow || readIndexedWorkflow)(projectRoot, spec.path));
|
|
982
1166
|
if (!indexed.equals(expected)) {
|
|
983
1167
|
return { success: false, error: `A staged Bun pin change requires the complete generated workflow set; ${spec.path} does not match Bun ${targetVersion}.` };
|
|
984
1168
|
}
|
|
985
1169
|
}
|
|
986
1170
|
|
|
987
|
-
const
|
|
988
|
-
|
|
1171
|
+
const indexedNpm = Buffer.from(snapshot
|
|
1172
|
+
? snapshot.indexed.get(npmWorkflow.NPM_PUBLISH_WORKFLOW_PATH)
|
|
1173
|
+
: (options.readIndexedNpmPublishWorkflow || npmWorkflow.readIndexedNpmPublishWorkflow)(projectRoot));
|
|
989
1174
|
if (!indexedNpm.equals(Buffer.from(npmWorkflow.renderNpmPublishWorkflow(targetVersion)))) {
|
|
990
1175
|
return { success: false, error: `A staged Bun pin change requires the complete generated workflow set; ${npmWorkflow.NPM_PUBLISH_WORKFLOW_PATH} does not match Bun ${targetVersion}.` };
|
|
991
1176
|
}
|
|
1177
|
+
const testUpdate = (options.resolveTestWorkflowUpdate || testWorkflow.resolveTestWorkflowUpdate)(
|
|
1178
|
+
projectRoot,
|
|
1179
|
+
sourceHead,
|
|
1180
|
+
options,
|
|
1181
|
+
);
|
|
1182
|
+
const indexedTest = Buffer.from(snapshot
|
|
1183
|
+
? snapshot.indexed.get(testWorkflow.TEST_WORKFLOW_PATH)
|
|
1184
|
+
: testUpdate.indexedWorkflow);
|
|
1185
|
+
if (!indexedTest.equals(testUpdate.indexedWorkflow)) {
|
|
1186
|
+
throw new Error(`${testWorkflow.TEST_WORKFLOW_PATH} changed while reading the complete Bun pin snapshot`);
|
|
1187
|
+
}
|
|
1188
|
+
if (!indexedTest.equals(testUpdate.content)) {
|
|
1189
|
+
return { success: false, error: `A staged Bun pin change requires the complete generated workflow set; ${testWorkflow.TEST_WORKFLOW_PATH} does not match Bun ${targetVersion}.` };
|
|
1190
|
+
}
|
|
992
1191
|
return { success: true, sourceVersion, targetVersion };
|
|
993
1192
|
} catch (error) {
|
|
994
1193
|
return { success: false, error: `Could not verify the complete staged Bun pin update: ${error.message}` };
|
|
@@ -1091,6 +1290,8 @@ module.exports = {
|
|
|
1091
1290
|
completeNpmPublishWorkflowAuthorization,
|
|
1092
1291
|
issueBunWorkflowAuthorization,
|
|
1093
1292
|
completeBunWorkflowAuthorization,
|
|
1293
|
+
issueTestWorkflowAuthorization,
|
|
1294
|
+
completeTestWorkflowAuthorization,
|
|
1094
1295
|
cancelBunWorkflowBatchAuthorizations,
|
|
1095
1296
|
issueGeneratedHarnessClaudeAuthorization,
|
|
1096
1297
|
completeGeneratedHarnessClaudeAuthorization,
|
package/lib/release-readiness.js
CHANGED
|
@@ -318,20 +318,19 @@ function collectAgentInstructionSurfaces(projectRoot) {
|
|
|
318
318
|
};
|
|
319
319
|
}
|
|
320
320
|
|
|
321
|
-
function getRootDocFiles(projectRoot) {
|
|
322
|
-
return new Set(
|
|
321
|
+
function getRootDocFiles(projectRoot, surfaces = collectAgentInstructionSurfaces(projectRoot)) {
|
|
322
|
+
return new Set(surfaces.rootDocFiles);
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
-
function getInstructionPrefixes(projectRoot) {
|
|
326
|
-
return
|
|
325
|
+
function getInstructionPrefixes(projectRoot, surfaces = collectAgentInstructionSurfaces(projectRoot)) {
|
|
326
|
+
return surfaces.instructionRoots.map(root => `${root}/`);
|
|
327
327
|
}
|
|
328
328
|
|
|
329
|
-
function getSkillPrefixes(projectRoot) {
|
|
330
|
-
return
|
|
329
|
+
function getSkillPrefixes(projectRoot, surfaces = collectAgentInstructionSurfaces(projectRoot)) {
|
|
330
|
+
return surfaces.skillRoots.map(root => `${root}/`);
|
|
331
331
|
}
|
|
332
332
|
|
|
333
|
-
function getDefaultScanRoots(projectRoot) {
|
|
334
|
-
const surfaces = collectAgentInstructionSurfaces(projectRoot);
|
|
333
|
+
function getDefaultScanRoots(projectRoot, surfaces = collectAgentInstructionSurfaces(projectRoot)) {
|
|
335
334
|
return [
|
|
336
335
|
...new Set([
|
|
337
336
|
...surfaces.rootDocFiles,
|
|
@@ -342,18 +341,18 @@ function getDefaultScanRoots(projectRoot) {
|
|
|
342
341
|
];
|
|
343
342
|
}
|
|
344
343
|
|
|
345
|
-
function isTextFile(relativePath, projectRoot) {
|
|
344
|
+
function isTextFile(relativePath, projectRoot, surfaces = collectAgentInstructionSurfaces(projectRoot)) {
|
|
346
345
|
const basename = path.basename(relativePath);
|
|
347
346
|
if (basename.endsWith('.test.js')) {
|
|
348
347
|
return false;
|
|
349
348
|
}
|
|
350
|
-
if (getRootDocFiles(projectRoot).has(relativePath) || basename === 'lefthook.yml') {
|
|
349
|
+
if (getRootDocFiles(projectRoot, surfaces).has(relativePath) || basename === 'lefthook.yml') {
|
|
351
350
|
return true;
|
|
352
351
|
}
|
|
353
352
|
return TEXT_EXTENSIONS.has(path.extname(relativePath));
|
|
354
353
|
}
|
|
355
354
|
|
|
356
|
-
function walkFiles(projectRoot, relativeRoot) {
|
|
355
|
+
function walkFiles(projectRoot, relativeRoot, surfaces = collectAgentInstructionSurfaces(projectRoot)) {
|
|
357
356
|
const rootPath = absolutePath(projectRoot, relativeRoot);
|
|
358
357
|
if (!fs.existsSync(rootPath)) {
|
|
359
358
|
return [];
|
|
@@ -362,7 +361,7 @@ function walkFiles(projectRoot, relativeRoot) {
|
|
|
362
361
|
const stat = fs.statSync(rootPath);
|
|
363
362
|
if (stat.isFile()) {
|
|
364
363
|
const normalized = toRepoPath(relativeRoot);
|
|
365
|
-
return isTextFile(normalized, projectRoot) && !shouldSkipRelativePath(normalized) ? [normalized] : [];
|
|
364
|
+
return isTextFile(normalized, projectRoot, surfaces) && !shouldSkipRelativePath(normalized) ? [normalized] : [];
|
|
366
365
|
}
|
|
367
366
|
|
|
368
367
|
const files = [];
|
|
@@ -380,8 +379,8 @@ function walkFiles(projectRoot, relativeRoot) {
|
|
|
380
379
|
}
|
|
381
380
|
|
|
382
381
|
if (entry.isDirectory()) {
|
|
383
|
-
files.push(...walkFiles(projectRoot, relativePath));
|
|
384
|
-
} else if (entry.isFile() && isTextFile(relativePath, projectRoot)) {
|
|
382
|
+
files.push(...walkFiles(projectRoot, relativePath, surfaces));
|
|
383
|
+
} else if (entry.isFile() && isTextFile(relativePath, projectRoot, surfaces)) {
|
|
385
384
|
files.push(relativePath);
|
|
386
385
|
}
|
|
387
386
|
}
|
|
@@ -389,7 +388,7 @@ function walkFiles(projectRoot, relativeRoot) {
|
|
|
389
388
|
return files;
|
|
390
389
|
}
|
|
391
390
|
|
|
392
|
-
function readSyncManifestScanRoots(projectRoot) {
|
|
391
|
+
function readSyncManifestScanRoots(projectRoot, surfaces = collectAgentInstructionSurfaces(projectRoot)) {
|
|
393
392
|
const manifestPath = absolutePath(projectRoot, '.forge/sync-manifest.json');
|
|
394
393
|
if (!fs.existsSync(manifestPath)) {
|
|
395
394
|
return [];
|
|
@@ -406,7 +405,7 @@ function readSyncManifestScanRoots(projectRoot) {
|
|
|
406
405
|
.filter(file => (
|
|
407
406
|
file.length > 0 &&
|
|
408
407
|
isSafeRepoPath(projectRoot, file) &&
|
|
409
|
-
isTextFile(file, projectRoot)
|
|
408
|
+
isTextFile(file, projectRoot, surfaces)
|
|
410
409
|
));
|
|
411
410
|
} catch (_error) {
|
|
412
411
|
return [];
|
|
@@ -426,22 +425,22 @@ function normalizeScanRoot(root) {
|
|
|
426
425
|
return normalizeRepoPath(root).replace(/[/\\]+$/, '');
|
|
427
426
|
}
|
|
428
427
|
|
|
429
|
-
function getScanRoots(projectRoot, options = {}) {
|
|
430
|
-
const roots = options.scanRoots || getDefaultScanRoots(projectRoot);
|
|
428
|
+
function getScanRoots(projectRoot, options = {}, surfaces = collectAgentInstructionSurfaces(projectRoot)) {
|
|
429
|
+
const roots = options.scanRoots || getDefaultScanRoots(projectRoot, surfaces);
|
|
431
430
|
return [
|
|
432
431
|
...new Set(
|
|
433
|
-
[...roots, ...readSyncManifestScanRoots(projectRoot)]
|
|
432
|
+
[...roots, ...readSyncManifestScanRoots(projectRoot, surfaces)]
|
|
434
433
|
.map(normalizeScanRoot)
|
|
435
434
|
.filter(Boolean),
|
|
436
435
|
),
|
|
437
436
|
];
|
|
438
437
|
}
|
|
439
438
|
|
|
440
|
-
function isInstructionPath(relativePath, projectRoot) {
|
|
441
|
-
return getInstructionPrefixes(projectRoot).some(prefix => relativePath.startsWith(prefix));
|
|
439
|
+
function isInstructionPath(relativePath, projectRoot, surfaces = collectAgentInstructionSurfaces(projectRoot)) {
|
|
440
|
+
return getInstructionPrefixes(projectRoot, surfaces).some(prefix => relativePath.startsWith(prefix));
|
|
442
441
|
}
|
|
443
442
|
|
|
444
|
-
function classifyGroup(relativePath, projectRoot) {
|
|
443
|
+
function classifyGroup(relativePath, projectRoot, surfaces = collectAgentInstructionSurfaces(projectRoot)) {
|
|
445
444
|
if (
|
|
446
445
|
relativePath === 'lefthook.yml' ||
|
|
447
446
|
relativePath.startsWith('.forge/hooks/') ||
|
|
@@ -451,7 +450,7 @@ function classifyGroup(relativePath, projectRoot) {
|
|
|
451
450
|
}
|
|
452
451
|
|
|
453
452
|
if (
|
|
454
|
-
getSkillPrefixes(projectRoot).some(prefix => relativePath.startsWith(prefix)) ||
|
|
453
|
+
getSkillPrefixes(projectRoot, surfaces).some(prefix => relativePath.startsWith(prefix)) ||
|
|
455
454
|
relativePath.startsWith('packages/skills/')
|
|
456
455
|
) {
|
|
457
456
|
return 'skills';
|
|
@@ -463,8 +462,8 @@ function classifyGroup(relativePath, projectRoot) {
|
|
|
463
462
|
|
|
464
463
|
if (
|
|
465
464
|
relativePath.startsWith('docs/') ||
|
|
466
|
-
getRootDocFiles(projectRoot).has(relativePath) ||
|
|
467
|
-
isInstructionPath(relativePath, projectRoot)
|
|
465
|
+
getRootDocFiles(projectRoot, surfaces).has(relativePath) ||
|
|
466
|
+
isInstructionPath(relativePath, projectRoot, surfaces)
|
|
468
467
|
) {
|
|
469
468
|
return 'docs';
|
|
470
469
|
}
|
|
@@ -534,11 +533,16 @@ function trackedRepoFiles(projectRoot) {
|
|
|
534
533
|
}
|
|
535
534
|
|
|
536
535
|
function auditBdCallSites(projectRoot, options = {}) {
|
|
537
|
-
const
|
|
536
|
+
const surfaces = collectAgentInstructionSurfaces(projectRoot);
|
|
537
|
+
return auditBdCallSitesWithSurfaces(projectRoot, options, surfaces);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function auditBdCallSitesWithSurfaces(projectRoot, options, surfaces) {
|
|
541
|
+
const roots = getScanRoots(projectRoot, options, surfaces);
|
|
538
542
|
// Only audit git-tracked files, so untracked working-tree pollution (e.g. a concurrent
|
|
539
543
|
// test's temp files under a scan root) can't perturb the census (issue 1d4077d8).
|
|
540
544
|
const tracked = trackedRepoFiles(projectRoot);
|
|
541
|
-
const files = [...new Set(roots.flatMap(root => walkFiles(projectRoot, root)))]
|
|
545
|
+
const files = [...new Set(roots.flatMap(root => walkFiles(projectRoot, root, surfaces)))]
|
|
542
546
|
.filter(relativePath => !tracked || tracked.has(normalizeRepoPath(relativePath)))
|
|
543
547
|
.sort((left, right) => left.localeCompare(right));
|
|
544
548
|
const groups = newGroupedAudit();
|
|
@@ -559,7 +563,7 @@ function auditBdCallSites(projectRoot, options = {}) {
|
|
|
559
563
|
}
|
|
560
564
|
}
|
|
561
565
|
|
|
562
|
-
addCallSites(groups, classifyGroup(relativePath, projectRoot), relativePath, lineEntries);
|
|
566
|
+
addCallSites(groups, classifyGroup(relativePath, projectRoot, surfaces), relativePath, lineEntries);
|
|
563
567
|
}
|
|
564
568
|
|
|
565
569
|
return {
|
|
@@ -578,13 +582,17 @@ function auditBdCallSites(projectRoot, options = {}) {
|
|
|
578
582
|
// SKIP_DIR_NAMES — rather than a parallel list that could drift from the gate.
|
|
579
583
|
function isBdCensusPath(projectRoot, filePath, options = {}) {
|
|
580
584
|
const relativePath = safeNormalizeRepoPath(projectRoot, filePath);
|
|
581
|
-
if (!relativePath || shouldSkipRelativePath(relativePath)
|
|
585
|
+
if (!relativePath || shouldSkipRelativePath(relativePath)) {
|
|
586
|
+
return false;
|
|
587
|
+
}
|
|
588
|
+
const surfaces = collectAgentInstructionSurfaces(projectRoot);
|
|
589
|
+
if (!isTextFile(relativePath, projectRoot, surfaces)) {
|
|
582
590
|
return false;
|
|
583
591
|
}
|
|
584
592
|
|
|
585
593
|
// getScanRoots already returns normalized roots — re-normalizing here would be a second
|
|
586
594
|
// copy of that rule, free to drift from the one auditBdCallSites walks.
|
|
587
|
-
return getScanRoots(projectRoot, options)
|
|
595
|
+
return getScanRoots(projectRoot, options, surfaces)
|
|
588
596
|
.some(root => {
|
|
589
597
|
// walkFiles applies no directory skips to a root that is itself a file.
|
|
590
598
|
if (relativePath === root) {
|
|
@@ -738,8 +746,11 @@ function commandModuleHasRegistryContract(projectRoot, name) {
|
|
|
738
746
|
hasRegistryCommandHandler(findObjectMember(exportedObject, 'handler'), bindings.functions);
|
|
739
747
|
}
|
|
740
748
|
|
|
741
|
-
function isD20HotPath(
|
|
742
|
-
|
|
749
|
+
function isD20HotPath(
|
|
750
|
+
relativePath,
|
|
751
|
+
projectRoot,
|
|
752
|
+
surfaces = collectAgentInstructionSurfaces(projectRoot),
|
|
753
|
+
) {
|
|
743
754
|
const hotPathFiles = new Set([
|
|
744
755
|
...D20_HOT_PATH_FILES,
|
|
745
756
|
...surfaces.rootDocFiles,
|
|
@@ -754,14 +765,18 @@ function isD20HotPath(relativePath, projectRoot) {
|
|
|
754
765
|
hotPathPrefixes.some(prefix => relativePath.startsWith(prefix));
|
|
755
766
|
}
|
|
756
767
|
|
|
757
|
-
function flattenHotPathEvidence(
|
|
768
|
+
function flattenHotPathEvidence(
|
|
769
|
+
audit,
|
|
770
|
+
projectRoot,
|
|
771
|
+
surfaces = collectAgentInstructionSurfaces(projectRoot),
|
|
772
|
+
) {
|
|
758
773
|
const files = [];
|
|
759
774
|
for (const group of GROUPS) {
|
|
760
775
|
files.push(...audit.groups[group].files);
|
|
761
776
|
}
|
|
762
777
|
|
|
763
778
|
return files
|
|
764
|
-
.filter(file => isD20HotPath(file.path, projectRoot))
|
|
779
|
+
.filter(file => isD20HotPath(file.path, projectRoot, surfaces))
|
|
765
780
|
.filter(Boolean)
|
|
766
781
|
.map(file => ({
|
|
767
782
|
path: file.path,
|
|
@@ -1779,8 +1794,8 @@ function missingArtifactEvidence(relativePath) {
|
|
|
1779
1794
|
return [{ path: relativePath, missing: true }];
|
|
1780
1795
|
}
|
|
1781
1796
|
|
|
1782
|
-
function hotPathBlocker(audit, projectRoot) {
|
|
1783
|
-
const hotPathEvidence = flattenHotPathEvidence(audit, projectRoot);
|
|
1797
|
+
function hotPathBlocker(audit, projectRoot, surfaces) {
|
|
1798
|
+
const hotPathEvidence = flattenHotPathEvidence(audit, projectRoot, surfaces);
|
|
1784
1799
|
if (hotPathEvidence.length > 0) {
|
|
1785
1800
|
return {
|
|
1786
1801
|
id: 'bd-hot-path-issue-commands',
|
|
@@ -1966,9 +1981,9 @@ function freshCloneBlocker(projectRoot) {
|
|
|
1966
1981
|
return null;
|
|
1967
1982
|
}
|
|
1968
1983
|
|
|
1969
|
-
function buildBlockers(projectRoot, audit) {
|
|
1984
|
+
function buildBlockers(projectRoot, audit, surfaces) {
|
|
1970
1985
|
return [
|
|
1971
|
-
hotPathBlocker(audit, projectRoot),
|
|
1986
|
+
hotPathBlocker(audit, projectRoot, surfaces),
|
|
1972
1987
|
auditArtifactBlocker(projectRoot, audit),
|
|
1973
1988
|
issueSurfaceBlocker(projectRoot),
|
|
1974
1989
|
primeBlocker(projectRoot),
|
|
@@ -1982,6 +1997,7 @@ function buildBlockers(projectRoot, audit) {
|
|
|
1982
1997
|
|
|
1983
1998
|
function buildReadinessReport(projectRoot, options = {}) {
|
|
1984
1999
|
const target = options.target || SUPPORTED_TARGET;
|
|
2000
|
+
const surfaces = collectAgentInstructionSurfaces(projectRoot);
|
|
1985
2001
|
// Accept prerelease variants (0.1.0-beta.1, 0.1.0-rc.1, …) against their base version's
|
|
1986
2002
|
// readiness profile — a beta's readiness content is identical to its release.
|
|
1987
2003
|
const baseTarget = String(target).split('-')[0];
|
|
@@ -1995,12 +2011,12 @@ function buildReadinessReport(projectRoot, options = {}) {
|
|
|
1995
2011
|
detail: `Unsupported release readiness target: ${target}. Supported target: ${SUPPORTED_TARGET} (prerelease suffixes like -beta.1 are accepted).`,
|
|
1996
2012
|
evidence: [],
|
|
1997
2013
|
}],
|
|
1998
|
-
audit:
|
|
2014
|
+
audit: auditBdCallSitesWithSurfaces(projectRoot, options, surfaces),
|
|
1999
2015
|
};
|
|
2000
2016
|
}
|
|
2001
2017
|
|
|
2002
|
-
const audit =
|
|
2003
|
-
const blockers = buildBlockers(projectRoot, audit);
|
|
2018
|
+
const audit = auditBdCallSitesWithSurfaces(projectRoot, options, surfaces);
|
|
2019
|
+
const blockers = buildBlockers(projectRoot, audit, surfaces);
|
|
2004
2020
|
|
|
2005
2021
|
return {
|
|
2006
2022
|
success: blockers.length === 0,
|
package/lib/shell-utils.js
CHANGED
|
@@ -13,6 +13,7 @@ const path = require('node:path');
|
|
|
13
13
|
// (spawnSync ENOENT, kernel issue 9997d516) and patched Node refuses .cmd/.bat
|
|
14
14
|
// without shell:true (CVE-2024-27980).
|
|
15
15
|
const WINDOWS_DIRECT_SPAWN_EXTS = new Set(['.exe', '.com']);
|
|
16
|
+
const executableResolutionCache = new Map();
|
|
16
17
|
|
|
17
18
|
// Conservative allowlist for tokens executed through cmd.exe. Blocks every cmd
|
|
18
19
|
// metacharacter (& | < > ^ % ! " ' ` ( ) ; and whitespace) so shell execution
|
|
@@ -56,6 +57,13 @@ function resolveWindowsSpawnSpec(command, candidates) {
|
|
|
56
57
|
return { file: command, shell: true };
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
function windowsEnvironmentValue(environment, name) {
|
|
61
|
+
const key = Object.keys(environment)
|
|
62
|
+
.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0))
|
|
63
|
+
.find((candidate) => candidate.toUpperCase() === name);
|
|
64
|
+
return key ? environment[key] : '';
|
|
65
|
+
}
|
|
66
|
+
|
|
59
67
|
/**
|
|
60
68
|
* Securely execute a command with PATH validation.
|
|
61
69
|
* Mitigates SonarCloud S4036: Ensures executables are from trusted locations.
|
|
@@ -71,29 +79,47 @@ function secureExecFileSync(command, args = [], options = {}) {
|
|
|
71
79
|
_execFileSync = execFileSync,
|
|
72
80
|
_spawnSync = spawnSync,
|
|
73
81
|
_platform = process.platform,
|
|
82
|
+
_resolutionCache = _spawnSync === spawnSync ? executableResolutionCache : null,
|
|
74
83
|
...execOptions
|
|
75
84
|
} = options;
|
|
76
85
|
|
|
77
86
|
const isWindows = _platform === 'win32';
|
|
78
87
|
const pathResolver = isWindows ? 'where.exe' : 'which';
|
|
88
|
+
const commandEnv = execOptions.env || process.env;
|
|
89
|
+
const lookupDirectory = isWindows ? path.resolve(execOptions.cwd ?? process.cwd()) : undefined;
|
|
90
|
+
const cacheKey = isWindows && _resolutionCache
|
|
91
|
+
? [
|
|
92
|
+
_platform,
|
|
93
|
+
command,
|
|
94
|
+
windowsEnvironmentValue(commandEnv, 'PATH'),
|
|
95
|
+
windowsEnvironmentValue(commandEnv, 'PATHEXT'),
|
|
96
|
+
lookupDirectory,
|
|
97
|
+
].join('\0')
|
|
98
|
+
: null;
|
|
79
99
|
|
|
80
|
-
let candidates =
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
100
|
+
let candidates = cacheKey ? _resolutionCache.get(cacheKey) : null;
|
|
101
|
+
if (!candidates) {
|
|
102
|
+
candidates = [];
|
|
103
|
+
try {
|
|
104
|
+
// Resolve command's full path to validate it's in a trusted location.
|
|
105
|
+
const result = _spawnSync(pathResolver, [command], {
|
|
106
|
+
cwd: lookupDirectory,
|
|
107
|
+
encoding: 'utf8',
|
|
108
|
+
env: commandEnv,
|
|
109
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
110
|
+
});
|
|
87
111
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
112
|
+
if (result.status === 0 && result.stdout) {
|
|
113
|
+
// Handle both CRLF (Windows) and LF (Unix) line endings
|
|
114
|
+
candidates = result.stdout
|
|
115
|
+
.trim()
|
|
116
|
+
.split(/\r?\n/)
|
|
117
|
+
.map((line) => line.trim())
|
|
118
|
+
.filter(Boolean);
|
|
119
|
+
if (cacheKey && candidates.length > 0) _resolutionCache.set(cacheKey, candidates);
|
|
120
|
+
}
|
|
121
|
+
} catch (_err) { // NOSONAR - S2486: Intentionally ignored; falls back to direct command execution below
|
|
95
122
|
}
|
|
96
|
-
} catch (_err) { // NOSONAR - S2486: Intentionally ignored; falls back to direct command execution below
|
|
97
123
|
}
|
|
98
124
|
|
|
99
125
|
if (isWindows) {
|