code-foundry 1.42.0 → 1.43.1

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.
@@ -33,6 +33,11 @@ on:
33
33
  required: false
34
34
  type: string
35
35
  default: release-please-config.json
36
+ prerelease:
37
+ description: Publish a batched draft release as a prerelease; manual hotfixes use false.
38
+ required: false
39
+ type: boolean
40
+ default: false
36
41
  defer-publication:
37
42
  description: Create a root draft release for an external qualified publisher; skip legacy publication and post-release jobs.
38
43
  required: false
@@ -139,6 +144,7 @@ jobs:
139
144
  }
140
145
  if (releaseType === 'none' || !fs.existsSync('package.json')) npmPublish = 'false'
141
146
  let legacyReleaseType = ''
147
+ const batchedPublication = Boolean(config.release_batching_schedule) && config.release_batching_prerelease !== 'false' && process.env.DEFER_PUBLICATION !== 'true'
142
148
  if (releaseType !== 'none') {
143
149
  const sourcePath = process.env.RELEASE_CONFIG_FILE || 'release-please-config.json'
144
150
  if (!/^(?:[A-Za-z0-9_.-]+\/)*[A-Za-z0-9_.-]+\.json$/.test(sourcePath) || sourcePath.split('/').includes('..')) {
@@ -151,12 +157,12 @@ jobs:
151
157
  throw new Error('config-file must be a file inside the repository')
152
158
  }
153
159
  const releaseConfig = JSON.parse(fs.readFileSync(sourcePath, 'utf8'))
154
- if (process.env.DEFER_PUBLICATION === 'true') {
160
+ if (process.env.DEFER_PUBLICATION === 'true' || batchedPublication) {
155
161
  const rootPackage = releaseConfig.packages?.['.']
156
162
  if (!rootPackage || Object.keys(releaseConfig.packages).length !== 1 ||
157
163
  (rootPackage.draft ?? releaseConfig.draft) !== true ||
158
164
  (rootPackage['force-tag-creation'] ?? releaseConfig['force-tag-creation']) !== true) {
159
- throw new Error('defer-publication requires one root package with draft and force-tag-creation enabled')
165
+ throw new Error('Deferred or batched publication requires one root package with draft and force-tag-creation enabled')
160
166
  }
161
167
  }
162
168
  if (!releaseConfig.packages && !releaseConfig['release-type']) {
@@ -167,7 +173,7 @@ jobs:
167
173
  }
168
174
  fs.appendFileSync(
169
175
  process.env.GITHUB_OUTPUT,
170
- `release_type=${releaseType}\nlegacy_release_type=${legacyReleaseType}\nnpm_publish=${npmPublish}\nrelease_merge_strategy=${releaseMergeStrategy}\n`,
176
+ `batched_publication=${batchedPublication}\nrelease_type=${releaseType}\nlegacy_release_type=${legacyReleaseType}\nnpm_publish=${npmPublish}\nrelease_merge_strategy=${releaseMergeStrategy}\n`,
171
177
  )
172
178
  NODE
173
179
  - name: Detect release credentials
@@ -243,6 +249,17 @@ jobs:
243
249
  echo "prs_created=$WORKFLOW_PRS_CREATED"
244
250
  } >> "$GITHUB_OUTPUT"
245
251
  fi
252
+ - name: Publish batched draft with atomic visibility
253
+ if: steps.profile.outputs.batched_publication == 'true' && steps.release.outputs.release_created == 'true'
254
+ env:
255
+ GH_TOKEN: ${{ steps.credentials.outputs.token_source == 'configured' && secrets.CODE_FOUNDRY_TOKEN || github.token }}
256
+ RELEASE_TAG: ${{ steps.release.outputs.tag_name }}
257
+ PRERELEASE: ${{ inputs.prerelease }}
258
+ run: |
259
+ set -euo pipefail
260
+ latest=false
261
+ if [ "$PRERELEASE" != true ]; then latest=true; fi
262
+ gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false --prerelease="$PRERELEASE" --latest="$latest"
246
263
  - name: Normalize generated release PR draft state
247
264
  if: steps.release.outcome == 'success'
248
265
  env:
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.43.1](https://github.com/0xPlayerOne/code-foundry/compare/v1.43.0...v1.43.1) (2026-10-03)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **release:** make batched publication and stable promotion reliable ([#703](https://github.com/0xPlayerOne/code-foundry/issues/703)) ([271731d](https://github.com/0xPlayerOne/code-foundry/commit/271731d8fac3bff42a99b3d5f4a774eccc0a7788))
9
+
10
+ ## [1.43.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.42.0...v1.43.0) (2026-10-03)
11
+
12
+
13
+ ### Features
14
+
15
+ * release-batching ([#700](https://github.com/0xPlayerOne/code-foundry/issues/700)) ([3dd5c5f](https://github.com/0xPlayerOne/code-foundry/commit/3dd5c5fd50883608baa3e2f9daef6b8aa83f269f))
16
+
3
17
  ## [1.42.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.41.0...v1.42.0) (2026-10-02)
4
18
 
5
19
 
@@ -115,26 +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. |
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. |
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` | five-field numeric cron | Batch Release Please into scheduled windows. Ordinary pushes skip the producer; release-PR squashes and manual dispatches still run. Batched releases are created privately as drafts and published with prerelease visibility atomically. Manual dispatches publish stable hotfixes. Absent, releases publish per merge. Requires a single root Release Please package. |
139
+ | `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. |
140
+ | `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. |
138
141
 
139
142
  Supported task capabilities are `format`, `lint`, `type_check`, `build`, `unit`,
140
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.42.0",
3
+ "version": "1.43.1",
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": {
@@ -135,6 +135,21 @@ function synchronize(options) {
135
135
  const changed = []
136
136
  if (!Object.keys(existingConfig).length && !options.init)
137
137
  throw new Error('Missing .github/code-foundry.yml; run init first.')
138
+ validateReleaseBatchingConfig(existingConfig)
139
+ if (
140
+ existingConfig.release_batching_schedule &&
141
+ existingConfig.release_batching_prerelease !== 'false'
142
+ ) {
143
+ const releasePath = join(target, 'release-please-config.json')
144
+ if (existsSync(releasePath)) {
145
+ const packages = JSON.parse(readFileSync(releasePath, 'utf8')).packages
146
+ if (packages && (Object.keys(packages).length !== 1 || !packages['.'])) {
147
+ throw new Error(
148
+ 'Unsupported release_batching_schedule: batched publication requires a single root Release Please package.'
149
+ )
150
+ }
151
+ }
152
+ }
138
153
  const defaults = createDefaultConfig(target, source, existingConfig.git_workflow)
139
154
  let config = { ...defaults, ...existingConfig }
140
155
  const workflow = gitWorkflow(config.git_workflow)
@@ -269,7 +284,7 @@ function synchronize(options) {
269
284
  if (file === '.github/CODEOWNERS' && existsSync(destination)) continue
270
285
  let content = readFileSync(sourceFile)
271
286
  if (file === 'release-please-config.json') {
272
- content = Buffer.from(renderReleaseConfig(target, sourceFile))
287
+ content = Buffer.from(renderReleaseConfig(target, sourceFile, config))
273
288
  }
274
289
  if (file.endsWith('.yml') && file.startsWith('.github/workflows/')) {
275
290
  content = Buffer.from(
@@ -284,6 +299,15 @@ function synchronize(options) {
284
299
  )
285
300
  )
286
301
  }
302
+ if (file === '.github/workflows/release.yml') {
303
+ // The batch-soak promoter rides on the release caller's sync: rendered
304
+ // from release_batching_soak_hours, and removed when the key is gone so
305
+ // a disabled soak stops scheduling promotions.
306
+ const promoter = renderPromoteStable(config)
307
+ const promoterPath = join(target, '.github/workflows/promote-stable.yml')
308
+ if (promoter) writeOrReport(promoterPath, promoter, dryRun)
309
+ else if (existsSync(promoterPath) && !dryRun) rmSync(promoterPath)
310
+ }
287
311
  if (file === '.github/dependabot.yml') {
288
312
  content = Buffer.from(renderDependabot(content.toString('utf8'), config, languages))
289
313
  }
@@ -616,9 +640,34 @@ function mergeReleaseConfig(target, sourceFile) {
616
640
  return buildReleaseConfig(target, merged)
617
641
  }
618
642
 
619
- /** @param {string} target @param {string} sourceFile @returns {string} */
620
- function renderReleaseConfig(target, sourceFile) {
621
- return `${JSON.stringify(mergeReleaseConfig(target, sourceFile), null, 2)}\n`
643
+ /** @param {string} target @param {string} sourceFile @param {Record<string, string>} config @returns {string} */
644
+ function renderReleaseConfig(target, sourceFile, config) {
645
+ const merged = mergeReleaseConfig(target, sourceFile)
646
+ if (
647
+ config.release_batching_schedule &&
648
+ configured(config.release_batching_prerelease, 'true') === 'true'
649
+ ) {
650
+ // Stage privately so publication can set visibility atomically. A polling
651
+ // flagger cannot prevent clients seeing an initially stable release.
652
+ merged.draft = true
653
+ merged['force-tag-creation'] = true
654
+ for (const value of Object.values(merged.packages ?? {})) {
655
+ value.draft = true
656
+ value['force-tag-creation'] = true
657
+ }
658
+ } else {
659
+ const caller = join(target, '.github/workflows/release.yml')
660
+ const previouslyBatched =
661
+ existsSync(caller) &&
662
+ readFileSync(caller, 'utf8').includes(
663
+ "prerelease: ${{ github.event_name != 'workflow_dispatch' }}"
664
+ )
665
+ if (previouslyBatched) {
666
+ merged.draft = false
667
+ for (const value of Object.values(merged.packages ?? {})) value.draft = false
668
+ }
669
+ }
670
+ return `${JSON.stringify(merged, null, 2)}\n`
622
671
  }
623
672
 
624
673
  /** @param {string} file @param {string} languages @param {string} features @param {Record<string, string>} config */
@@ -674,6 +723,188 @@ function sourcePath(source, file) {
674
723
  return join(source, file)
675
724
  }
676
725
 
726
+ /**
727
+ * The batch-soak promoter for repositories with `release_batching_soak_hours`.
728
+ * Repo-agnostic by construction: every call goes through `$GITHUB_REPOSITORY`.
729
+ * The newest pre-release published after the last stable flips to stable once
730
+ * the batch's oldest member has been public for the soak; skipped intermediates
731
+ * are folded into the promoted release's notes. `__SOAK_HOURS__` is replaced
732
+ * with the configured value at render time.
733
+ */
734
+ const PROMOTE_STABLE_TEMPLATE = `# Generated by Code Foundry sync from release_batching_soak_hours; rerun sync after changing or removing the key.
735
+
736
+ name: Promote stable
737
+
738
+ on:
739
+ schedule:
740
+ # Daily, shortly after the release window settles. The soak, not the cron,
741
+ # decides when a batch is ready.
742
+ - cron: '53 12 * * *'
743
+ workflow_dispatch:
744
+ inputs:
745
+ version:
746
+ description: Promote this specific version now (for example 0.75.1). Leave empty to follow the soak rule.
747
+ required: false
748
+ type: string
749
+ force:
750
+ description: Promote the newest pre-release regardless of the soak.
751
+ required: false
752
+ type: boolean
753
+ default: false
754
+
755
+ permissions:
756
+ contents: write
757
+
758
+ concurrency:
759
+ group: promote-stable
760
+ cancel-in-progress: false
761
+
762
+ jobs:
763
+ promote:
764
+ name: Apply the promotion policy
765
+ runs-on: ubuntu-latest
766
+ timeout-minutes: 10
767
+ steps:
768
+ - name: Evaluate the batch and promote the tip
769
+ env:
770
+ GH_TOKEN: \${{ github.token }}
771
+ SOAK_HOURS: '__SOAK_HOURS__'
772
+ VERSION: \${{ inputs.version }}
773
+ FORCE: \${{ inputs.force }}
774
+ STABLE_PROMOTION_HELD: \${{ vars.STABLE_PROMOTION_HELD }}
775
+ run: |
776
+ set -euo pipefail
777
+ repo="$GITHUB_REPOSITORY"
778
+ summary="$GITHUB_STEP_SUMMARY"
779
+
780
+ # The repository variable applies equally to scheduled and manual runs.
781
+ if [ "$STABLE_PROMOTION_HELD" = "true" ] && [ "$FORCE" != "true" ]; then
782
+ echo "Stable promotion is held." | tee -a "$summary"
783
+ exit 0
784
+ fi
785
+
786
+ if [ -n "$VERSION" ]; then
787
+ if ! [[ "$VERSION" =~ ^[0-9]+[.][0-9]+[.][0-9]+$ ]]; then
788
+ echo "::error::version must be a plain semantic version" >&2
789
+ exit 1
790
+ fi
791
+ gh release edit "v$VERSION" --repo "$repo" --prerelease=false --latest
792
+ echo "Promoted v$VERSION to stable by name." | tee -a "$summary"
793
+ exit 0
794
+ fi
795
+
796
+ releases="$(mktemp)"
797
+ trap 'rm -f "$releases"' EXIT
798
+ gh api --paginate --slurp "repos/$repo/releases?per_page=100" | jq 'add' > "$releases"
799
+
800
+ # The last promoted stable anchors the candidate batch.
801
+ last_stable_published="$(jq -r '
802
+ [ .[] | select(.draft == false and .prerelease == false)
803
+ | select(.tag_name | test("^v[0-9]+[.][0-9]+[.][0-9]+$")) ]
804
+ | sort_by(.published_at) | .[-1].published_at // "null"
805
+ ' "$releases")"
806
+ echo "Last stable published: $last_stable_published"
807
+
808
+ # The batch: every non-draft pre-release published since then, with
809
+ # the plain release tag shape (dev builds carry a -dev suffix and
810
+ # live on their own channel forever; they are never promoted).
811
+ batch="$(jq -r --arg last_stable_published "$last_stable_published" '
812
+ [ .[]
813
+ | select(.draft == false and .prerelease == true)
814
+ | select(.tag_name | test("^v[0-9]+[.][0-9]+[.][0-9]+$"))
815
+ | select($last_stable_published == "null" or .published_at > $last_stable_published)
816
+ | { tag: .tag_name, published_at: .published_at, body: .body } ]
817
+ | sort_by(.published_at)
818
+ ' "$releases")"
819
+
820
+ oldest="$(jq -r '.[0].published_at // "empty"' <<<"$batch")"
821
+ if [ "$oldest" = "empty" ]; then
822
+ echo "No pre-release batch is waiting; nothing to promote." | tee -a "$summary"
823
+ exit 0
824
+ fi
825
+
826
+ oldest_epoch="$(date -d "$oldest" +%s)"
827
+ now_epoch="$(date -u +%s)"
828
+ age_hours=$(( (now_epoch - oldest_epoch) / 3600 ))
829
+ tip="$(jq -r '.[-1].tag' <<<"$batch")"
830
+
831
+ if [ "$age_hours" -lt "$SOAK_HOURS" ] && [ "$FORCE" != "true" ]; then
832
+ echo "The batch has soaked \${age_hours}h of \${SOAK_HOURS}h; holding. Tip: $tip" | tee -a "$summary"
833
+ exit 0
834
+ fi
835
+
836
+ # Stitch the batch's notes oldest→newest so the stable release
837
+ # documents everything stable users are getting, not just the tip.
838
+ jq -r '.[] | "## " + .tag + "\\n\\n" + (.body // "")' <<<"$batch" > batch-notes.md
839
+ gh release edit "$tip" --repo "$repo" --prerelease=false --latest --notes-file batch-notes.md
840
+ count="$(jq 'length' <<<"$batch")"
841
+ echo "Promoted $tip to stable: the batch of $count had soaked \${age_hours}h." | tee -a "$summary"
842
+ `
843
+
844
+ /** @param {Record<string, string>} config */
845
+ function validateReleaseBatchingConfig(config) {
846
+ const schedule = String(config.release_batching_schedule ?? '').trim()
847
+ if (schedule) {
848
+ const limits = [
849
+ [0, 59],
850
+ [0, 23],
851
+ [1, 31],
852
+ [1, 12],
853
+ [0, 6],
854
+ ]
855
+ const fields = schedule.split(/\s+/)
856
+ const valid =
857
+ fields.length === 5 &&
858
+ fields.every((field, index) => {
859
+ const [min, max] = limits[index]
860
+ return field.split(',').every((part) => {
861
+ const match = part.match(/^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/)
862
+ if (!match || (match[2] && (Number(match[2]) < 1 || Number(match[2]) > max))) return false
863
+ if (match[1] === '*') return true
864
+ const [start, end = start] = match[1].split('-').map(Number)
865
+ return start >= min && end <= max && start <= end
866
+ })
867
+ })
868
+ if (!valid)
869
+ throw new Error(
870
+ `Unsupported release_batching_schedule: ${schedule}; use a five-field numeric cron expression.`
871
+ )
872
+ }
873
+ if (
874
+ config.release_batching_prerelease &&
875
+ !['true', 'false'].includes(config.release_batching_prerelease)
876
+ ) {
877
+ throw new Error(
878
+ `Unsupported release_batching_prerelease: ${config.release_batching_prerelease}; use true or false.`
879
+ )
880
+ }
881
+ if (config.release_batching_soak_hours) {
882
+ const hours = Number(config.release_batching_soak_hours)
883
+ if (!Number.isInteger(hours) || hours < 1 || hours > 336) {
884
+ throw new Error(
885
+ `Unsupported release_batching_soak_hours: ${config.release_batching_soak_hours}; use an integer between 1 and 336.`
886
+ )
887
+ }
888
+ if (!schedule || config.release_batching_prerelease === 'false') {
889
+ throw new Error(
890
+ 'Unsupported release_batching_soak_hours: configure a batching schedule with prerelease publication.'
891
+ )
892
+ }
893
+ }
894
+ }
895
+
896
+ /** Render the batch-soak promoter for a repository, or null when the
897
+ * repository has not configured a soak.
898
+ *
899
+ * @param {Record<string, string>} config
900
+ * @returns {string | null}
901
+ */
902
+ export function renderPromoteStable(config) {
903
+ const soakHours = Number(config.release_batching_soak_hours ?? '')
904
+ if (!Number.isInteger(soakHours) || soakHours < 1 || soakHours > 336) return null
905
+ return PROMOTE_STABLE_TEMPLATE.replaceAll('__SOAK_HOURS__', String(soakHours))
906
+ }
907
+
677
908
  /**
678
909
  * @param {string} content
679
910
  * @param {Record<string,string>} config
@@ -771,6 +1002,28 @@ function renderWorkflow(content, config, repository, ref, rustCodeql, file, self
771
1002
  /^ if: github\.ref == 'refs\/heads\/main' && \(vars\.CI_BILLING_PAUSED != 'true' \|\| \(github\.event_name == 'workflow_dispatch' && inputs\['release-while-paused'\] == true\)\)\n/m,
772
1003
  " if: vars.CI_BILLING_PAUSED != 'true' || (github.event_name == 'workflow_dispatch' && inputs['release-while-paused'] == true)\n"
773
1004
  )
1005
+ rendered = rendered.replace(
1006
+ /^ if: github\.ref == 'refs\/heads\/main' && \(vars\.CI_BILLING_PAUSED != 'true' \|\| \(github\.event_name == 'workflow_dispatch' && inputs\['release-while-paused'\] == true\)\)\n/m,
1007
+ " if: vars.CI_BILLING_PAUSED != 'true' || (github.event_name == 'workflow_dispatch' && inputs['release-while-paused'] == true)\n"
1008
+ )
1009
+ // Gate ordinary pushes while allowing the release squash and manual hotfixes.
1010
+ const batchingSchedule = String(config.release_batching_schedule ?? '').trim()
1011
+ if (batchingSchedule) {
1012
+ rendered = rendered.replace(
1013
+ ' workflow_dispatch:',
1014
+ ` schedule:\n - cron: '${batchingSchedule}'\n workflow_dispatch:`
1015
+ )
1016
+ rendered = rendered.replace(
1017
+ " if: vars.CI_BILLING_PAUSED != 'true' || (github.event_name == 'workflow_dispatch' && inputs['release-while-paused'] == true)\n",
1018
+ " if: >-\n (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"
1019
+ )
1020
+ if (configured(config.release_batching_prerelease, 'true') === 'true') {
1021
+ rendered = rendered.replace(
1022
+ ' git-workflow:',
1023
+ " prerelease: ${{ github.event_name != 'workflow_dispatch' }}\n git-workflow:"
1024
+ )
1025
+ }
1026
+ }
774
1027
  rendered = rendered.replace(
775
1028
  /(\n secrets:\n CODE_FOUNDRY_TOKEN: \$\{\{ secrets\.CODE_FOUNDRY_TOKEN \}\}\n)/,
776
1029
  '$1 NPM_TOKEN: ${{ secrets.NPM_TOKEN }}\n'