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.
- package/.github/workflows/release.yml +20 -3
- package/.github/workflows/release_self-ci.yml +0 -47
- package/CHANGELOG.md +14 -0
- package/docs/CONFIGURATION.md +24 -23
- package/package.json +1 -1
- package/src/commands/sync.mjs +158 -58
|
@@ -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:
|
|
@@ -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
|
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -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
|
|
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`
|
|
138
|
-
| `release_batching_schedule`
|
|
139
|
-
| `release_batching_prerelease`
|
|
140
|
-
| `release_batching_soak_hours`
|
|
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.
|
|
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": {
|
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(
|
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
#
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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
|
-
|
|
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 [ "$
|
|
760
|
-
echo "
|
|
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
|
-
|
|
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)
|
|
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]
|
|
784
|
-
| select(
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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)/,
|