code-foundry 0.32.4 → 0.34.0

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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { existsSync, readFileSync } from 'node:fs'
4
4
  import { join } from 'node:path'
5
+ import { isReleasePleaseHead } from './validation-policy.mjs'
5
6
 
6
7
  const DEFAULT_RELEASE_FILES = new Set([
7
8
  '.release-please-manifest.json',
@@ -125,6 +126,56 @@ export function validateReleasePullRequests(prs, changedPathsByPr, allowed) {
125
126
  return { valid: errors.length === 0, generated, errors }
126
127
  }
127
128
 
129
+ /** Files that must change for a generated release PR to count as a version bump. */
130
+ const VERSION_METADATA_FILES = new Set([
131
+ '.release-please-manifest.json',
132
+ 'package.json',
133
+ 'Cargo.toml',
134
+ 'pyproject.toml',
135
+ 'version.txt',
136
+ ])
137
+
138
+ /**
139
+ * Strict diff and version policy for the release validation tier. The head
140
+ * must carry the exact approved Release Please prefix, come from the same
141
+ * repository, change at least one path, change no unexpected paths, and bump
142
+ * at least one version-metadata file (or a declared extra file).
143
+ * @param {{ headRef?: string, headRepo?: string, repository?: string, changedPaths?: string[], config?: Record<string, unknown> }} input
144
+ * @returns {{ valid: boolean, errors: string[], changedPaths: string[] }}
145
+ */
146
+ export function validateGeneratedReleaseDiff(input) {
147
+ const { headRef = '', headRepo = '', repository = '', changedPaths = [], config = {} } = input
148
+ /** @type {string[]} */
149
+ const errors = []
150
+ if (!isReleasePleaseHead(headRef)) {
151
+ errors.push(`Head branch ${headRef || '(missing)'} is not a generated Release Please branch.`)
152
+ }
153
+ if (!repository || !headRepo) {
154
+ errors.push('Generated release validation requires both the head and base repository identities.')
155
+ } else if (headRepo !== repository) {
156
+ errors.push(`Head repository ${headRepo} is not ${repository}; generated release validation requires a same-repository pull request.`)
157
+ }
158
+ const paths = [...new Set(changedPaths.map((path) => String(path).trim()).filter(Boolean))]
159
+ if (!paths.length) errors.push('Generated release pull request has no changed files.')
160
+ const unexpected = unexpectedReleasePaths(paths, approvedReleaseFiles(config))
161
+ if (unexpected.length) errors.push(`Generated release pull request contains unexpected paths: ${unexpected.join(', ')}`)
162
+ const versionMetadata = new Set(VERSION_METADATA_FILES)
163
+ addExtraFiles(versionMetadata, config['extra-files'])
164
+ const packages = config.packages
165
+ if (packages && typeof packages === 'object' && !Array.isArray(packages)) {
166
+ for (const [directory, value] of Object.entries(packages)) {
167
+ const prefix = directory === '.' ? '' : directory.replace(/\/$/, '')
168
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
169
+ addExtraFiles(versionMetadata, value['extra-files'], prefix)
170
+ }
171
+ }
172
+ }
173
+ if (!paths.some((path) => versionMetadata.has(path))) {
174
+ errors.push('Generated release pull request changes no version metadata.')
175
+ }
176
+ return { valid: errors.length === 0, errors, changedPaths: paths }
177
+ }
178
+
128
179
  /**
129
180
  * Build a non-destructive release recovery plan from independent release
130
181
  * metadata sources.
@@ -166,41 +217,59 @@ function compareVersions(a, b) {
166
217
  }
167
218
 
168
219
  /**
169
- * Classify the relationship between main and staging. A fast-forward is safe
170
- * only when main is the ancestor of staging or staging is the ancestor of main
171
- * and the intervening main files are release metadata.
172
- * @param {{ mainSha: string, stagingSha: string, mergeBaseSha?: string, mainChangedPaths?: string[], stagingChangedPaths?: string[], allowed?: Set<string> }} input
220
+ * Classify the relationship between main and staging using commit-level divergence.
221
+ * Any non-release main-only commit fails. Any staging-only commit triggers a replay
222
+ * path so no staging-only work can be silently discarded.
223
+ * @param {{
224
+ * mainSha: string,
225
+ * stagingSha: string,
226
+ * mainOnlyCommits?: Array<{ sha?: string, changedPaths?: string[] }>,
227
+ * stagingOnlyCommits?: Array<{ sha?: string, changedPaths?: string[] }>,
228
+ * allowed?: Set<string>,
229
+ * }} input
173
230
  */
174
231
  export function classifyReconciliation(input) {
175
232
  const {
176
233
  mainSha,
177
234
  stagingSha,
178
- mergeBaseSha = '',
179
- mainChangedPaths = [],
180
- stagingChangedPaths = [],
235
+ mainOnlyCommits = [],
236
+ stagingOnlyCommits = [],
181
237
  allowed = approvedReleaseFiles(),
182
238
  } = input
183
239
  if (!mainSha || !stagingSha) return { action: 'fail', reason: 'Missing branch SHA.' }
184
240
  if (mainSha === stagingSha) return { action: 'aligned', reason: 'Branches already point at the same commit.' }
185
- if (mainSha === mergeBaseSha) {
186
- return { action: 'none', reason: 'staging contains commits that are not on main; no release-only reconciliation is needed.' }
241
+ const hasIndeterminateMainCommit = mainOnlyCommits.some((commit) => typeof commit.sha !== 'string' || !Array.isArray(commit.changedPaths))
242
+ if (hasIndeterminateMainCommit) return {
243
+ action: 'fail',
244
+ reason: 'Unable to inspect main-only commit metadata.',
187
245
  }
188
- if (!mainChangedPaths.length && !stagingChangedPaths.length) {
189
- return { action: 'aligned', targetSha: mainSha, reason: 'Branches have different history but identical content.' }
246
+ const hasIndeterminateStagingCommit = stagingOnlyCommits.some((commit) => typeof commit.sha !== 'string' || !Array.isArray(commit.changedPaths))
247
+ if (hasIndeterminateStagingCommit) return {
248
+ action: 'fail',
249
+ reason: 'Unable to inspect staging-only commit metadata.',
190
250
  }
191
- const unexpectedMain = unexpectedReleasePaths(mainChangedPaths, allowed)
192
- const unexpectedStaging = unexpectedReleasePaths(stagingChangedPaths, allowed)
193
- if (unexpectedMain.length || unexpectedStaging.length) {
251
+ const unexpectedMain = unexpectedReleasePaths(
252
+ [...new Set(mainOnlyCommits.flatMap((commit) => commit.changedPaths || []))],
253
+ allowed,
254
+ )
255
+ if (unexpectedMain.length) {
194
256
  return {
195
257
  action: 'fail',
196
- reason: 'Branch divergence includes repository code or configuration.',
197
- unexpected: [...new Set([...unexpectedMain, ...unexpectedStaging])],
258
+ reason: 'main contains commits that are not release metadata.',
259
+ unexpected: unexpectedMain,
198
260
  }
199
261
  }
200
- if (stagingSha === mergeBaseSha) {
201
- return { action: 'fast-forward', targetSha: mainSha, reason: 'main only added approved release metadata.' }
262
+ if (stagingOnlyCommits.length) {
263
+ return {
264
+ action: 'rebase-staging',
265
+ targetSha: mainSha,
266
+ mainOnly: mainOnlyCommits.map((commit) => commit.sha).filter(Boolean),
267
+ stagingOnly: stagingOnlyCommits.map((commit) => commit.sha).filter(Boolean),
268
+ reason: 'staging contains unpromoted commits; replay them onto main.',
269
+ }
202
270
  }
203
- return { action: 'pull-request', targetSha: mainSha, reason: 'Branches diverged, but only approved release metadata changed.' }
271
+ if (mainOnlyCommits.length) return { action: 'fast-forward', targetSha: mainSha, reason: 'main only added approved release metadata.' }
272
+ return { action: 'aligned', targetSha: mainSha, reason: 'Branches have different history but identical content.' }
204
273
  }
205
274
 
206
275
  export { DEFAULT_RELEASE_FILES }
@@ -0,0 +1,127 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Tiered validation policy shared by the generated validation caller, the
5
+ * reusable validation orchestrator, and their deterministic tests.
6
+ *
7
+ * The caller classifies every triggering event into exactly one of three
8
+ * fail-closed modes; the orchestrator's aggregate gate evaluates normalized
9
+ * job results against a mode-aware truth table. The shell steps embedded in
10
+ * the generated workflows are thin mirrors of these functions; keep the
11
+ * workflow YAML and this module in lockstep.
12
+ */
13
+
14
+ /** The three validation tiers. */
15
+ export const VALIDATION_MODES = ['fast', 'audit', 'release']
16
+
17
+ /** Exact branch prefix used by Release Please for generated version pull requests. */
18
+ export const RELEASE_PLEASE_PREFIX = 'release-please--branches--main'
19
+
20
+ /** Stable aggregate check name emitted by the validation orchestrator's gate job. */
21
+ export const AGGREGATE_CHECK_NAME = 'Validation / Gate'
22
+ /** Job ids owned by the validation orchestrator. */
23
+ export const VALIDATION_JOBS = ['ci', 'test', 'security', 'codeql', 'release-policy']
24
+
25
+ /** Events that may trigger canonical validation. */
26
+ export const VALIDATION_EVENTS = ['pull_request', 'schedule', 'workflow_dispatch']
27
+
28
+ /**
29
+ * True when the head branch is a generated Release Please version branch:
30
+ * either the exact prefix or the prefix followed by the `--` separator.
31
+ * Any other string (including prefix lookalikes without the exact boundary)
32
+ * is not approved.
33
+ * @param {unknown} headRef
34
+ * @returns {boolean}
35
+ */
36
+ export function isReleasePleaseHead(headRef) {
37
+ if (typeof headRef !== 'string' || headRef.length === 0) return false
38
+ return headRef === RELEASE_PLEASE_PREFIX || headRef.startsWith(`${RELEASE_PLEASE_PREFIX}--`)
39
+ }
40
+
41
+ /**
42
+ * Classify an event into exactly one validation mode, failing closed:
43
+ * - fast: pull_request targeting staging
44
+ * - release: pull_request targeting main whose head is the exact approved
45
+ * Release Please prefix
46
+ * - audit: every other pull_request targeting main, plus schedule and
47
+ * workflow_dispatch
48
+ * Unsupported events (for example push) and unsupported base branches are
49
+ * rejected so canonical validation can never silently run or silently skip.
50
+ * @param {{ eventName: string, baseRef?: string, headRef?: string }} input
51
+ * @returns {'fast'|'audit'|'release'}
52
+ */
53
+ export function classifyValidationMode(input) {
54
+ const { eventName, baseRef, headRef } = input
55
+ if (typeof eventName !== 'string' || !VALIDATION_EVENTS.includes(eventName)) {
56
+ throw new Error(
57
+ `Unsupported validation event: ${eventName ?? '(missing)'}; canonical validation must not run on this event.`
58
+ )
59
+ }
60
+ if (eventName === 'schedule' || eventName === 'workflow_dispatch') return 'audit'
61
+ if (typeof baseRef !== 'string' || baseRef.length === 0) {
62
+ throw new Error('pull_request classification requires base_ref.')
63
+ }
64
+ if (typeof headRef !== 'string' || headRef.length === 0) {
65
+ throw new Error('pull_request classification requires head_ref.')
66
+ }
67
+ if (baseRef === 'staging') return 'fast'
68
+ if (baseRef === 'main') return isReleasePleaseHead(headRef) ? 'release' : 'audit'
69
+ throw new Error(`Unsupported pull_request base branch: ${baseRef}; expected staging or main.`)
70
+ }
71
+
72
+ /** @type {Record<'fast'|'audit'|'release', string[]>} */
73
+ const REQUIRED_JOBS_BY_MODE = {
74
+ fast: ['ci', 'test'],
75
+ audit: ['ci', 'test', 'security', 'codeql'],
76
+ release: ['release-policy'],
77
+ }
78
+
79
+ /**
80
+ * Job ids that the aggregate gate requires for a mode. Expected skips never
81
+ * appear here: anything outside the returned list is not part of the gate.
82
+ * @param {string} mode
83
+ * @returns {string[]}
84
+ */
85
+ export function requiredValidationJobs(mode) {
86
+ const jobs = REQUIRED_JOBS_BY_MODE[/** @type {'fast'|'audit'|'release'} */ (mode)]
87
+ if (!jobs) {
88
+ throw new Error(
89
+ `Unknown validation mode: ${mode ?? '(missing)'}; expected one of ${VALIDATION_MODES.join(', ')}.`
90
+ )
91
+ }
92
+ return [...jobs]
93
+ }
94
+
95
+ /**
96
+ * Evaluate the aggregate gate for one mode against normalized job results.
97
+ * Only jobs required for the mode are consulted, so expected skips of
98
+ * non-required jobs never fail the gate. Every required job must report
99
+ * exactly `success`; failure, cancellation, an unexpected skip, a missing
100
+ * result, or an unknown result fails the gate (fail closed).
101
+ * @param {{ mode: string, results?: Record<string, string | null | undefined> }} input
102
+ * @returns {{ valid: boolean, required: string[], failures: Array<{ job: string, result: string }> }}
103
+ */
104
+ export function evaluateValidationGate(input) {
105
+ const { mode, results = {} } = input
106
+ let required
107
+ try {
108
+ required = requiredValidationJobs(mode)
109
+ } catch (error) {
110
+ return {
111
+ valid: false,
112
+ required: [],
113
+ failures: [{ job: 'mode', result: error instanceof Error ? error.message : String(error) }],
114
+ }
115
+ }
116
+ /** @type {Array<{ job: string, result: string }>} */
117
+ const failures = []
118
+ for (const job of required) {
119
+ const result = results[job]
120
+ if (result === 'success') continue
121
+ failures.push({
122
+ job,
123
+ result: result === undefined || result === null ? 'missing' : String(result),
124
+ })
125
+ }
126
+ return { valid: failures.length === 0, required, failures }
127
+ }
package/src/runtime.mjs CHANGED
@@ -6,6 +6,8 @@ import { resolve } from 'node:path'
6
6
  import { spawnSync } from 'node:child_process'
7
7
  import { detectPackageManager, resolveProfile } from './lib/profile.mjs'
8
8
  import { configured, readConfig } from './lib/config.mjs'
9
+ import { classifyValidationMode, evaluateValidationGate } from './lib/validation-policy.mjs'
10
+ import { readReleaseConfig, validateGeneratedReleaseDiff } from './lib/release-policy.mjs'
9
11
 
10
12
  const root = process.cwd()
11
13
  const config = readConfig(resolve(root, '.github/code-foundry.yml'))
@@ -43,6 +45,57 @@ function readPackage() {
43
45
  try { return JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) } catch { return null }
44
46
  }
45
47
 
48
+ /** @param {string} task */
49
+ function validation(task) {
50
+ if (task === 'mode') {
51
+ writeOutput('mode', classifyValidationMode({
52
+ eventName: process.env.FOUNDRY_EVENT_NAME ?? '',
53
+ baseRef: process.env.FOUNDRY_BASE_REF ?? '',
54
+ headRef: process.env.FOUNDRY_HEAD_REF ?? '',
55
+ }))
56
+ return
57
+ }
58
+ if (task === 'gate') {
59
+ const mode = process.env.FOUNDRY_MODE ?? ''
60
+ const gate = evaluateValidationGate({
61
+ mode,
62
+ results: {
63
+ ci: process.env.FOUNDRY_CI,
64
+ test: process.env.FOUNDRY_TEST,
65
+ security: process.env.FOUNDRY_SECURITY,
66
+ codeql: process.env.FOUNDRY_CODEQL,
67
+ 'release-policy': process.env.FOUNDRY_RELEASE_POLICY,
68
+ },
69
+ })
70
+ if (gate.valid) {
71
+ console.log(`Validation gate passed for ${mode} mode.`)
72
+ return
73
+ }
74
+ for (const failure of gate.failures) console.error(`::error::${failure.job}: ${failure.result}`)
75
+ process.exitCode = 1
76
+ return
77
+ }
78
+ if (task === 'release_diff') {
79
+ const baseSha = process.env.FOUNDRY_BASE_SHA ?? ''
80
+ const changedPaths = baseSha ? capture('git', ['diff', '--name-only', `${baseSha}...HEAD`]).split(/\r?\n/) : []
81
+ const result = validateGeneratedReleaseDiff({
82
+ headRef: process.env.FOUNDRY_HEAD_REF ?? '',
83
+ headRepo: process.env.FOUNDRY_HEAD_REPO ?? '',
84
+ repository: process.env.FOUNDRY_REPOSITORY ?? '',
85
+ changedPaths,
86
+ config: readReleaseConfig(root),
87
+ })
88
+ if (!result.valid) {
89
+ for (const error of result.errors) console.error(`::error::${error}`)
90
+ process.exitCode = 1
91
+ return
92
+ }
93
+ console.log(`Release policy passed: ${result.changedPaths.length} approved changed path(s).`)
94
+ return
95
+ }
96
+ throw new Error(`Unknown validation task: ${task || '(missing)'}`)
97
+ }
98
+
46
99
  /** @param {string} key @param {unknown} value */
47
100
  function writeOutput(key, value) {
48
101
  const line = `${key}=${typeof value === 'string' ? value : JSON.stringify(value)}\n`
@@ -347,6 +400,7 @@ try {
347
400
  else if (area === 'security') security(task, ecosystem)
348
401
  else if (area === 'codeql') codeql()
349
402
  else if (area === 'profile') printProfile(task)
403
+ else if (area === 'validation') validation(task)
350
404
  else if (area === 'pre-commit') preCommit()
351
405
  else throw new Error(`Unknown runtime command: ${area || '(missing)'}`)
352
406
  } catch (error) {
@@ -1,21 +0,0 @@
1
- name: Code Foundry
2
-
3
- on:
4
- push:
5
- branches: [main, staging]
6
- pull_request:
7
- branches: [main, staging]
8
- workflow_dispatch:
9
-
10
- permissions:
11
- contents: read
12
-
13
- jobs:
14
- ci:
15
- name: CI
16
- uses: ./.github/workflows/ci.yml
17
- with:
18
- runtime-repository: 0xPlayerOne/code-foundry
19
- runtime-ref: ${{ github.sha }}
20
- runner: ubuntu-latest
21
- secrets: inherit
@@ -1,29 +0,0 @@
1
- name: Code Foundry
2
-
3
- on:
4
- push:
5
- branches: [main, staging]
6
- pull_request:
7
- branches: [main, staging]
8
- schedule:
9
- - cron: '31 6 * * 1'
10
- workflow_dispatch:
11
-
12
- permissions:
13
- actions: read
14
- contents: read
15
- packages: read
16
- security-events: write
17
-
18
- jobs:
19
- codeql:
20
- name: CodeQL
21
- uses: ./.github/workflows/codeql.yml
22
- with:
23
- runtime-repository: 0xPlayerOne/code-foundry
24
- runtime-ref: ${{ github.sha }}
25
- runner: ubuntu-latest
26
- rust-shards: '["all"]'
27
- rust-threads: '1'
28
- rust-max-parallel: 1
29
- secrets: inherit
@@ -1,21 +0,0 @@
1
- name: Code Foundry
2
-
3
- on:
4
- push:
5
- branches: [main, staging]
6
- pull_request:
7
- branches: [main, staging]
8
- workflow_dispatch:
9
-
10
- permissions:
11
- contents: read
12
-
13
- jobs:
14
- security:
15
- name: Security
16
- uses: ./.github/workflows/security.yml
17
- with:
18
- runtime-repository: 0xPlayerOne/code-foundry
19
- runtime-ref: ${{ github.sha }}
20
- runner: ubuntu-slim
21
- secrets: inherit
@@ -1,22 +0,0 @@
1
- name: Code Foundry
2
-
3
- on:
4
- push:
5
- branches: [main, staging]
6
- pull_request:
7
- branches: [main, staging]
8
- workflow_dispatch:
9
-
10
- permissions:
11
- contents: read
12
-
13
- jobs:
14
- test:
15
- name: Test
16
- uses: ./.github/workflows/test.yml
17
- with:
18
- runtime-repository: 0xPlayerOne/code-foundry
19
- runtime-ref: ${{ github.sha }}
20
- runner: ubuntu-latest
21
- unit-runner: ubuntu-slim
22
- secrets: inherit