code-foundry 1.46.5 → 1.47.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.47.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.46.6...v1.47.0) (2026-10-08)
4
+
5
+
6
+ ### Features
7
+
8
+ * **release:** configurable dev channel on top of batched releases ([#749](https://github.com/0xPlayerOne/code-foundry/issues/749)) ([8fd8556](https://github.com/0xPlayerOne/code-foundry/commit/8fd85567a1a89735ab0db589649741253fbbeb21)), closes [#699](https://github.com/0xPlayerOne/code-foundry/issues/699)
9
+
10
+ ## [1.46.6](https://github.com/0xPlayerOne/code-foundry/compare/v1.46.5...v1.46.6) (2026-10-08)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **pre-commit:** build before lint and type-check when the gate opts in ([#748](https://github.com/0xPlayerOne/code-foundry/issues/748)) ([f56454f](https://github.com/0xPlayerOne/code-foundry/commit/f56454f313a1a5b802b8a17b09d850902de1ba55)), closes [#691](https://github.com/0xPlayerOne/code-foundry/issues/691)
16
+
3
17
  ## [1.46.5](https://github.com/0xPlayerOne/code-foundry/compare/v1.46.4...v1.46.5) (2026-10-08)
4
18
 
5
19
 
@@ -126,23 +140,9 @@
126
140
 
127
141
  * **cli:** assert expected doctor warnings ([#712](https://github.com/0xPlayerOne/code-foundry/issues/712)) ([d036bee](https://github.com/0xPlayerOne/code-foundry/commit/d036beee79a9b200315cdbee5509fc3c2a6c4a04))
128
142
 
129
- ## [1.44.2](https://github.com/0xPlayerOne/code-foundry/compare/v1.44.1...v1.44.2) (2026-10-03)
130
-
131
-
132
- ### Bug Fixes
133
-
134
- * **ci:** cancel only superseded pull-request runs in managed workflows ([#710](https://github.com/0xPlayerOne/code-foundry/issues/710)) ([f5fead6](https://github.com/0xPlayerOne/code-foundry/commit/f5fead677c032d253f2e1a2a256da4f207ddfa2a))
135
-
136
- ## [1.44.1](https://github.com/0xPlayerOne/code-foundry/compare/v1.44.0...v1.44.1) (2026-10-03)
137
-
138
-
139
- ### Bug Fixes
140
-
141
- * **release:** budget the package without the changelog and ship recent history only ([#708](https://github.com/0xPlayerOne/code-foundry/issues/708)) ([ab365e0](https://github.com/0xPlayerOne/code-foundry/commit/ab365e0910472c3596429adc86a1c8f3e19730f9))
142
-
143
143
  <!-- code-foundry: packaged changelog -->
144
144
 
145
145
  ---
146
146
 
147
- This packaged changelog lists the 20 most recent releases; 240 older releases are omitted to keep the package small.
147
+ This packaged changelog lists the 20 most recent releases; 242 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.
@@ -131,7 +131,7 @@ See [Merge queue validation](merge-queues.md) before enabling it.
131
131
  | `coverage_minimum` | `0`–`100` | Minimum percentage; defaults to `80`. |
132
132
  | `coverage_metrics` | `lines`, `functions`, `branches`, `statements` | Metrics checked by the coverage gate. |
133
133
  | `coverage_report` | comma-separated repository paths | Istanbul JSON summary or LCOV evidence files. |
134
- | `pre_commit_build` | `true`, `false` | Run the project's build in the local pre-commit gate. Defaults to `false`; see [Pre-commit gate](#pre-commit-gate). |
134
+ | `pre_commit_build` | `true`, `false` | Run the project's build in the local pre-commit gate, before lint and type-check (mirroring CI's ordering). Defaults to `false`; see [Pre-commit gate](#pre-commit-gate). |
135
135
  | `filter_<task>` | comma-separated repository path globs | Skip a lane when no changed path matches; see [Lane path filters](#lane-path-filters). |
136
136
  | `rust_sccache` | `true`, `false` | Install sccache and wrap rustc so compile artifacts survive manifest/lockfile churn. |
137
137
  | `rust_nextest` | `true`, `false` | Install cargo-nextest and prefer `cargo nextest run` over `cargo test` in Rust test lanes. |
@@ -141,6 +141,8 @@ See [Merge queue validation](merge-queues.md) before enabling it.
141
141
  | `release_batching_prerelease` | `true`, `false` | Defaults to `true` with a schedule. Sync enables `draft` and `force-tag-creation` in Release Please; the producer publishes the draft with the selected visibility. `false` keeps scheduled releases stable-visible. |
142
142
  | `release_batching_soak_hours` | `1`–`336` | Generate a daily promoter that promotes the newest prerelease when the oldest candidate since the last stable has soaked this many hours, stitching batch notes. Requires a batching schedule and prerelease publication. Set `STABLE_PROMOTION_HELD=true` in repository Actions variables to hold scheduled and manual promotion; `force` overrides the hold or soak, and `version` selects a plain semantic version. |
143
143
  | `release_batching_qualification_workflow` | workflow filename (for example `release-assets.yml`) | Require the latest release-event run of this workflow for the candidate tag’s exact source commit to succeed before stable promotion. Requires a batching soak. Running, missing, failed, or cancelled runs hold promotion, including named and forced promotions; `force` only bypasses hold and soak. The promoter receives read-only Actions access. |
144
+ | `release_batching_dev_channel` | `true`, `false` | Publish a dev channel: every push to main tags a `v<stable>-dev.<run>` pre-release on top of the newest stable and dispatches the configured asset lane. Dev builds are never promoted to stable, auto builds pause with `CI_BILLING_PAUSED`, and manual dispatches accept a `version` input for repairs. Defaults to `false`. |
145
+ | `release_batching_dev_workflow` | workflow filename | The `workflow_dispatch` asset lane the dev channel dispatches with a `version` input; the lane and its version injection stay app-provided because packaging differs per app. Required by `release_batching_dev_channel`. |
144
146
 
145
147
  Supported task capabilities are `format`, `lint`, `type_check`, `build`, `unit`,
146
148
  `integration`, `e2e`, `smoke`, `eval`, and `performance`. `coverage` is a policy
@@ -194,7 +196,11 @@ gate is change-aware and never runs work for code the commit cannot affect:
194
196
  Clippy.
195
197
  - The build does not run at commit time; CI and explicit commands own it. Set
196
198
  `pre_commit_build: true` to run the `build` script (or `cargo build`) when
197
- code is staged.
199
+ code is staged. The build then runs before lint and type-check — mirroring
200
+ CI's ordering — because those tasks read built package output. With the
201
+ build disabled, a lint or type-check failure that a fresh clone's missing
202
+ build output could explain is printed with a one-line note saying so instead
203
+ of a bare error.
198
204
 
199
205
  Tools read working-tree contents, so a partially staged file is checked as it
200
206
  exists on disk. Task scripts that declare dependencies of their own, such as a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-foundry",
3
- "version": "1.46.5",
3
+ "version": "1.47.0",
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": {
@@ -308,6 +308,13 @@ function synchronize(options) {
308
308
  const promoterPath = join(target, '.github/workflows/promote-stable.yml')
309
309
  if (promoter) writeOrReport(promoterPath, promoter, dryRun)
310
310
  else if (existsSync(promoterPath) && !dryRun) rmSync(promoterPath)
311
+ // The dev channel rides along the same way: rendered from
312
+ // release_batching_dev_channel, removed when the key is gone so a
313
+ // disabled channel stops tagging dev builds on every push.
314
+ const devChannel = renderDevChannel(config)
315
+ const devChannelPath = join(target, '.github/workflows/dev-channel.yml')
316
+ if (devChannel) writeOrReport(devChannelPath, devChannel, dryRun)
317
+ else if (existsSync(devChannelPath) && !dryRun) rmSync(devChannelPath)
311
318
  }
312
319
  if (file === '.github/dependabot.yml') {
313
320
  content = Buffer.from(renderDependabot(content.toString('utf8'), config, languages))
@@ -936,6 +943,29 @@ function validateReleaseBatchingConfig(config) {
936
943
  )
937
944
  }
938
945
  }
946
+ if (
947
+ config.release_batching_dev_channel &&
948
+ !['true', 'false'].includes(config.release_batching_dev_channel)
949
+ ) {
950
+ throw new Error(
951
+ `Unsupported release_batching_dev_channel: ${config.release_batching_dev_channel}; use true or false.`
952
+ )
953
+ }
954
+ if (configured(config.release_batching_dev_channel, 'false') === 'true') {
955
+ if (
956
+ !config.release_batching_dev_workflow ||
957
+ !/^[A-Za-z0-9_.-]+\.ya?ml$/.test(config.release_batching_dev_workflow)
958
+ ) {
959
+ throw new Error(
960
+ 'Unsupported release_batching_dev_workflow: use the filename of the workflow_dispatch asset lane the dev build should dispatch.'
961
+ )
962
+ }
963
+ }
964
+ if (config.release_batching_dev_workflow && config.release_batching_dev_channel !== 'true') {
965
+ throw new Error(
966
+ 'Unsupported release_batching_dev_workflow: requires release_batching_dev_channel: true.'
967
+ )
968
+ }
939
969
  }
940
970
 
941
971
  /** Render the batch-soak promoter for a repository, or null when the
@@ -953,6 +983,102 @@ export function renderPromoteStable(config) {
953
983
  )
954
984
  }
955
985
 
986
+ /**
987
+ * The dev channel for repositories with `release_batching_dev_channel`. Every
988
+ * push to main tags a `v<stable>-dev.<run>` pre-release on top of the newest
989
+ * stable and dispatches the repository's own asset lane with a `version`
990
+ * input; the oldest dev builds are pruned so the channel stays bounded. Dev
991
+ * builds are never promoted: the batch-soak promoter's batch selector already
992
+ * excludes `-dev.` tags, so the channel lives beside stable forever.
993
+ * `__DEV_WORKFLOW__` is replaced with the configured asset lane at render
994
+ * time; the lane stays app-provided because packaging differs per app.
995
+ */
996
+ const DEV_CHANNEL_TEMPLATE = `# Generated by Code Foundry sync from release_batching_dev_channel; rerun sync after changing or removing the key.
997
+
998
+ name: Publish dev channel
999
+
1000
+ on:
1001
+ push:
1002
+ branches: [main]
1003
+ workflow_dispatch:
1004
+ inputs:
1005
+ version:
1006
+ description: Publish this dev version now (for example v0.75.1-dev.42). Leave empty to derive it from the newest stable.
1007
+ required: false
1008
+ type: string
1009
+
1010
+ permissions:
1011
+ contents: write
1012
+ actions: write
1013
+
1014
+ concurrency:
1015
+ group: dev-channel
1016
+ cancel-in-progress: false
1017
+
1018
+ jobs:
1019
+ publish:
1020
+ name: Tag the dev build and dispatch the asset lane
1021
+ # Auto dev builds pause with the shared billing pause; the manual dispatch
1022
+ # (the repair path) still runs.
1023
+ if: github.event_name == 'workflow_dispatch' || vars.CI_BILLING_PAUSED != 'true'
1024
+ runs-on: ubuntu-latest
1025
+ timeout-minutes: 10
1026
+ steps:
1027
+ - name: Publish the dev prerelease and dispatch the asset lane
1028
+ env:
1029
+ GH_TOKEN: \${{ github.token }}
1030
+ DEV_WORKFLOW: '__DEV_WORKFLOW__'
1031
+ REQUESTED: \${{ inputs.version }}
1032
+ run: |
1033
+ set -euo pipefail
1034
+ repo="$GITHUB_REPOSITORY"
1035
+ if [ -n "$REQUESTED" ]; then
1036
+ tag="$REQUESTED"
1037
+ else
1038
+ stable="$(gh api --paginate --slurp "repos/$repo/releases?per_page=100" | jq -r '
1039
+ [ .[] | select(.draft == false and .prerelease == false)
1040
+ | select(.tag_name | test("^v[0-9]+[.][0-9]+[.][0-9]+$")) ]
1041
+ | sort_by(.published_at) | .[-1].tag_name // empty')"
1042
+ if [ -z "$stable" ]; then
1043
+ echo "No stable release exists yet; a dev channel needs a stable baseline." | tee "$GITHUB_STEP_SUMMARY"
1044
+ exit 0
1045
+ fi
1046
+ tag="\${stable}-dev.\${GITHUB_RUN_NUMBER}"
1047
+ fi
1048
+ if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then
1049
+ echo "$tag already exists; nothing to do." | tee "$GITHUB_STEP_SUMMARY"
1050
+ exit 0
1051
+ fi
1052
+ gh release create "$tag" --repo "$repo" --prerelease --target "$GITHUB_SHA" \
1053
+ --title "$tag" --notes "Dev build of \${GITHUB_SHA::7}; a dev-channel build is never promoted to stable."
1054
+ gh workflow run "$DEV_WORKFLOW" --repo "$repo" --ref main -f version="$tag"
1055
+ echo "Dispatched $DEV_WORKFLOW for $tag." | tee "$GITHUB_STEP_SUMMARY"
1056
+
1057
+ # Keep the channel bounded: the newest dev builds stay, the rest go.
1058
+ gh api --paginate --slurp "repos/$repo/releases?per_page=100" | jq -r '
1059
+ [ .[] | select(.draft == false and .prerelease == true)
1060
+ | select(.tag_name | test("^v[0-9]+[.][0-9]+[.][0-9]+-dev[.][0-9]+$")) ]
1061
+ | sort_by(.created_at) | reverse | .[3:] | .[].tag_name' | while read -r stale; do
1062
+ echo "Pruning $stale."
1063
+ gh release delete "$stale" --repo "$repo" --yes --cleanup-tag
1064
+ done
1065
+ `
1066
+
1067
+ /**
1068
+ * Render the dev channel workflow for a repository, or null when the
1069
+ * repository has not opted in.
1070
+ *
1071
+ * @param {Record<string, string>} config
1072
+ * @returns {string | null}
1073
+ */
1074
+ export function renderDevChannel(config) {
1075
+ if (configured(config.release_batching_dev_channel, 'false') !== 'true') return null
1076
+ return DEV_CHANNEL_TEMPLATE.replaceAll(
1077
+ '__DEV_WORKFLOW__',
1078
+ config.release_batching_dev_workflow ?? ''
1079
+ )
1080
+ }
1081
+
956
1082
  /**
957
1083
  * @param {string} content
958
1084
  * @param {Record<string,string>} config
@@ -98,6 +98,25 @@ export function preCommitBuildEnabled(value) {
98
98
  return resolved === 'true'
99
99
  }
100
100
 
101
+ /**
102
+ * The one-line explanation printed when lint or type-check fails while the
103
+ * commit-time build is disabled. CI builds before those tasks, so a fresh
104
+ * clone can fail the gate against an unmodified tree — a bare ENOENT trains
105
+ * `--no-verify` unless the gate names the actual problem.
106
+ *
107
+ * @param {{ build: boolean, buildScript: boolean, packageManager: string | null }} options
108
+ * @returns {string | undefined} The hint, or undefined when it cannot apply.
109
+ */
110
+ export function freshCloneBuildHint({ build, buildScript, packageManager }) {
111
+ if (build || !buildScript) return undefined
112
+ const runner = packageManager ?? 'npm'
113
+ return (
114
+ `note: these checks may be reading missing build output on a fresh clone; ` +
115
+ `run "${runner} run build" once, or set pre_commit_build: true in ` +
116
+ `.github/code-foundry.yml to build before lint and type-check`
117
+ )
118
+ }
119
+
101
120
  /**
102
121
  * Split a file list so one tool invocation never exceeds the platform's
103
122
  * argument-length limit on very large commits.
@@ -25,6 +25,7 @@ import {
25
25
  parseStagedFiles,
26
26
  planPreCommit,
27
27
  preCommitBuildEnabled,
28
+ freshCloneBuildHint,
28
29
  } from './lib/pre-commit.mjs'
29
30
  import { runNodePackagePerformance } from './lib/node-package-performance.mjs'
30
31
  import {
@@ -428,16 +429,20 @@ function commandExists(command) {
428
429
  return spawnSync(command, ['--version'], { stdio: 'ignore' }).status === 0
429
430
  }
430
431
 
431
- /** @param {string} command @param {string[]} [args] @param {Record<string, unknown>} [options] */
432
+ /** @param {string} command @param {string[]} [args] @param {{ onFailure?: string }} [options] */
432
433
  function run(command, args = [], options = {}) {
434
+ const { onFailure, ...spawnOptions } = options
433
435
  const result = spawnSync(command, args, {
434
436
  cwd: root,
435
437
  stdio: 'inherit',
436
438
  env: process.env,
437
- ...options,
439
+ ...spawnOptions,
438
440
  })
439
441
  if (result.error) throw result.error
440
- if (result.status !== 0) process.exit(result.status ?? 1)
442
+ if (result.status !== 0) {
443
+ if (onFailure) console.error(onFailure)
444
+ process.exit(result.status ?? 1)
445
+ }
441
446
  }
442
447
 
443
448
  // pip-audit can report its clean result with a non-zero status when the
@@ -494,28 +499,28 @@ function packageCommand(args) {
494
499
  }
495
500
  }
496
501
 
497
- /** @param {string[]} names */
498
- function runScript(names) {
502
+ /** @param {string[]} names @param {{ onFailure?: string }} [options] */
503
+ function runScript(names, options = {}) {
499
504
  const name = names.find((candidate) => hasScript(candidate))
500
505
  if (!name) return false
501
506
  const [manager, args] = packageCommand(['run', name])
502
507
  if (!manager) return false
503
- run(manager, args)
508
+ run(manager, args, options)
504
509
  return true
505
510
  }
506
511
 
507
- /** @param {string} tool @param {string[]} [args] */
508
- function runTool(tool, args = []) {
512
+ /** @param {string} tool @param {string[]} [args] @param {{ onFailure?: string }} [options] */
513
+ function runTool(tool, args = [], options = {}) {
509
514
  if (['ruff', 'pytest', 'pylint'].includes(tool)) {
510
515
  const venvTool = resolve(root, `.venv/bin/${tool}`)
511
- if (existsSync(venvTool)) return run(venvTool, args)
516
+ if (existsSync(venvTool)) return run(venvTool, args, options)
512
517
  }
513
518
  const [manager] = packageCommand([])
514
- if (manager === 'bun') return run('bunx', ['--no-install', tool, ...args])
515
- if (manager === 'pnpm') return run('pnpm', ['exec', tool, ...args])
516
- if (manager === 'yarn') return run('yarn', ['exec', tool, ...args])
517
- if (manager === 'npm') return run('npx', ['--no-install', tool, ...args])
518
- return run(tool, args)
519
+ if (manager === 'bun') return run('bunx', ['--no-install', tool, ...args], options)
520
+ if (manager === 'pnpm') return run('pnpm', ['exec', tool, ...args], options)
521
+ if (manager === 'yarn') return run('yarn', ['exec', tool, ...args], options)
522
+ if (manager === 'npm') return run('npx', ['--no-install', tool, ...args], options)
523
+ return run(tool, args, options)
519
524
  }
520
525
 
521
526
  function install() {
@@ -1193,9 +1198,11 @@ function runToolOnFiles(tool, args, files) {
1193
1198
  *
1194
1199
  * The gate is change-aware: it formats and lints only staged files, runs the
1195
1200
  * project's type-check command only when typed sources or compiler config are
1196
- * staged, and never builds unless `pre_commit_build: true` opts back in. The
1197
- * full suite still runs in CI. Tools see working-tree contents, so a partially
1198
- * staged file is checked as it exists on disk.
1201
+ * staged, and never builds unless `pre_commit_build: true` opts back in — in
1202
+ * which case the build runs before lint and type-check, mirroring CI's
1203
+ * ordering, because those tasks read built package output. The full suite
1204
+ * still runs in CI. Tools see working-tree contents, so a partially staged
1205
+ * file is checked as it exists on disk.
1199
1206
  */
1200
1207
  export function preCommit() {
1201
1208
  const staged = parseStagedFiles(
@@ -1225,9 +1232,22 @@ export function preCommit() {
1225
1232
  runScript(['format:check', 'format', 'fmt'])
1226
1233
  }
1227
1234
  }
1235
+ // Mirror CI's prerequisite ordering: lint, boundary, and type-check tasks
1236
+ // read built package output, so the opt-in build runs before them. Format
1237
+ // stays first for the fastest feedback on the most common failure. Without
1238
+ // the opt-in, a failure that CI (which builds first) cannot reproduce gets
1239
+ // the one-line explanation instead of training `--no-verify`.
1240
+ if (plan.build) ci('build')
1241
+ const buildHint = freshCloneBuildHint({
1242
+ build: plan.build,
1243
+ buildScript: hasScript('build'),
1244
+ packageManager: packageCommand([])[0],
1245
+ })
1246
+
1228
1247
  if (js && (plan.lintAll || plan.lintFiles.length)) {
1229
1248
  if (plan.lintAll) {
1230
- if (!runScript(['lint']) && hasOxlintSetup()) runTool('oxlint', ['--deny-warnings'])
1249
+ if (!runScript(['lint'], { onFailure: buildHint }) && hasOxlintSetup())
1250
+ runTool('oxlint', ['--deny-warnings'])
1231
1251
  } else if (hasOxlintSetup()) {
1232
1252
  runToolOnFiles(
1233
1253
  'oxlint',
@@ -1241,8 +1261,11 @@ export function preCommit() {
1241
1261
  if (js && plan.typeCheck) {
1242
1262
  // Use the project's own command so project references, workspaces, and
1243
1263
  // path aliases resolve exactly as they do in CI.
1244
- const scripted = runScript(['type-check', 'typecheck', 'type:check'])
1245
- if (!scripted && existsSync(resolve(root, 'tsconfig.json'))) runTool('tsc', ['--noEmit'])
1264
+ const scripted = runScript(['type-check', 'typecheck', 'type:check'], {
1265
+ onFailure: buildHint,
1266
+ })
1267
+ if (!scripted && existsSync(resolve(root, 'tsconfig.json')))
1268
+ runTool('tsc', ['--noEmit'], { onFailure: buildHint })
1246
1269
  }
1247
1270
  if (plan.rust) {
1248
1271
  run('cargo', ['fmt', '--check'])
@@ -1255,7 +1278,6 @@ export function preCommit() {
1255
1278
  runToolOnFiles('ruff', ['format', '--check', '--force-exclude'], plan.pythonFiles)
1256
1279
  runToolOnFiles('ruff', ['check', '--force-exclude'], plan.pythonFiles)
1257
1280
  }
1258
- if (plan.build) ci('build')
1259
1281
  }
1260
1282
 
1261
1283
  // Only dispatch when this module is the process entry point. The CLI imports