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