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.
- package/.github/workflows/release.yml +20 -3
- package/CHANGELOG.md +14 -0
- package/docs/CONFIGURATION.md +23 -20
- package/package.json +1 -1
- package/src/commands/sync.mjs +257 -4
|
@@ -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('
|
|
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
|
-
`
|
|
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
|
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -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
|
|
119
|
-
|
|
|
120
|
-
| `performance`
|
|
121
|
-
| `performance_command`
|
|
122
|
-
| `performance_profile`
|
|
123
|
-
| `performance_budget_file`
|
|
124
|
-
| `eval`
|
|
125
|
-
| `eval_command`
|
|
126
|
-
| `eval_report_file`
|
|
127
|
-
| `eval_budget_file`
|
|
128
|
-
| `eval_runner`
|
|
129
|
-
| `required_capabilities`
|
|
130
|
-
| `coverage_enforcement`
|
|
131
|
-
| `coverage_minimum`
|
|
132
|
-
| `coverage_metrics`
|
|
133
|
-
| `coverage_report`
|
|
134
|
-
| `filter_<task>`
|
|
135
|
-
| `rust_sccache`
|
|
136
|
-
| `rust_nextest`
|
|
137
|
-
| `e2e_shards`
|
|
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.
|
|
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": {
|
package/src/commands/sync.mjs
CHANGED
|
@@ -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
|
-
|
|
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'
|