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