code-foundry 1.41.0 → 1.43.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.
@@ -3,6 +3,12 @@ name: Code Foundry Release
3
3
  on:
4
4
  push:
5
5
  branches: [main]
6
+ # Release batching (release_batching_schedule in code-foundry.yml). The
7
+ # window gates the pipeline to the version-PR squash so ordinary merges
8
+ # accumulate; sync strips this block and the mark-prerelease job below for
9
+ # repositories without the config, and rewrites the cron when configured.
10
+ schedule:
11
+ - cron: '53 12 * * *'
6
12
  workflow_dispatch:
7
13
  inputs:
8
14
  release-while-paused:
@@ -258,3 +264,44 @@ jobs:
258
264
  billing-pause-bypass: ${{ github.event_name == 'workflow_dispatch' && inputs['release-while-paused'] == true }}
259
265
  secrets:
260
266
  NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
267
+
268
+ mark-prerelease:
269
+ name: Mark the release a pre-release
270
+ # Release batching (release_batching_schedule): the window's release
271
+ # publishes as a GitHub pre-release so the stable feed (releases/latest)
272
+ # never sees it until a promotion workflow flips the flag. Runs in
273
+ # parallel with the pipeline, not after it: a failed (unqualified) build
274
+ # is exactly when a release must not be stable-visible. A dispatched run
275
+ # has no head commit to parse, so a hand-dispatched release — the hotfix
276
+ # path — stays stable immediately. Sync strips this job for repositories
277
+ # without the batching config.
278
+ if: >-
279
+ (vars.CI_BILLING_PAUSED != 'true' || (github.event_name == 'workflow_dispatch' && inputs['release-while-paused'] == true)) &&
280
+ github.event_name == 'push' && startsWith(github.event.head_commit.message, 'chore(main): release ')
281
+ runs-on: ubuntu-latest
282
+ timeout-minutes: 25
283
+ permissions:
284
+ contents: write
285
+ steps:
286
+ - name: Flag the freshly published release
287
+ env:
288
+ GH_TOKEN: ${{ github.token }}
289
+ COMMIT_SUBJECT: ${{ github.event.head_commit.message }}
290
+ run: |
291
+ set -euo pipefail
292
+ version="$(sed -n 's/^chore(main): release \([0-9][0-9.]*\).*$/\1/p' <<<"$COMMIT_SUBJECT")"
293
+ if [[ -z "$version" ]]; then
294
+ echo "No release version in the head commit subject; nothing to mark." >&2
295
+ exit 0
296
+ fi
297
+ tag="v${version}"
298
+ for _ in $(seq 1 50); do
299
+ if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
300
+ gh release edit "$tag" --repo "$GITHUB_REPOSITORY" --prerelease
301
+ echo "Marked $tag as a pre-release."
302
+ exit 0
303
+ fi
304
+ sleep 30
305
+ done
306
+ echo "::error::release $tag did not appear within 25 minutes; it is stable-visible — flip it manually or investigate the release job" >&2
307
+ exit 1
@@ -38,6 +38,16 @@ on:
38
38
  required: false
39
39
  type: string
40
40
  default: task-result
41
+ e2e-shard-list:
42
+ description: Comma-separated E2E shard indices, for example "1,2". A single index runs one unsharded job.
43
+ required: false
44
+ type: string
45
+ default: '1'
46
+ e2e-total-shards:
47
+ description: Total E2E shard count handed to the repository script as E2E_TOTAL_SHARDS.
48
+ required: false
49
+ type: string
50
+ default: '1'
41
51
  secrets:
42
52
  TURBO_TOKEN:
43
53
  required: false
@@ -309,6 +319,14 @@ jobs:
309
319
  if: vars.CI_BILLING_PAUSED != 'true' && inputs.unit-only != true
310
320
  runs-on: ${{ inputs.runner }}
311
321
  timeout-minutes: 45
