code-foundry 1.43.0 → 1.43.2

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:
@@ -3,12 +3,6 @@ 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 * * *'
12
6
  workflow_dispatch:
13
7
  inputs:
14
8
  release-while-paused:
@@ -264,44 +258,3 @@ jobs:
264
258
  billing-pause-bypass: ${{ github.event_name == 'workflow_dispatch' && inputs['release-while-paused'] == true }}
265
259
  secrets:
266
260
  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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.43.2](https://github.com/0xPlayerOne/code-foundry/compare/v1.43.1...v1.43.2) (2026-10-03)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * require candidate asset qualification before stable promotion ([#705](https://github.com/0xPlayerOne/code-foundry/issues/705)) ([f42e4f5](https://github.com/0xPlayerOne/code-foundry/commit/f42e4f5fb063ea22fa25791a13c75c90d41bf17a))
9
+
10
+ ## [1.43.1](https://github.com/0xPlayerOne/code-foundry/compare/v1.43.0...v1.43.1) (2026-10-03)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **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))
16
+
3
17
  ## [1.43.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.42.0...v1.43.0) (2026-10-03)
4
18
 
5
19
 
@@ -115,29 +115,30 @@ 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. |
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. |
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. |
141
+ | `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. |
141
142
 
142
143
  Supported task capabilities are `format`, `lint`, `type_check`, `build`, `unit`,
143
144
  `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.43.0",
3
+ "version": "1.43.2",
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(
@@ -625,9 +640,34 @@ function mergeReleaseConfig(target, sourceFile) {
625
640
  return buildReleaseConfig(target, merged)
626
641
  }
627
642
 
628
- /** @param {string} target @param {string} sourceFile @returns {string} */
629
- function renderReleaseConfig(target, sourceFile) {
630
- 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`
631
671
  }
632
672
 
633
673
  /** @param {string} file @param {string} languages @param {string} features @param {Record<string, string>} config */
@@ -711,14 +751,10 @@ on:
711
751
  required: false
712
752
  type: boolean
713
753
  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
754
 
719
755
  permissions:
720
756
  contents: write
721
- actions: write
757
+ actions: read
722
758
 
723
759
  concurrency:
724
760
  group: promote-stable
@@ -734,30 +770,41 @@ jobs:
734
770
  env:
735
771
  GH_TOKEN: \${{ github.token }}
736
772
  SOAK_HOURS: '__SOAK_HOURS__'
773
+ QUALIFICATION_WORKFLOW: '__QUALIFICATION_WORKFLOW__'
737
774
  VERSION: \${{ inputs.version }}
738
775
  FORCE: \${{ inputs.force }}
739
- HOLD: \${{ inputs.hold }}
776
+ STABLE_PROMOTION_HELD: \${{ vars.STABLE_PROMOTION_HELD }}
740
777
  run: |
741
778
  set -euo pipefail
742
779
  repo="$GITHUB_REPOSITORY"
743
780
  summary="$GITHUB_STEP_SUMMARY"
744
781
 
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
782
+ # Force bypasses hold and soak, but never asset qualification.
783
+ require_qualification() {
784
+ [ -z "$QUALIFICATION_WORKFLOW" ] && return 0
785
+ local candidate_sha conclusion
786
+ candidate_sha="$(gh api "repos/$repo/commits/$1" --jq '.sha')" || return 1
787
+ [ -n "$candidate_sha" ] && [ "$candidate_sha" != "null" ] || return 1
788
+ conclusion="$(gh run list --repo "$repo" --workflow "$QUALIFICATION_WORKFLOW" --event release --commit "$candidate_sha" --limit 1 --json conclusion --jq '.[0].conclusion // "missing"')" || return 1
789
+ if [ "$conclusion" != "success" ]; then
790
+ echo "Asset qualification for $1 is $conclusion; holding stable promotion." | tee -a "$summary"
791
+ return 1
753
792
  fi
754
- echo "Promotion hold set to $wanted." | tee -a "$summary"
793
+ }
794
+
795
+ # The repository variable applies equally to scheduled and manual runs.
796
+ if [ "$STABLE_PROMOTION_HELD" = "true" ] && [ "$FORCE" != "true" ]; then
797
+ echo "Stable promotion is held." | tee -a "$summary"
798
+ exit 0
755
799
  fi
756
800
 
757
- # A manually named version wins over everything but the hold.
758
801
  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"
802
+ if ! [[ "$VERSION" =~ ^[0-9]+[.][0-9]+[.][0-9]+$ ]]; then
803
+ echo "::error::version must be a plain semantic version" >&2
804
+ exit 1
805
+ fi
806
+ if ! require_qualification "v$VERSION"; then
807
+ echo "Qualification unavailable or unsuccessful; holding v$VERSION." | tee -a "$summary"
761
808
  exit 0
762
809
  fi
763
810
  gh release edit "v$VERSION" --repo "$repo" --prerelease=false --latest
@@ -766,22 +813,25 @@ jobs:
766
813
  fi
767
814
 
768
815
  releases="$(mktemp)"
769
- gh api "repos/$repo/releases?per_page=100" > "$releases"
816
+ trap 'rm -f "$releases"' EXIT
817
+ gh api --paginate --slurp "repos/$repo/releases?per_page=100" | jq 'add' > "$releases"
770
818
 
771
819
  # The last promoted stable anchors the candidate batch.
772
820
  last_stable_published="$(jq -r '
773
- [ .[] | select(.draft == false and .prerelease == false) ][0].published_at // "null"
821
+ [ .[] | select(.draft == false and .prerelease == false)
822
+ | select(.tag_name | test("^v[0-9]+[.][0-9]+[.][0-9]+$")) ]
823
+ | sort_by(.published_at) | .[-1].published_at // "null"
774
824
  ' "$releases")"
775
825
  echo "Last stable published: $last_stable_published"
776
826
 
777
827
  # The batch: every non-draft pre-release published since then, with
778
828
  # the plain release tag shape (dev builds carry a -dev suffix and
779
829
  # live on their own channel forever; they are never promoted).
780
- batch="$(jq -r '
830
+ batch="$(jq -r --arg last_stable_published "$last_stable_published" '
781
831
  [ .[]
782
832
  | 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")
833
+ | select(.tag_name | test("^v[0-9]+[.][0-9]+[.][0-9]+$"))
834
+ | select($last_stable_published == "null" or .published_at > $last_stable_published)
785
835
  | { tag: .tag_name, published_at: .published_at, body: .body } ]
786
836
  | sort_by(.published_at)
787
837
  ' "$releases")"
@@ -802,6 +852,11 @@ jobs:
802
852
  exit 0
803
853
  fi
804
854
 
855
+ if ! require_qualification "$tip"; then
856
+ echo "Qualification unavailable or unsuccessful; holding $tip." | tee -a "$summary"
857
+ exit 0
858
+ fi
859
+
805
860
  # Stitch the batch's notes oldest→newest so the stable release
806
861
  # documents everything stable users are getting, not just the tip.
807
862
  jq -r '.[] | "## " + .tag + "\\n\\n" + (.body // "")' <<<"$batch" > batch-notes.md
@@ -810,6 +865,68 @@ jobs:
810
865
  echo "Promoted $tip to stable: the batch of $count had soaked \${age_hours}h." | tee -a "$summary"
811
866
  `
812
867
 
868
+ /** @param {Record<string, string>} config */
869
+ function validateReleaseBatchingConfig(config) {
870
+ const schedule = String(config.release_batching_schedule ?? '').trim()
871
+ if (schedule) {
872
+ const limits = [
873
+ [0, 59],
874
+ [0, 23],
875
+ [1, 31],
876
+ [1, 12],
877
+ [0, 6],
878
+ ]
879
+ const fields = schedule.split(/\s+/)
880
+ const valid =
881
+ fields.length === 5 &&
882
+ fields.every((field, index) => {
883
+ const [min, max] = limits[index]
884
+ return field.split(',').every((part) => {
885
+ const match = part.match(/^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/)
886
+ if (!match || (match[2] && (Number(match[2]) < 1 || Number(match[2]) > max))) return false
887
+ if (match[1] === '*') return true
888
+ const [start, end = start] = match[1].split('-').map(Number)
889
+ return start >= min && end <= max && start <= end
890
+ })
891
+ })
892
+ if (!valid)
893
+ throw new Error(
894
+ `Unsupported release_batching_schedule: ${schedule}; use a five-field numeric cron expression.`
895
+ )
896
+ }
897
+ if (
898
+ config.release_batching_prerelease &&
899
+ !['true', 'false'].includes(config.release_batching_prerelease)
900
+ ) {
901
+ throw new Error(
902
+ `Unsupported release_batching_prerelease: ${config.release_batching_prerelease}; use true or false.`
903
+ )
904
+ }
905
+ if (config.release_batching_qualification_workflow) {
906
+ if (
907
+ !/^[A-Za-z0-9_.-]+\.ya?ml$/.test(config.release_batching_qualification_workflow) ||
908
+ !config.release_batching_soak_hours
909
+ ) {
910
+ throw new Error(
911
+ 'Unsupported release_batching_qualification_workflow: use a workflow filename with a batching soak.'
912
+ )
913
+ }
914
+ }
915
+ if (config.release_batching_soak_hours) {
916
+ const hours = Number(config.release_batching_soak_hours)
917
+ if (!Number.isInteger(hours) || hours < 1 || hours > 336) {
918
+ throw new Error(
919
+ `Unsupported release_batching_soak_hours: ${config.release_batching_soak_hours}; use an integer between 1 and 336.`
920
+ )
921
+ }
922
+ if (!schedule || config.release_batching_prerelease === 'false') {
923
+ throw new Error(
924
+ 'Unsupported release_batching_soak_hours: configure a batching schedule with prerelease publication.'
925
+ )
926
+ }
927
+ }
928
+ }
929
+
813
930
  /** Render the batch-soak promoter for a repository, or null when the
814
931
  * repository has not configured a soak.
815
932
  *
@@ -819,7 +936,10 @@ jobs:
819
936
  export function renderPromoteStable(config) {
820
937
  const soakHours = Number(config.release_batching_soak_hours ?? '')
821
938
  if (!Number.isInteger(soakHours) || soakHours < 1 || soakHours > 336) return null
822
- return PROMOTE_STABLE_TEMPLATE.replaceAll('__SOAK_HOURS__', String(soakHours))
939
+ return PROMOTE_STABLE_TEMPLATE.replaceAll('__SOAK_HOURS__', String(soakHours)).replaceAll(
940
+ '__QUALIFICATION_WORKFLOW__',
941
+ config.release_batching_qualification_workflow ?? ''
942
+ )
823
943
  }
824
944
 
825
945
  /**
@@ -923,43 +1043,23 @@ function renderWorkflow(content, config, repository, ref, rustCodeql, file, self
923
1043
  /^ if: github\.ref == 'refs\/heads\/main' && \(vars\.CI_BILLING_PAUSED != 'true' \|\| \(github\.event_name == 'workflow_dispatch' && inputs\['release-while-paused'\] == true\)\)\n/m,
924
1044
  " if: vars.CI_BILLING_PAUSED != 'true' || (github.event_name == 'workflow_dispatch' && inputs['release-while-paused'] == true)\n"
925
1045
  )
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.
1046
+ // Gate ordinary pushes while allowing the release squash and manual hotfixes.
933
1047
  const batchingSchedule = String(config.release_batching_schedule ?? '').trim()
934
1048
  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
1049
  rendered = rendered.replace(
947
- /^ - cron: '53 12 \* \* \*'\n/m,
948
- ` - cron: '${batchingSchedule}'\n`
1050
+ ' workflow_dispatch:',
1051
+ ` schedule:\n - cron: '${batchingSchedule}'\n workflow_dispatch:`
949
1052
  )
950
1053
  rendered = rendered.replace(
951
1054
  " 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"
1055
+ " 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"
953
1056
  )
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')
1057
+ if (configured(config.release_batching_prerelease, 'true') === 'true') {
1058
+ rendered = rendered.replace(
1059
+ ' git-workflow:',
1060
+ " prerelease: ${{ github.event_name != 'workflow_dispatch' }}\n git-workflow:"
1061
+ )
1062
+ }
963
1063
  }
964
1064
  rendered = rendered.replace(
965
1065
  /(\n secrets:\n CODE_FOUNDRY_TOKEN: \$\{\{ secrets\.CODE_FOUNDRY_TOKEN \}\}\n)/,