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.
- package/.github/workflows/release.yml +144 -87
- package/.github/workflows/validation-billing.yml +1 -1
- package/.github/workflows/validation-no-codeql.yml +1 -1
- package/.github/workflows/validation.yml +1 -1
- package/CHANGELOG.md +15 -15
- package/docs/RELEASES.md +21 -0
- package/package.json +1 -1
- package/src/cli.mjs +5 -2
- package/src/commands/release.mjs +22 -0
- package/src/lib/bun-lock.mjs +149 -0
- package/src/lib/node-package-performance.mjs +37 -2
- package/src/lib/product-quality.mjs +11 -11
- package/src/lib/release-policy.mjs +2 -0
- package/src/runtime-core.mjs +22 -0
|
@@ -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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
(.
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
.
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
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
|
-
|
|
559
|
-
|
|
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;
|
|
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.
|
|
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
|
|
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))
|
package/src/commands/release.mjs
CHANGED
|
@@ -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 =
|
|
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 =
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
|
|
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
|
|
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 }))
|
package/src/runtime-core.mjs
CHANGED
|
@@ -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 ?? '',
|