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.
- package/.github/workflows/release.yml +47 -0
- package/CHANGELOG.md +15 -15
- package/docs/RELEASES.md +50 -0
- package/package.json +1 -1
- package/src/cli.mjs +5 -2
- package/src/commands/release.mjs +46 -2
- package/src/lib/bun-lock.mjs +149 -0
- package/src/lib/release-policy.mjs +192 -1
- package/src/runtime-core.mjs +10 -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:
|
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;
|
|
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.
|
|
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
|
|
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
|
@@ -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
|
|
package/src/runtime-core.mjs
CHANGED
|
@@ -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}`)
|