322
+ # Shards duplicate the whole job, but each shard owns a runner-local test
323
+ # database (repositories without DATABASE_URL fall back to their own local
324
+ # compose services), so shards stay isolated. A single-entry matrix keeps
325
+ # the unsharded default byte-compatible with the pre-matrix job.
326
+ strategy:
327
+ fail-fast: false
328
+ matrix:
329
+ shard: ${{ fromJson(format('[{0}]', inputs.e2e-shard-list)) }}
312
330
  steps:
313
331
  - name: Checkout
314
332
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
@@ -371,6 +389,9 @@ jobs:
371
389
  - name: E2E
372
390
  id: execute
373
391
  if: steps.applicability.outputs.applicable == 'true' && steps.applicability.outputs.affected != 'false'
392
+ env:
393
+ E2E_SHARD_INDEX: ${{ matrix.shard }}
394
+ E2E_TOTAL_SHARDS: ${{ inputs.e2e-total-shards }}
374
395
  run: node "$RUNNER_TEMP/code-foundry/src/runtime.mjs" ci e2e
375
396
  - name: Retain task receipt
376
397
  if: >-
@@ -380,7 +401,7 @@ jobs:
380
401
  hashFiles('.code-foundry/results/e2e.json') != ''
381
402
  uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
382
403
  with:
383
- name: ${{ inputs.artifact-prefix }}-${{ github.run_id }}-${{ github.run_attempt }}-e2e
404
+ name: ${{ inputs.artifact-prefix }}-${{ github.run_id }}-${{ github.run_attempt }}-e2e${{ inputs.e2e-total-shards != '1' && format('-s{0}', matrix.shard) || '' }}
384
405
  path: .code-foundry/results/e2e.json
385
406
  include-hidden-files: true
386
407
  if-no-files-found: error
@@ -70,6 +70,16 @@ on:
70
70
  required: false
71
71
  type: number
72
72
  default: 1
73
+ e2e-shard-list:
74
+ description: Comma-separated E2E shard indices passed to the test lane's matrix, e.g. "1,2".
75
+ required: false
76
+ type: string
77
+ default: '1'
78
+ e2e-total-shards:
79
+ description: Total E2E shard count passed to the repository script as E2E_TOTAL_SHARDS.
80
+ required: false
81
+ type: string
82
+ default: '1'
73
83
  artifact-prefix:
74
84
  description: Prefix for task receipts; use a unique value when calling this workflow more than once.
75
85
  required: false
@@ -119,6 +129,8 @@ jobs:
119
129
  # smoke, and performance lanes stay in the audit and scheduled lanes.
120
130
  unit-only: ${{ inputs.mode == 'fast' || inputs.mode == 'release' }}
121
131
  artifact-prefix: ${{ inputs.artifact-prefix }}
132
+ e2e-shard-list: ${{ inputs.e2e-shard-list }}
133
+ e2e-total-shards: ${{ inputs.e2e-total-shards }}
122
134
  secrets:
123
135
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
124
136
 
@@ -95,6 +95,10 @@ jobs:
95
95
  rust-shards: '["all"]'
96
96
  rust-threads: '1'
97
97
  rust-max-parallel: 1
98
+ # The sync renderer rewrites both values from the e2e_shards config key;
99
+ # the defaults keep repositories without that key on one unsharded job.
100
+ e2e-shard-list: '1'
101
+ e2e-total-shards: '1'
98
102
  secrets:
99
103
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
100
104
  NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.43.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.42.0...v1.43.0) (2026-10-03)
