From 858874e8ffd9b60bb84990747655cf37d716f3ca Mon Sep 17 00:00:00 2001 From: mikhail Date: Thu, 4 Jun 2026 13:05:29 +0300 Subject: [PATCH 1/2] feat: add maintained-branch release check workflow Adds scripts/checkReleases.js and a scheduled GitHub Actions workflow that report which maintained branches have unreleased feat:/fix: changes worth a patch release. Maintained branches are read from on.push.branches in validation.yml, so there is no second list to keep in sync. chore:/refactor: and other prefixes are ignored. The workflow keeps one issue per branch so each release can be tracked and assigned individually. It upserts by a stable title (editing the existing open issue instead of creating duplicates) and auto-closes a branch's issue once it is fully released. Issue title (branch-only so it stays stable across release cycles): Release pending: Issue body template: Branch `` has **** unreleased `feat:`/`fix:` commits since ``. Suggested next release: `` ### Changes to release - () ... --- _Auto-generated by `scripts/checkReleases.js` from `.github/workflows/validation.yml`. Last updated . Assign this issue to whoever owns the `` release._ Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release-check.yml | 77 +++++++++ scripts/checkReleases.js | 250 ++++++++++++++++++++++++++++ 2 files changed, 327 insertions(+) create mode 100644 .github/workflows/release-check.yml create mode 100755 scripts/checkReleases.js diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml new file mode 100644 index 00000000000..4495af16085 --- /dev/null +++ b/.github/workflows/release-check.yml @@ -0,0 +1,77 @@ +name: Maintained Branch Release Check + +# Reports which maintained branches (read from validation.yml) have unreleased +# feat:/fix: changes and keeps one GitHub issue per branch listing the patch +# release to do, so each release can be tracked and assigned individually. +on: + schedule: + # Mondays at 06:00 UTC + - cron: '0 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + release-check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # Full history + tags for every branch so the script can diff each + # maintained branch against its latest release tag. + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Compute pending releases + run: node ./scripts/checkReleases.js --out=release-check + + - name: Sync per-branch release issues + env: + GH_TOKEN: ${{ github.token }} + run: | + gh label create release-pending \ + --description "Maintained branch with unreleased feat:/fix: changes" \ + --color 0E8A16 2>/dev/null || true + + # Look up an open release-pending issue by its exact (stable) title. + # List by label and match the title exactly to avoid search tokenizing + # the ":" in the title. + find_issue() { + gh issue list --state open --label release-pending --limit 100 \ + --json number,title --jq ".[] | select(.title==\"$1\") | .number" | head -n1 + } + + # Create or update one issue per branch with pending changes. + jq -c '.pending[]' release-check/manifest.json | while read -r item; do + title=$(echo "$item" | jq -r '.title') + body=$(echo "$item" | jq -r '.body') + existing=$(find_issue "$title") + if [ -n "$existing" ]; then + gh issue edit "$existing" --body-file "$body" + echo "Updated #$existing — $title" + else + gh issue create --title "$title" --label release-pending --body-file "$body" + echo "Created — $title" + fi + done + + # Close issues for branches that are now fully released. + jq -r '.clean[]' release-check/manifest.json | while read -r title; do + existing=$(find_issue "$title") + if [ -n "$existing" ]; then + gh issue close "$existing" \ + --comment "All \`feat:\`/\`fix:\` changes on this branch are released. Closing automatically." + echo "Closed #$existing — $title" + fi + done + + - name: Job summary + if: always() + run: cat release-check/summary.md >> "$GITHUB_STEP_SUMMARY" diff --git a/scripts/checkReleases.js b/scripts/checkReleases.js new file mode 100755 index 00000000000..80787f6f44f --- /dev/null +++ b/scripts/checkReleases.js @@ -0,0 +1,250 @@ +#!/usr/bin/env node +/** + * Reports which maintained branches have unreleased changes worth a patch + * release, producing one issue body per branch so releases can be tracked and + * assigned individually. + * + * The maintained branches are read from `.github/workflows/validation.yml` + * (the `on.push.branches` list) so this stays in sync with CI and there is no + * second list to keep up to date. + * + * For each branch it finds the latest release tag on that version line and + * lists the `feat:` and `fix:` commits merged since then. `chore:`, + * `refactor:` and other prefixes are ignored because they don't, on their own, + * justify a release. + * + * Usage: + * ./scripts/checkReleases.js [--out=release-check] [--no-fetch] + * + * Outputs, under the out directory (default `release-check`): + * - `.md` Issue body for each branch with pending changes. + * - `manifest.json` { pending: [{branch,title,next,count,body}], clean: [branch] } + * The workflow uses this to upsert one issue per pending + * branch and to close issues for branches now up to date. + * - `summary.md` Combined overview for the Actions job summary. + * + * The script fetches the maintained branches and tags itself, so the workflow + * only needs a shallow checkout. + */ + +const fs = require('fs'); +const { execFileSync } = require('child_process'); + +const REMOTE = process.env.RELEASE_CHECK_REMOTE || 'origin'; +const VALIDATION_YML = '.github/workflows/validation.yml'; + +function git(args) { + return execFileSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); +} + +function parseArgs(argv) { + const opts = { outDir: 'release-check', fetch: true }; + for (const arg of argv) { + if (arg.startsWith('--out=')) opts.outDir = arg.slice('--out='.length); + else if (arg === '--no-fetch') opts.fetch = false; + } + return opts; +} + +/** + * Extracts the maintained branch list from the `on.push.branches` entry of + * validation.yml. Done with a focused regex to avoid a YAML dependency, but + * anchored to the `push:` block so it doesn't pick up `pull_request.branches`. + */ +function readMaintainedBranches() { + const yml = fs.readFileSync(VALIDATION_YML, 'utf8'); + const pushMatch = yml.match(/\bpush:\s*\n(?:[^\n]*\n)*?\s*branches:\s*\[([^\]]*)\]/); + if (!pushMatch) { + throw new Error(`Could not find on.push.branches in ${VALIDATION_YML}`); + } + return pushMatch[1] + .split(',') + .map((s) => s.trim().replace(/^['"]|['"]$/g, '')) + .filter(Boolean); +} + +/** Parses a tag like `24.9.19` or `25.2.0-beta1` into a comparable structure. */ +function parseVersion(tag) { + const m = tag.match(/^(\d+)\.(\d+)\.(\d+)(?:[.-]([0-9A-Za-z.-]+))?$/); + if (!m) return null; + return { + tag, + major: Number(m[1]), + minor: Number(m[2]), + patch: Number(m[3]), + pre: m[4] || null, // e.g. "beta1", "alpha13", "rc1" + }; +} + +/** Semver-style comparison where a release outranks its pre-releases. */ +function compareVersions(a, b) { + if (a.major !== b.major) return a.major - b.major; + if (a.minor !== b.minor) return a.minor - b.minor; + if (a.patch !== b.patch) return a.patch - b.patch; + if (a.pre === b.pre) return 0; + if (!a.pre) return 1; // final release > pre-release + if (!b.pre) return -1; + return a.pre < b.pre ? -1 : 1; // lexical is good enough for alpha/beta/rc +} + +function fetchRefs(branches) { + const refspecs = branches.map((b) => `+${b}:refs/remotes/${REMOTE}/${b}`); + // Tags first (so the version line is fully known), then the branch tips. + git(['fetch', '--quiet', '--tags', REMOTE, ...refspecs]); +} + +/** Latest tag (release or pre-release) on a branch's version line, or null. */ +function latestTagFor(branch) { + const prefix = `${branch}.`; + const versions = git(['tag', '--list', `${prefix}*`]) + .split('\n') + .map((t) => t.trim()) + .filter(Boolean) + .map(parseVersion) + .filter(Boolean); + if (!versions.length) return null; + versions.sort(compareVersions); + return versions[versions.length - 1]; +} + +/** The patch version that the pending changes would be released as. */ +function suggestNextVersion(version) { + if (!version) return null; + // Don't guess the next pre-release identifier; only suggest for finals. + if (version.pre) return null; + return `${version.major}.${version.minor}.${version.patch + 1}`; +} + +const RELEASABLE = /^(feat|fix)(\([^)]*\))?!?:\s/i; + +/** feat:/fix: commit subjects merged since `tag` on the branch. */ +function pendingCommits(tag, branch) { + const range = tag ? `${tag}..${REMOTE}/${branch}` : `${REMOTE}/${branch}`; + const lines = git(['log', '--no-merges', '--format=%h\t%s', range]) + .split('\n') + .filter(Boolean); + return lines + .map((line) => { + const tab = line.indexOf('\t'); + return { sha: line.slice(0, tab), subject: line.slice(tab + 1) }; + }) + .filter((c) => RELEASABLE.test(c.subject)); +} + +/** + * The issue title is intentionally branch-only (no version) so it is stable + * across release cycles. That gives the workflow a reliable key to find and + * update the existing open issue for a branch instead of opening duplicates, + * which keeps any assignee attached. The target version lives in the body. + */ +function issueTitle(branch) { + return `Release pending: ${branch}`; +} + +/** Issue body for a single branch's pending release. */ +function branchBody(r, now) { + const target = r.next ? `\`${r.next}\`` : 'the next pre-release'; + const n = r.commits.length; + const lines = []; + lines.push(`Branch \`${r.branch}\` has **${n}** unreleased \`feat:\`/\`fix:\` commit${n === 1 ? '' : 's'} since \`${r.lastTag || 'the start of the branch'}\`.`); + lines.push(''); + lines.push(`Suggested next release: ${target}`); + lines.push(''); + lines.push('### Changes to release'); + lines.push(''); + for (const c of r.commits) { + lines.push(`- ${c.subject} (${c.sha})`); + } + lines.push(''); + lines.push('---'); + lines.push( + `_Auto-generated by \`scripts/checkReleases.js\` from \`${VALIDATION_YML}\`. ` + + `Last updated ${now}. Assign this issue to whoever owns the \`${r.branch}\` release._` + ); + return lines.join('\n'); +} + +/** Combined overview written to the Actions job summary. */ +function buildSummary(results, now) { + const pending = results.filter((r) => r.commits.length > 0); + const lines = []; + lines.push('## Maintained branch release check'); + lines.push(''); + lines.push(`_Generated ${now} from \`${VALIDATION_YML}\` maintained branches._`); + lines.push(''); + if (!pending.length) { + lines.push('No maintained branch has unreleased `feat:`/`fix:` changes. 🎉'); + return lines.join('\n'); + } + lines.push('One issue is opened/updated per branch below.'); + lines.push(''); + lines.push('| Branch | Last release | Next | feat/fix commits |'); + lines.push('| --- | --- | --- | --- |'); + for (const r of pending) { + lines.push(`| \`${r.branch}\` | ${r.lastTag || '—'} | ${r.next || '—'} | ${r.commits.length} |`); + } + const clean = results.filter((r) => r.commits.length === 0); + if (clean.length) { + lines.push(''); + lines.push(`Up to date: ${clean.map((r) => `\`${r.branch}\``).join(', ')}.`); + } + return lines.join('\n'); +} + +function main() { + const opts = parseArgs(process.argv.slice(2)); + const branches = readMaintainedBranches(); + if (opts.fetch) { + fetchRefs(branches); + } + + const now = new Date().toISOString().slice(0, 10); + const results = branches.map((branch) => { + const version = latestTagFor(branch); + const commits = pendingCommits(version && version.tag, branch); + return { + branch, + lastTag: version && version.tag, + next: suggestNextVersion(version), + commits, + }; + }); + + fs.mkdirSync(opts.outDir, { recursive: true }); + + const pending = []; + for (const r of results) { + if (r.commits.length === 0) continue; + const bodyFile = `${opts.outDir}/${r.branch}.md`; + fs.writeFileSync(bodyFile, branchBody(r, now) + '\n'); + pending.push({ + branch: r.branch, + title: issueTitle(r.branch), + next: r.next, + count: r.commits.length, + body: bodyFile, + }); + } + const clean = results.filter((r) => r.commits.length === 0).map((r) => issueTitle(r.branch)); + + fs.writeFileSync( + `${opts.outDir}/manifest.json`, + JSON.stringify({ pending, clean }, null, 2) + '\n' + ); + fs.writeFileSync(`${opts.outDir}/summary.md`, buildSummary(results, now) + '\n'); + + console.log( + pending.length + ? `Pending releases on: ${pending.map((p) => p.next || p.branch).join(', ')}` + : 'No pending patch releases' + ); + for (const r of results) { + console.log(` ${r.branch}: ${r.commits.length} pending (last ${r.lastTag || 'none'})`); + } + + if (process.env.GITHUB_OUTPUT) { + fs.appendFileSync(process.env.GITHUB_OUTPUT, `has_pending=${pending.length > 0}\n`); + } +} + +main(); From cc6dfcc7bd8f9b355b1adc01efcb0d1a0d6be317 Mon Sep 17 00:00:00 2001 From: mikhail Date: Wed, 17 Jun 2026 16:00:52 +0300 Subject: [PATCH 2/2] remove scheduled trigger --- .github/workflows/release-check.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index 4495af16085..ebfbafbf3b6 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -4,9 +4,6 @@ name: Maintained Branch Release Check # feat:/fix: changes and keeps one GitHub issue per branch listing the patch # release to do, so each release can be tracked and assigned individually. on: - schedule: - # Mondays at 06:00 UTC - - cron: '0 6 * * 1' workflow_dispatch: permissions: