fix(ci): close dev-released issues on stable release (#1396)

This commit is contained in:
Kai (Tam Nhu) Tran
2026-05-27 08:28:01 -04:00
committed by GitHub
parent aecd8beb54
commit 52a427a7a3
4 changed files with 332 additions and 71 deletions
+1 -71
View File
@@ -102,74 +102,4 @@ jobs:
if: success() && steps.release.outputs.released == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=$(jq -r '.version' package.json)
RELEASE_BODY=$(gh release view "v${VERSION}" --repo "${{ github.repository }}" --json body --jq '.body' 2>/dev/null || echo "")
PREVIOUS_STABLE_TAG=$(git tag -l "v[0-9]*.[0-9]*.[0-9]" --sort=-v:refname | \
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | \
grep -vx "v${VERSION}" | head -1 || echo "")
RANGE="${PREVIOUS_STABLE_TAG:+${PREVIOUS_STABLE_TAG}..HEAD~1}"
if [[ -z "$RANGE" ]]; then
RANGE="HEAD~50..HEAD~1"
fi
COMMIT_TEXT=$(git log $RANGE --pretty=format:"%s%n%b" 2>/dev/null || true)
PR_CANDIDATES=$(printf '%s\n' "$COMMIT_TEXT" | \
grep -oE "Merge pull request #[0-9]+|\\(#[0-9]+\\)" | \
grep -oE "[0-9]+" | sort -u || true)
PR_TEXT=""
for PR_NUM in $PR_CANDIDATES; do
DETAILS=$(gh pr view "$PR_NUM" --repo "${{ github.repository }}" --json title,body --jq '.title + "\n" + (.body // "")' 2>/dev/null || true)
if [[ -n "$DETAILS" ]]; then
PR_TEXT="${PR_TEXT}"$'\n'"${DETAILS}"
fi
done
RELEASE_ISSUES_FROM_BODY=$(printf '%s\n' "$RELEASE_BODY" | \
perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \
grep -oE '#[0-9]+' | tr -d '#' || true)
RELEASE_ISSUES_FROM_COMMITS=$(printf '%s\n' "$COMMIT_TEXT" | \
perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \
grep -oE '#[0-9]+' | tr -d '#' || true)
RELEASE_ISSUES_FROM_PRS=$(printf '%s\n' "$PR_TEXT" | \
perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \
grep -oE '#[0-9]+' | tr -d '#' || true)
RELEASE_ISSUES=$(printf '%s\n%s\n%s\n' \
"$RELEASE_ISSUES_FROM_BODY" \
"$RELEASE_ISSUES_FROM_COMMITS" \
"$RELEASE_ISSUES_FROM_PRS" | sort -u || true)
RESOLVED_ISSUES_FROM_BODY=$(printf '%s\n' "$RELEASE_BODY" | \
perl -ne 'if (/(fixes|closes|resolves)(.*)/i) { print "$2\n"; }' | \
grep -oE '#[0-9]+' | tr -d '#' || true)
RESOLVED_ISSUES_FROM_COMMITS=$(printf '%s\n' "$COMMIT_TEXT" | \
perl -ne 'if (/(fixes|closes|resolves)(.*)/i) { print "$2\n"; }' | \
grep -oE '#[0-9]+' | tr -d '#' || true)
RESOLVED_ISSUES_FROM_PRS=$(printf '%s\n' "$PR_TEXT" | \
perl -ne 'if (/(fixes|closes|resolves)(.*)/i) { print "$2\n"; }' | \
grep -oE '#[0-9]+' | tr -d '#' || true)
RESOLVED_ISSUES=$(printf '%s\n%s\n%s\n' \
"$RESOLVED_ISSUES_FROM_BODY" \
"$RESOLVED_ISSUES_FROM_COMMITS" \
"$RESOLVED_ISSUES_FROM_PRS" | sort -u || true)
if [[ -z "$RELEASE_ISSUES" ]]; then
echo "No release-scoped issues found for v${VERSION}"
exit 0
fi
for NUM in $RELEASE_ISSUES; do
echo "Cleaning release state on issue #$NUM"
gh issue edit "$NUM" \
--remove-label "released-dev" \
--remove-label "pending-release" \
--repo "${{ github.repository }}" 2>/dev/null || true
HAS_RELEASED=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json labels --jq '[.labels[].name | select(. == "released")] | length' 2>/dev/null || echo "0")
if [[ "$HAS_RELEASED" -gt 0 ]] && printf '%s\n' "$RESOLVED_ISSUES" | grep -qx "$NUM"; then
gh issue close "$NUM" \
--comment "[bot] Closing issue because this fix/feature is now in stable release (@latest)." \
--repo "${{ github.repository }}" || true
fi
done
run: node scripts/github/stable-release-issue-cleanup.mjs
@@ -0,0 +1,113 @@
import { readFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
const ISSUE_REF_PATTERN = /#([0-9]+)/g;
const ACTION_VERB_PATTERN = /\b(fixes|closes|resolves|refs?)\b(.*)/gi;
const RESOLVE_VERB_PATTERN = /\b(fixes|closes|resolves)\b(.*)/gi;
const PR_REF_PATTERN = /(?:Merge pull request #|\(#)([0-9]+)/g;
const STABLE_TAG_PATTERN = /^v[0-9]+\.[0-9]+\.[0-9]+$/;
export function extractIssueNumbers(text, { includeRefs = true } = {}) {
const pattern = includeRefs ? ACTION_VERB_PATTERN : RESOLVE_VERB_PATTERN;
const issues = new Set();
let actionMatch;
pattern.lastIndex = 0;
while ((actionMatch = pattern.exec(text || '')) !== null) {
const tail = actionMatch[2] || '';
let issueMatch;
ISSUE_REF_PATTERN.lastIndex = 0;
while ((issueMatch = ISSUE_REF_PATTERN.exec(tail)) !== null) {
issues.add(Number(issueMatch[1]));
}
}
return [...issues].sort((a, b) => a - b);
}
export function extractPrNumbers(text) {
const prs = new Set();
let match;
PR_REF_PATTERN.lastIndex = 0;
while ((match = PR_REF_PATTERN.exec(text || '')) !== null) {
prs.add(Number(match[1]));
}
return [...prs].sort((a, b) => a - b);
}
export function planIssueCleanup({ releaseIssues, resolvedIssues, issueStates }) {
const resolved = new Set(resolvedIssues);
return releaseIssues.map((number) => {
const state = issueStates.get(number) || { labels: [], state: 'UNKNOWN' };
const labels = new Set(state.labels);
const wasReleasedDev = labels.has('released-dev');
const shouldClose = state.state === 'OPEN' && (wasReleasedDev || resolved.has(number));
return {
number,
removeLabels: ['released-dev', 'pending-release'],
addReleasedLabel: shouldClose,
close: shouldClose,
reason: wasReleasedDev ? 'promoted from dev to stable' : 'resolved by stable release',
};
});
}
export function getStableReleaseContext({ env = process.env, exec = runCommand } = {}) {
const repo = env.GITHUB_REPOSITORY;
if (!repo) throw new Error('GITHUB_REPOSITORY is required');
const version = JSON.parse(readFileSync('package.json', 'utf8')).version;
const currentTag = `v${version}`;
const releaseBody = exec('gh', [
'release',
'view',
currentTag,
'--repo',
repo,
'--json',
'body',
'--jq',
'.body',
]);
const tags = exec('git', ['tag', '-l', 'v[0-9]*.[0-9]*.[0-9]*', '--sort=-v:refname'])
.split('\n')
.map((tag) => tag.trim())
.filter((tag) => STABLE_TAG_PATTERN.test(tag) && tag !== currentTag);
const previousStableTag = tags[0] || '';
const range = previousStableTag ? `${previousStableTag}..HEAD~1` : 'HEAD~50..HEAD~1';
const commitText = exec('git', ['log', range, '--pretty=format:%s%n%b'], { optional: true });
return { repo, version, currentTag, releaseBody, range, commitText };
}
export function buildReleaseIssueSet({ releaseBody, commitText, prText }) {
const releaseIssues = new Set([
...extractIssueNumbers(releaseBody, { includeRefs: true }),
...extractIssueNumbers(commitText, { includeRefs: true }),
...extractIssueNumbers(prText, { includeRefs: true }),
]);
const resolvedIssues = new Set([
...extractIssueNumbers(releaseBody, { includeRefs: false }),
...extractIssueNumbers(commitText, { includeRefs: false }),
...extractIssueNumbers(prText, { includeRefs: false }),
]);
return {
releaseIssues: [...releaseIssues].sort((a, b) => a - b),
resolvedIssues: [...resolvedIssues].sort((a, b) => a - b),
};
}
export function runCommand(command, args, { optional = false } = {}) {
const result = spawnSync(command, args, { encoding: 'utf8' });
if (result.status !== 0) {
if (optional) return '';
throw new Error(
`${command} ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`
);
}
return result.stdout.trim();
}
@@ -0,0 +1,134 @@
import {
buildReleaseIssueSet,
extractPrNumbers,
getStableReleaseContext,
planIssueCleanup,
runCommand,
} from './stable-release-issue-cleanup-lib.mjs';
function gh(args, options) {
return runCommand('gh', args, options);
}
function fetchPrText(repo, commitText) {
let prText = '';
for (const prNumber of extractPrNumbers(commitText)) {
const details = gh(
[
'pr',
'view',
String(prNumber),
'--repo',
repo,
'--json',
'title,body',
'--jq',
'.title + "\n" + (.body // "")',
],
{ optional: true }
);
if (details) prText += `\n${details}`;
}
return prText;
}
function fetchIssueStates(repo, issueNumbers) {
const states = new Map();
for (const number of issueNumbers) {
const raw = gh(
[
'issue',
'view',
String(number),
'--repo',
repo,
'--json',
'state,labels',
'--jq',
'{state:.state,labels:[.labels[].name]}',
],
{ optional: true }
);
if (!raw) continue;
states.set(number, JSON.parse(raw));
}
return states;
}
function ensureReleasedLabel(repo) {
gh(
[
'label',
'create',
'released',
'--color',
'ededed',
'--description',
'Fix available in stable npm channel',
'--repo',
repo,
],
{ optional: true }
);
}
function applyAction(repo, action) {
console.log(`Cleaning release state on issue #${action.number}`);
for (const label of action.removeLabels) {
gh(['issue', 'edit', String(action.number), '--remove-label', label, '--repo', repo], {
optional: true,
});
}
if (!action.close) return;
gh(['issue', 'edit', String(action.number), '--add-label', 'released', '--repo', repo], {
optional: true,
});
gh(
[
'issue',
'close',
String(action.number),
'--comment',
'[bot] Closing issue because this fix/feature is now in stable release (@latest).',
'--repo',
repo,
],
{ optional: true }
);
console.log(`Closed issue #${action.number}: ${action.reason}`);
}
export function main() {
const context = getStableReleaseContext();
console.log(`Checking stable release issue lifecycle for ${context.currentTag}`);
console.log(`Checking commits in range: ${context.range}`);
const prText = fetchPrText(context.repo, context.commitText);
const { releaseIssues, resolvedIssues } = buildReleaseIssueSet({
releaseBody: context.releaseBody,
commitText: context.commitText,
prText,
});
if (releaseIssues.length === 0) {
console.log(`No release-scoped issues found for ${context.currentTag}`);
return;
}
ensureReleasedLabel(context.repo);
const issueStates = fetchIssueStates(context.repo, releaseIssues);
for (const action of planIssueCleanup({ releaseIssues, resolvedIssues, issueStates })) {
applyAction(context.repo, action);
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
try {
main();
} catch (error) {
console.error(error);
process.exit(1);
}
}
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'bun:test';
import {
buildReleaseIssueSet,
extractIssueNumbers,
extractPrNumbers,
planIssueCleanup,
} from '../../../scripts/github/stable-release-issue-cleanup-lib.mjs';
describe('stable release issue cleanup', () => {
it('extracts issue numbers from release action verbs', () => {
const text = [
'feat: promote dev to main (#1351), closes #1340 #1341',
'fix: support aliases (#1197)',
'Refs #760',
].join('\n');
expect(extractIssueNumbers(text, { includeRefs: true })).toEqual([760, 1340, 1341]);
expect(extractIssueNumbers(text, { includeRefs: false })).toEqual([1340, 1341]);
});
it('extracts PR numbers from merge and squash commit subjects', () => {
const text = [
'Merge pull request #1392 from kaitranntt/kai/fix/foo',
'fix(analytics): tighten top-bar layout (#1391)',
].join('\n');
expect(extractPrNumbers(text)).toEqual([1391, 1392]);
});
it('combines release body, commit text, and PR body issue references', () => {
const result = buildReleaseIssueSet({
releaseBody: 'closes #100',
commitText: 'fix: thing (#10)',
prText: 'Refs #200\nResolves #300',
});
expect(result.releaseIssues).toEqual([100, 200, 300]);
expect(result.resolvedIssues).toEqual([100, 300]);
});
it('closes dev-released issues when promoted to stable even if the PR used refs', () => {
const actions = planIssueCleanup({
releaseIssues: [760],
resolvedIssues: [],
issueStates: new Map([[760, { state: 'OPEN', labels: ['released-dev'] }]]),
});
expect(actions[0]).toMatchObject({
number: 760,
addReleasedLabel: true,
close: true,
reason: 'promoted from dev to stable',
});
});
it('does not close weak refs unless the issue was already marked released-dev', () => {
const actions = planIssueCleanup({
releaseIssues: [42],
resolvedIssues: [],
issueStates: new Map([[42, { state: 'OPEN', labels: ['enhancement'] }]]),
});
expect(actions[0]).toMatchObject({
number: 42,
addReleasedLabel: false,
close: false,
});
});
it('closes explicitly resolved issues without requiring a manual released label', () => {
const actions = planIssueCleanup({
releaseIssues: [1340],
resolvedIssues: [1340],
issueStates: new Map([[1340, { state: 'OPEN', labels: ['bug'] }]]),
});
expect(actions[0]).toMatchObject({
number: 1340,
addReleasedLabel: true,
close: true,
reason: 'resolved by stable release',
});
});
});