4
+
5
+
6
+ ### Features
7
+
8
+ * release-batching ([#700](https://github.com/0xPlayerOne/code-foundry/issues/700)) ([3dd5c5f](https://github.com/0xPlayerOne/code-foundry/commit/3dd5c5fd50883608baa3e2f9daef6b8aa83f269f))
9
+
10
+ ## [1.42.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.41.0...v1.42.0) (2026-10-02)
11
+
12
+
13
+ ### Features
14
+
15
+ * **validation:** shard the managed E2E lane via e2e_shards ([#697](https://github.com/0xPlayerOne/code-foundry/issues/697)) ([edb20b4](https://github.com/0xPlayerOne/code-foundry/commit/edb20b4e32e2055bf22e6f6f94b51f234a24687d))
16
+
3
17
  ## [1.41.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.40.0...v1.41.0) (2026-10-02)
4
18
 
5
19
 
@@ -115,25 +115,29 @@ See [Merge queue validation](merge-queues.md) before enabling it.
115
115
 
116
116
  ## Validation and quality
117
117
 
118
- | Key | Values | Purpose |
119
- | ------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
120
- | `performance` | `auto`, `true`, `false` | Discover, require, or disable performance checks. |
121
- | `performance_command` | JSON argv array or array of argv arrays | Ordered commands for a non-package performance harness. |
122
- | `performance_profile` | empty or `node-package` | Shared package import, memory, archive, and dependency audit. |
123
- | `performance_budget_file` | repository-relative path | Budget file for `node-package`; defaults to `performance-package-budgets.json`. |
124
- | `eval` | `auto`, `true`, `false` | Discover, require, or disable the deterministic eval tier. |
125
- | `eval_command` | JSON argv array | Explicit harness command when there is no `eval` package script. |
126
- | `eval_report_file` | repository-relative path | Report validated against the eval contract; defaults to `eval-results/result.json`. |
127
- | `eval_budget_file` | repository-relative path | Optional budget file; defaults to `eval-budgets.json`. |
128
- | `eval_runner` | runner label | Runner for the `Validation / Eval` lane; defaults to the repository runner. Browser evals need a Chrome-capable runner such as `ubuntu-latest`. |
129
- | `required_capabilities` | comma-separated task names | Fail closed when a required task or coverage evidence is unavailable. |
130
- | `coverage_enforcement` | `auto`, `required`, `off` | Shared coverage-report policy. |
131
- | `coverage_minimum` | `0`–`100` | Minimum percentage; defaults to `80`. |
132
- | `coverage_metrics` | `lines`, `functions`, `branches`, `statements` | Metrics checked by the coverage gate. |
133
- | `coverage_report` | comma-separated repository paths | Istanbul JSON summary or LCOV evidence files. |
134
- | `filter_<task>` | comma-separated repository path globs | Skip a lane when no changed path matches; see [Lane path filters](#lane-path-filters). |
135
- | `rust_sccache` | `true`, `false` | Install sccache and wrap rustc so compile artifacts survive manifest/lockfile churn. |
136
- | `rust_nextest` | `true`, `false` | Install cargo-nextest and prefer `cargo nextest run` over `cargo test` in Rust test lanes. |
118
+ | Key | Values | Purpose |
119
+ | ----------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
120
+ | `performance` | `auto`, `true`, `false` | Discover, require, or disable performance checks. |
121
+ | `performance_command` | JSON argv array or array of argv arrays | Ordered commands for a non-package performance harness. |
122
+ | `performance_profile` | empty or `node-package` | Shared package import, memory, archive, and dependency audit. |
123
+ | `performance_budget_file` | repository-relative path | Budget file for `node-package`; defaults to `performance-package-budgets.json`. |
124
+ | `eval` | `auto`, `true`, `false` | Discover, require, or disable the deterministic eval tier. |
125
+ | `eval_command` | JSON argv array | Explicit harness command when there is no `eval` package script. |
126
+ | `eval_report_file` | repository-relative path | Report validated against the eval contract; defaults to `eval-results/result.json`. |
127
+ | `eval_budget_file` | repository-relative path | Optional budget file; defaults to `eval-budgets.json`. |
128
+ | `eval_runner` | runner label | Runner for the `Validation / Eval` lane; defaults to the repository runner. Browser evals need a Chrome-capable runner such as `ubuntu-latest`. |
129
+ | `required_capabilities` | comma-separated task names | Fail closed when a required task or coverage evidence is unavailable. |
130
+ | `coverage_enforcement` | `auto`, `required`, `off` | Shared coverage-report policy. |
131
+ | `coverage_minimum` | `0`–`100` | Minimum percentage; defaults to `80`. |
132
+ | `coverage_metrics` | `lines`, `functions`, `branches`, `statements` | Metrics checked by the coverage gate. |
133
+ | `coverage_report` | comma-separated repository paths | Istanbul JSON summary or LCOV evidence files. |
134
+ | `filter_<task>` | comma-separated repository path globs | Skip a lane when no changed path matches; see [Lane path filters](#lane-path-filters). |
135
+ | `rust_sccache` | `true`, `false` | Install sccache and wrap rustc so compile artifacts survive manifest/lockfile churn. |
136
+ | `rust_nextest` | `true`, `false` | Install cargo-nextest and prefer `cargo nextest run` over `cargo test` in Rust test lanes. |
137
+ | `e2e_shards` | `1`–`8` | Fan the `Validation / Test / E2E` job into one runner per shard. The repository's `test:e2e`/`e2e` script receives `E2E_SHARD_INDEX` and `E2E_TOTAL_SHARDS` and partitions itself (Playwright takes `--shard=$E2E_SHARD_INDEX/$E2E_TOTAL_SHARDS`); each shard owns a runner-local test database, so shards stay isolated. |
138
+ | `release_batching_schedule` | cron expression | Batch Release Please to one window per day: ordinary merges to the default branch stop publishing their own release, and the generated caller carries the `chore(main): release …` push filter plus a `mark-prerelease` job that flags each window release as a GitHub pre-release (the stable feed skips pre-releases; hand-dispatched hotfix runs stay stable). Absent, releases publish per merge. |
139
+ | `release_batching_prerelease` | `true`, `false` | Defaults to `true` when a batching schedule is set. `false` drops the flagging job so window releases stay stable-visible. |
140
+ | `release_batching_soak_hours` | `1`–`336` | Generate a daily batch-soak promoter (`promote-stable.yml`): promote the newest pre-release once the oldest candidate published after the last stable has been public this many hours, stitching the batch's release notes. A `STABLE_PROMOTION_HELD` repository variable halts promotion; the workflow's dispatch inputs promote a named version or force the tip. |
137
141
 
138
142
  Supported task capabilities are `format`, `lint`, `type_check`, `build`, `unit`,
139
143
  `integration`, `e2e`, `smoke`, `eval`, and `performance`. `coverage` is a policy
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-foundry",
3
- "version": "1.41.0",
3
+ "version": "1.43.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": {
@@ -284,6 +284,15 @@ function synchronize(options) {
284
284
  )
285
285
  )
286
286
  }
287
+ if (file === '.github/workflows/release.yml') {
288
+ // The batch-soak promoter rides on the release caller's sync: rendered
289
+ // from release_batching_soak_hours, and removed when the key is gone so
290
+ // a disabled soak stops scheduling promotions.
291
+ const promoter = renderPromoteStable(config)
292
+ const promoterPath = join(target, '.github/workflows/promote-stable.yml')
293
+ if (promoter) writeOrReport(promoterPath, promoter, dryRun)
294
+ else if (existsSync(promoterPath) && !dryRun) rmSync(promoterPath)
295
+ }
287
296
  if (file === '.github/dependabot.yml') {
288
297
  content = Buffer.from(renderDependabot(content.toString('utf8'), config, languages))
289
298
  }
@@ -674,6 +683,145 @@ function sourcePath(source, file) {
674
683
  return join(source, file)
675
684
  }
676
685
 
686
+ /**
687
+ * The batch-soak promoter for repositories with `release_batching_soak_hours`.
688
+ * Repo-agnostic by construction: every call goes through `$GITHUB_REPOSITORY`.
689
+ * The newest pre-release published after the last stable flips to stable once
690
+ * the batch's oldest member has been public for the soak; skipped intermediates
691
+ * are folded into the promoted release's notes. `__SOAK_HOURS__` is replaced
692
+ * with the configured value at render time.
693
+ */
694
+ const PROMOTE_STABLE_TEMPLATE = `# Generated by Code Foundry sync from release_batching_soak_hours; rerun sync after changing or removing the key.
695
+
696
+ name: Promote stable
697
+
698
+ on:
699
+ schedule:
700
+ # Daily, shortly after the release window settles. The soak, not the cron,
701
+ # decides when a batch is ready.
702
+ - cron: '53 12 * * *'
703
+ workflow_dispatch:
704
+ inputs:
705
+ version:
706
+ description: Promote this specific version now (for example 0.75.1). Leave empty to follow the soak rule.
707
+ required: false
708
+ type: string
709
+ force:
710
+ description: Promote the newest pre-release regardless of the soak.
711
+ required: false
712
+ type: boolean
713
+ default: false
714
+ hold:
715
+ description: Set (true) or clear (false) the promotion hold; nothing promotes on schedule while held.
716
+ required: false
717
+ type: boolean
718
+
719
+ permissions:
720
+ contents: write
721
+ actions: write
722
+
723
+ concurrency:
724
+ group: promote-stable
725
+ cancel-in-progress: false
726
+
727
+ jobs:
728
+ promote:
729
+ name: Apply the promotion policy
730
+ runs-on: ubuntu-latest
731
+ timeout-minutes: 10
732
+ steps:
733
+ - name: Evaluate the batch and promote the tip
734
+ env:
735
+ GH_TOKEN: \${{ github.token }}
736
+ SOAK_HOURS: '__SOAK_HOURS__'
737
+ VERSION: \${{ inputs.version }}
738
+ FORCE: \${{ inputs.force }}
739
+ HOLD: \${{ inputs.hold }}
740
+ run: |
741
+ set -euo pipefail
742
+ repo="$GITHUB_REPOSITORY"
743
+ summary="$GITHUB_STEP_SUMMARY"
744
+
745
+ # The hold switch is a repository variable so a bad batch can be
746
+ # halted without a code change; a dispatch sets or clears it.
747
+ if [ "$HOLD" = "true" ] || [ "$HOLD" = "false" ]; then
748
+ wanted="false"; [ "$HOLD" = "true" ] && wanted="true"
749
+ if gh api "repos/$repo/actions/variables/STABLE_PROMOTION_HELD" >/dev/null 2>&1; then
750
+ gh api -X PATCH "repos/$repo/actions/variables/STABLE_PROMOTION_HELD" -f value="$wanted" >/dev/null
751
+ else
752
+ gh api -X POST "repos/$repo/actions/variables" -f name=STABLE_PROMOTION_HELD -f value="$wanted" >/dev/null
753
+ fi
754
+ echo "Promotion hold set to $wanted." | tee -a "$summary"
755
+ fi
756
+
757
+ # A manually named version wins over everything but the hold.
758
+ if [ -n "$VERSION" ]; then
759
+ if [ "$(gh api "repos/$repo/actions/variables/STABLE_PROMOTION_HELD" --jq '.value' 2>/dev/null || echo false)" = "true" ] && [ "$FORCE" != "true" ]; then
760
+ echo "Promotion is held; pass force=true to promote $VERSION anyway." | tee -a "$summary"
761
+ exit 0
762
+ fi
763
+ gh release edit "v$VERSION" --repo "$repo" --prerelease=false --latest
764
+ echo "Promoted v$VERSION to stable by name." | tee -a "$summary"
765
+ exit 0
766
+ fi
767
+
768
+ releases="$(mktemp)"
769
+ gh api "repos/$repo/releases?per_page=100" > "$releases"
770
+
771
+ # The last promoted stable anchors the candidate batch.
772
+ last_stable_published="$(jq -r '
773
+ [ .[] | select(.draft == false and .prerelease == false) ][0].published_at // "null"
774
+ ' "$releases")"
775
+ echo "Last stable published: $last_stable_published"
776
+
777
+ # The batch: every non-draft pre-release published since then, with
778
+ # the plain release tag shape (dev builds carry a -dev suffix and
779
+ # live on their own channel forever; they are never promoted).
780
+ batch="$(jq -r '
781
+ [ .[]
782
+ | select(.draft == false and .prerelease == true)
783
+ | select(.tag_name | test("^v[0-9]+\\.[0-9]+\\.[0-9]+$"))
784
+ | select("$last_stable_published" == "null" or .published_at > "$last_stable_published")
785
+ | { tag: .tag_name, published_at: .published_at, body: .body } ]
786
+ | sort_by(.published_at)
787
+ ' "$releases")"
788
+
789
+ oldest="$(jq -r '.[0].published_at // "empty"' <<<"$batch")"
790
+ if [ "$oldest" = "empty" ]; then
791
+ echo "No pre-release batch is waiting; nothing to promote." | tee -a "$summary"
792
+ exit 0
793
+ fi
794
+
795
+ oldest_epoch="$(date -d "$oldest" +%s)"
796
+ now_epoch="$(date -u +%s)"
797
+ age_hours=$(( (now_epoch - oldest_epoch) / 3600 ))
798
+ tip="$(jq -r '.[-1].tag' <<<"$batch")"
799
+
800
+ if [ "$age_hours" -lt "$SOAK_HOURS" ] && [ "$FORCE" != "true" ]; then
801
+ echo "The batch has soaked \${age_hours}h of \${SOAK_HOURS}h; holding. Tip: $tip" | tee -a "$summary"
802
+ exit 0
803
+ fi
804
+
805
+ # Stitch the batch's notes oldest→newest so the stable release
806
+ # documents everything stable users are getting, not just the tip.
807
+ jq -r '.[] | "## " + .tag + "\\n\\n" + (.body // "")' <<<"$batch" > batch-notes.md
808
+ gh release edit "$tip" --repo "$repo" --prerelease=false --latest --notes-file batch-notes.md
809
+ count="$(jq 'length' <<<"$batch")"
810
+ echo "Promoted $tip to stable: the batch of $count had soaked \${age_hours}h." | tee -a "$summary"
811
+ `
812
+
813
+ /** Render the batch-soak promoter for a repository, or null when the
814
+ * repository has not configured a soak.
815
+ *
816
+ * @param {Record<string, string>} config
817
+ * @returns {string | null}
818
+ */
819
+ export function renderPromoteStable(config) {
820
+ const soakHours = Number(config.release_batching_soak_hours ?? '')
821
+ if (!Number.isInteger(soakHours) || soakHours < 1 || soakHours > 336) return null
822
+ return PROMOTE_STABLE_TEMPLATE.replaceAll('__SOAK_HOURS__', String(soakHours))
823
+ }
824
+
677
825
  /**
678
826
  * @param {string} content
679
827
  * @param {Record<string,string>} config
@@ -738,6 +886,21 @@ function renderWorkflow(content, config, repository, ref, rustCodeql, file, self
738
886
  if (configured(config.draft_protection, 'true') === 'false') {
739
887
  rendered = rendered.replaceAll(' && github.event.pull_request.draft == false', '')
740
888
  }
889
+ // E2E sharding fans the managed E2E job into one runner per shard; shards
890
+ // stay isolated because each job owns a runner-local test database. Fail
891
+ // closed on a malformed count rather than silently rendering a single shard.
892
+ const e2eShards =
893
+ config.e2e_shards === undefined || config.e2e_shards === '' ? 1 : Number(config.e2e_shards)
894
+ if (!Number.isInteger(e2eShards) || e2eShards < 1 || e2eShards > 8) {
895
+ throw new Error(`Unsupported e2e_shards: ${config.e2e_shards}; use an integer between 1 and 8.`)
896
+ }
897
+ if (e2eShards > 1 && file === '.github/workflows/validation.yml') {
898
+ rendered = rendered.replace(
899
+ /^(\s+e2e-shard-list:)\s+.*$/gm,
900
+ `$1 ${Array.from({ length: e2eShards }, (_, index) => index + 1).join(',')}`
901
+ )
902
+ rendered = rendered.replace(/^(\s+e2e-total-shards:)\s+.*$/gm, `$1 ${e2eShards}`)
903
+ }
741
904
  // Consumer release callers are generic package release workflows. The
742
905
  // installed-consumer qualification harness is specific to Code Foundry's
743
906
  // own package and must remain in the self workflow rather than being
@@ -756,6 +919,48 @@ function renderWorkflow(content, config, repository, ref, rustCodeql, file, self
756
919
  /^ if: github\.ref == 'refs\/heads\/main' && \(vars\.CI_BILLING_PAUSED != 'true' \|\| \(github\.event_name == 'workflow_dispatch' && inputs\['release-while-paused'\] == true\)\)\n/m,
757
920
  " if: vars.CI_BILLING_PAUSED != 'true' || (github.event_name == 'workflow_dispatch' && inputs['release-while-paused'] == true)\n"
758
921
  )
922
+ rendered = rendered.replace(
923
+ /^ if: github\.ref == 'refs\/heads\/main' && \(vars\.CI_BILLING_PAUSED != 'true' \|\| \(github\.event_name == 'workflow_dispatch' && inputs\['release-while-paused'\] == true\)\)\n/m,
924
+ " if: vars.CI_BILLING_PAUSED != 'true' || (github.event_name == 'workflow_dispatch' && inputs['release-while-paused'] == true)\n"
925
+ )
926
+ // Release batching (issue #699): `release_batching_schedule` gates the
927
+ // pipeline to the `chore(main): release …` squash so ordinary merges
928
+ // accumulate into the configured window, and the mark-prerelease job
929
+ // flags each window release for the pre-release channel. Without the
930
+ // config the caller renders the classic per-merge form and the batching
931
+ // blocks are stripped. `release_batching_prerelease: false` opts out of
932
+ // the flagging job while keeping the window.
933
+ const batchingSchedule = String(config.release_batching_schedule ?? '').trim()
934
+ if (batchingSchedule) {
935
+ // The minute field must be numeric or a wildcard: that single rule
936
+ // rejects prose ('every morning') while accepting every real cron form
937
+ // the field needs, including lists, steps, and ranges.
938
+ if (
939
+ !/^[\d*]+([/,][\d*]+)*( \S+){0,4}$/.test(batchingSchedule) ||
940
+ batchingSchedule.length > 64
941
+ ) {
942
+ throw new Error(
943
+ `Unsupported release_batching_schedule: ${config.release_batching_schedule}; use a cron expression.`
944
+ )
945
+ }
946
+ rendered = rendered.replace(
947
+ /^ - cron: '53 12 \* \* \*'\n/m,
948
+ ` - cron: '${batchingSchedule}'\n`
949
+ )
950
+ rendered = rendered.replace(
951
+ " if: vars.CI_BILLING_PAUSED != 'true' || (github.event_name == 'workflow_dispatch' && inputs['release-while-paused'] == true)\n",
952
+ " if: vars.CI_BILLING_PAUSED != 'true' || (github.event_name == 'workflow_dispatch' && inputs['release-while-paused'] == true) && (github.event_name != 'push' || startsWith(github.event.head_commit.message, 'chore(main): release '))\n"
953
+ )
954
+ } else {
955
+ rendered = rendered.replace(
956
+ /^ # Release batching \(release_batching_schedule[^\n]*\n( #[^\n]*\n)* schedule:\n - cron: '53 12 \* \* \*'\n/m,
957
+ ''
958
+ )
959
+ rendered = removeWorkflowBlock(rendered, 'mark-prerelease')
960
+ }
961
+ if (configured(config.release_batching_prerelease, 'true') === 'false') {
962
+ rendered = removeWorkflowBlock(rendered, 'mark-prerelease')
963
+ }
759
964
  rendered = rendered.replace(
760
965
  /(\n secrets:\n CODE_FOUNDRY_TOKEN: \$\{\{ secrets\.CODE_FOUNDRY_TOKEN \}\}\n)/,
761
966
  '$1 NPM_TOKEN: ${{ secrets.NPM_TOKEN }}\n'