forge-workflow 0.1.0-beta.6 → 0.1.0-beta.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/bin/forge-gh-proxy.js +15 -0
  3. package/bin/forge-github-credential.js +21 -0
  4. package/bin/forge.js +16 -1
  5. package/docs/guides/MIGRATION.md +4 -3
  6. package/docs/reference/RELEASE.md +5 -3
  7. package/docs/reference/VALIDATION.md +14 -0
  8. package/docs/reference/github-accounts.md +114 -38
  9. package/lib/bun-workflow-pins.js +129 -3
  10. package/lib/commands/github.js +234 -49
  11. package/lib/commands/merge.js +2 -2
  12. package/lib/commands/push.js +308 -47
  13. package/lib/commands/release.js +18 -1
  14. package/lib/commands/shepherd.js +1 -1
  15. package/lib/commands/test.js +8 -0
  16. package/lib/commands/validate.js +219 -67
  17. package/lib/gh-proxy.js +309 -0
  18. package/lib/github-context.js +160 -25
  19. package/lib/github-credential.js +47 -0
  20. package/lib/github-router.js +774 -0
  21. package/lib/kernel/migrations.js +1 -1
  22. package/lib/kernel/sqlite-driver.js +1 -1
  23. package/lib/memory/router.js +1 -1
  24. package/lib/memory/usage-evidence.js +1 -1
  25. package/lib/native-gh.js +177 -0
  26. package/lib/npm-publish-workflow.js +5 -5
  27. package/lib/pr-monitor/flow-monitor.js +127 -28
  28. package/lib/pr-monitor/reconcile-executor.js +1 -1
  29. package/lib/project-memory.js +1 -1
  30. package/lib/protected-state-authority.js +214 -13
  31. package/lib/release-readiness.js +57 -41
  32. package/lib/shell-utils.js +41 -15
  33. package/lib/test-workflow.js +389 -0
  34. package/lib/validation-receipt.js +305 -0
  35. package/lib/workflow-templates/test.yml +654 -0
  36. package/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
  37. package/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
  38. package/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
  39. package/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
  40. package/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
  41. package/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
  42. package/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
  43. package/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
  44. package/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
  45. package/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
  46. package/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
  47. package/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
  48. package/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
  49. package/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
  50. package/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
  51. package/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
  52. package/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
  53. package/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
  54. package/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
  55. package/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
  56. package/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
  57. package/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
  58. package/node_modules/@forge/contracts/index.js +32 -0
  59. package/node_modules/@forge/contracts/package.json +35 -0
  60. package/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
  61. package/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
  62. package/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
  63. package/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
  64. package/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
  65. package/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
  66. package/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
  67. package/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
  68. package/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
  69. package/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
  70. package/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
  71. package/node_modules/@forge/contracts/src/baseline.js +23 -0
  72. package/node_modules/@forge/contracts/src/canonical.js +151 -0
  73. package/node_modules/@forge/contracts/src/definitions.js +176 -0
  74. package/node_modules/@forge/contracts/src/identity.js +42 -0
  75. package/node_modules/@forge/contracts/src/schema.js +72 -0
  76. package/node_modules/@forge/contracts/src/validate.js +305 -0
  77. package/node_modules/@forge/flow/index.js +114 -0
  78. package/node_modules/@forge/flow/package.json +34 -0
  79. package/node_modules/@forge/flow/src/bounded-loop.js +415 -0
  80. package/node_modules/@forge/flow/src/efficiency-supervisor.js +89 -0
  81. package/node_modules/@forge/flow/src/executor.js +279 -0
  82. package/node_modules/@forge/flow/src/monitor-durability.js +419 -0
  83. package/node_modules/@forge/flow/src/monitor-runtime.js +460 -0
  84. package/node_modules/@forge/flow/src/process-lifecycle.js +469 -0
  85. package/node_modules/@forge/flow/src/skill-runtime.js +343 -0
  86. package/node_modules/@forge/memory/index.js +331 -0
  87. package/node_modules/@forge/memory/package.json +34 -0
  88. package/node_modules/@forge/memory/src/authority-provider.js +67 -0
  89. package/node_modules/@forge/memory/src/backend-registry.js +166 -0
  90. package/node_modules/@forge/memory/src/feedback-intake.js +243 -0
  91. package/node_modules/@forge/memory/src/pr-lifecycle-authority.js +946 -0
  92. package/node_modules/@forge/memory/src/usage-evidence.js +205 -0
  93. package/package.json +12 -2
  94. package/packages/flow/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
  95. package/packages/flow/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
  96. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
  97. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
  98. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
  99. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
  100. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
  101. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
  102. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
  103. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
  104. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
  105. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
  106. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
  107. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
  108. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
  109. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
  110. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
  111. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
  112. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
  113. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
  114. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
  115. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
  116. package/packages/flow/node_modules/@forge/contracts/index.js +32 -0
  117. package/packages/flow/node_modules/@forge/contracts/package.json +35 -0
  118. package/packages/flow/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
  119. package/packages/flow/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
  120. package/packages/flow/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
  121. package/packages/flow/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
  122. package/packages/flow/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
  123. package/packages/flow/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
  124. package/packages/flow/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
  125. package/packages/flow/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
  126. package/packages/flow/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
  127. package/packages/flow/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
  128. package/packages/flow/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
  129. package/packages/flow/node_modules/@forge/contracts/src/baseline.js +23 -0
  130. package/packages/flow/node_modules/@forge/contracts/src/canonical.js +151 -0
  131. package/packages/flow/node_modules/@forge/contracts/src/definitions.js +176 -0
  132. package/packages/flow/node_modules/@forge/contracts/src/identity.js +42 -0
  133. package/packages/flow/node_modules/@forge/contracts/src/schema.js +72 -0
  134. package/packages/flow/node_modules/@forge/contracts/src/validate.js +305 -0
  135. package/packages/memory/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
  136. package/packages/memory/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
  137. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
  138. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
  139. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
  140. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
  141. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
  142. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
  143. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
  144. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
  145. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
  146. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
  147. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
  148. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
  149. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
  150. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
  151. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
  152. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
  153. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
  154. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
  155. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
  156. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
  157. package/packages/memory/node_modules/@forge/contracts/index.js +32 -0
  158. package/packages/memory/node_modules/@forge/contracts/package.json +35 -0
  159. package/packages/memory/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
  160. package/packages/memory/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
  161. package/packages/memory/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
  162. package/packages/memory/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
  163. package/packages/memory/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
  164. package/packages/memory/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
  165. package/packages/memory/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
  166. package/packages/memory/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
  167. package/packages/memory/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
  168. package/packages/memory/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
  169. package/packages/memory/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
  170. package/packages/memory/node_modules/@forge/contracts/src/baseline.js +23 -0
  171. package/packages/memory/node_modules/@forge/contracts/src/canonical.js +151 -0
  172. package/packages/memory/node_modules/@forge/contracts/src/definitions.js +176 -0
  173. package/packages/memory/node_modules/@forge/contracts/src/identity.js +42 -0
  174. package/packages/memory/node_modules/@forge/contracts/src/schema.js +72 -0
  175. package/packages/memory/node_modules/@forge/contracts/src/validate.js +305 -0
  176. package/scripts/check-forge-token.js +85 -67
  177. package/scripts/gen-embedded-assets.mjs +31 -5
  178. package/scripts/test-full-suite.js +69 -15
  179. package/scripts/test.js +11 -1
