code-foundry 1.48.5 → 1.48.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.48.6](https://github.com/0xPlayerOne/code-foundry/compare/v1.48.5...v1.48.6) (2026-10-23)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **release:** audit generated release content for stale snapshots ([#775](https://github.com/0xPlayerOne/code-foundry/issues/775)) ([f842c27](https://github.com/0xPlayerOne/code-foundry/commit/f842c271e6b270b63a6d9b839795a849237c7a7d))
9
+
3
10
  ## [1.48.5](https://github.com/0xPlayerOne/code-foundry/compare/v1.48.4...v1.48.5) (2026-10-22)
4
11
 
5
12
 
@@ -133,16 +140,9 @@
133
140
 
134
141
  * **billing:** run fast-lane executes concurrently ([#738](https://github.com/0xPlayerOne/code-foundry/issues/738)) ([b40e82a](https://github.com/0xPlayerOne/code-foundry/commit/b40e82a0709b288a8be46998cfdc8b909f92c23c))
135
142
 
136
- ## [1.45.2](https://github.com/0xPlayerOne/code-foundry/compare/v1.45.1...v1.45.2) (2026-10-06)
137
-
138
-
139
- ### Bug Fixes
140
-
141
- * **billing:** emit the codeql input as a YAML boolean ([#735](https://github.com/0xPlayerOne/code-foundry/issues/735)) ([e0b348c](https://github.com/0xPlayerOne/code-foundry/commit/e0b348c9a3c730e46210fe0b9dc46af49c9db605))
142
-
143
143
  <!-- code-foundry: packaged changelog -->
144
144
 
145
145
  ---
146
146
 
147
- This packaged changelog lists the 20 most recent releases; 253 older releases are omitted to keep the package small.
147
+ This packaged changelog lists the 20 most recent releases; 254 older releases are omitted to keep the package small.
148
148
  See the [full changelog](https://github.com/0xPlayerOne/code-foundry/blob/main/CHANGELOG.md) for the complete history.
package/docs/RELEASES.md CHANGED
@@ -108,6 +108,35 @@ and sync never overwrites existing manifest versions. Legacy configs without
108
108
  manifest; `code-foundry doctor` fails when a manifest-mode config is missing
109
109
  its manifest.
110
110
 
111
+ ## Generated release content audit
112
+
113
+ Release Please writes the release commit from file bodies it reads over the
114
+ GitHub API. A run that starts on one push and finishes after another can commit
115
+ bodies read before the second push onto the newer `main`, so the version PR
116
+ silently reverts the merged change inside an approved file (agent-sim #133
117
+ reverted `@adea-ai/ui` from `^0.144.0` to `^0.143.0` in six `package.json`
118
+ files). A path audit cannot see this, because every changed path is an approved
119
+ release file.
120
+
121
+ Both release checks therefore read the unified diff against the base and require
122
+ every added or removed line to be a release mutation: a version assignment
123
+ (`"version": "1.2.3"`, `version = "1.2.3"`, a bare version file, a line carrying
124
+ an `x-release-please-*` annotation, or a `jsonpath` key declared in
125
+ `extra-files`), a `.release-please-manifest.json` package version, or an addition
126
+ to a changelog. Any other change, a deleted file, or a rename fails closed and
127
+ names the file and the first offending lines. A minified single-line JSON
128
+ manifest passes when only its version keys differ. An unreadable diff also
129
+ fails; it is never treated as empty.
130
+
131
+ - `validation release_diff` (the required release-tier gate) audits
132
+ `base...HEAD` of the pull request merge commit, so a branch that is behind
133
+ `main` is judged by what merging it would do.
134
+ - `release validate-prs` audits `gh pr diff` before auto-merge is requested.
135
+
136
+ A failed audit leaves the version PR unmerged. Re-run the Release workflow
137
+ (`workflow_dispatch`) so Release Please regenerates the branch from the current
138
+ `main`; do not edit the generated branch or release manually.
139
+
111
140
  ## Pull request permissions
112
141
 
113
142
  The release job validates the configured automation token
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-foundry",
3
- "version": "1.48.5",
3
+ "version": "1.48.6",
4
4
  "description": "A fast, language-aware repository factory for agent-ready workflows, testing, security, and release automation.",
5
5
  "homepage": "https://github.com/0xPlayerOne/code-foundry#readme",
6
6
  "bugs": {
@@ -7,6 +7,7 @@ import { spawnSync } from 'node:child_process'
7
7
  import {
8
8
  approvedReleaseFiles,
9
9
  buildReleaseRecoveryPlan,
10
+ auditReleaseDiffContent,
10
11
  buildReconciliationPullRequestBody,
11
12
  classifyReconciliation,
12
13
  readReleaseConfig,
@@ -794,6 +795,8 @@ export function validateReleasePullRequestDiffs(root) {
794
795
  const paths = new Map()
795
796
  /** @type {string[]} */
796
797
  const headErrors = []
798
+ /** @type {string[]} */
799
+ const contentErrors = []
797
800
  for (const pr of generated) {
798
801
  const number = Number(pr.number)
799
802
  const result = spawnSync(
@@ -803,6 +806,25 @@ export function validateReleasePullRequestDiffs(root) {
803
806
  )
804
807
  paths.set(number, result.status === 0 ? result.stdout.split(/\r?\n/).filter(Boolean) : [])
805
808
 
809
+ // Paths prove only which files changed. Audit the content too, so a release
810
+ // generated from a stale snapshot cannot revert merged changes inside an
811
+ // approved file and still be queued for merge.
812
+ const content = spawnSync(
813
+ 'gh',
814
+ ['pr', 'diff', String(number), '--repo', repository, '--color', 'never'],
815
+ { cwd: resolve(root), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }
816
+ )
817
+ if (content.status === 0) {
818
+ for (const error of auditReleaseDiffContent(content.stdout, {
819
+ config: readReleaseConfig(root),
820
+ }))
821
+ contentErrors.push(`Generated release PR #${number}: ${error}`)
822
+ } else {
823
+ contentErrors.push(
824
+ `Generated release PR #${number}: unable to read its content diff; refusing to pass an unaudited release PR.`
825
+ )
826
+ }
827
+
806
828
  const auditedHead = typeof pr.headRefOid === 'string' ? pr.headRefOid : ''
807
829
  const currentPr = ghJson(root, [
808
830
  'pr',
@@ -828,9 +850,9 @@ export function validateReleasePullRequestDiffs(root) {
828
850
  paths,
829
851
  approvedReleaseFiles(readReleaseConfig(root))
830
852
  )
831
- if (headErrors.length) {
853
+ if (headErrors.length || contentErrors.length) {
832
854
  validation.valid = false
833
- validation.errors.push(...headErrors)
855
+ validation.errors.push(...headErrors, ...contentErrors)
834
856
  }
835
857
  console.log(JSON.stringify(validation, null, 2))
836
858
  if (!validation.valid) throw new Error(validation.errors.join(' '))
@@ -209,6 +209,187 @@ export function validateReleasePullRequests(prs, changedPathsByPr, allowed) {
209
209
  return { valid: errors.length === 0, generated, errors }
210
210
  }
211
211
 
212
+ /** A `key = value` or `"key": value` assignment whose key names a version. */
213
+ const VERSION_ASSIGNMENT =
214
+ /^\s*["']?[\w.-]*version[\w.-]*["']?\s*[:=]\s*["'][^"']*["']\s*[,;]?\s*$/i
215
+ const BARE_VERSION = /^\s*v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\s*$/
216
+ const MANIFEST_ENTRY = /^\s*"[^"]+"\s*:\s*"v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?"\s*,?\s*$/
217
+ const RELEASE_ANNOTATION = /x-release-please-(?:version|major|minor|patch|start-\w+|end)/
218
+
219
+ /**
220
+ * JSON keys that a configured `jsonpath` extra file is allowed to rewrite,
221
+ * beyond `version`. Keyed by repository-relative path.
222
+ * @param {Record<string, unknown>} config
223
+ * @returns {Map<string, Set<string>>}
224
+ */
225
+ function extraFileJsonKeys(config) {
226
+ /** @type {Map<string, Set<string>>} */
227
+ const keys = new Map()
228
+ /** @param {unknown} entries @param {string} prefix */
229
+ const collect = (entries, prefix) => {
230
+ if (!Array.isArray(entries)) return
231
+ for (const entry of entries) {
232
+ if (!entry || typeof entry !== 'object' || typeof entry.path !== 'string') continue
233
+ if (typeof entry.jsonpath !== 'string') continue
234
+ const key = /(?:\.|\["|\['|\[)?([^.[\]"']+)(?:"\]|'\]|\])?$/.exec(entry.jsonpath)?.[1]
235
+ if (!key) continue
236
+ const clean = entry.path.replace(/^\.\//, '').replace(/\\/g, '/')
237
+ const path = prefix ? `${prefix}/${clean}` : clean
238
+ keys.set(path, (keys.get(path) ?? new Set()).add(key))
239
+ }
240
+ }
241
+ collect(config['extra-files'], '')
242
+ const packages = config.packages
243
+ if (packages && typeof packages === 'object' && !Array.isArray(packages)) {
244
+ for (const [directory, value] of Object.entries(packages)) {
245
+ if (value && typeof value === 'object' && !Array.isArray(value))
246
+ collect(value['extra-files'], directory === '.' ? '' : directory.replace(/\/$/, ''))
247
+ }
248
+ }
249
+ return keys
250
+ }
251
+
252
+ /**
253
+ * @param {{ removed: string[], added: string[] }} entry
254
+ * @param {Set<string>|undefined} extraKeys
255
+ * @returns {boolean}
256
+ */
257
+ function sameJsonExceptVersions(entry, extraKeys) {
258
+ if (!entry.removed.length || entry.removed.length !== entry.added.length) return false
259
+ const ignored = new Set(['version', ...(extraKeys ?? [])])
260
+ /** @param {string} text */
261
+ const normalize = (text) => {
262
+ const parsed = JSON.parse(text)
263
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
264
+ throw new Error('not an object')
265
+ for (const key of ignored) delete parsed[key]
266
+ return JSON.stringify(parsed)
267
+ }
268
+ try {
269
+ return entry.removed.every((line, index) => normalize(line) === normalize(entry.added[index]))
270
+ } catch {
271
+ return false
272
+ }
273
+ }
274
+
275
+ /** @param {string[]} values @returns {string} */
276
+ function summarize(values) {
277
+ if (!values.length) return 'none'
278
+ const more = values.length > 3 ? ` (+${values.length - 3} more)` : ''
279
+ return `${values.slice(0, 3).join(' | ')}${more}`
280
+ }
281
+
282
+ /** @param {string} line @returns {string} */
283
+ function clip(line) {
284
+ const text = line.trim()
285
+ return text.length > 100 ? `${text.slice(0, 97)}...` : text
286
+ }
287
+
288
+ /**
289
+ * Decide whether one changed line is a legitimate Release Please mutation of
290
+ * `path`. Only version assignments, the manifest's package versions, and
291
+ * changelog additions qualify; any other line means the generator wrote
292
+ * content it did not read from the branch it is releasing.
293
+ * @param {string} path @param {'-'|'+'} side @param {string} line
294
+ * @param {Map<string, Set<string>>} extraKeys
295
+ */
296
+ function isReleaseMutation(path, side, line, extraKeys) {
297
+ const name = path.split('/').pop() ?? path
298
+ if (name === '.release-please-manifest.json')
299
+ return MANIFEST_ENTRY.test(line) || /^\s*[{}]\s*$/.test(line)
300
+ if (/^CHANGELOG(?:\.\w+)?$/i.test(name)) return side === '+'
301
+ if (VERSION_ASSIGNMENT.test(line) || BARE_VERSION.test(line) || RELEASE_ANNOTATION.test(line))
302
+ return true
303
+ for (const key of extraKeys.get(path) ?? []) {
304
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
305
+ if (new RegExp(`^\\s*"${escaped}"\\s*:\\s*"[^"]*"\\s*,?\\s*$`).test(line)) return true
306
+ }
307
+ return false
308
+ }
309
+
310
+ /**
311
+ * Audit the content of a generated release diff. The path audit only proves
312
+ * that approved files changed; a Release Please run that straddles a push to
313
+ * main can write the release commit with file bodies read before that push,
314
+ * silently reverting the merged change inside an approved file (for example a
315
+ * dependency range in package.json). Every added or removed line must be a
316
+ * release mutation, so a reverted range, script or dependency fails closed.
317
+ * @param {string} patch Unified diff of the release PR against its base.
318
+ * @param {{ config?: Record<string, unknown> }} [options]
319
+ * @returns {string[]}
320
+ */
321
+ export function auditReleaseDiffContent(patch, options = {}) {
322
+ const extraKeys = extraFileJsonKeys(options.config ?? {})
323
+ /** @type {string[]} */
324
+ const errors = []
325
+ /** @type {Map<string, { removed: string[], added: string[] }>} */
326
+ const foreign = new Map()
327
+ const lines = String(patch).split(/\r?\n/)
328
+ let oldPath = ''
329
+ let path = ''
330
+ let inHunk = false
331
+ let removedLeft = 0
332
+ let addedLeft = 0
333
+ for (const line of lines) {
334
+ if (inHunk && (removedLeft > 0 || addedLeft > 0)) {
335
+ if (line.startsWith('\\')) continue
336
+ if (line.startsWith('-') && removedLeft > 0) {
337
+ removedLeft -= 1
338
+ record(path, '-', line.slice(1))
339
+ continue
340
+ }
341
+ if (line.startsWith('+') && addedLeft > 0) {
342
+ addedLeft -= 1
343
+ record(path, '+', line.slice(1))
344
+ continue
345
+ }
346
+ if (line.startsWith(' ')) {
347
+ // -U0 emits no context; tolerate it when a caller passes a wider diff.
348
+ if (removedLeft > 0) removedLeft -= 1
349
+ if (addedLeft > 0) addedLeft -= 1
350
+ continue
351
+ }
352
+ }
353
+ if (line.startsWith('diff --git ')) {
354
+ inHunk = false
355
+ oldPath = ''
356
+ path = /^diff --git a\/.* b\/(.*)$/.exec(line)?.[1] ?? ''
357
+ } else if (line.startsWith('--- ')) {
358
+ oldPath = line.slice(4).replace(/^a\//, '')
359
+ } else if (line.startsWith('+++ ')) {
360
+ const next = line.slice(4)
361
+ path = next === '/dev/null' ? oldPath : next.replace(/^b\//, '')
362
+ } else if (line.startsWith('deleted file mode')) {
363
+ errors.push(`Generated release diff deletes ${path || oldPath || 'a file'}.`)
364
+ } else if (line.startsWith('rename from ')) {
365
+ errors.push(`Generated release diff renames ${line.slice('rename from '.length)}.`)
366
+ } else if (line.startsWith('@@')) {
367
+ const match = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/.exec(line)
368
+ inHunk = Boolean(match)
369
+ removedLeft = match ? Number(match[1] ?? 1) : 0
370
+ addedLeft = match ? Number(match[2] ?? 1) : 0
371
+ }
372
+ }
373
+ /** @param {string} file @param {'-'|'+'} side @param {string} text */
374
+ function record(file, side, text) {
375
+ if (isReleaseMutation(file, side, text, extraKeys)) return
376
+ const entry = foreign.get(file) ?? { removed: [], added: [] }
377
+ entry[side === '-' ? 'removed' : 'added'].push(text)
378
+ foreign.set(file, entry)
379
+ }
380
+ for (const [file, entry] of foreign) {
381
+ // A minified manifest holds the whole document on one line, so its version
382
+ // bump cannot be isolated by line. Accept it when only version keys differ.
383
+ if (sameJsonExceptVersions(entry, extraKeys.get(file))) continue
384
+ entry.removed = entry.removed.map(clip)
385
+ entry.added = entry.added.map(clip)
386
+ errors.push(
387
+ `Generated release PR changes ${file} beyond release metadata (stale or foreign content). Removed: ${summarize(entry.removed)}. Added: ${summarize(entry.added)}.`
388
+ )
389
+ }
390
+ return errors
391
+ }
392
+
212
393
  /** Files that must change for a generated release PR to count as a version bump. */
213
394
  const VERSION_METADATA_FILES = new Set([
214
395
  '.release-please-manifest.json',
@@ -223,7 +404,7 @@ const VERSION_METADATA_FILES = new Set([
223
404
  * must carry the exact approved Release Please prefix, come from the same
224
405
  * repository, change at least one path, change no unexpected paths, and bump
225
406
  * at least one version-metadata file (or a declared extra file).
226
- * @param {{ headRef?: string, headRepo?: string, repository?: string, changedPaths?: string[], config?: Record<string, unknown>, root?: string }} input
407
+ * @param {{ headRef?: string, headRepo?: string, repository?: string, changedPaths?: string[], config?: Record<string, unknown>, root?: string, patch?: string|null }} input
227
408
  * @returns {{ valid: boolean, errors: string[], changedPaths: string[] }}
228
409
  */
229
410
  export function validateGeneratedReleaseDiff(input) {
@@ -234,6 +415,7 @@ export function validateGeneratedReleaseDiff(input) {
234
415
  changedPaths = [],
235
416
  config = {},
236
417
  root = process.cwd(),
418
+ patch,
237
419
  } = input
238
420
  /** @type {string[]} */
239
421
  const errors = []
@@ -271,6 +453,13 @@ export function validateGeneratedReleaseDiff(input) {
271
453
  errors.push('Generated release pull request changes no version metadata.')
272
454
  }
273
455
  errors.push(...validateCargoLockVersions(root, config))
456
+ // Callers that can read the unified diff opt into the content audit. A null
457
+ // patch means the diff was requested but unreadable, which must not pass.
458
+ if (typeof patch === 'string') errors.push(...auditReleaseDiffContent(patch, { config }))
459
+ else if (patch === null)
460
+ errors.push(
461
+ 'Unable to read the release content diff; refusing to pass an unaudited release PR.'
462
+ )
274
463
  return { valid: errors.length === 0, errors, changedPaths: paths }
275
464
  }
276
465
 
@@ -395,6 +395,15 @@ function validation(task) {
395
395
  const changedPaths = baseSha
396
396
  ? capture('git', ['diff', '--name-only', `${baseSha}...HEAD`]).split(/\r?\n/)
397
397
  : []
398
+ // The content audit needs the real diff. An unreadable diff must fail the
399
+ // gate, not pass as an empty patch.
400
+ const diff = baseSha
401
+ ? spawnSync(
402
+ 'git',
403
+ ['diff', '--no-color', '--no-ext-diff', '--no-renames', '-U0', `${baseSha}...HEAD`],
404
+ { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }
405
+ )
406
+ : undefined
398
407
  const result = validateGeneratedReleaseDiff({
399
408
  headRef: process.env.FOUNDRY_HEAD_REF ?? '',
400
409
  headRepo: process.env.FOUNDRY_HEAD_REPO ?? '',
@@ -402,6 +411,7 @@ function validation(task) {
402
411
  changedPaths,
403
412
  config: readReleaseConfig(root),
404
413
  root,
414
+ patch: diff ? (diff.status === 0 ? diff.stdout : null) : undefined,
405
415
  })
406
416
  if (!result.valid) {
407
417
  for (const error of result.errors) console.error(`::error::${error}`)