code-foundry 1.48.5 → 1.48.7

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.
@@ -439,6 +439,53 @@ jobs:
439
439
  latest=false
440
440
  if [ "$PRERELEASE" != true ]; then latest=true; fi
441
441
  gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false --prerelease="$PRERELEASE" --latest="$latest"
442
+ - name: Sync lockfile workspace versions on generated release PRs
443
+ # Release Please bumps every workspace package.json but has no updater
444
+ # for bun.lock, which records each workspace's version, so a generated
445
+ # release PR leaves the lock naming the previous version of every
446
+ # workspace. Commit the corrected lock onto the release branch before
447
+ # the draft-state and merge steps read its head. The commit is made with
448
+ # the validated automation token so the PR's required checks run on it;
449
+ # a push made with the workflow token starts no workflows, so that
450
+ # credential skips this step and the release gate reports the stale
451
+ # lock instead. A rejected push (Release Please moved the branch) is
452
+ # not retried: the next release run starts again from the new head.
453
+ if: steps.release.outcome == 'success' && steps.credentials.outputs.token_source == 'configured'
454
+ continue-on-error: true
455
+ env:
456
+ GH_TOKEN: ${{ secrets.CODE_FOUNDRY_TOKEN }}
457
+ run: |
458
+ set -euo pipefail
459
+ mapfile -t release_branches < <(
460
+ gh pr list \
461
+ --repo "$GITHUB_REPOSITORY" \
462
+ --state open \
463
+ --base main \
464
+ --json headRefName \
465
+ --jq '.[] | select(.headRefName == "release-please--branches--main" or (.headRefName | startswith("release-please--branches--main--"))) | .headRefName'
466
+ )
467
+ if [ ${#release_branches[@]} -eq 0 ]; then
468
+ exit 0
469
+ fi
470
+ gh auth setup-git
471
+ index=0
472
+ for branch in "${release_branches[@]}"; do
473
+ index=$((index + 1))
474
+ worktree="$RUNNER_TEMP/release-lock-$index"
475
+ git fetch --no-tags --depth=1 origin "refs/heads/$branch"
476
+ git worktree add --detach "$worktree" FETCH_HEAD
477
+ node "$RUNNER_TEMP/code-foundry/src/cli.mjs" release sync-lock --target "$worktree"
478
+ if git -C "$worktree" diff --quiet -- bun.lock; then
479
+ continue
480
+ fi
481
+ git -C "$worktree" \
482
+ -c user.name='github-actions[bot]' \
483
+ -c user.email='41898282+github-actions[bot]@users.noreply.github.com' \
484
+ commit --quiet -m 'chore(release): sync bun.lock workspace versions' -- bun.lock
485
+ if ! git -C "$worktree" push origin "HEAD:refs/heads/$branch"; then
486
+ echo "::warning title=Release lock sync::$branch moved before the lockfile sync could be pushed; the next release run will sync it."
487
+ fi
488
+ done
442
489
  - name: Normalize generated release PR draft state
443
490
  if: steps.release.outcome == 'success'
444
491
  env:
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.48.7](https://github.com/0xPlayerOne/code-foundry/compare/v1.48.6...v1.48.7) (2026-10-23)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **release:** sync bun.lock workspace versions on generated release PRs ([#777](https://github.com/0xPlayerOne/code-foundry/issues/777)) ([b24bfae](https://github.com/0xPlayerOne/code-foundry/commit/b24bfae309ac7003fcc4cd57f79ddc418c27b039))
9
+
10
+ ## [1.48.6](https://github.com/0xPlayerOne/code-foundry/compare/v1.48.5...v1.48.6) (2026-10-23)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **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))
16
+
3
17
  ## [1.48.5](https://github.com/0xPlayerOne/code-foundry/compare/v1.48.4...v1.48.5) (2026-10-22)
4
18
 
5
19
 
@@ -126,23 +140,9 @@
126
140
 
127
141
  * **qualification:** skip parallel-syntax files until actionlint supports them ([#740](https://github.com/0xPlayerOne/code-foundry/issues/740)) ([db98c7f](https://github.com/0xPlayerOne/code-foundry/commit/db98c7f40cae080cdc9dcab7baac4976e5e0656e))
128
142
 
129
- ## [1.46.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.45.2...v1.46.0) (2026-10-07)
130
-
131
-
132
- ### Features
133
-
134
- * **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
-
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; 255 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,56 @@ 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
+
140
+ ## Lockfile workspace versions
141
+
142
+ Release Please's Node updater bumps every workspace `package.json` but has no
143
+ updater for `bun.lock`, which records each workspace's `version`. A version PR
144
+ therefore leaves the lock naming the previous version of every workspace (the
145
+ agent-sim 0.15.14 release left all 14 workspaces at 0.15.10).
146
+
147
+ - `validation release_diff` fails a generated release whose `bun.lock` records a
148
+ workspace version that differs from its `package.json`, and names the first
149
+ workspaces and the fix.
150
+ - `code-foundry release sync-lock [--target PATH] [--dry-run]` rewrites only
151
+ those drifted `version` values; it never touches dependencies, the `packages`
152
+ section or line endings, and does nothing when the lock already matches.
153
+ - The Release workflow runs it on each open generated release branch before the
154
+ draft-state and merge steps read the PR head, then pushes one
155
+ `chore(release): sync bun.lock workspace versions` commit with the validated
156
+ automation token so the required checks run on it. With the fallback workflow
157
+ token (whose pushes start no workflows) the step is skipped and the gate
158
+ reports the stale lock instead. A push rejected because Release Please moved
159
+ the branch is left for the next run.
160
+
111
161
  ## Pull request permissions
112
162
 
113
163
  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.7",
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": {
package/src/cli.mjs CHANGED
@@ -23,6 +23,7 @@ Usage:
23
23
  npx code-foundry ci pause|resume|status [--target PATH]
24
24
  npx code-foundry release reconcile [--github] [--base BRANCH] [--head BRANCH]
25
25
  npx code-foundry release hook --tag TAG --workflow WORKFLOW
26
+ npx code-foundry release sync-lock [--target PATH] [--dry-run]
26
27
  npx code-foundry release-integrity settings --repo OWNER/REPO
27
28
  npx code-foundry release-integrity release --repo OWNER/REPO --tag TAG
28
29
  npx code-foundry release-integrity manifest --asset PATH [--root PATH]
@@ -93,9 +94,9 @@ function parseArgs(argv) {
93
94
 
94
95
  if (command === 'release') {
95
96
  const subcommand = argv.shift() ?? ''
96
- if (!['reconcile', 'hook', 'validate-prs', 'recovery-plan'].includes(subcommand))
97
+ if (!['reconcile', 'hook', 'validate-prs', 'recovery-plan', 'sync-lock'].includes(subcommand))
97
98
  fail(
98
- `unknown release command: ${subcommand ?? '(missing)'}; use release reconcile, release hook, release validate-prs, or release recovery-plan`
99
+ `unknown release command: ${subcommand ?? '(missing)'}; use release reconcile, release hook, release validate-prs, release recovery-plan, or release sync-lock`
99
100
  )
100
101
  options.releaseSubcommand = subcommand
101
102
  } else if (command === 'release-integrity') {
@@ -247,11 +248,13 @@ async function main() {
247
248
  dispatchPostReleaseHook,
248
249
  reconcileRelease,
249
250
  releaseRecoveryPlan,
251
+ syncReleaseLock,
250
252
  validateReleasePullRequestDiffs,
251
253
  } = await import('./commands/release.mjs')
252
254
  if (options.releaseSubcommand === 'hook') dispatchPostReleaseHook(target, options)
253
255
  else if (options.releaseSubcommand === 'validate-prs') validateReleasePullRequestDiffs(target)
254
256
  else if (options.releaseSubcommand === 'recovery-plan') releaseRecoveryPlan(target)
257
+ else if (options.releaseSubcommand === 'sync-lock') syncReleaseLock(target, options)
255
258
  else reconcileRelease(target, options)
256
259
  } catch (error) {
257
260
  fail(error instanceof Error ? error.message : String(error))
@@ -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,
@@ -16,6 +17,7 @@ import {
16
17
  selectReconciliationPullRequest,
17
18
  validateReleasePullRequests,
18
19
  } from '../lib/release-policy.mjs'
20
+ import { syncBunLockWorkspaceVersions } from '../lib/bun-lock.mjs'
19
21
  import { hasDeliveredHook, releaseDeliveryKey, selectHookDelivery } from '../lib/release-hook.mjs'
20
22
 
21
23
  /** @typedef {{ target: string, dryRun: boolean, github: boolean, base: string, head: string }} ReleaseOptions */
@@ -767,6 +769,27 @@ export function dispatchPostReleaseHook(root, options) {
767
769
  return { ...decision, deliveryKey: key, dispatched: true }
768
770
  }
769
771
 
772
+ /**
773
+ * Rewrite the workspace versions `bun.lock` records to match the manifests a
774
+ * Release Please run just bumped.
775
+ * @param {string} root
776
+ * @param {{ dryRun: boolean }} options
777
+ */
778
+ export function syncReleaseLock(root, options) {
779
+ const changes = syncBunLockWorkspaceVersions(root, { dryRun: options.dryRun })
780
+ if (!changes.length) {
781
+ console.log('bun.lock workspace versions already match package.json; nothing to sync.')
782
+ return changes
783
+ }
784
+ const verb = options.dryRun ? 'Would update' : 'Updated'
785
+ for (const change of changes) {
786
+ console.log(
787
+ `${verb} bun.lock ${change.path || '.'} (${change.name}): ${change.from} -> ${change.to}`
788
+ )
789
+ }
790
+ return changes
791
+ }
792
+
770
793
  /** @param {string} root */
771
794
  export function validateReleasePullRequestDiffs(root) {
772
795
  const repository = process.env.GITHUB_REPOSITORY
@@ -794,6 +817,8 @@ export function validateReleasePullRequestDiffs(root) {
794
817
  const paths = new Map()
795
818
  /** @type {string[]} */
796
819
  const headErrors = []
820
+ /** @type {string[]} */
821
+ const contentErrors = []
797
822
  for (const pr of generated) {
798
823
  const number = Number(pr.number)
799
824
  const result = spawnSync(
@@ -803,6 +828,25 @@ export function validateReleasePullRequestDiffs(root) {
803
828
  )
804
829
  paths.set(number, result.status === 0 ? result.stdout.split(/\r?\n/).filter(Boolean) : [])
805
830
 
831
+ // Paths prove only which files changed. Audit the content too, so a release
832
+ // generated from a stale snapshot cannot revert merged changes inside an
833
+ // approved file and still be queued for merge.
834
+ const content = spawnSync(
835
+ 'gh',
836
+ ['pr', 'diff', String(number), '--repo', repository, '--color', 'never'],
837
+ { cwd: resolve(root), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }
838
+ )
839
+ if (content.status === 0) {
840
+ for (const error of auditReleaseDiffContent(content.stdout, {
841
+ config: readReleaseConfig(root),
842
+ }))
843
+ contentErrors.push(`Generated release PR #${number}: ${error}`)
844
+ } else {
845
+ contentErrors.push(
846
+ `Generated release PR #${number}: unable to read its content diff; refusing to pass an unaudited release PR.`
847
+ )
848
+ }
849
+
806
850
  const auditedHead = typeof pr.headRefOid === 'string' ? pr.headRefOid : ''
807
851
  const currentPr = ghJson(root, [
808
852
  'pr',
@@ -828,9 +872,9 @@ export function validateReleasePullRequestDiffs(root) {
828
872
  paths,
829
873
  approvedReleaseFiles(readReleaseConfig(root))
830
874
  )
831
- if (headErrors.length) {
875
+ if (headErrors.length || contentErrors.length) {
832
876
  validation.valid = false
833
- validation.errors.push(...headErrors)
877
+ validation.errors.push(...headErrors, ...contentErrors)
834
878
  }
835
879
  console.log(JSON.stringify(validation, null, 2))
836
880
  if (!validation.valid) throw new Error(validation.errors.join(' '))
@@ -0,0 +1,149 @@
1
+ // @ts-check
2
+
3
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+
6
+ /**
7
+ * Release Please's Node updater bumps every workspace `package.json` but has no
8
+ * updater for `bun.lock`, which records each workspace's version. A release
9
+ * commit therefore leaves the lock naming the previous version of every
10
+ * workspace. These helpers read and rewrite only those recorded versions; the
11
+ * rest of the lock is never touched.
12
+ */
13
+
14
+ const WORKSPACES_START = /^ {2}"workspaces": \{\s*$/
15
+ const WORKSPACES_END = /^ {2}\},?\s*$/
16
+ const ENTRY_START = /^ {4}"([^"]*)": \{\s*$/
17
+ const ENTRY_END = /^ {4}\},?\s*$/
18
+ const NAME_LINE = /^ {6}"name": "([^"]*)",?\s*$/
19
+ const VERSION_LINE = /^( {6}"version": ")([^"]*)(".*)$/
20
+
21
+ /**
22
+ * @typedef {{ path: string, name: string, version: string, line: number }} LockedWorkspace
23
+ */
24
+
25
+ /**
26
+ * The workspace versions a `bun.lock` records, with the line each sits on.
27
+ * Workspaces without a `version` line (the root, private packages without a
28
+ * version) are not listed: bun omits the key when the manifest has none.
29
+ * @param {string} text
30
+ * @returns {LockedWorkspace[]}
31
+ */
32
+ export function lockedWorkspaces(text) {
33
+ const lines = text.split(/\r?\n/)
34
+ /** @type {LockedWorkspace[]} */
35
+ const found = []
36
+ let inWorkspaces = false
37
+ let entry = ''
38
+ let name = ''
39
+ for (let index = 0; index < lines.length; index += 1) {
40
+ const line = lines[index]
41
+ if (!inWorkspaces) {
42
+ if (WORKSPACES_START.test(line)) inWorkspaces = true
43
+ continue
44
+ }
45
+ if (!entry) {
46
+ if (WORKSPACES_END.test(line)) break
47
+ const start = ENTRY_START.exec(line)
48
+ if (start) {
49
+ entry = start[1]
50
+ name = ''
51
+ }
52
+ continue
53
+ }
54
+ if (ENTRY_END.test(line)) {
55
+ entry = ''
56
+ continue
57
+ }
58
+ const nameLine = NAME_LINE.exec(line)
59
+ if (nameLine) {
60
+ name = nameLine[1]
61
+ continue
62
+ }
63
+ const versionLine = VERSION_LINE.exec(line)
64
+ if (versionLine) found.push({ path: entry, name, version: versionLine[2], line: index })
65
+ }
66
+ return found
67
+ }
68
+
69
+ /**
70
+ * @param {string} root
71
+ * @param {string} workspacePath
72
+ * @returns {string | undefined}
73
+ */
74
+ function manifestVersion(root, workspacePath) {
75
+ const file = join(root, workspacePath, 'package.json')
76
+ if (!existsSync(file)) return undefined
77
+ try {
78
+ const version = JSON.parse(readFileSync(file, 'utf8')).version
79
+ return typeof version === 'string' && version ? version : undefined
80
+ } catch {
81
+ return undefined
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Locked workspace versions that differ from their manifest.
87
+ * @param {string} root
88
+ * @returns {Array<LockedWorkspace & { manifest: string }>}
89
+ */
90
+ export function bunLockVersionDrift(root) {
91
+ const lockPath = join(root, 'bun.lock')
92
+ if (!existsSync(lockPath)) return []
93
+ /** @type {Array<LockedWorkspace & { manifest: string }>} */
94
+ const drift = []
95
+ for (const workspace of lockedWorkspaces(readFileSync(lockPath, 'utf8'))) {
96
+ const manifest = manifestVersion(root, workspace.path)
97
+ if (manifest !== undefined && manifest !== workspace.version)
98
+ drift.push({ ...workspace, manifest })
99
+ }
100
+ return drift
101
+ }
102
+
103
+ /**
104
+ * Release-gate errors for a stale `bun.lock`.
105
+ * @param {string} root
106
+ * @returns {string[]}
107
+ */
108
+ export function validateBunLockVersions(root) {
109
+ const drift = bunLockVersionDrift(root)
110
+ if (!drift.length) return []
111
+ const shown = drift
112
+ .slice(0, 3)
113
+ .map(
114
+ (entry) =>
115
+ `${entry.path || '.'} (${entry.name || 'unnamed'}) is ${entry.version} in bun.lock but ${entry.manifest} in package.json`
116
+ )
117
+ const more = drift.length > shown.length ? ` (+${drift.length - shown.length} more)` : ''
118
+ return [
119
+ `bun.lock records ${drift.length} workspace version(s) that differ from package.json: ${shown.join('; ')}${more}. Run code-foundry release sync-lock.`,
120
+ ]
121
+ }
122
+
123
+ /**
124
+ * Rewrite stale workspace versions in `bun.lock` to match their manifests.
125
+ * Only the `version` value of a drifted workspace changes.
126
+ * @param {string} root
127
+ * @param {{ dryRun?: boolean }} [options]
128
+ * @returns {Array<{ path: string, name: string, from: string, to: string }>}
129
+ */
130
+ export function syncBunLockWorkspaceVersions(root, options = {}) {
131
+ const drift = bunLockVersionDrift(root)
132
+ if (!drift.length) return []
133
+ const lockPath = join(root, 'bun.lock')
134
+ const text = readFileSync(lockPath, 'utf8')
135
+ const newline = text.includes('\r\n') ? '\r\n' : '\n'
136
+ const lines = text.split(/\r?\n/)
137
+ for (const entry of drift) {
138
+ const match = VERSION_LINE.exec(lines[entry.line])
139
+ if (!match) continue
140
+ lines[entry.line] = `${match[1]}${entry.manifest}${match[3]}`
141
+ }
142
+ if (!options.dryRun) writeFileSync(lockPath, lines.join(newline))
143
+ return drift.map((entry) => ({
144
+ path: entry.path,
145
+ name: entry.name,
146
+ from: entry.version,
147
+ to: entry.manifest,
148
+ }))
149
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { existsSync, readFileSync } from 'node:fs'
4
4
  import { dirname, join } from 'node:path'
5
+ import { validateBunLockVersions } from './bun-lock.mjs'
5
6
  import { isReleasePleaseHead } from './validation-policy.mjs'
6
7
 
7
8
  const DEFAULT_RELEASE_FILES = new Set([
@@ -209,6 +210,187 @@ export function validateReleasePullRequests(prs, changedPathsByPr, allowed) {
209
210
  return { valid: errors.length === 0, generated, errors }
210
211
  }
211
212
 
213
+ /** A `key = value` or `"key": value` assignment whose key names a version. */
214
+ const VERSION_ASSIGNMENT =
215
+ /^\s*["']?[\w.-]*version[\w.-]*["']?\s*[:=]\s*["'][^"']*["']\s*[,;]?\s*$/i
216
+ const BARE_VERSION = /^\s*v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\s*$/
217
+ const MANIFEST_ENTRY = /^\s*"[^"]+"\s*:\s*"v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?"\s*,?\s*$/
218
+ const RELEASE_ANNOTATION = /x-release-please-(?:version|major|minor|patch|start-\w+|end)/
219
+
220
+ /**
221
+ * JSON keys that a configured `jsonpath` extra file is allowed to rewrite,
222
+ * beyond `version`. Keyed by repository-relative path.
223
+ * @param {Record<string, unknown>} config
224
+ * @returns {Map<string, Set<string>>}
225
+ */
226
+ function extraFileJsonKeys(config) {
227
+ /** @type {Map<string, Set<string>>} */
228
+ const keys = new Map()
229
+ /** @param {unknown} entries @param {string} prefix */
230
+ const collect = (entries, prefix) => {
231
+ if (!Array.isArray(entries)) return
232
+ for (const entry of entries) {
233
+ if (!entry || typeof entry !== 'object' || typeof entry.path !== 'string') continue
234
+ if (typeof entry.jsonpath !== 'string') continue
235
+ const key = /(?:\.|\["|\['|\[)?([^.[\]"']+)(?:"\]|'\]|\])?$/.exec(entry.jsonpath)?.[1]
236
+ if (!key) continue
237
+ const clean = entry.path.replace(/^\.\//, '').replace(/\\/g, '/')
238
+ const path = prefix ? `${prefix}/${clean}` : clean
239
+ keys.set(path, (keys.get(path) ?? new Set()).add(key))
240
+ }
241
+ }
242
+ collect(config['extra-files'], '')
243
+ const packages = config.packages
244
+ if (packages && typeof packages === 'object' && !Array.isArray(packages)) {
245
+ for (const [directory, value] of Object.entries(packages)) {
246
+ if (value && typeof value === 'object' && !Array.isArray(value))
247
+ collect(value['extra-files'], directory === '.' ? '' : directory.replace(/\/$/, ''))
248
+ }
249
+ }
250
+ return keys
251
+ }
252
+
253
+ /**
254
+ * @param {{ removed: string[], added: string[] }} entry
255
+ * @param {Set<string>|undefined} extraKeys
256
+ * @returns {boolean}
257
+ */
258
+ function sameJsonExceptVersions(entry, extraKeys) {
259
+ if (!entry.removed.length || entry.removed.length !== entry.added.length) return false
260
+ const ignored = new Set(['version', ...(extraKeys ?? [])])
261
+ /** @param {string} text */
262
+ const normalize = (text) => {
263
+ const parsed = JSON.parse(text)
264
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
265
+ throw new Error('not an object')
266
+ for (const key of ignored) delete parsed[key]
267
+ return JSON.stringify(parsed)
268
+ }
269
+ try {
270
+ return entry.removed.every((line, index) => normalize(line) === normalize(entry.added[index]))
271
+ } catch {
272
+ return false
273
+ }
274
+ }
275
+
276
+ /** @param {string[]} values @returns {string} */
277
+ function summarize(values) {
278
+ if (!values.length) return 'none'
279
+ const more = values.length > 3 ? ` (+${values.length - 3} more)` : ''
280
+ return `${values.slice(0, 3).join(' | ')}${more}`
281
+ }
282
+
283
+ /** @param {string} line @returns {string} */
284
+ function clip(line) {
285
+ const text = line.trim()
286
+ return text.length > 100 ? `${text.slice(0, 97)}...` : text
287
+ }
288
+
289
+ /**
290
+ * Decide whether one changed line is a legitimate Release Please mutation of
291
+ * `path`. Only version assignments, the manifest's package versions, and
292
+ * changelog additions qualify; any other line means the generator wrote
293
+ * content it did not read from the branch it is releasing.
294
+ * @param {string} path @param {'-'|'+'} side @param {string} line
295
+ * @param {Map<string, Set<string>>} extraKeys
296
+ */
297
+ function isReleaseMutation(path, side, line, extraKeys) {
298
+ const name = path.split('/').pop() ?? path
299
+ if (name === '.release-please-manifest.json')
300
+ return MANIFEST_ENTRY.test(line) || /^\s*[{}]\s*$/.test(line)
301
+ if (/^CHANGELOG(?:\.\w+)?$/i.test(name)) return side === '+'
302
+ if (VERSION_ASSIGNMENT.test(line) || BARE_VERSION.test(line) || RELEASE_ANNOTATION.test(line))
303
+ return true
304
+ for (const key of extraKeys.get(path) ?? []) {
305
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
306
+ if (new RegExp(`^\\s*"${escaped}"\\s*:\\s*"[^"]*"\\s*,?\\s*$`).test(line)) return true
307
+ }
308
+ return false
309
+ }
310
+
311
+ /**
312
+ * Audit the content of a generated release diff. The path audit only proves
313
+ * that approved files changed; a Release Please run that straddles a push to
314
+ * main can write the release commit with file bodies read before that push,
315
+ * silently reverting the merged change inside an approved file (for example a
316
+ * dependency range in package.json). Every added or removed line must be a
317
+ * release mutation, so a reverted range, script or dependency fails closed.
318
+ * @param {string} patch Unified diff of the release PR against its base.
319
+ * @param {{ config?: Record<string, unknown> }} [options]
320
+ * @returns {string[]}
321
+ */
322
+ export function auditReleaseDiffContent(patch, options = {}) {
323
+ const extraKeys = extraFileJsonKeys(options.config ?? {})
324
+ /** @type {string[]} */
325
+ const errors = []
326
+ /** @type {Map<string, { removed: string[], added: string[] }>} */
327
+ const foreign = new Map()
328
+ const lines = String(patch).split(/\r?\n/)
329
+ let oldPath = ''
330
+ let path = ''
331
+ let inHunk = false
332
+ let removedLeft = 0
333
+ let addedLeft = 0
334
+ for (const line of lines) {
335
+ if (inHunk && (removedLeft > 0 || addedLeft > 0)) {
336
+ if (line.startsWith('\\')) continue
337
+ if (line.startsWith('-') && removedLeft > 0) {
338
+ removedLeft -= 1
339
+ record(path, '-', line.slice(1))
340
+ continue
341
+ }
342
+ if (line.startsWith('+') && addedLeft > 0) {
343
+ addedLeft -= 1
344
+ record(path, '+', line.slice(1))
345
+ continue
346
+ }
347
+ if (line.startsWith(' ')) {
348
+ // -U0 emits no context; tolerate it when a caller passes a wider diff.
349
+ if (removedLeft > 0) removedLeft -= 1
350
+ if (addedLeft > 0) addedLeft -= 1
351
+ continue
352
+ }
353
+ }
354
+ if (line.startsWith('diff --git ')) {
355
+ inHunk = false
356
+ oldPath = ''
357
+ path = /^diff --git a\/.* b\/(.*)$/.exec(line)?.[1] ?? ''
358
+ } else if (line.startsWith('--- ')) {
359
+ oldPath = line.slice(4).replace(/^a\//, '')
360
+ } else if (line.startsWith('+++ ')) {
361
+ const next = line.slice(4)
362
+ path = next === '/dev/null' ? oldPath : next.replace(/^b\//, '')
363
+ } else if (line.startsWith('deleted file mode')) {
364
+ errors.push(`Generated release diff deletes ${path || oldPath || 'a file'}.`)
365
+ } else if (line.startsWith('rename from ')) {
366
+ errors.push(`Generated release diff renames ${line.slice('rename from '.length)}.`)
367
+ } else if (line.startsWith('@@')) {
368
+ const match = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/.exec(line)
369
+ inHunk = Boolean(match)
370
+ removedLeft = match ? Number(match[1] ?? 1) : 0
371
+ addedLeft = match ? Number(match[2] ?? 1) : 0
372
+ }
373
+ }
374
+ /** @param {string} file @param {'-'|'+'} side @param {string} text */
375
+ function record(file, side, text) {
376
+ if (isReleaseMutation(file, side, text, extraKeys)) return
377
+ const entry = foreign.get(file) ?? { removed: [], added: [] }
378
+ entry[side === '-' ? 'removed' : 'added'].push(text)
379
+ foreign.set(file, entry)
380
+ }
381
+ for (const [file, entry] of foreign) {
382
+ // A minified manifest holds the whole document on one line, so its version
383
+ // bump cannot be isolated by line. Accept it when only version keys differ.
384
+ if (sameJsonExceptVersions(entry, extraKeys.get(file))) continue
385
+ entry.removed = entry.removed.map(clip)
386
+ entry.added = entry.added.map(clip)
387
+ errors.push(
388
+ `Generated release PR changes ${file} beyond release metadata (stale or foreign content). Removed: ${summarize(entry.removed)}. Added: ${summarize(entry.added)}.`
389
+ )
390
+ }
391
+ return errors
392
+ }
393
+
212
394
  /** Files that must change for a generated release PR to count as a version bump. */
213
395
  const VERSION_METADATA_FILES = new Set([
214
396
  '.release-please-manifest.json',
@@ -223,7 +405,7 @@ const VERSION_METADATA_FILES = new Set([
223
405
  * must carry the exact approved Release Please prefix, come from the same
224
406
  * repository, change at least one path, change no unexpected paths, and bump
225
407
  * 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
408
+ * @param {{ headRef?: string, headRepo?: string, repository?: string, changedPaths?: string[], config?: Record<string, unknown>, root?: string, patch?: string|null }} input
227
409
  * @returns {{ valid: boolean, errors: string[], changedPaths: string[] }}
228
410
  */
229
411
  export function validateGeneratedReleaseDiff(input) {
@@ -234,6 +416,7 @@ export function validateGeneratedReleaseDiff(input) {
234
416
  changedPaths = [],
235
417
  config = {},
236
418
  root = process.cwd(),
419
+ patch,
237
420
  } = input
238
421
  /** @type {string[]} */
239
422
  const errors = []
@@ -271,6 +454,14 @@ export function validateGeneratedReleaseDiff(input) {
271
454
  errors.push('Generated release pull request changes no version metadata.')
272
455
  }
273
456
  errors.push(...validateCargoLockVersions(root, config))
457
+ errors.push(...validateBunLockVersions(root))
458
+ // Callers that can read the unified diff opt into the content audit. A null
459
+ // patch means the diff was requested but unreadable, which must not pass.
460
+ if (typeof patch === 'string') errors.push(...auditReleaseDiffContent(patch, { config }))
461
+ else if (patch === null)
462
+ errors.push(
463
+ 'Unable to read the release content diff; refusing to pass an unaudited release PR.'
464
+ )
274
465
  return { valid: errors.length === 0, errors, changedPaths: paths }
275
466
  }
276
467
 
@@ -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}`)