From c2fbff15eec0ecf7a2bbef2e4bdf44e0e806ae75 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Fri, 24 Jul 2026 17:33:39 +0200 Subject: [PATCH 1/9] chore: auto-close stale release PRs after 3 hours of inactivity Abandoned release/* PRs block others from starting new releases; close them, delete the branch, and document the escape-hatch label. --- .github/workflows/close-stale-release-prs.yml | 150 ++++++++++++++++++ docs/processes/releasing.md | 1 + 2 files changed, 151 insertions(+) create mode 100644 .github/workflows/close-stale-release-prs.yml diff --git a/.github/workflows/close-stale-release-prs.yml b/.github/workflows/close-stale-release-prs.yml new file mode 100644 index 00000000000..9ebc89d09fd --- /dev/null +++ b/.github/workflows/close-stale-release-prs.yml @@ -0,0 +1,150 @@ +name: Close Stale Release PRs + +# Release PRs on `release/*` branches are expected to merge quickly. Abandoned +# ones block other engineers from starting a new release. This workflow closes +# inactive release PRs, leaves a comment, and deletes the branch. +on: + schedule: + # Check twice an hour so the 3h window is reasonably precise. + - cron: '*/30 * * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: close-stale-release-prs + cancel-in-progress: false + +jobs: + close-stale-release-prs: + name: Close stale release PRs + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Close inactive release PRs + uses: actions/github-script@v8 + env: + STALE_HOURS: '3' + # Optional escape hatch for long-running releases that must stay open. + EXEMPT_LABEL: 'release:keep-open' + with: + script: | + const staleHours = Number(process.env.STALE_HOURS); + const staleMs = staleHours * 60 * 60 * 1000; + const exemptLabel = process.env.EXEMPT_LABEL; + const now = Date.now(); + const { owner, repo } = context.repo; + + const pulls = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: 'open', + per_page: 100, + }); + + const releasePrs = pulls.filter((pr) => { + if (!pr.head.ref.startsWith('release/')) { + return false; + } + + // Only manage same-repo release branches (never forks). + if (pr.head.repo?.full_name !== `${owner}/${repo}`) { + return false; + } + + if (pr.labels.some((label) => label.name === exemptLabel)) { + core.info( + `Skipping #${pr.number} (${pr.head.ref}): exempt label "${exemptLabel}"`, + ); + return false; + } + + return true; + }); + + if (releasePrs.length === 0) { + core.info('No open release PRs to evaluate.'); + return; + } + + for (const pr of releasePrs) { + const ageMs = now - Date.parse(pr.updated_at); + if (ageMs < staleMs) { + core.info( + `Skipping #${pr.number} (${pr.head.ref}): updated ${Math.round(ageMs / 60000)}m ago`, + ); + continue; + } + + const mergeQueue = await github.graphql( + ` + query ($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + isInMergeQueue + autoMergeRequest { + enabledAt + } + } + } + } + `, + { owner, repo, number: pr.number }, + ); + + const pullRequest = mergeQueue.repository.pullRequest; + if (pullRequest.isInMergeQueue || pullRequest.autoMergeRequest) { + core.info( + `Skipping #${pr.number} (${pr.head.ref}): merge in progress`, + ); + continue; + } + + const inactiveHours = (ageMs / (60 * 60 * 1000)).toFixed(1); + const body = [ + '## Stale release PR closed', + '', + `This release PR was automatically closed because it had no activity for ${staleHours} hours (last updated ${inactiveHours}h ago).`, + '', + 'Open release PRs on `release/*` branches are expected to merge promptly so they do not block others from starting a new release.', + '', + 'The release branch will be deleted. If you still need to publish these packages, start a fresh release with `yarn create-release-branch`.', + '', + `To keep a release PR open longer in exceptional cases, add the \`${exemptLabel}\` label.`, + '', + '', + ].join('\n'); + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body, + }); + + await github.rest.pulls.update({ + owner, + repo, + pull_number: pr.number, + state: 'closed', + }); + + try { + await github.rest.git.deleteRef({ + owner, + repo, + ref: `heads/${pr.head.ref}`, + }); + core.info( + `Closed #${pr.number} and deleted branch ${pr.head.ref}`, + ); + } catch (error) { + // Branch may already be gone; still treat the PR close as success. + core.warning( + `Closed #${pr.number} but failed to delete ${pr.head.ref}: ${error.message}`, + ); + } + } diff --git a/docs/processes/releasing.md b/docs/processes/releasing.md index e472f9db616..0009ec21569 100644 --- a/docs/processes/releasing.md +++ b/docs/processes/releasing.md @@ -4,6 +4,7 @@ Have changes that you need to release? There are a few things to understand: - The responsibility of maintenance is not the only thing shared among multiple teams at MetaMask; releases are as well. That means **if you work on a team that has codeownership over a package, you are free to create a new release without needing the Wallet Framework team to do so.** - Unlike clients, releases are not issued on a schedule; **anyone may create a release at any time**. Because of this, you may wish to review the Pull Requests tab on GitHub and ensure that no one else has a release candidate already in progress. If not, then you are free to start the process. +- Release PRs on `release/*` branches that sit inactive for **3 hours** are automatically closed, with the branch deleted, so abandoned releases do not block others. Add the `release:keep-open` label if you need a longer-lived release PR in exceptional cases. - The release process is a work in progress. Further improvements to simplify the process are planned, but in the meantime, if you encounter any issues, please reach out to the Wallet Framework team. - Breaking changes take special consideration. [Read the guide](./breaking-changes.md) on how to prepare and handle them effectively. From 0fd851f210679339c77707e622787aa4e30c7477 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Fri, 24 Jul 2026 17:47:14 +0200 Subject: [PATCH 2/9] fix: harden stale release PR closer against races and failed closes Re-fetch each PR before acting, close before commenting, and continue on per-PR failures so a merge race or failed close cannot leave a misleading comment or abort the whole run. --- .github/workflows/close-stale-release-prs.yml | 63 +++++++++++++++---- 1 file changed, 50 insertions(+), 13 deletions(-) diff --git a/.github/workflows/close-stale-release-prs.yml b/.github/workflows/close-stale-release-prs.yml index 9ebc89d09fd..90dc3a72e5a 100644 --- a/.github/workflows/close-stale-release-prs.yml +++ b/.github/workflows/close-stale-release-prs.yml @@ -70,7 +70,29 @@ jobs: return; } - for (const pr of releasePrs) { + for (const candidate of releasePrs) { + // Re-fetch before acting: the initial list is a snapshot and the + // PR may have merged/closed (or become active) since then. + const { data: pr } = await github.rest.pulls.get({ + owner, + repo, + pull_number: candidate.number, + }); + + if (pr.state !== 'open') { + core.info( + `Skipping #${pr.number} (${pr.head.ref}): no longer open`, + ); + continue; + } + + if (pr.labels.some((label) => label.name === exemptLabel)) { + core.info( + `Skipping #${pr.number} (${pr.head.ref}): exempt label "${exemptLabel}"`, + ); + continue; + } + const ageMs = now - Date.parse(pr.updated_at); if (ageMs < staleMs) { core.info( @@ -118,19 +140,34 @@ jobs: '', ].join('\n'); - await github.rest.issues.createComment({ - owner, - repo, - issue_number: pr.number, - body, - }); + // Close before commenting so a failed close does not bump + // updated_at and postpone the next stale attempt. + try { + await github.rest.pulls.update({ + owner, + repo, + pull_number: pr.number, + state: 'closed', + }); + } catch (error) { + core.warning( + `Failed to close #${pr.number} (${pr.head.ref}): ${error.message}`, + ); + continue; + } - await github.rest.pulls.update({ - owner, - repo, - pull_number: pr.number, - state: 'closed', - }); + try { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body, + }); + } catch (error) { + core.warning( + `Closed #${pr.number} but failed to comment: ${error.message}`, + ); + } try { await github.rest.git.deleteRef({ From d9cf15124c62c9899b22805115840951b0002a35 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Fri, 24 Jul 2026 17:50:00 +0200 Subject: [PATCH 3/9] fix: continue past failed PR refresh and merge-state checks Wrap per-PR pulls.get and GraphQL merge-queue lookups in try/catch so one transient failure does not abort the rest of the stale-close loop. --- .github/workflows/close-stale-release-prs.yml | 52 ++++++++++++------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/.github/workflows/close-stale-release-prs.yml b/.github/workflows/close-stale-release-prs.yml index 90dc3a72e5a..faf216f81b5 100644 --- a/.github/workflows/close-stale-release-prs.yml +++ b/.github/workflows/close-stale-release-prs.yml @@ -73,11 +73,19 @@ jobs: for (const candidate of releasePrs) { // Re-fetch before acting: the initial list is a snapshot and the // PR may have merged/closed (or become active) since then. - const { data: pr } = await github.rest.pulls.get({ - owner, - repo, - pull_number: candidate.number, - }); + let pr; + try { + ({ data: pr } = await github.rest.pulls.get({ + owner, + repo, + pull_number: candidate.number, + })); + } catch (error) { + core.warning( + `Failed to refresh #${candidate.number} (${candidate.head.ref}): ${error.message}`, + ); + continue; + } if (pr.state !== 'open') { core.info( @@ -101,23 +109,31 @@ jobs: continue; } - const mergeQueue = await github.graphql( - ` - query ($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) { - isInMergeQueue - autoMergeRequest { - enabledAt + let pullRequest; + try { + const mergeQueue = await github.graphql( + ` + query ($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + isInMergeQueue + autoMergeRequest { + enabledAt + } } } } - } - `, - { owner, repo, number: pr.number }, - ); + `, + { owner, repo, number: pr.number }, + ); + pullRequest = mergeQueue.repository.pullRequest; + } catch (error) { + core.warning( + `Failed to check merge state for #${pr.number} (${pr.head.ref}): ${error.message}`, + ); + continue; + } - const pullRequest = mergeQueue.repository.pullRequest; if (pullRequest.isInMergeQueue || pullRequest.autoMergeRequest) { core.info( `Skipping #${pr.number} (${pr.head.ref}): merge in progress`, From e0f1468fee3e34d23714dc0022ae41479b554a77 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Fri, 24 Jul 2026 18:02:52 +0200 Subject: [PATCH 4/9] fix: recheck PR activity immediately before close and delete Abort if updated_at, head SHA/ref, labels, staleness, or merge-queue state changed after the earlier refresh so a late push is not discarded. --- .github/workflows/close-stale-release-prs.yml | 102 ++++++++++++++++-- 1 file changed, 95 insertions(+), 7 deletions(-) diff --git a/.github/workflows/close-stale-release-prs.yml b/.github/workflows/close-stale-release-prs.yml index faf216f81b5..29b6e27bdeb 100644 --- a/.github/workflows/close-stale-release-prs.yml +++ b/.github/workflows/close-stale-release-prs.yml @@ -141,6 +141,10 @@ jobs: continue; } + const expectedUpdatedAt = pr.updated_at; + const expectedHeadSha = pr.head.sha; + const expectedHeadRef = pr.head.ref; + const inactiveHours = (ageMs / (60 * 60 * 1000)).toFixed(1); const body = [ '## Stale release PR closed', @@ -156,18 +160,102 @@ jobs: '', ].join('\n'); + // Final recheck immediately before destructive ops: activity, + // head SHA, labels, or merge state may have changed since the + // earlier refresh / GraphQL query. + let latestPr; + try { + ({ data: latestPr } = await github.rest.pulls.get({ + owner, + repo, + pull_number: pr.number, + })); + } catch (error) { + core.warning( + `Failed final refresh for #${pr.number} (${expectedHeadRef}): ${error.message}`, + ); + continue; + } + + if (latestPr.state !== 'open') { + core.info( + `Skipping #${pr.number} (${latestPr.head.ref}): no longer open before close`, + ); + continue; + } + + if (latestPr.labels.some((label) => label.name === exemptLabel)) { + core.info( + `Skipping #${pr.number} (${latestPr.head.ref}): exempt label added before close`, + ); + continue; + } + + if ( + latestPr.updated_at !== expectedUpdatedAt || + latestPr.head.sha !== expectedHeadSha || + latestPr.head.ref !== expectedHeadRef + ) { + core.info( + `Skipping #${pr.number} (${latestPr.head.ref}): activity or head changed before close`, + ); + continue; + } + + if (Date.now() - Date.parse(latestPr.updated_at) < staleMs) { + core.info( + `Skipping #${pr.number} (${latestPr.head.ref}): no longer stale before close`, + ); + continue; + } + + let latestMergeState; + try { + const mergeQueue = await github.graphql( + ` + query ($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + isInMergeQueue + autoMergeRequest { + enabledAt + } + } + } + } + `, + { owner, repo, number: latestPr.number }, + ); + latestMergeState = mergeQueue.repository.pullRequest; + } catch (error) { + core.warning( + `Failed final merge-state check for #${latestPr.number} (${latestPr.head.ref}): ${error.message}`, + ); + continue; + } + + if ( + latestMergeState.isInMergeQueue || + latestMergeState.autoMergeRequest + ) { + core.info( + `Skipping #${latestPr.number} (${latestPr.head.ref}): merge started before close`, + ); + continue; + } + // Close before commenting so a failed close does not bump // updated_at and postpone the next stale attempt. try { await github.rest.pulls.update({ owner, repo, - pull_number: pr.number, + pull_number: latestPr.number, state: 'closed', }); } catch (error) { core.warning( - `Failed to close #${pr.number} (${pr.head.ref}): ${error.message}`, + `Failed to close #${latestPr.number} (${latestPr.head.ref}): ${error.message}`, ); continue; } @@ -176,12 +264,12 @@ jobs: await github.rest.issues.createComment({ owner, repo, - issue_number: pr.number, + issue_number: latestPr.number, body, }); } catch (error) { core.warning( - `Closed #${pr.number} but failed to comment: ${error.message}`, + `Closed #${latestPr.number} but failed to comment: ${error.message}`, ); } @@ -189,15 +277,15 @@ jobs: await github.rest.git.deleteRef({ owner, repo, - ref: `heads/${pr.head.ref}`, + ref: `heads/${latestPr.head.ref}`, }); core.info( - `Closed #${pr.number} and deleted branch ${pr.head.ref}`, + `Closed #${latestPr.number} and deleted branch ${latestPr.head.ref}`, ); } catch (error) { // Branch may already be gone; still treat the PR close as success. core.warning( - `Closed #${pr.number} but failed to delete ${pr.head.ref}: ${error.message}`, + `Closed #${latestPr.number} but failed to delete ${latestPr.head.ref}: ${error.message}`, ); } } From 6495138f1dea08577e949a42dbff56623f165071 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Fri, 24 Jul 2026 18:05:53 +0200 Subject: [PATCH 5/9] fix: verify full label set and branch tip before delete Compare the complete label set on the final pre-close refresh, and re-fetch the branch ref immediately before deleteRef so a late push is not discarded. --- .github/workflows/close-stale-release-prs.yml | 43 ++++++++++++++++--- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/.github/workflows/close-stale-release-prs.yml b/.github/workflows/close-stale-release-prs.yml index 29b6e27bdeb..2ad75216c86 100644 --- a/.github/workflows/close-stale-release-prs.yml +++ b/.github/workflows/close-stale-release-prs.yml @@ -144,6 +144,10 @@ jobs: const expectedUpdatedAt = pr.updated_at; const expectedHeadSha = pr.head.sha; const expectedHeadRef = pr.head.ref; + const expectedLabels = pr.labels + .map((label) => label.name) + .sort() + .join('\0'); const inactiveHours = (ageMs / (60 * 60 * 1000)).toFixed(1); const body = [ @@ -184,9 +188,14 @@ jobs: continue; } - if (latestPr.labels.some((label) => label.name === exemptLabel)) { + const latestLabels = latestPr.labels + .map((label) => label.name) + .sort() + .join('\0'); + + if (latestLabels !== expectedLabels) { core.info( - `Skipping #${pr.number} (${latestPr.head.ref}): exempt label added before close`, + `Skipping #${pr.number} (${latestPr.head.ref}): labels changed before close`, ); continue; } @@ -273,19 +282,43 @@ jobs: ); } + // Re-fetch the branch tip immediately before delete so a push + // that landed after close is not discarded. + let branchSha; + try { + const { data: branchRef } = await github.rest.git.getRef({ + owner, + repo, + ref: `heads/${expectedHeadRef}`, + }); + branchSha = branchRef.object.sha; + } catch (error) { + core.warning( + `Closed #${latestPr.number} but failed to refresh ${expectedHeadRef} before delete: ${error.message}`, + ); + continue; + } + + if (branchSha !== expectedHeadSha) { + core.warning( + `Closed #${latestPr.number} but skipped deleting ${expectedHeadRef}: head moved from ${expectedHeadSha} to ${branchSha}`, + ); + continue; + } + try { await github.rest.git.deleteRef({ owner, repo, - ref: `heads/${latestPr.head.ref}`, + ref: `heads/${expectedHeadRef}`, }); core.info( - `Closed #${latestPr.number} and deleted branch ${latestPr.head.ref}`, + `Closed #${latestPr.number} and deleted branch ${expectedHeadRef}`, ); } catch (error) { // Branch may already be gone; still treat the PR close as success. core.warning( - `Closed #${latestPr.number} but failed to delete ${latestPr.head.ref}: ${error.message}`, + `Closed #${latestPr.number} but failed to delete ${expectedHeadRef}: ${error.message}`, ); } } From 1538aed35887bf7484336aa9046cdde424928ceb Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Fri, 24 Jul 2026 18:21:28 +0200 Subject: [PATCH 6/9] fix: comment after branch delete with accurate status Post the stale-close comment only after the delete attempt so it reports whether the branch was removed, skipped due to a tip move, or failed to delete. --- .github/workflows/close-stale-release-prs.yml | 105 +++++++++++------- 1 file changed, 67 insertions(+), 38 deletions(-) diff --git a/.github/workflows/close-stale-release-prs.yml b/.github/workflows/close-stale-release-prs.yml index 2ad75216c86..7e9198a0e99 100644 --- a/.github/workflows/close-stale-release-prs.yml +++ b/.github/workflows/close-stale-release-prs.yml @@ -150,19 +150,6 @@ jobs: .join('\0'); const inactiveHours = (ageMs / (60 * 60 * 1000)).toFixed(1); - const body = [ - '## Stale release PR closed', - '', - `This release PR was automatically closed because it had no activity for ${staleHours} hours (last updated ${inactiveHours}h ago).`, - '', - 'Open release PRs on `release/*` branches are expected to merge promptly so they do not block others from starting a new release.', - '', - 'The release branch will be deleted. If you still need to publish these packages, start a fresh release with `yarn create-release-branch`.', - '', - `To keep a release PR open longer in exceptional cases, add the \`${exemptLabel}\` label.`, - '', - '', - ].join('\n'); // Final recheck immediately before destructive ops: activity, // head SHA, labels, or merge state may have changed since the @@ -269,22 +256,13 @@ jobs: continue; } - try { - await github.rest.issues.createComment({ - owner, - repo, - issue_number: latestPr.number, - body, - }); - } catch (error) { - core.warning( - `Closed #${latestPr.number} but failed to comment: ${error.message}`, - ); - } - // Re-fetch the branch tip immediately before delete so a push - // that landed after close is not discarded. + // that landed after close is not discarded. Comment afterward + // so the message matches whether the branch was actually removed. + let branchOutcome = 'deleted'; + let branchOutcomeDetail = ''; let branchSha; + try { const { data: branchRef } = await github.rest.git.getRef({ owner, @@ -293,32 +271,83 @@ jobs: }); branchSha = branchRef.object.sha; } catch (error) { + branchOutcome = 'kept-refresh-failed'; + branchOutcomeDetail = error.message; core.warning( `Closed #${latestPr.number} but failed to refresh ${expectedHeadRef} before delete: ${error.message}`, ); - continue; } - if (branchSha !== expectedHeadSha) { + if (branchOutcome === 'deleted' && branchSha !== expectedHeadSha) { + branchOutcome = 'kept-head-moved'; + branchOutcomeDetail = `head moved from ${expectedHeadSha} to ${branchSha}`; core.warning( - `Closed #${latestPr.number} but skipped deleting ${expectedHeadRef}: head moved from ${expectedHeadSha} to ${branchSha}`, + `Closed #${latestPr.number} but skipped deleting ${expectedHeadRef}: ${branchOutcomeDetail}`, ); - continue; } + if (branchOutcome === 'deleted') { + try { + await github.rest.git.deleteRef({ + owner, + repo, + ref: `heads/${expectedHeadRef}`, + }); + core.info( + `Closed #${latestPr.number} and deleted branch ${expectedHeadRef}`, + ); + } catch (error) { + branchOutcome = 'kept-delete-failed'; + branchOutcomeDetail = error.message; + core.warning( + `Closed #${latestPr.number} but failed to delete ${expectedHeadRef}: ${error.message}`, + ); + } + } + + let branchStatusLines; + if (branchOutcome === 'deleted') { + branchStatusLines = [ + `The release branch \`${expectedHeadRef}\` has been deleted. If you still need to publish these packages, start a fresh release with \`yarn create-release-branch\`.`, + ]; + } else if (branchOutcome === 'kept-head-moved') { + branchStatusLines = [ + `The release branch \`${expectedHeadRef}\` was **not** deleted because its tip changed after close (\`${expectedHeadSha}\` → \`${branchSha}\`).`, + '', + 'Delete the branch manually if it is no longer needed, or open a new release PR from the updated tip.', + ]; + } else { + branchStatusLines = [ + `The release branch \`${expectedHeadRef}\` was **not** deleted (${branchOutcomeDetail}).`, + '', + 'Delete the branch manually if it is no longer needed, or start a fresh release with `yarn create-release-branch`.', + ]; + } + + const body = [ + '## Stale release PR closed', + '', + `This release PR was automatically closed because it had no activity for ${staleHours} hours (last updated ${inactiveHours}h ago).`, + '', + 'Open release PRs on `release/*` branches are expected to merge promptly so they do not block others from starting a new release.', + '', + ...branchStatusLines, + '', + `To keep a release PR open longer in exceptional cases, add the \`${exemptLabel}\` label.`, + '', + '', + ].join('\n'); + try { - await github.rest.git.deleteRef({ + await github.rest.issues.createComment({ owner, repo, - ref: `heads/${expectedHeadRef}`, + issue_number: latestPr.number, + body, }); - core.info( - `Closed #${latestPr.number} and deleted branch ${expectedHeadRef}`, - ); } catch (error) { - // Branch may already be gone; still treat the PR close as success. core.warning( - `Closed #${latestPr.number} but failed to delete ${expectedHeadRef}: ${error.message}`, + `Closed #${latestPr.number} but failed to comment: ${error.message}`, ); } } From 0c750f5ab5611a941a48a02a62ea6c6135051069 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Sat, 25 Jul 2026 00:37:41 +0200 Subject: [PATCH 7/9] refactor: extract stale release closer into a script with inline constants Move the github-script body to .github/scripts and replace env-parsed STALE_HOURS/EXEMPT_LABEL with script constants, per review feedback. --- .github/scripts/close-stale-release-prs.cjs | 318 +++++++++++++++++ .github/workflows/close-stale-release-prs.yml | 329 +----------------- 2 files changed, 324 insertions(+), 323 deletions(-) create mode 100644 .github/scripts/close-stale-release-prs.cjs diff --git a/.github/scripts/close-stale-release-prs.cjs b/.github/scripts/close-stale-release-prs.cjs new file mode 100644 index 00000000000..6d68a301e4c --- /dev/null +++ b/.github/scripts/close-stale-release-prs.cjs @@ -0,0 +1,318 @@ +/** + * Close inactive same-repo `release/*` PRs, comment with the outcome, and + * delete the branch when the tip is unchanged. + * + * Intended to be invoked from `actions/github-script`: + * `await require('./.github/scripts/close-stale-release-prs.cjs')({ github, context, core });` + * + * @param {object} params + * @param {object} params.github - Octokit client from `actions/github-script`. + * @param {object} params.context - GitHub Actions context. + * @param {object} params.core - `@actions/core` helpers. + */ +module.exports = async function closeStaleReleasePrs({ + github, + context, + core, +}) { + // Optional escape hatch for long-running releases that must stay open. + const STALE_HOURS = 3; + const EXEMPT_LABEL = 'release:keep-open'; + + const staleMs = STALE_HOURS * 60 * 60 * 1000; + const now = Date.now(); + const { owner, repo } = context.repo; + + const labelKey = (labels) => + labels + .map((label) => label.name) + .sort() + .join('\0'); + + const getMergeState = async (pullNumber) => { + const mergeQueue = await github.graphql( + ` + query ($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + isInMergeQueue + autoMergeRequest { + enabledAt + } + } + } + } + `, + { owner, repo, number: pullNumber }, + ); + return mergeQueue.repository.pullRequest; + }; + + const pulls = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: 'open', + per_page: 100, + }); + + const releasePrs = pulls.filter((pr) => { + if (!pr.head.ref.startsWith('release/')) { + return false; + } + + // Only manage same-repo release branches (never forks). + if (pr.head.repo?.full_name !== `${owner}/${repo}`) { + return false; + } + + if (pr.labels.some((label) => label.name === EXEMPT_LABEL)) { + core.info( + `Skipping #${pr.number} (${pr.head.ref}): exempt label "${EXEMPT_LABEL}"`, + ); + return false; + } + + return true; + }); + + if (releasePrs.length === 0) { + core.info('No open release PRs to evaluate.'); + return; + } + + for (const candidate of releasePrs) { + // Re-fetch before acting: the initial list is a snapshot and the + // PR may have merged/closed (or become active) since then. + let pr; + try { + ({ data: pr } = await github.rest.pulls.get({ + owner, + repo, + pull_number: candidate.number, + })); + } catch (error) { + core.warning( + `Failed to refresh #${candidate.number} (${candidate.head.ref}): ${error.message}`, + ); + continue; + } + + if (pr.state !== 'open') { + core.info(`Skipping #${pr.number} (${pr.head.ref}): no longer open`); + continue; + } + + if (pr.labels.some((label) => label.name === EXEMPT_LABEL)) { + core.info( + `Skipping #${pr.number} (${pr.head.ref}): exempt label "${EXEMPT_LABEL}"`, + ); + continue; + } + + const ageMs = now - Date.parse(pr.updated_at); + if (ageMs < staleMs) { + core.info( + `Skipping #${pr.number} (${pr.head.ref}): updated ${Math.round(ageMs / 60000)}m ago`, + ); + continue; + } + + let pullRequest; + try { + pullRequest = await getMergeState(pr.number); + } catch (error) { + core.warning( + `Failed to check merge state for #${pr.number} (${pr.head.ref}): ${error.message}`, + ); + continue; + } + + if (pullRequest.isInMergeQueue || pullRequest.autoMergeRequest) { + core.info(`Skipping #${pr.number} (${pr.head.ref}): merge in progress`); + continue; + } + + const expectedUpdatedAt = pr.updated_at; + const expectedHeadSha = pr.head.sha; + const expectedHeadRef = pr.head.ref; + const expectedLabels = labelKey(pr.labels); + const inactiveHours = (ageMs / (60 * 60 * 1000)).toFixed(1); + + // Final recheck immediately before destructive ops: activity, + // head SHA, labels, or merge state may have changed since the + // earlier refresh / GraphQL query. + let latestPr; + try { + ({ data: latestPr } = await github.rest.pulls.get({ + owner, + repo, + pull_number: pr.number, + })); + } catch (error) { + core.warning( + `Failed final refresh for #${pr.number} (${expectedHeadRef}): ${error.message}`, + ); + continue; + } + + if (latestPr.state !== 'open') { + core.info( + `Skipping #${pr.number} (${latestPr.head.ref}): no longer open before close`, + ); + continue; + } + + if (labelKey(latestPr.labels) !== expectedLabels) { + core.info( + `Skipping #${pr.number} (${latestPr.head.ref}): labels changed before close`, + ); + continue; + } + + if ( + latestPr.updated_at !== expectedUpdatedAt || + latestPr.head.sha !== expectedHeadSha || + latestPr.head.ref !== expectedHeadRef + ) { + core.info( + `Skipping #${pr.number} (${latestPr.head.ref}): activity or head changed before close`, + ); + continue; + } + + if (Date.now() - Date.parse(latestPr.updated_at) < staleMs) { + core.info( + `Skipping #${pr.number} (${latestPr.head.ref}): no longer stale before close`, + ); + continue; + } + + let latestMergeState; + try { + latestMergeState = await getMergeState(latestPr.number); + } catch (error) { + core.warning( + `Failed final merge-state check for #${latestPr.number} (${latestPr.head.ref}): ${error.message}`, + ); + continue; + } + + if (latestMergeState.isInMergeQueue || latestMergeState.autoMergeRequest) { + core.info( + `Skipping #${latestPr.number} (${latestPr.head.ref}): merge started before close`, + ); + continue; + } + + // Close before commenting so a failed close does not bump + // updated_at and postpone the next stale attempt. + try { + await github.rest.pulls.update({ + owner, + repo, + pull_number: latestPr.number, + state: 'closed', + }); + } catch (error) { + core.warning( + `Failed to close #${latestPr.number} (${latestPr.head.ref}): ${error.message}`, + ); + continue; + } + + // Re-fetch the branch tip immediately before delete so a push + // that landed after close is not discarded. Comment afterward + // so the message matches whether the branch was actually removed. + let branchOutcome = 'deleted'; + let branchOutcomeDetail = ''; + let branchSha; + + try { + const { data: branchRef } = await github.rest.git.getRef({ + owner, + repo, + ref: `heads/${expectedHeadRef}`, + }); + branchSha = branchRef.object.sha; + } catch (error) { + branchOutcome = 'kept-refresh-failed'; + branchOutcomeDetail = error.message; + core.warning( + `Closed #${latestPr.number} but failed to refresh ${expectedHeadRef} before delete: ${error.message}`, + ); + } + + if (branchOutcome === 'deleted' && branchSha !== expectedHeadSha) { + branchOutcome = 'kept-head-moved'; + branchOutcomeDetail = `head moved from ${expectedHeadSha} to ${branchSha}`; + core.warning( + `Closed #${latestPr.number} but skipped deleting ${expectedHeadRef}: ${branchOutcomeDetail}`, + ); + } + + if (branchOutcome === 'deleted') { + try { + await github.rest.git.deleteRef({ + owner, + repo, + ref: `heads/${expectedHeadRef}`, + }); + core.info( + `Closed #${latestPr.number} and deleted branch ${expectedHeadRef}`, + ); + } catch (error) { + branchOutcome = 'kept-delete-failed'; + branchOutcomeDetail = error.message; + core.warning( + `Closed #${latestPr.number} but failed to delete ${expectedHeadRef}: ${error.message}`, + ); + } + } + + let branchStatusLines; + if (branchOutcome === 'deleted') { + branchStatusLines = [ + `The release branch \`${expectedHeadRef}\` has been deleted. If you still need to publish these packages, start a fresh release with \`yarn create-release-branch\`.`, + ]; + } else if (branchOutcome === 'kept-head-moved') { + branchStatusLines = [ + `The release branch \`${expectedHeadRef}\` was **not** deleted because its tip changed after close (\`${expectedHeadSha}\` → \`${branchSha}\`).`, + '', + 'Delete the branch manually if it is no longer needed, or open a new release PR from the updated tip.', + ]; + } else { + branchStatusLines = [ + `The release branch \`${expectedHeadRef}\` was **not** deleted (${branchOutcomeDetail}).`, + '', + 'Delete the branch manually if it is no longer needed, or start a fresh release with `yarn create-release-branch`.', + ]; + } + + const body = [ + '## Stale release PR closed', + '', + `This release PR was automatically closed because it had no activity for ${STALE_HOURS} hours (last updated ${inactiveHours}h ago).`, + '', + 'Open release PRs on `release/*` branches are expected to merge promptly so they do not block others from starting a new release.', + '', + ...branchStatusLines, + '', + `To keep a release PR open longer in exceptional cases, add the \`${EXEMPT_LABEL}\` label.`, + '', + '', + ].join('\n'); + + try { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: latestPr.number, + body, + }); + } catch (error) { + core.warning( + `Closed #${latestPr.number} but failed to comment: ${error.message}`, + ); + } + } +}; diff --git a/.github/workflows/close-stale-release-prs.yml b/.github/workflows/close-stale-release-prs.yml index 7e9198a0e99..cabe0f619b6 100644 --- a/.github/workflows/close-stale-release-prs.yml +++ b/.github/workflows/close-stale-release-prs.yml @@ -24,330 +24,13 @@ jobs: contents: write pull-requests: write steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Close inactive release PRs uses: actions/github-script@v8 - env: - STALE_HOURS: '3' - # Optional escape hatch for long-running releases that must stay open. - EXEMPT_LABEL: 'release:keep-open' with: script: | - const staleHours = Number(process.env.STALE_HOURS); - const staleMs = staleHours * 60 * 60 * 1000; - const exemptLabel = process.env.EXEMPT_LABEL; - const now = Date.now(); - const { owner, repo } = context.repo; - - const pulls = await github.paginate(github.rest.pulls.list, { - owner, - repo, - state: 'open', - per_page: 100, - }); - - const releasePrs = pulls.filter((pr) => { - if (!pr.head.ref.startsWith('release/')) { - return false; - } - - // Only manage same-repo release branches (never forks). - if (pr.head.repo?.full_name !== `${owner}/${repo}`) { - return false; - } - - if (pr.labels.some((label) => label.name === exemptLabel)) { - core.info( - `Skipping #${pr.number} (${pr.head.ref}): exempt label "${exemptLabel}"`, - ); - return false; - } - - return true; - }); - - if (releasePrs.length === 0) { - core.info('No open release PRs to evaluate.'); - return; - } - - for (const candidate of releasePrs) { - // Re-fetch before acting: the initial list is a snapshot and the - // PR may have merged/closed (or become active) since then. - let pr; - try { - ({ data: pr } = await github.rest.pulls.get({ - owner, - repo, - pull_number: candidate.number, - })); - } catch (error) { - core.warning( - `Failed to refresh #${candidate.number} (${candidate.head.ref}): ${error.message}`, - ); - continue; - } - - if (pr.state !== 'open') { - core.info( - `Skipping #${pr.number} (${pr.head.ref}): no longer open`, - ); - continue; - } - - if (pr.labels.some((label) => label.name === exemptLabel)) { - core.info( - `Skipping #${pr.number} (${pr.head.ref}): exempt label "${exemptLabel}"`, - ); - continue; - } - - const ageMs = now - Date.parse(pr.updated_at); - if (ageMs < staleMs) { - core.info( - `Skipping #${pr.number} (${pr.head.ref}): updated ${Math.round(ageMs / 60000)}m ago`, - ); - continue; - } - - let pullRequest; - try { - const mergeQueue = await github.graphql( - ` - query ($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) { - isInMergeQueue - autoMergeRequest { - enabledAt - } - } - } - } - `, - { owner, repo, number: pr.number }, - ); - pullRequest = mergeQueue.repository.pullRequest; - } catch (error) { - core.warning( - `Failed to check merge state for #${pr.number} (${pr.head.ref}): ${error.message}`, - ); - continue; - } - - if (pullRequest.isInMergeQueue || pullRequest.autoMergeRequest) { - core.info( - `Skipping #${pr.number} (${pr.head.ref}): merge in progress`, - ); - continue; - } - - const expectedUpdatedAt = pr.updated_at; - const expectedHeadSha = pr.head.sha; - const expectedHeadRef = pr.head.ref; - const expectedLabels = pr.labels - .map((label) => label.name) - .sort() - .join('\0'); - - const inactiveHours = (ageMs / (60 * 60 * 1000)).toFixed(1); - - // Final recheck immediately before destructive ops: activity, - // head SHA, labels, or merge state may have changed since the - // earlier refresh / GraphQL query. - let latestPr; - try { - ({ data: latestPr } = await github.rest.pulls.get({ - owner, - repo, - pull_number: pr.number, - })); - } catch (error) { - core.warning( - `Failed final refresh for #${pr.number} (${expectedHeadRef}): ${error.message}`, - ); - continue; - } - - if (latestPr.state !== 'open') { - core.info( - `Skipping #${pr.number} (${latestPr.head.ref}): no longer open before close`, - ); - continue; - } - - const latestLabels = latestPr.labels - .map((label) => label.name) - .sort() - .join('\0'); - - if (latestLabels !== expectedLabels) { - core.info( - `Skipping #${pr.number} (${latestPr.head.ref}): labels changed before close`, - ); - continue; - } - - if ( - latestPr.updated_at !== expectedUpdatedAt || - latestPr.head.sha !== expectedHeadSha || - latestPr.head.ref !== expectedHeadRef - ) { - core.info( - `Skipping #${pr.number} (${latestPr.head.ref}): activity or head changed before close`, - ); - continue; - } - - if (Date.now() - Date.parse(latestPr.updated_at) < staleMs) { - core.info( - `Skipping #${pr.number} (${latestPr.head.ref}): no longer stale before close`, - ); - continue; - } - - let latestMergeState; - try { - const mergeQueue = await github.graphql( - ` - query ($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) { - isInMergeQueue - autoMergeRequest { - enabledAt - } - } - } - } - `, - { owner, repo, number: latestPr.number }, - ); - latestMergeState = mergeQueue.repository.pullRequest; - } catch (error) { - core.warning( - `Failed final merge-state check for #${latestPr.number} (${latestPr.head.ref}): ${error.message}`, - ); - continue; - } - - if ( - latestMergeState.isInMergeQueue || - latestMergeState.autoMergeRequest - ) { - core.info( - `Skipping #${latestPr.number} (${latestPr.head.ref}): merge started before close`, - ); - continue; - } - - // Close before commenting so a failed close does not bump - // updated_at and postpone the next stale attempt. - try { - await github.rest.pulls.update({ - owner, - repo, - pull_number: latestPr.number, - state: 'closed', - }); - } catch (error) { - core.warning( - `Failed to close #${latestPr.number} (${latestPr.head.ref}): ${error.message}`, - ); - continue; - } - - // Re-fetch the branch tip immediately before delete so a push - // that landed after close is not discarded. Comment afterward - // so the message matches whether the branch was actually removed. - let branchOutcome = 'deleted'; - let branchOutcomeDetail = ''; - let branchSha; - - try { - const { data: branchRef } = await github.rest.git.getRef({ - owner, - repo, - ref: `heads/${expectedHeadRef}`, - }); - branchSha = branchRef.object.sha; - } catch (error) { - branchOutcome = 'kept-refresh-failed'; - branchOutcomeDetail = error.message; - core.warning( - `Closed #${latestPr.number} but failed to refresh ${expectedHeadRef} before delete: ${error.message}`, - ); - } - - if (branchOutcome === 'deleted' && branchSha !== expectedHeadSha) { - branchOutcome = 'kept-head-moved'; - branchOutcomeDetail = `head moved from ${expectedHeadSha} to ${branchSha}`; - core.warning( - `Closed #${latestPr.number} but skipped deleting ${expectedHeadRef}: ${branchOutcomeDetail}`, - ); - } - - if (branchOutcome === 'deleted') { - try { - await github.rest.git.deleteRef({ - owner, - repo, - ref: `heads/${expectedHeadRef}`, - }); - core.info( - `Closed #${latestPr.number} and deleted branch ${expectedHeadRef}`, - ); - } catch (error) { - branchOutcome = 'kept-delete-failed'; - branchOutcomeDetail = error.message; - core.warning( - `Closed #${latestPr.number} but failed to delete ${expectedHeadRef}: ${error.message}`, - ); - } - } - - let branchStatusLines; - if (branchOutcome === 'deleted') { - branchStatusLines = [ - `The release branch \`${expectedHeadRef}\` has been deleted. If you still need to publish these packages, start a fresh release with \`yarn create-release-branch\`.`, - ]; - } else if (branchOutcome === 'kept-head-moved') { - branchStatusLines = [ - `The release branch \`${expectedHeadRef}\` was **not** deleted because its tip changed after close (\`${expectedHeadSha}\` → \`${branchSha}\`).`, - '', - 'Delete the branch manually if it is no longer needed, or open a new release PR from the updated tip.', - ]; - } else { - branchStatusLines = [ - `The release branch \`${expectedHeadRef}\` was **not** deleted (${branchOutcomeDetail}).`, - '', - 'Delete the branch manually if it is no longer needed, or start a fresh release with `yarn create-release-branch`.', - ]; - } - - const body = [ - '## Stale release PR closed', - '', - `This release PR was automatically closed because it had no activity for ${staleHours} hours (last updated ${inactiveHours}h ago).`, - '', - 'Open release PRs on `release/*` branches are expected to merge promptly so they do not block others from starting a new release.', - '', - ...branchStatusLines, - '', - `To keep a release PR open longer in exceptional cases, add the \`${exemptLabel}\` label.`, - '', - '', - ].join('\n'); - - try { - await github.rest.issues.createComment({ - owner, - repo, - issue_number: latestPr.number, - body, - }); - } catch (error) { - core.warning( - `Closed #${latestPr.number} but failed to comment: ${error.message}`, - ); - } - } + const closeStaleReleasePrs = require('./.github/scripts/close-stale-release-prs.cjs'); + await closeStaleReleasePrs({ github, context, core }); From 792c39e41665ad06866c5de1c636944756904446 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Sat, 25 Jul 2026 00:46:39 +0200 Subject: [PATCH 8/9] refactor: split stale release closer into scoped helpers and fix JSDoc lint Break the script into named functions for eligibility, merge-state, close, delete, and comment, and satisfy jsdoc/require-param-description. --- .github/scripts/close-stale-release-prs.cjs | 781 +++++++++++++------- 1 file changed, 505 insertions(+), 276 deletions(-) diff --git a/.github/scripts/close-stale-release-prs.cjs b/.github/scripts/close-stale-release-prs.cjs index 6d68a301e4c..dd967e29ac6 100644 --- a/.github/scripts/close-stale-release-prs.cjs +++ b/.github/scripts/close-stale-release-prs.cjs @@ -4,315 +4,544 @@ * * Intended to be invoked from `actions/github-script`: * `await require('./.github/scripts/close-stale-release-prs.cjs')({ github, context, core });` - * - * @param {object} params - * @param {object} params.github - Octokit client from `actions/github-script`. - * @param {object} params.context - GitHub Actions context. - * @param {object} params.core - `@actions/core` helpers. */ -module.exports = async function closeStaleReleasePrs({ - github, - context, - core, -}) { - // Optional escape hatch for long-running releases that must stay open. - const STALE_HOURS = 3; - const EXEMPT_LABEL = 'release:keep-open'; - const staleMs = STALE_HOURS * 60 * 60 * 1000; - const now = Date.now(); - const { owner, repo } = context.repo; +// Optional escape hatch for long-running releases that must stay open. +const STALE_HOURS = 3; +const EXEMPT_LABEL = 'release:keep-open'; +const STALE_MS = STALE_HOURS * 60 * 60 * 1000; - const labelKey = (labels) => - labels - .map((label) => label.name) - .sort() - .join('\0'); - - const getMergeState = async (pullNumber) => { - const mergeQueue = await github.graphql( - ` - query ($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) { - isInMergeQueue - autoMergeRequest { - enabledAt - } - } - } +const MERGE_STATE_QUERY = ` + query ($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + isInMergeQueue + autoMergeRequest { + enabledAt } - `, - { owner, repo, number: pullNumber }, - ); - return mergeQueue.repository.pullRequest; - }; + } + } + } +`; - const pulls = await github.paginate(github.rest.pulls.list, { +/** + * Stable fingerprint of a PR's label set for equality checks. + * + * @param {Array<{ name: string }>} labels - PR labels from the GitHub API. + * @returns {string} Sorted, joined label names. + */ +function labelKey(labels) { + return labels + .map((label) => label.name) + .sort() + .join('\0'); +} + +/** + * Load merge-queue / auto-merge state for a pull request. + * + * @param {object} github - Octokit client from `actions/github-script`. + * @param {string} owner - Repository owner. + * @param {string} repo - Repository name. + * @param {number} pullNumber - Pull request number. + * @returns {Promise<{ isInMergeQueue: boolean, autoMergeRequest: object | null }>} + * Merge state for the pull request. + */ +async function getMergeState(github, owner, repo, pullNumber) { + const mergeQueue = await github.graphql(MERGE_STATE_QUERY, { owner, repo, - state: 'open', - per_page: 100, + number: pullNumber, }); + return mergeQueue.repository.pullRequest; +} - const releasePrs = pulls.filter((pr) => { - if (!pr.head.ref.startsWith('release/')) { - return false; - } +/** + * Fetch a pull request by number. + * + * @param {object} github - Octokit client from `actions/github-script`. + * @param {string} owner - Repository owner. + * @param {string} repo - Repository name. + * @param {number} pullNumber - Pull request number. + * @returns {Promise} Pull request payload. + */ +async function getPull(github, owner, repo, pullNumber) { + const { data } = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + return data; +} - // Only manage same-repo release branches (never forks). - if (pr.head.repo?.full_name !== `${owner}/${repo}`) { - return false; - } +/** + * Whether a PR head is a same-repo `release/*` branch that is not exempt. + * + * @param {object} pr - Pull request payload. + * @param {string} owner - Repository owner. + * @param {string} repo - Repository name. + * @param {object} core - `@actions/core` helpers. + * @returns {boolean} True when the PR is a candidate for stale close. + */ +function isReleasePrCandidate(pr, owner, repo, core) { + if (!pr.head.ref.startsWith('release/')) { + return false; + } - if (pr.labels.some((label) => label.name === EXEMPT_LABEL)) { - core.info( - `Skipping #${pr.number} (${pr.head.ref}): exempt label "${EXEMPT_LABEL}"`, - ); - return false; - } + // Only manage same-repo release branches (never forks). + if (pr.head.repo?.full_name !== `${owner}/${repo}`) { + return false; + } + + if (pr.labels.some((label) => label.name === EXEMPT_LABEL)) { + core.info( + `Skipping #${pr.number} (${pr.head.ref}): exempt label "${EXEMPT_LABEL}"`, + ); + return false; + } + + return true; +} + +/** + * Whether merge-queue or auto-merge is active for the PR. + * + * @param {{ isInMergeQueue: boolean, autoMergeRequest: object | null }} mergeState - + * GraphQL merge state. + * @returns {boolean} True when a merge is already in progress. + */ +function isMergeInProgress(mergeState) { + return Boolean(mergeState.isInMergeQueue || mergeState.autoMergeRequest); +} +/** + * Evaluate whether a refreshed PR is still eligible to close as stale. + * + * @param {object} options - Evaluation inputs. + * @param {object} options.pr - Fresh pull request payload. + * @param {number} options.now - Epoch ms used for the stale window. + * @param {object} options.core - `@actions/core` helpers. + * @param {string} [options.phase] - Log suffix describing the check phase. + * @returns {{ eligible: boolean, ageMs?: number }} Eligibility result. + */ +function evaluateStaleEligibility({ pr, now, core, phase = '' }) { + const suffix = phase ? ` ${phase}` : ''; + + if (pr.state !== 'open') { + core.info(`Skipping #${pr.number} (${pr.head.ref}): no longer open${suffix}`); + return { eligible: false }; + } + + if (pr.labels.some((label) => label.name === EXEMPT_LABEL)) { + core.info( + `Skipping #${pr.number} (${pr.head.ref}): exempt label "${EXEMPT_LABEL}"${suffix}`, + ); + return { eligible: false }; + } + + const ageMs = now - Date.parse(pr.updated_at); + if (ageMs < STALE_MS) { + core.info( + `Skipping #${pr.number} (${pr.head.ref}): updated ${Math.round(ageMs / 60000)}m ago${suffix}`, + ); + return { eligible: false }; + } + + return { eligible: true, ageMs }; +} + +/** + * Confirm the PR snapshot has not changed since the pre-close checks. + * + * @param {object} options - Comparison inputs. + * @param {object} options.latestPr - Most recent pull request payload. + * @param {string} options.expectedUpdatedAt - Previously observed `updated_at`. + * @param {string} options.expectedHeadSha - Previously observed head SHA. + * @param {string} options.expectedHeadRef - Previously observed head ref. + * @param {string} options.expectedLabels - Previously observed label fingerprint. + * @param {object} options.core - `@actions/core` helpers. + * @returns {boolean} True when the snapshot is unchanged and still stale. + */ +function isUnchangedBeforeClose({ + latestPr, + expectedUpdatedAt, + expectedHeadSha, + expectedHeadRef, + expectedLabels, + core, +}) { + if (labelKey(latestPr.labels) !== expectedLabels) { + core.info( + `Skipping #${latestPr.number} (${latestPr.head.ref}): labels changed before close`, + ); + return false; + } + + if ( + latestPr.updated_at !== expectedUpdatedAt || + latestPr.head.sha !== expectedHeadSha || + latestPr.head.ref !== expectedHeadRef + ) { + core.info( + `Skipping #${latestPr.number} (${latestPr.head.ref}): activity or head changed before close`, + ); + return false; + } + + if (Date.now() - Date.parse(latestPr.updated_at) < STALE_MS) { + core.info( + `Skipping #${latestPr.number} (${latestPr.head.ref}): no longer stale before close`, + ); + return false; + } + + return true; +} + +/** + * Close the pull request. + * + * @param {object} github - Octokit client from `actions/github-script`. + * @param {string} owner - Repository owner. + * @param {string} repo - Repository name. + * @param {object} pr - Pull request payload. + * @param {object} core - `@actions/core` helpers. + * @returns {Promise} True when the close succeeded. + */ +async function closePullRequest(github, owner, repo, pr, core) { + try { + await github.rest.pulls.update({ + owner, + repo, + pull_number: pr.number, + state: 'closed', + }); return true; - }); + } catch (error) { + core.warning( + `Failed to close #${pr.number} (${pr.head.ref}): ${error.message}`, + ); + return false; + } +} - if (releasePrs.length === 0) { - core.info('No open release PRs to evaluate.'); - return; +/** + * Delete the release branch when its tip still matches the expected SHA. + * + * @param {object} options - Delete inputs. + * @param {object} options.github - Octokit client from `actions/github-script`. + * @param {string} options.owner - Repository owner. + * @param {string} options.repo - Repository name. + * @param {number} options.pullNumber - Closed pull request number. + * @param {string} options.expectedHeadRef - Branch name to delete. + * @param {string} options.expectedHeadSha - SHA that must still be the tip. + * @param {object} options.core - `@actions/core` helpers. + * @returns {Promise<{ outcome: string, detail: string, branchSha?: string }>} + * Branch deletion outcome. + */ +async function deleteBranchIfUnchanged({ + github, + owner, + repo, + pullNumber, + expectedHeadRef, + expectedHeadSha, + core, +}) { + let branchSha; + + try { + const { data: branchRef } = await github.rest.git.getRef({ + owner, + repo, + ref: `heads/${expectedHeadRef}`, + }); + branchSha = branchRef.object.sha; + } catch (error) { + core.warning( + `Closed #${pullNumber} but failed to refresh ${expectedHeadRef} before delete: ${error.message}`, + ); + return { outcome: 'kept-refresh-failed', detail: error.message }; } - for (const candidate of releasePrs) { - // Re-fetch before acting: the initial list is a snapshot and the - // PR may have merged/closed (or become active) since then. - let pr; - try { - ({ data: pr } = await github.rest.pulls.get({ - owner, - repo, - pull_number: candidate.number, - })); - } catch (error) { - core.warning( - `Failed to refresh #${candidate.number} (${candidate.head.ref}): ${error.message}`, - ); - continue; - } + if (branchSha !== expectedHeadSha) { + const detail = `head moved from ${expectedHeadSha} to ${branchSha}`; + core.warning( + `Closed #${pullNumber} but skipped deleting ${expectedHeadRef}: ${detail}`, + ); + return { outcome: 'kept-head-moved', detail, branchSha }; + } - if (pr.state !== 'open') { - core.info(`Skipping #${pr.number} (${pr.head.ref}): no longer open`); - continue; - } + try { + await github.rest.git.deleteRef({ + owner, + repo, + ref: `heads/${expectedHeadRef}`, + }); + core.info(`Closed #${pullNumber} and deleted branch ${expectedHeadRef}`); + return { outcome: 'deleted', detail: '', branchSha }; + } catch (error) { + core.warning( + `Closed #${pullNumber} but failed to delete ${expectedHeadRef}: ${error.message}`, + ); + return { outcome: 'kept-delete-failed', detail: error.message, branchSha }; + } +} - if (pr.labels.some((label) => label.name === EXEMPT_LABEL)) { - core.info( - `Skipping #${pr.number} (${pr.head.ref}): exempt label "${EXEMPT_LABEL}"`, - ); - continue; - } +/** + * Build the PR comment body for a completed stale close. + * + * @param {object} options - Comment inputs. + * @param {string} options.inactiveHours - Formatted inactivity duration. + * @param {string} options.expectedHeadRef - Release branch name. + * @param {string} options.expectedHeadSha - Head SHA at close time. + * @param {string} options.outcome - Branch deletion outcome key. + * @param {string} options.detail - Extra outcome detail. + * @param {string} [options.branchSha] - Current branch tip when kept. + * @returns {string} Markdown comment body. + */ +function buildCloseComment({ + inactiveHours, + expectedHeadRef, + expectedHeadSha, + outcome, + detail, + branchSha, +}) { + let branchStatusLines; + if (outcome === 'deleted') { + branchStatusLines = [ + `The release branch \`${expectedHeadRef}\` has been deleted. If you still need to publish these packages, start a fresh release with \`yarn create-release-branch\`.`, + ]; + } else if (outcome === 'kept-head-moved') { + branchStatusLines = [ + `The release branch \`${expectedHeadRef}\` was **not** deleted because its tip changed after close (\`${expectedHeadSha}\` → \`${branchSha}\`).`, + '', + 'Delete the branch manually if it is no longer needed, or open a new release PR from the updated tip.', + ]; + } else { + branchStatusLines = [ + `The release branch \`${expectedHeadRef}\` was **not** deleted (${detail}).`, + '', + 'Delete the branch manually if it is no longer needed, or start a fresh release with `yarn create-release-branch`.', + ]; + } - const ageMs = now - Date.parse(pr.updated_at); - if (ageMs < staleMs) { - core.info( - `Skipping #${pr.number} (${pr.head.ref}): updated ${Math.round(ageMs / 60000)}m ago`, - ); - continue; - } + return [ + '## Stale release PR closed', + '', + `This release PR was automatically closed because it had no activity for ${STALE_HOURS} hours (last updated ${inactiveHours}h ago).`, + '', + 'Open release PRs on `release/*` branches are expected to merge promptly so they do not block others from starting a new release.', + '', + ...branchStatusLines, + '', + `To keep a release PR open longer in exceptional cases, add the \`${EXEMPT_LABEL}\` label.`, + '', + '', + ].join('\n'); +} - let pullRequest; - try { - pullRequest = await getMergeState(pr.number); - } catch (error) { - core.warning( - `Failed to check merge state for #${pr.number} (${pr.head.ref}): ${error.message}`, - ); - continue; - } +/** + * Post the stale-close comment on the pull request. + * + * @param {object} github - Octokit client from `actions/github-script`. + * @param {string} owner - Repository owner. + * @param {string} repo - Repository name. + * @param {number} pullNumber - Pull request number. + * @param {string} body - Markdown comment body. + * @param {object} core - `@actions/core` helpers. + * @returns {Promise} + */ +async function commentOnPull(github, owner, repo, pullNumber, body, core) { + try { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pullNumber, + body, + }); + } catch (error) { + core.warning(`Closed #${pullNumber} but failed to comment: ${error.message}`); + } +} - if (pullRequest.isInMergeQueue || pullRequest.autoMergeRequest) { - core.info(`Skipping #${pr.number} (${pr.head.ref}): merge in progress`); - continue; - } +/** + * Process a single stale release PR candidate through refresh, close, delete, + * and comment. + * + * @param {object} options - Processing inputs. + * @param {object} options.github - Octokit client from `actions/github-script`. + * @param {string} options.owner - Repository owner. + * @param {string} options.repo - Repository name. + * @param {object} options.candidate - Candidate from the initial open-PR list. + * @param {number} options.now - Epoch ms used for the stale window. + * @param {object} options.core - `@actions/core` helpers. + * @returns {Promise} + */ +async function processReleasePr({ + github, + owner, + repo, + candidate, + now, + core, +}) { + let pr; + try { + pr = await getPull(github, owner, repo, candidate.number); + } catch (error) { + core.warning( + `Failed to refresh #${candidate.number} (${candidate.head.ref}): ${error.message}`, + ); + return; + } - const expectedUpdatedAt = pr.updated_at; - const expectedHeadSha = pr.head.sha; - const expectedHeadRef = pr.head.ref; - const expectedLabels = labelKey(pr.labels); - const inactiveHours = (ageMs / (60 * 60 * 1000)).toFixed(1); - - // Final recheck immediately before destructive ops: activity, - // head SHA, labels, or merge state may have changed since the - // earlier refresh / GraphQL query. - let latestPr; - try { - ({ data: latestPr } = await github.rest.pulls.get({ - owner, - repo, - pull_number: pr.number, - })); - } catch (error) { - core.warning( - `Failed final refresh for #${pr.number} (${expectedHeadRef}): ${error.message}`, - ); - continue; - } + const eligibility = evaluateStaleEligibility({ pr, now, core }); + if (!eligibility.eligible) { + return; + } - if (latestPr.state !== 'open') { - core.info( - `Skipping #${pr.number} (${latestPr.head.ref}): no longer open before close`, - ); - continue; - } + let mergeState; + try { + mergeState = await getMergeState(github, owner, repo, pr.number); + } catch (error) { + core.warning( + `Failed to check merge state for #${pr.number} (${pr.head.ref}): ${error.message}`, + ); + return; + } - if (labelKey(latestPr.labels) !== expectedLabels) { - core.info( - `Skipping #${pr.number} (${latestPr.head.ref}): labels changed before close`, - ); - continue; - } + if (isMergeInProgress(mergeState)) { + core.info(`Skipping #${pr.number} (${pr.head.ref}): merge in progress`); + return; + } - if ( - latestPr.updated_at !== expectedUpdatedAt || - latestPr.head.sha !== expectedHeadSha || - latestPr.head.ref !== expectedHeadRef - ) { - core.info( - `Skipping #${pr.number} (${latestPr.head.ref}): activity or head changed before close`, - ); - continue; - } + const expectedUpdatedAt = pr.updated_at; + const expectedHeadSha = pr.head.sha; + const expectedHeadRef = pr.head.ref; + const expectedLabels = labelKey(pr.labels); + const inactiveHours = (eligibility.ageMs / (60 * 60 * 1000)).toFixed(1); - if (Date.now() - Date.parse(latestPr.updated_at) < staleMs) { - core.info( - `Skipping #${pr.number} (${latestPr.head.ref}): no longer stale before close`, - ); - continue; - } + let latestPr; + try { + latestPr = await getPull(github, owner, repo, pr.number); + } catch (error) { + core.warning( + `Failed final refresh for #${pr.number} (${expectedHeadRef}): ${error.message}`, + ); + return; + } - let latestMergeState; - try { - latestMergeState = await getMergeState(latestPr.number); - } catch (error) { - core.warning( - `Failed final merge-state check for #${latestPr.number} (${latestPr.head.ref}): ${error.message}`, - ); - continue; - } + const finalEligibility = evaluateStaleEligibility({ + pr: latestPr, + now: Date.now(), + core, + phase: 'before close', + }); + if (!finalEligibility.eligible) { + return; + } - if (latestMergeState.isInMergeQueue || latestMergeState.autoMergeRequest) { - core.info( - `Skipping #${latestPr.number} (${latestPr.head.ref}): merge started before close`, - ); - continue; - } + if ( + !isUnchangedBeforeClose({ + latestPr, + expectedUpdatedAt, + expectedHeadSha, + expectedHeadRef, + expectedLabels, + core, + }) + ) { + return; + } - // Close before commenting so a failed close does not bump - // updated_at and postpone the next stale attempt. - try { - await github.rest.pulls.update({ - owner, - repo, - pull_number: latestPr.number, - state: 'closed', - }); - } catch (error) { - core.warning( - `Failed to close #${latestPr.number} (${latestPr.head.ref}): ${error.message}`, - ); - continue; - } + let latestMergeState; + try { + latestMergeState = await getMergeState(github, owner, repo, latestPr.number); + } catch (error) { + core.warning( + `Failed final merge-state check for #${latestPr.number} (${latestPr.head.ref}): ${error.message}`, + ); + return; + } - // Re-fetch the branch tip immediately before delete so a push - // that landed after close is not discarded. Comment afterward - // so the message matches whether the branch was actually removed. - let branchOutcome = 'deleted'; - let branchOutcomeDetail = ''; - let branchSha; - - try { - const { data: branchRef } = await github.rest.git.getRef({ - owner, - repo, - ref: `heads/${expectedHeadRef}`, - }); - branchSha = branchRef.object.sha; - } catch (error) { - branchOutcome = 'kept-refresh-failed'; - branchOutcomeDetail = error.message; - core.warning( - `Closed #${latestPr.number} but failed to refresh ${expectedHeadRef} before delete: ${error.message}`, - ); - } + if (isMergeInProgress(latestMergeState)) { + core.info( + `Skipping #${latestPr.number} (${latestPr.head.ref}): merge started before close`, + ); + return; + } - if (branchOutcome === 'deleted' && branchSha !== expectedHeadSha) { - branchOutcome = 'kept-head-moved'; - branchOutcomeDetail = `head moved from ${expectedHeadSha} to ${branchSha}`; - core.warning( - `Closed #${latestPr.number} but skipped deleting ${expectedHeadRef}: ${branchOutcomeDetail}`, - ); - } + // Close before commenting so a failed close does not bump updated_at. + const closed = await closePullRequest(github, owner, repo, latestPr, core); + if (!closed) { + return; + } - if (branchOutcome === 'deleted') { - try { - await github.rest.git.deleteRef({ - owner, - repo, - ref: `heads/${expectedHeadRef}`, - }); - core.info( - `Closed #${latestPr.number} and deleted branch ${expectedHeadRef}`, - ); - } catch (error) { - branchOutcome = 'kept-delete-failed'; - branchOutcomeDetail = error.message; - core.warning( - `Closed #${latestPr.number} but failed to delete ${expectedHeadRef}: ${error.message}`, - ); - } - } + const branchResult = await deleteBranchIfUnchanged({ + github, + owner, + repo, + pullNumber: latestPr.number, + expectedHeadRef, + expectedHeadSha, + core, + }); - let branchStatusLines; - if (branchOutcome === 'deleted') { - branchStatusLines = [ - `The release branch \`${expectedHeadRef}\` has been deleted. If you still need to publish these packages, start a fresh release with \`yarn create-release-branch\`.`, - ]; - } else if (branchOutcome === 'kept-head-moved') { - branchStatusLines = [ - `The release branch \`${expectedHeadRef}\` was **not** deleted because its tip changed after close (\`${expectedHeadSha}\` → \`${branchSha}\`).`, - '', - 'Delete the branch manually if it is no longer needed, or open a new release PR from the updated tip.', - ]; - } else { - branchStatusLines = [ - `The release branch \`${expectedHeadRef}\` was **not** deleted (${branchOutcomeDetail}).`, - '', - 'Delete the branch manually if it is no longer needed, or start a fresh release with `yarn create-release-branch`.', - ]; - } + const body = buildCloseComment({ + inactiveHours, + expectedHeadRef, + expectedHeadSha, + outcome: branchResult.outcome, + detail: branchResult.detail, + branchSha: branchResult.branchSha, + }); - const body = [ - '## Stale release PR closed', - '', - `This release PR was automatically closed because it had no activity for ${STALE_HOURS} hours (last updated ${inactiveHours}h ago).`, - '', - 'Open release PRs on `release/*` branches are expected to merge promptly so they do not block others from starting a new release.', - '', - ...branchStatusLines, - '', - `To keep a release PR open longer in exceptional cases, add the \`${EXEMPT_LABEL}\` label.`, - '', - '', - ].join('\n'); - - try { - await github.rest.issues.createComment({ - owner, - repo, - issue_number: latestPr.number, - body, - }); - } catch (error) { - core.warning( - `Closed #${latestPr.number} but failed to comment: ${error.message}`, - ); - } + await commentOnPull(github, owner, repo, latestPr.number, body, core); +} + +/** + * Close inactive same-repo `release/*` PRs. + * + * @param {object} params - `actions/github-script` runtime bindings. + * @param {object} params.github - Octokit client from `actions/github-script`. + * @param {object} params.context - GitHub Actions context. + * @param {object} params.core - `@actions/core` helpers. + * @returns {Promise} + */ +module.exports = async function closeStaleReleasePrs({ + github, + context, + core, +}) { + const now = Date.now(); + const { owner, repo } = context.repo; + + const pulls = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: 'open', + per_page: 100, + }); + + const releasePrs = pulls.filter((pr) => + isReleasePrCandidate(pr, owner, repo, core), + ); + + if (releasePrs.length === 0) { + core.info('No open release PRs to evaluate.'); + return; + } + + for (const candidate of releasePrs) { + await processReleasePr({ + github, + owner, + repo, + candidate, + now, + core, + }); } }; From 7c1b13c0e50e8a3902f38386993fdb6a61434596 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Sat, 25 Jul 2026 00:48:57 +0200 Subject: [PATCH 9/9] style: format close-stale-release-prs script --- .github/scripts/close-stale-release-prs.cjs | 24 +++++++++++---------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/.github/scripts/close-stale-release-prs.cjs b/.github/scripts/close-stale-release-prs.cjs index dd967e29ac6..c74ac13f495 100644 --- a/.github/scripts/close-stale-release-prs.cjs +++ b/.github/scripts/close-stale-release-prs.cjs @@ -128,7 +128,9 @@ function evaluateStaleEligibility({ pr, now, core, phase = '' }) { const suffix = phase ? ` ${phase}` : ''; if (pr.state !== 'open') { - core.info(`Skipping #${pr.number} (${pr.head.ref}): no longer open${suffix}`); + core.info( + `Skipping #${pr.number} (${pr.head.ref}): no longer open${suffix}`, + ); return { eligible: false }; } @@ -362,7 +364,9 @@ async function commentOnPull(github, owner, repo, pullNumber, body, core) { body, }); } catch (error) { - core.warning(`Closed #${pullNumber} but failed to comment: ${error.message}`); + core.warning( + `Closed #${pullNumber} but failed to comment: ${error.message}`, + ); } } @@ -379,14 +383,7 @@ async function commentOnPull(github, owner, repo, pullNumber, body, core) { * @param {object} options.core - `@actions/core` helpers. * @returns {Promise} */ -async function processReleasePr({ - github, - owner, - repo, - candidate, - now, - core, -}) { +async function processReleasePr({ github, owner, repo, candidate, now, core }) { let pr; try { pr = await getPull(github, owner, repo, candidate.number); @@ -458,7 +455,12 @@ async function processReleasePr({ let latestMergeState; try { - latestMergeState = await getMergeState(github, owner, repo, latestPr.number); + latestMergeState = await getMergeState( + github, + owner, + repo, + latestPr.number, + ); } catch (error) { core.warning( `Failed final merge-state check for #${latestPr.number} (${latestPr.head.ref}): ${error.message}`,