code-foundry 1.48.6 → 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,12 @@
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
+
3
10
  ## [1.48.6](https://github.com/0xPlayerOne/code-foundry/compare/v1.48.5...v1.48.6) (2026-10-23)
4
11
 
5
12
 
@@ -133,16 +140,9 @@
133
140
 
134
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))
135
142
 
136
- ## [1.46.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.45.2...v1.46.0) (2026-10-07)
137
-
138
-
139
- ### Features
140
-
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))
142
-
143
143
  <!-- code-foundry: packaged changelog -->
144
144
 
145
145
  ---
146
146
 
147
- This packaged changelog lists the 20 most recent releases; 254 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
@@ -137,6 +137,27 @@ A failed audit leaves the version PR unmerged. Re-run the Release workflow
137
137
  (`workflow_dispatch`) so Release Please regenerates the branch from the current
138
138
  `main`; do not edit the generated branch or release manually.
139
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
+
140
161
  ## Pull request permissions
141
162
 
142
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.6",
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))
@@ -17,6 +17,7 @@ import {
17
17
  selectReconciliationPullRequest,
18
18
  validateReleasePullRequests,
19
19
  } from '../lib/release-policy.mjs'
20
+ import { syncBunLockWorkspaceVersions } from '../lib/bun-lock.mjs'
20
21
  import { hasDeliveredHook, releaseDeliveryKey, selectHookDelivery } from '../lib/release-hook.mjs'
21
22
 
22
23
  /** @typedef {{ target: string, dryRun: boolean, github: boolean, base: string, head: string }} ReleaseOptions */
@@ -768,6 +769,27 @@ export function dispatchPostReleaseHook(root, options) {
768
769
  return { ...decision, deliveryKey: key, dispatched: true }
769
770
  }
770
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
+
771
793
  /** @param {string} root */
772
794
  export function validateReleasePullRequestDiffs(root) {
773
795
  const repository = process.env.GITHUB_REPOSITORY
@@ -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([
@@ -453,6 +454,7 @@ export function validateGeneratedReleaseDiff(input) {
453
454
  errors.push('Generated release pull request changes no version metadata.')
454
455
  }
455
456
  errors.push(...validateCargoLockVersions(root, config))
457
+ errors.push(...validateBunLockVersions(root))
456
458
  // Callers that can read the unified diff opt into the content audit. A null
457
459
  // patch means the diff was requested but unreadable, which must not pass.
458
460
  if (typeof patch === 'string') errors.push(...auditReleaseDiffContent(patch, { config }))