@@ -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
+ };
@@ -0,0 +1,305 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const { execFileSync } = require('node:child_process');
5
+ const fs = require('node:fs');
6
+ const os = require('node:os');
7
+ const path = require('node:path');
8
+ const { resolveWorktreeScope } = require('./protected-state-authority');
9
+ const { QUICK_LANE_ENV_VAR, QUICK_LANE_VALUE } = require('../scripts/test');
10
+
11
+ const SCHEMA_VERSION = 1;
12
+ const RECEIPT_TTL_MS = 60 * 60 * 1000;
13
+ const FUTURE_SKEW_MS = 60 * 1000;
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
+ });
20
+
21
+ function git(projectRoot, args, deps = {}) {
22
+ return (deps.execFileSync || execFileSync)('git', args, {
23
+ cwd: projectRoot,
24
+ encoding: 'utf8',
25
+ stdio: ['ignore', 'pipe', 'pipe'],
26
+ }).trim();
27
+ }
28
+
29
+ function resolveGitRoot(projectRoot, deps = {}) {
30
+ return path.resolve(git(projectRoot, ['rev-parse', '--show-toplevel'], deps));
31
+ }
32
+
33
+ function resolveReceiptPath(projectRoot, deps = {}) {
34
+ const root = resolveGitRoot(projectRoot, deps);
35
+ const gitPath = git(root, ['rev-parse', '--git-path', 'forge/validation-receipt.json'], deps);
36
+ return path.resolve(root, gitPath);
37
+ }
38
+
39
+ function resolveKeyPath(deps = {}) {
40
+ return path.join(deps.homeDir || os.homedir(), '.forge', 'validation-receipt.key');
41
+ }
42
+
43
+ function runtimeIdentity(deps = {}) {
44
+ return deps.runtimeIdentity
45
+ || [process.platform, process.arch, process.versions.node, process.versions.bun || 'node'].join(':');
46
+ }
47
+
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
+ }
61
+ const located = path.isAbsolute(command)
62
+ ? fs.realpathSync.native(command)
63
+ : run(process.platform === 'win32' ? 'where.exe' : 'which', [command], {
64
+ encoding: 'utf8',
65
+ env,
66
+ stdio: ['ignore', 'pipe', 'pipe'],
67
+ }).trim().split(/\r?\n/)[0];
68
+ return `${process.platform === 'win32' ? located.toLowerCase() : located}:${version}`;
69
+ }
70
+
71
+ function testRuntimeIdentity(projectRoot, deps = {}, options = {}) {
72
+ if (deps.testRuntimeIdentity) return deps.testRuntimeIdentity;
73
+ const env = deps.env || process.env;
74
+ const run = deps.runtimeExecFileSync || execFileSync;
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('|');
84
+ }
85
+
86
+ function captureState(projectRoot, deps = {}) {
87
+ const root = resolveGitRoot(projectRoot, deps);
88
+ return {
89
+ worktree: resolveWorktreeScope(root, deps),
90
+ head: git(root, ['rev-parse', 'HEAD'], deps),
91
+ clean: git(root, ['status', '--porcelain=v1', '--untracked-files=all'], deps) === '',
92
+ runtime: runtimeIdentity(deps),
93
+ testRuntime: testRuntimeIdentity(root, deps),
94
+ runner: RUNNER_ID,
95
+ };
96
+ }
97
+
98
+ function sameState(left, right) {
99
+ return left?.clean === true
100
+ && right?.clean === true
101
+ && left.worktree === right.worktree
102
+ && left.head === right.head
103
+ && left.runtime === right.runtime
104
+ && left.testRuntime === right.testRuntime
105
+ && left.runner === right.runner;
106
+ }
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
+
200
+ function validationIsComplete(result) {
201
+ const checks = result?.checks || {};
202
+ const tests = checks.tests || {};
203
+ return result?.success === true
204
+ && checks.conflictMarkers?.success === true
205
+ && checks.typeCheck?.success === true
206
+ && (checks.typeCheck.skipped !== true || checks.typeCheck.notConfigured === true)
207
+ && checks.lint?.success === true && checks.lint?.skipped !== true
208
+ && checks.security?.success === true && checks.security?.skipped !== true
209
+ && tests.success === true && tests.skipped !== true
210
+ && tests.fullSuite === true && tests.testsFound === true
211
+ && tests.total > 0 && tests.failed === 0;
212
+ }
213
+
214
+ function readOrCreateKey(deps = {}) {
215
+ const keyPath = resolveKeyPath(deps);
216
+ fs.mkdirSync(path.dirname(keyPath), { recursive: true });
217
+ try {
218
+ fs.writeFileSync(keyPath, crypto.randomBytes(32), { flag: 'wx', mode: 0o600 });
219
+ } catch (error) {
220
+ if (error.code !== 'EEXIST') throw error;
221
+ }
222
+ return fs.readFileSync(keyPath);
223
+ }
224
+
225
+ function sign(payload, deps = {}) {
226
+ return crypto.createHmac('sha256', readOrCreateKey(deps))
227
+ .update(JSON.stringify(payload))
228
+ .digest('hex');
229
+ }
230
+
231
+ function signatureMatches(payload, signature, deps = {}) {
232
+ if (!/^[a-f0-9]{64}$/.test(signature || '')) return false;
233
+ const expected = Buffer.from(sign(payload, deps), 'hex');
234
+ const actual = Buffer.from(signature, 'hex');
235
+ return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
236
+ }
237
+
238
+ function writeAtomic(filePath, content) {
239
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
240
+ const temporary = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
241
+ try {
242
+ fs.writeFileSync(temporary, content, { flag: 'wx', mode: 0o600 });
243
+ fs.renameSync(temporary, filePath);
244
+ } finally {
245
+ fs.rmSync(temporary, { force: true });
246
+ }
247
+ }
248
+
249
+ function beginValidation(projectRoot, deps = {}) {
250
+ try {
251
+ fs.rmSync(resolveReceiptPath(projectRoot, deps), { force: true });
252
+ const state = captureState(projectRoot, deps);
253
+ return state.clean ? state : null;
254
+ } catch {
255
+ return null;
256
+ }
257
+ }
258
+
259
+ function completeValidation(projectRoot, snapshot, result, deps = {}) {
260
+ try {
261
+ if (!snapshot || !validationIsComplete(result)) return false;
262
+ const current = captureState(projectRoot, deps);
263
+ if (!sameState(snapshot, current)) return false;
264
+ const issuedAt = (deps.now || Date.now)();
265
+ const payload = {
266
+ schemaVersion: SCHEMA_VERSION,
267
+ ...current,
268
+ issuedAt,
269
+ expiresAt: issuedAt + RECEIPT_TTL_MS,
270
+ gates: ['conflictMarkers', 'typeCheck', 'lint', 'security', 'tests'],
271
+ };
272
+ writeAtomic(resolveReceiptPath(projectRoot, deps), JSON.stringify({ payload, signature: sign(payload, deps) }));
273
+ return true;
274
+ } catch {
275
+ return false;
276
+ }
277
+ }
278
+
279
+ function verifyValidationReceipt(projectRoot, deps = {}) {
280
+ try {
281
+ const receipt = JSON.parse(fs.readFileSync(resolveReceiptPath(projectRoot, deps), 'utf8'));
282
+ if (receipt?.payload?.schemaVersion !== SCHEMA_VERSION) return { valid: false, reason: 'schema' };
283
+ if (!signatureMatches(receipt.payload, receipt.signature, deps)) return { valid: false, reason: 'signature' };
284
+ const now = (deps.now || Date.now)();
285
+ if (receipt.payload.issuedAt > now + FUTURE_SKEW_MS) return { valid: false, reason: 'future' };
286
+ if (receipt.payload.expiresAt < now) return { valid: false, reason: 'expired' };
287
+ if (!sameState(receipt.payload, captureState(projectRoot, deps))) return { valid: false, reason: 'state' };
288
+ return { valid: true, head: receipt.payload.head, identity: receipt.signature };
289
+ } catch {
290
+ return { valid: false, reason: 'missing-or-invalid' };
291
+ }
292
+ }
293
+
294
+ module.exports = {
295
+ beginValidation,
296
+ completeValidation,
297
+ resolveReceiptPath,
298
+ validationIsComplete,
299
+ verifyValidationReceipt,
300
+ _pushProof: {
301
+ begin: beginPushProof,
302
+ create: createPushProof,
303
+ verify: verifyPushProof,
304
+ },
305
+ };