From 8b32d8bebfbc1569cb17d9b432a3cc9cc734475c Mon Sep 17 00:00:00 2001 From: Bailey Hayes Date: Tue, 2 Jun 2026 09:53:00 -0400 Subject: [PATCH 1/2] ci: stabilize-next automation for release ci When a feature is voted as stabilized, add the stablize-next annotation above the feature gate, and the release automation will remove the gate after rev'ing to the next version as part of the automated release. Signed-off-by: Bailey Hayes --- .github/scripts/stabilize-features.js | 104 ++++++++++++++++++++++++++ .github/scripts/validate-since.js | 22 ++++++ .github/workflows/release.yml | 15 ++++ 3 files changed, 141 insertions(+) create mode 100644 .github/scripts/stabilize-features.js diff --git a/.github/scripts/stabilize-features.js b/.github/scripts/stabilize-features.js new file mode 100644 index 00000000..4349243e --- /dev/null +++ b/.github/scripts/stabilize-features.js @@ -0,0 +1,104 @@ +#!/usr/bin/env node + +// Promote feature gates marked for stabilization during a release. +// +// Convention: +// +// // @stabilize-next +// @unstable(feature = cli-exit-with-code) +// exit-with-code: func(status-code: u8); +// +// The `// @stabilize-next` marker line must sit immediately above an +// `@unstable(feature = ...)` gate. When a release for version NEXT is cut, +// this script rewrites the gate to `@since(version = NEXT)` and removes the +// marker line, so the feature graduates exactly when the package version is +// bumped to NEXT (the only point at which `@since(NEXT)` is legal). +// +// Usage: node stabilize-features.js + +const fs = require('fs'); +const path = require('path'); + +const MARKER_PATTERN = /^\s*\/\/\s*@stabilize-next\s*$/; +const UNSTABLE_PATTERN = /^(\s*)@unstable\s*\(\s*feature\s*=\s*([a-z][a-z0-9-]*)\s*\)\s*$/i; + +function stabilizeFile(filePath, nextVersion) { + const lines = fs.readFileSync(filePath, 'utf-8').split('\n'); + const out = []; + const promoted = []; + + for (let i = 0; i < lines.length; i++) { + if (MARKER_PATTERN.test(lines[i])) { + const next = lines[i + 1]; + const m = next && next.match(UNSTABLE_PATTERN); + if (!m) { + throw new Error( + `${filePath}:${i + 1}: '// @stabilize-next' is not immediately followed by an @unstable(...) gate` + ); + } + const indent = m[1]; + // Drop the marker line, replace the gate on the following line. + out.push(`${indent}@since(version = ${nextVersion})`); + promoted.push({ feature: m[2], line: i + 1 }); + i++; // consume the @unstable line we just rewrote + continue; + } + out.push(lines[i]); + } + + if (promoted.length > 0) { + fs.writeFileSync(filePath, out.join('\n')); + } + return promoted; +} + +function walkWit(dir, cb) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'deps') continue; // pulled packages, leave untouched + walkWit(full, cb); + } else if (entry.name.endsWith('.wit')) { + cb(full); + } + } +} + +function stabilizeDirectory(dir, nextVersion) { + const all = []; + walkWit(dir, (file) => { + for (const p of stabilizeFile(file, nextVersion)) { + all.push({ file, ...p }); + } + }); + return all; +} + +if (require.main === module) { + const [dir, nextVersion] = process.argv.slice(2); + if (!dir || !nextVersion) { + console.error('Usage: node stabilize-features.js '); + process.exit(1); + } + if (!fs.existsSync(dir)) { + console.error(`Directory not found: ${dir}`); + process.exit(1); + } + + try { + const promoted = stabilizeDirectory(dir, nextVersion); + if (promoted.length === 0) { + console.log(`No @stabilize-next markers in ${dir}`); + } else { + for (const p of promoted) { + const rel = path.relative(process.cwd(), p.file); + console.log(`Stabilized feature '${p.feature}' -> @since(version = ${nextVersion}) (${rel})`); + } + } + } catch (err) { + console.error(`::error::${err.message}`); + process.exit(1); + } +} + +module.exports = { stabilizeFile, stabilizeDirectory }; diff --git a/.github/scripts/validate-since.js b/.github/scripts/validate-since.js index 8dd8fe08..383d9405 100644 --- a/.github/scripts/validate-since.js +++ b/.github/scripts/validate-since.js @@ -24,6 +24,14 @@ const DECLARATION_PATTERNS = [ const SINCE_PATTERN = /@since\s*\(\s*version\s*=\s*[0-9a-z.\-]+\s*\)/i; const UNSTABLE_PATTERN = /@unstable\s*\(\s*feature\s*=\s*[a-z][a-z0-9-]*\s*\)/i; +/** + * Marker promoted by .github/scripts/stabilize-features.js during a release. + * It must sit immediately above an @unstable(feature = ...) gate; the release + * rewrites that gate to @since(version = NEXT) and drops the marker. A marker + * that survives (no gate below it) means a stabilization was left half-applied. + */ +const STABILIZE_MARKER_PATTERN = /^\s*\/\/\s*@stabilize-next\s*$/; + /** * Check if a line has a preceding @since or @unstable annotation. * Looks backward through lines, skipping doc comments (///). @@ -81,6 +89,20 @@ function validateFile(filePath) { for (let i = 0; i < lines.length; i++) { const line = lines[i]; + // A @stabilize-next marker must be immediately followed by an @unstable gate. + if (STABILIZE_MARKER_PATTERN.test(line)) { + const next = lines[i + 1] || ''; + if (!UNSTABLE_PATTERN.test(next.trim())) { + errors.push({ + file: filePath, + line: i + 1, + declaration: 'marker', + name: '@stabilize-next', + message: `'// @stabilize-next' must be immediately followed by an @unstable(feature = ...) gate`, + }); + } + } + for (const { name, regex } of DECLARATION_PATTERNS) { const match = line.match(regex); if (match) { diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7d77f445..7d6c271d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -141,6 +141,21 @@ jobs: fi done + - name: Stabilize gated features + run: | + WIT_DIR="${{ needs.prepare-release.outputs.wit_dir }}" + NEXT="${{ needs.prepare-release.outputs.next_version }}" + + # Promote any feature gate marked `// @stabilize-next` to + # @since(version = NEXT). This runs after the package version is + # bumped to NEXT, which is the point at which @since(NEXT) is legal. + for proposal in $PROPOSALS; do + wit_path="proposals/$proposal/$WIT_DIR" + if [ -d "$wit_path" ]; then + node .github/scripts/stabilize-features.js "$wit_path" "$NEXT" + fi + done + - name: Update wit-deps run: | WIT_DIR="${{ needs.prepare-release.outputs.wit_dir }}" From 50ee2151a7d514f6f75d35a1c0d8659918efaa9d Mon Sep 17 00:00:00 2001 From: Bailey Hayes Date: Tue, 2 Jun 2026 09:53:23 -0400 Subject: [PATCH 2/2] feat(cli): stabilize-next cli-exit-with-code Signed-off-by: Bailey Hayes --- proposals/cli/wit/exit.wit | 1 + 1 file changed, 1 insertion(+) diff --git a/proposals/cli/wit/exit.wit b/proposals/cli/wit/exit.wit index 427935c8..682cf36a 100644 --- a/proposals/cli/wit/exit.wit +++ b/proposals/cli/wit/exit.wit @@ -12,6 +12,7 @@ interface exit { /// /// This function does not return; the effect is analogous to a trap, but /// without the connotation that something bad has happened. + // @stabilize-next @unstable(feature = cli-exit-with-code) exit-with-code: func(status-code: u8); }