code-foundry 1.48.6 → 1.48.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.
@@ -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:
@@ -467,96 +514,106 @@ jobs:
467
514
  fi
468
515
 
469
516
  for pr in "${release_prs[@]}"; do
470
- pr_state=$(gh pr view "$pr" --repo "$GITHUB_REPOSITORY" \
471
- --json isDraft,headRefOid,headRefName \
472
- --jq '[.isDraft, .headRefOid, .headRefName] | @tsv')
473
- read -r PR_DRAFT PR_HEAD PR_BRANCH <<< "$pr_state"
474
- if [[ ! "$PR_DRAFT" =~ ^(true|false)$ || ! "$PR_HEAD" =~ ^[a-f0-9]{40}$ ]]; then
475
- echo 'Unable to verify generated release PR state.' >&2
476
- exit 1
477
- fi
478
- case "$PR_BRANCH" in
479
- release-please--branches--main|release-please--branches--main--*) ;;
480
- *) echo 'Release PR head left the generated namespace; refusing readiness mutation.' >&2; exit 1 ;;
481
- esac
482
- if [ "$AUTO_MERGE" = true ]; then
483
- if [ "$PR_DRAFT" = false ]; then
484
- # A ready head already receives synchronize validation. Preserve
485
- # its live/completed owner instead of cancelling and reallocating
486
- # CI every time another queued main release run prepares it.
487
- runs=$(GH_TOKEN="$VALIDATION_TOKEN" gh api \
488
- "repos/$GITHUB_REPOSITORY/actions/runs" --method GET \
489
- -f event=pull_request -f head_sha="$PR_HEAD" -f per_page=100)
490
- validation=$(jq -er --arg head "$PR_HEAD" --arg branch "$PR_BRANCH" \
491
- --arg repo "$GITHUB_REPOSITORY" '
492
- def active: .status == "queued" or .status == "in_progress" or
493
- .status == "waiting" or .status == "pending" or .status == "requested";
494
- def valid_run:
495
- type == "object" and
496
- (.id | type == "number" and . > 0 and floor == .) and
497
- (.run_attempt | type == "number" and . >= 1 and floor == .) and
498
- (.head_sha | type == "string" and test("^[a-f0-9]{40}$")) and
499
- (.head_branch | type == "string" and length > 0) and
500
- (.event | type == "string" and length > 0) and
501
- (.repository | type == "object") and
502
- (.repository.full_name | type == "string" and length > 0) and
503
- (.path | type == "string" and length > 0) and
504
- ((active and has("conclusion") and .conclusion == null) or
505
- (.status == "completed" and
506
- (.conclusion == "success" or .conclusion == "failure" or
507
- .conclusion == "timed_out" or .conclusion == "action_required" or
508
- .conclusion == "cancelled" or .conclusion == "skipped" or
509
- .conclusion == "neutral")));
510
- if (.workflow_runs | type) != "array" or
511
- (.total_count | type) != "number" then
512
- error("Invalid validation owner response")
513
- elif .total_count != (.workflow_runs | length) then
514
- error("Incomplete validation owner response")
515
- elif any(.workflow_runs[]; valid_run | not) then
516
- error("Invalid validation run identity or state")
517
- else
518
- [.workflow_runs[] | (.path | split("@")[0]) as $workflow | select(
519
- .head_sha == $head and .head_branch == $branch and
520
- .event == "pull_request" and .repository.full_name == $repo and
521
- ($workflow == ".github/workflows/validation.yml" or
522
- $workflow == ".github/workflows/validation_self-ci.yml")
523
- )] | sort_by(.id) | last |
524
- if . == null then "missing"
525
- elif active or (.status == "completed" and .conclusion == "success") then "owned"
526
- elif .status == "completed" and
527
- (.conclusion == "failure" or .conclusion == "timed_out" or
528
- .conclusion == "action_required") then "failed"
529
- elif .status == "completed" and
530
- (.conclusion == "cancelled" or .conclusion == "skipped" or
531
- .conclusion == "neutral") then "missing"
532
- else error("Unconfirmed validation owner state") end
533
- end' <<< "$runs")
534
- fresh_state=$(gh pr view "$pr" --repo "$GITHUB_REPOSITORY" \
535
- --json isDraft,headRefOid,headRefName \
536
- --jq '[.isDraft, .headRefOid, .headRefName] | @tsv')
537
- if [ "$fresh_state" != "$pr_state" ]; then
538
- echo 'Release PR state changed during validation lookup; refusing a stale readiness mutation.' >&2
539
- exit 1
540
- fi
541
- if [ "$validation" = owned ]; then
542
- echo "Release PR #$pr already has current-head validation; preserving readiness."
543
- continue
517
+ # A lock-sync/synchronize transition can move the PR while Actions is
518
+ # being read. Re-evaluate the whole tuple and its owner; never carry
519
+ # eligibility across heads or retry a stable failed qualification.
520
+ for state_attempt in 1 2 3; do
521
+ pr_state=$(gh pr view "$pr" --repo "$GITHUB_REPOSITORY" \
522
+ --json isDraft,headRefOid,headRefName \
523
+ --jq '[.isDraft, .headRefOid, .headRefName] | @tsv')
524
+ read -r PR_DRAFT PR_HEAD PR_BRANCH <<< "$pr_state"
525
+ if [[ ! "$PR_DRAFT" =~ ^(true|false)$ || ! "$PR_HEAD" =~ ^[a-f0-9]{40}$ ]]; then
526
+ echo 'Unable to verify generated release PR state.' >&2
527
+ exit 1
528
+ fi
529
+ case "$PR_BRANCH" in
530
+ release-please--branches--main|release-please--branches--main--*) ;;
531
+ *) echo 'Release PR head left the generated namespace; refusing readiness mutation.' >&2; exit 1 ;;
532
+ esac
533
+ if [ "$AUTO_MERGE" = true ]; then
534
+ if [ "$PR_DRAFT" = false ]; then
535
+ # A ready head already receives synchronize validation. Preserve
536
+ # its live/completed owner instead of cancelling and reallocating
537
+ # CI every time another queued main release run prepares it.
538
+ runs=$(GH_TOKEN="$VALIDATION_TOKEN" gh api \
539
+ "repos/$GITHUB_REPOSITORY/actions/runs" --method GET \
540
+ -f event=pull_request -f head_sha="$PR_HEAD" -f per_page=100)
541
+ validation=$(jq -er --arg head "$PR_HEAD" --arg branch "$PR_BRANCH" \
542
+ --arg repo "$GITHUB_REPOSITORY" '
543
+ def active: .status == "queued" or .status == "in_progress" or
544
+ .status == "waiting" or .status == "pending" or .status == "requested";
545
+ def valid_run:
546
+ type == "object" and
547
+ (.id | type == "number" and . > 0 and floor == .) and
548
+ (.run_attempt | type == "number" and . >= 1 and floor == .) and
549
+ (.head_sha | type == "string" and test("^[a-f0-9]{40}$")) and
550
+ (.head_branch | type == "string" and length > 0) and
551
+ (.event | type == "string" and length > 0) and
552
+ (.repository | type == "object") and
553
+ (.repository.full_name | type == "string" and length > 0) and
554
+ (.path | type == "string" and length > 0) and
555
+ ((active and has("conclusion") and .conclusion == null) or
556
+ (.status == "completed" and
557
+ (.conclusion == "success" or .conclusion == "failure" or
558
+ .conclusion == "timed_out" or .conclusion == "action_required" or
559
+ .conclusion == "cancelled" or .conclusion == "skipped" or
560
+ .conclusion == "neutral")));
561
+ if (.workflow_runs | type) != "array" or
562
+ (.total_count | type) != "number" then
563
+ error("Invalid validation owner response")
564
+ elif .total_count != (.workflow_runs | length) then
565
+ error("Incomplete validation owner response")
566
+ elif any(.workflow_runs[]; valid_run | not) then
567
+ error("Invalid validation run identity or state")
568
+ else
569
+ [.workflow_runs[] | (.path | split("@")[0]) as $workflow | select(
570
+ .head_sha == $head and .head_branch == $branch and
571
+ .event == "pull_request" and .repository.full_name == $repo and
572
+ ($workflow == ".github/workflows/validation.yml" or
573
+ $workflow == ".github/workflows/validation_self-ci.yml")
574
+ )] | sort_by(.id) | last |
575
+ if . == null then "missing"
576
+ elif active or (.status == "completed" and .conclusion == "success") then "owned"
577
+ elif .status == "completed" and
578
+ (.conclusion == "failure" or .conclusion == "timed_out" or
579
+ .conclusion == "action_required") then "failed"
580
+ elif .status == "completed" and
581
+ (.conclusion == "cancelled" or .conclusion == "skipped" or
582
+ .conclusion == "neutral") then "missing"
583
+ else error("Unconfirmed validation owner state") end
584
+ end' <<< "$runs")
585
+ fresh_state=$(gh pr view "$pr" --repo "$GITHUB_REPOSITORY" \
586
+ --json isDraft,headRefOid,headRefName \
587
+ --jq '[.isDraft, .headRefOid, .headRefName] | @tsv')
588
+ if [ "$fresh_state" != "$pr_state" ]; then
589
+ if [ "$state_attempt" -eq 3 ]; then
590
+ echo 'Release PR state did not stabilize during validation lookup; refusing readiness mutation.' >&2
591
+ exit 1
592
+ fi
593
+ echo "Release PR #$pr changed during validation lookup; reading its complete state and owner again."
594
+ continue
595
+ fi
596
+ if [ "$validation" = owned ]; then
597
+ echo "Release PR #$pr already has current-head validation; preserving readiness."
598
+ break
599
+ fi
600
+ if [ "$validation" = failed ]; then
601
+ echo "Current-head validation failed for release PR #$pr; resolve or explicitly retry it before release." >&2
602
+ exit 1
603
+ fi
604
+ # A missing, cancelled or skipped owner still needs the existing
605
+ # explicit ready_for_review transition with the validated token.
606
+ gh pr ready --undo "$pr" --repo "$GITHUB_REPOSITORY"
544
607
  fi
545
- if [ "$validation" = failed ]; then
546
- echo "Current-head validation failed for release PR #$pr; resolve or explicitly retry it before release." >&2
547
- exit 1
608
+ gh pr ready "$pr" --repo "$GITHUB_REPOSITORY"
609
+ else
610
+ if [ "$PR_DRAFT" = false ]; then
611
+ gh pr ready --undo "$pr" --repo "$GITHUB_REPOSITORY"
548
612
  fi
549
- # A missing, cancelled or skipped owner still needs the existing
550
- # explicit ready_for_review transition with the validated token.
551
- gh pr ready --undo "$pr" --repo "$GITHUB_REPOSITORY"
552
- fi
553
- gh pr ready "$pr" --repo "$GITHUB_REPOSITORY"
554
- else
555
- if [ "$PR_DRAFT" = false ]; then
556
- gh pr ready --undo "$pr" --repo "$GITHUB_REPOSITORY"
613
+ echo "::notice title=Manual release merge required::Release Please created or updated a version pull request. No valid automation token is available (absent or rejected by GitHub), so release PRs are left in draft and need manual readiness before validation and merge workflows trigger."
557
614
  fi
558
- echo "::notice title=Manual release merge required::Release Please created or updated a version pull request. No valid automation token is available (absent or rejected by GitHub), so release PRs are left in draft and need manual readiness before validation and merge workflows trigger."
559
- fi
615
+ break
616
+ done
560
617
  done
561
618
  - name: Leave release pull request for manual merge
562
619
  if: steps.release.outputs.prs_created == 'true' && steps.credentials.outputs.auto_merge != 'true'
@@ -1161,8 +1161,8 @@ jobs:
1161
1161
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
1162
1162
  with:
1163
1163
  persist-credentials: false
1164
+ # The content audit needs historical blobs after checkout removes credentials.
1164
1165
  fetch-depth: 0
1165
- filter: blob:none
1166
1166
  - name: Validate generated release diff
1167
1167
  if: ${{ inputs.mode == 'release' }}
1168
1168
  env:
@@ -205,8 +205,8 @@ jobs:
205
205
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
206
206
  with:
207
207
  persist-credentials: false
208
+ # The content audit needs historical blobs after checkout removes credentials.
208
209
  fetch-depth: 0
209
- filter: blob:none
210
210
  - name: Validate generated release diff
211
211
  if: ${{ inputs.mode == 'release' }}
212
212
  env:
@@ -216,8 +216,8 @@ jobs:
216
216
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
217
217
  with:
218
218
  persist-credentials: false
219
+ # The content audit needs historical blobs after checkout removes credentials.
219
220
  fetch-depth: 0
220
- filter: blob:none
221
221
  - name: Validate generated release diff
222
222
  if: ${{ inputs.mode == 'release' }}
223
223
  env:
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.48.8](https://github.com/0xPlayerOne/code-foundry/compare/v1.48.7...v1.48.8) (2026-10-24)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **release:** hydrate audit blobs and refresh changing PR state ([#779](https://github.com/0xPlayerOne/code-foundry/issues/779)) ([8734ed7](https://github.com/0xPlayerOne/code-foundry/commit/8734ed77d159818cd5ff613f40a5dab796789d8a))
9
+
10
+ ## [1.48.7](https://github.com/0xPlayerOne/code-foundry/compare/v1.48.6...v1.48.7) (2026-10-23)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **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))
16
+
3
17
  ## [1.48.6](https://github.com/0xPlayerOne/code-foundry/compare/v1.48.5...v1.48.6) (2026-10-23)
4
18
 
5
19
 
@@ -126,23 +140,9 @@
126
140
 
127
141
  * **billing:** join fast-lane receipts in a second parallel group ([#742](https://github.com/0xPlayerOne/code-foundry/issues/742)) ([c63a39c](https://github.com/0xPlayerOne/code-foundry/commit/c63a39cf5e236d6747bd85cf10b04bd10f690b11))
128
142
 
129
- ## [1.46.1](https://github.com/0xPlayerOne/code-foundry/compare/v1.46.0...v1.46.1) (2026-10-07)
130
-
131
-
132
- ### Bug Fixes
133
-
134
- * **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
-
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; 256 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.8",
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
+ }
@@ -17,6 +17,42 @@ const METRICS = new Set([
17
17
  'productionDependencyCount',
18
18
  ])
19
19
 
20
+ /**
21
+ * npm 12 keys pack reports by package name; older npm versions return an array.
22
+ * Count reports before validating their shape so an extra malformed entry cannot be ignored.
23
+ * @param {unknown} value
24
+ * @returns {{filename: string, size: number, unpackedSize: number, files: Array<{path: string}>}}
25
+ */
26
+ export function singleNpmPackReport(value) {
27
+ let reports
28
+ if (Array.isArray(value)) reports = value
29
+ else if (value && typeof value === 'object') {
30
+ const object = /** @type {Record<string, unknown>} */ (value)
31
+ reports = typeof object.filename === 'string' ? [object] : Object.values(object)
32
+ } else reports = []
33
+ if (reports.length !== 1) throw new Error('Expected one npm pack report.')
34
+ const packed = reports[0]
35
+ if (!packed || typeof packed !== 'object' || Array.isArray(packed))
36
+ throw new Error('npm pack returned no package result.')
37
+ if (typeof packed.filename !== 'string' || packed.filename.length === 0)
38
+ throw new Error('npm pack result is missing filename.')
39
+ for (const field of ['size', 'unpackedSize'])
40
+ if (typeof packed[field] !== 'number' || !Number.isFinite(packed[field]) || packed[field] < 0)
41
+ throw new Error(`npm pack result is missing non-negative numeric ${field}.`)
42
+ if (
43
+ !Array.isArray(packed.files) ||
44
+ !packed.files.every(
45
+ (/** @type {unknown} */ file) =>
46
+ file !== null &&
47
+ typeof file === 'object' &&
48
+ !Array.isArray(file) &&
49
+ typeof (/** @type {{path?: unknown}} */ (file).path) === 'string'
50
+ )
51
+ )
52
+ throw new Error('npm pack result is missing a valid files list.')
53
+ return packed
54
+ }
55
+
20
56
  /** @param {number[]} values @param {number} percent */
21
57
  export function percentile(values, percent) {
22
58
  if (values.length === 0) throw new Error('percentile requires at least one value')
@@ -59,8 +95,7 @@ export function runNodePackagePerformance(
59
95
  ? measureImports(root, String(policy.controlImport), samples)
60
96
  : null
61
97
  const pack = npmJson(root, ['pack', '--dry-run', '--json', '--ignore-scripts'], 'npm pack')
62
- const packed = Array.isArray(pack) ? pack[0] : pack
63
- if (!packed || typeof packed !== 'object') throw new Error('npm pack returned no package result.')
98
+ const packed = singleNpmPackReport(pack)
64
99
  /** @type {Array<{path?: string}>} */
65
100
  const files = Array.isArray(packed.files) ? packed.files : []
66
101
  const productionDependencyCount = npmProductionDependencyCount(root)
@@ -17,6 +17,7 @@ import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
17
17
  import { pathToFileURL } from 'node:url'
18
18
  import { tmpdir } from 'node:os'
19
19
  import { gzipSync } from 'node:zlib'
20
+ import { singleNpmPackReport } from './node-package-performance.mjs'
20
21
 
21
22
  /** @typedef {Record<string, any>} Profile */
22
23
  /** @param {unknown} condition @param {string} message @returns {asserts condition} */
@@ -602,24 +603,23 @@ export function checkPackage(root, profile, output) {
602
603
  // Pack outside the consumer repository so test installations cannot become package input.
603
604
  const temporary = mkdtempSync(join(tmpdir(), 'foundry-package-quality-'))
604
605
  try {
605
- const pack = JSON.parse(
606
- runQualityCommand(
607
- ['npm', 'pack', '--ignore-scripts', '--json', '--pack-destination', temporary],
608
- root,
609
- output
606
+ const pack = singleNpmPackReport(
607
+ JSON.parse(
608
+ runQualityCommand(
609
+ ['npm', 'pack', '--ignore-scripts', '--json', '--pack-destination', temporary],
610
+ root,
611
+ output
612
+ )
610
613
  )
611
614
  )
615
+ requireValue(/^[A-Za-z0-9._-]+\.tgz$/.test(pack.filename), 'Expected one package archive')
612
616
  requireValue(
613
- Array.isArray(pack) && pack.length === 1 && /^[A-Za-z0-9._-]+\.tgz$/.test(pack[0].filename),
614
- 'Expected one package archive'
615
- )
616
- requireValue(
617
- pack[0].files.every(
617
+ pack.files.every(
618
618
  (/** @type {{path:string}} */ file) => !/^\.code-foundry(?:\/|$)/.test(file.path)
619
619
  ),
620
620
  'Exclude .code-foundry quality evidence from the published package'
621
621
  )
622
- const archive = ownedPath(temporary, pack[0].filename)
622
+ const archive = ownedPath(temporary, pack.filename)
623
623
  const archiveSha256 = createHash('sha256').update(readFileSync(archive)).digest('hex')
624
624
  const consumer = join(temporary, 'consumer')
625
625
  mkdirSync(consumer)
@@ -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 }))
@@ -404,6 +404,28 @@ function validation(task) {
404
404
  { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }
405
405
  )
406
406
  : undefined
407
+ if (diff && diff.status !== 0) {
408
+ // Git can relay arbitrary remote messages, URLs and credentials on stderr. Emit only
409
+ // bounded, recognised failure facts; never replay remote text as workflow annotations.
410
+ const stderr = (diff.stderr ?? '').slice(0, 8192)
411
+ const missingBlob = /could not fetch ([0-9a-f]{40,64}) from promisor remote/i.exec(stderr)
412
+ const reason = missingBlob
413
+ ? `historical blob ${missingBlob[1]} unavailable from promisor remote`
414
+ : /authentication failed|could not read (?:username|password)|terminal prompts disabled/i.test(
415
+ stderr
416
+ )
417
+ ? 'authentication unavailable'
418
+ : /could not read from remote repository/i.test(stderr)
419
+ ? 'remote repository unavailable'
420
+ : /bad object|unknown revision|invalid symmetric difference|ambiguous argument/i.test(
421
+ stderr
422
+ )
423
+ ? 'revision unavailable'
424
+ : 'Git error details omitted'
425
+ console.error(
426
+ `::error::Git content diff failed (status ${diff.status ?? 'unavailable'}): ${reason}.`
427
+ )
428
+ }
407
429
  const result = validateGeneratedReleaseDiff({
408
430
  headRef: process.env.FOUNDRY_HEAD_REF ?? '',
409
431
  headRepo: process.env.FOUNDRY_HEAD_REPO ?? '',