From c2fbff15eec0ecf7a2bbef2e4bdf44e0e806ae75 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Fri, 24 Jul 2026 17:33:39 +0200 Subject: [PATCH 01/19] 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 02/19] 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 03/19] 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 04/19] 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 05/19] 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 06/19] 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 07/19] 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 08/19] 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 09/19] 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}`, From 365060a38708d609f7f6c964347aac4cfdc990cb Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Tue, 28 Jul 2026 09:05:17 +0100 Subject: [PATCH 10/19] refactor: address clear-win review nits on stale release closer Use SKIP_LABEL and a single stale duration, rename helpers/vars, detect forks via head.repo.fork, and simplify the close comment to only call out branch-delete failures. --- .github/scripts/close-stale-release-prs.cjs | 134 ++++++++++---------- 1 file changed, 67 insertions(+), 67 deletions(-) diff --git a/.github/scripts/close-stale-release-prs.cjs b/.github/scripts/close-stale-release-prs.cjs index c74ac13f495..2b8fc44b996 100644 --- a/.github/scripts/close-stale-release-prs.cjs +++ b/.github/scripts/close-stale-release-prs.cjs @@ -6,10 +6,15 @@ * `await require('./.github/scripts/close-stale-release-prs.cjs')({ 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 STALE_MS = STALE_HOURS * 60 * 60 * 1000; +/** + * The label users can use to prevent stale release PRs from being auto-closed. + */ +const SKIP_LABEL = 'release:keep-open'; + +/** + * How long inactive release PRs stay open before auto-close (milliseconds). + */ +const STALE_DURATION_MS = 3 * 60 * 60 * 1000; const MERGE_STATE_QUERY = ` query ($owner: String!, $repo: String!, $number: Int!) { @@ -65,7 +70,7 @@ async function getMergeState(github, owner, repo, pullNumber) { * @param {number} pullNumber - Pull request number. * @returns {Promise} Pull request payload. */ -async function getPull(github, owner, repo, pullNumber) { +async function getPullRequest(github, owner, repo, pullNumber) { const { data } = await github.rest.pulls.get({ owner, repo, @@ -75,27 +80,25 @@ async function getPull(github, owner, repo, pullNumber) { } /** - * Whether a PR head is a same-repo `release/*` branch that is not exempt. + * Whether a PR head is a same-repo `release/*` branch that is not skipped. * * @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) { +function isReleasePrCandidate(pr, core) { if (!pr.head.ref.startsWith('release/')) { return false; } // Only manage same-repo release branches (never forks). - if (pr.head.repo?.full_name !== `${owner}/${repo}`) { + if (!pr.head.repo || pr.head.repo.fork) { return false; } - if (pr.labels.some((label) => label.name === EXEMPT_LABEL)) { + if (pr.labels.some((label) => label.name === SKIP_LABEL)) { core.info( - `Skipping #${pr.number} (${pr.head.ref}): exempt label "${EXEMPT_LABEL}"`, + `Skipping #${pr.number} (${pr.head.ref}): skip label "${SKIP_LABEL}"`, ); return false; } @@ -134,15 +137,15 @@ function evaluateStaleEligibility({ pr, now, core, phase = '' }) { return { eligible: false }; } - if (pr.labels.some((label) => label.name === EXEMPT_LABEL)) { + if (pr.labels.some((label) => label.name === SKIP_LABEL)) { core.info( - `Skipping #${pr.number} (${pr.head.ref}): exempt label "${EXEMPT_LABEL}"${suffix}`, + `Skipping #${pr.number} (${pr.head.ref}): skip label "${SKIP_LABEL}"${suffix}`, ); return { eligible: false }; } const ageMs = now - Date.parse(pr.updated_at); - if (ageMs < STALE_MS) { + if (ageMs < STALE_DURATION_MS) { core.info( `Skipping #${pr.number} (${pr.head.ref}): updated ${Math.round(ageMs / 60000)}m ago${suffix}`, ); @@ -190,7 +193,7 @@ function isUnchangedBeforeClose({ return false; } - if (Date.now() - Date.parse(latestPr.updated_at) < STALE_MS) { + if (Date.now() - Date.parse(latestPr.updated_at) < STALE_DURATION_MS) { core.info( `Skipping #${latestPr.number} (${latestPr.head.ref}): no longer stale before close`, ); @@ -290,58 +293,48 @@ async function deleteBranchIfUnchanged({ } } +/** + * Build a link to the current workflow run. + * + * @param {object} context - GitHub Actions context. + * @returns {string} Workflow run URL. + */ +function getWorkflowRunUrl(context) { + return `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; +} + /** * 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. + * @param {string} options.workflowRunUrl - Link to this workflow run. * @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`.', - ]; - } - - 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).`, +function buildCloseComment({ inactiveHours, outcome, workflowRunUrl }) { + const staleHours = STALE_DURATION_MS / (60 * 60 * 1000); + const lines = [ + '## This pull request has been closed', '', - 'Open release PRs on `release/*` branches are expected to merge promptly so they do not block others from starting a new release.', + `This release PR was automatically closed because it had no activity for ${staleHours} hours (last updated ${inactiveHours}h ago).`, '', - ...branchStatusLines, + 'Open release PRs are expected to merge promptly so they do not block others from starting a new release.', '', - `To keep a release PR open longer in exceptional cases, add the \`${EXEMPT_LABEL}\` label.`, - '', - '', - ].join('\n'); + `To keep a release PR open longer in exceptional cases, add the \`${SKIP_LABEL}\` label.`, + ]; + + // GitHub already surfaces successful branch deletion on the closed PR. + // Only call out failures / intentional skips. + if (outcome !== 'deleted') { + lines.push( + '', + `> (A failed attempt was made to delete this branch. See more details here: ${workflowRunUrl})`, + ); + } + + lines.push('', ''); + return lines.join('\n'); } /** @@ -376,6 +369,7 @@ async function commentOnPull(github, owner, repo, pullNumber, body, core) { * * @param {object} options - Processing inputs. * @param {object} options.github - Octokit client from `actions/github-script`. + * @param {object} options.context - GitHub Actions context. * @param {string} options.owner - Repository owner. * @param {string} options.repo - Repository name. * @param {object} options.candidate - Candidate from the initial open-PR list. @@ -383,10 +377,18 @@ 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, + context, + owner, + repo, + candidate, + now, + core, +}) { let pr; try { - pr = await getPull(github, owner, repo, candidate.number); + pr = await getPullRequest(github, owner, repo, candidate.number); } catch (error) { core.warning( `Failed to refresh #${candidate.number} (${candidate.head.ref}): ${error.message}`, @@ -422,7 +424,7 @@ async function processReleasePr({ github, owner, repo, candidate, now, core }) { let latestPr; try { - latestPr = await getPull(github, owner, repo, pr.number); + latestPr = await getPullRequest(github, owner, repo, pr.number); } catch (error) { core.warning( `Failed final refresh for #${pr.number} (${expectedHeadRef}): ${error.message}`, @@ -493,11 +495,8 @@ async function processReleasePr({ github, owner, repo, candidate, now, core }) { const body = buildCloseComment({ inactiveHours, - expectedHeadRef, - expectedHeadSha, outcome: branchResult.outcome, - detail: branchResult.detail, - branchSha: branchResult.branchSha, + workflowRunUrl: getWorkflowRunUrl(context), }); await commentOnPull(github, owner, repo, latestPr.number, body, core); @@ -520,15 +519,15 @@ module.exports = async function closeStaleReleasePrs({ const now = Date.now(); const { owner, repo } = context.repo; - const pulls = await github.paginate(github.rest.pulls.list, { + const pullRequests = await github.paginate(github.rest.pulls.list, { owner, repo, state: 'open', per_page: 100, }); - const releasePrs = pulls.filter((pr) => - isReleasePrCandidate(pr, owner, repo, core), + const releasePrs = pullRequests.filter((pr) => + isReleasePrCandidate(pr, core), ); if (releasePrs.length === 0) { @@ -539,6 +538,7 @@ module.exports = async function closeStaleReleasePrs({ for (const candidate of releasePrs) { await processReleasePr({ github, + context, owner, repo, candidate, From 624f8146db4cf206e3c381fae569dda101431ac9 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Tue, 28 Jul 2026 09:36:23 +0100 Subject: [PATCH 11/19] refactor: migrate stale release closer to TypeScript with @actions packages Replace the github-script CJS entrypoint with an executable tsx script that uses @actions/github and @actions/core, and fetch PR eligibility via a single GraphQL snapshot query. --- .github/scripts/close-stale-release-prs.cjs | 549 ----------------- .github/workflows/close-stale-release-prs.yml | 14 +- package.json | 2 + scripts/close-stale-release-prs.ts | 562 ++++++++++++++++++ yarn.lock | 209 +++++++ 5 files changed, 780 insertions(+), 556 deletions(-) delete mode 100644 .github/scripts/close-stale-release-prs.cjs create mode 100755 scripts/close-stale-release-prs.ts diff --git a/.github/scripts/close-stale-release-prs.cjs b/.github/scripts/close-stale-release-prs.cjs deleted file mode 100644 index 2b8fc44b996..00000000000 --- a/.github/scripts/close-stale-release-prs.cjs +++ /dev/null @@ -1,549 +0,0 @@ -/** - * 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 });` - */ - -/** - * The label users can use to prevent stale release PRs from being auto-closed. - */ -const SKIP_LABEL = 'release:keep-open'; - -/** - * How long inactive release PRs stay open before auto-close (milliseconds). - */ -const STALE_DURATION_MS = 3 * 60 * 60 * 1000; - -const MERGE_STATE_QUERY = ` - query ($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) { - isInMergeQueue - autoMergeRequest { - enabledAt - } - } - } - } -`; - -/** - * 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, - number: pullNumber, - }); - return mergeQueue.repository.pullRequest; -} - -/** - * 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 getPullRequest(github, owner, repo, pullNumber) { - const { data } = await github.rest.pulls.get({ - owner, - repo, - pull_number: pullNumber, - }); - return data; -} - -/** - * Whether a PR head is a same-repo `release/*` branch that is not skipped. - * - * @param {object} pr - Pull request payload. - * @param {object} core - `@actions/core` helpers. - * @returns {boolean} True when the PR is a candidate for stale close. - */ -function isReleasePrCandidate(pr, core) { - if (!pr.head.ref.startsWith('release/')) { - return false; - } - - // Only manage same-repo release branches (never forks). - if (!pr.head.repo || pr.head.repo.fork) { - return false; - } - - if (pr.labels.some((label) => label.name === SKIP_LABEL)) { - core.info( - `Skipping #${pr.number} (${pr.head.ref}): skip label "${SKIP_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 === SKIP_LABEL)) { - core.info( - `Skipping #${pr.number} (${pr.head.ref}): skip label "${SKIP_LABEL}"${suffix}`, - ); - return { eligible: false }; - } - - const ageMs = now - Date.parse(pr.updated_at); - if (ageMs < STALE_DURATION_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_DURATION_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; - } -} - -/** - * 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 }; - } - - 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 }; - } - - 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 }; - } -} - -/** - * Build a link to the current workflow run. - * - * @param {object} context - GitHub Actions context. - * @returns {string} Workflow run URL. - */ -function getWorkflowRunUrl(context) { - return `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; -} - -/** - * 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.outcome - Branch deletion outcome key. - * @param {string} options.workflowRunUrl - Link to this workflow run. - * @returns {string} Markdown comment body. - */ -function buildCloseComment({ inactiveHours, outcome, workflowRunUrl }) { - const staleHours = STALE_DURATION_MS / (60 * 60 * 1000); - const lines = [ - '## This pull request has been closed', - '', - `This release PR was automatically closed because it had no activity for ${staleHours} hours (last updated ${inactiveHours}h ago).`, - '', - 'Open release PRs are expected to merge promptly so they do not block others from starting a new release.', - '', - `To keep a release PR open longer in exceptional cases, add the \`${SKIP_LABEL}\` label.`, - ]; - - // GitHub already surfaces successful branch deletion on the closed PR. - // Only call out failures / intentional skips. - if (outcome !== 'deleted') { - lines.push( - '', - `> (A failed attempt was made to delete this branch. See more details here: ${workflowRunUrl})`, - ); - } - - lines.push('', ''); - return lines.join('\n'); -} - -/** - * 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}`, - ); - } -} - -/** - * 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 {object} options.context - GitHub Actions context. - * @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, - context, - owner, - repo, - candidate, - now, - core, -}) { - let pr; - try { - pr = await getPullRequest(github, owner, repo, candidate.number); - } catch (error) { - core.warning( - `Failed to refresh #${candidate.number} (${candidate.head.ref}): ${error.message}`, - ); - return; - } - - const eligibility = evaluateStaleEligibility({ pr, now, core }); - if (!eligibility.eligible) { - return; - } - - 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 (isMergeInProgress(mergeState)) { - core.info(`Skipping #${pr.number} (${pr.head.ref}): merge in progress`); - return; - } - - 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); - - let latestPr; - try { - latestPr = await getPullRequest(github, owner, repo, pr.number); - } catch (error) { - core.warning( - `Failed final refresh for #${pr.number} (${expectedHeadRef}): ${error.message}`, - ); - return; - } - - const finalEligibility = evaluateStaleEligibility({ - pr: latestPr, - now: Date.now(), - core, - phase: 'before close', - }); - if (!finalEligibility.eligible) { - return; - } - - if ( - !isUnchangedBeforeClose({ - latestPr, - expectedUpdatedAt, - expectedHeadSha, - expectedHeadRef, - expectedLabels, - core, - }) - ) { - return; - } - - 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; - } - - if (isMergeInProgress(latestMergeState)) { - core.info( - `Skipping #${latestPr.number} (${latestPr.head.ref}): merge started before close`, - ); - return; - } - - // Close before commenting so a failed close does not bump updated_at. - const closed = await closePullRequest(github, owner, repo, latestPr, core); - if (!closed) { - return; - } - - const branchResult = await deleteBranchIfUnchanged({ - github, - owner, - repo, - pullNumber: latestPr.number, - expectedHeadRef, - expectedHeadSha, - core, - }); - - const body = buildCloseComment({ - inactiveHours, - outcome: branchResult.outcome, - workflowRunUrl: getWorkflowRunUrl(context), - }); - - 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 pullRequests = await github.paginate(github.rest.pulls.list, { - owner, - repo, - state: 'open', - per_page: 100, - }); - - const releasePrs = pullRequests.filter((pr) => - isReleasePrCandidate(pr, core), - ); - - if (releasePrs.length === 0) { - core.info('No open release PRs to evaluate.'); - return; - } - - for (const candidate of releasePrs) { - await processReleasePr({ - github, - context, - owner, - repo, - candidate, - now, - core, - }); - } -}; diff --git a/.github/workflows/close-stale-release-prs.yml b/.github/workflows/close-stale-release-prs.yml index cabe0f619b6..9f6520418e7 100644 --- a/.github/workflows/close-stale-release-prs.yml +++ b/.github/workflows/close-stale-release-prs.yml @@ -24,13 +24,13 @@ jobs: contents: write pull-requests: write steps: - - name: Checkout - uses: actions/checkout@v7 + - name: Checkout and setup environment + uses: MetaMask/action-checkout-and-setup@v3 with: + is-high-risk-environment: false persist-credentials: false + cache-node-modules: true - name: Close inactive release PRs - uses: actions/github-script@v8 - with: - script: | - const closeStaleReleasePrs = require('./.github/scripts/close-stale-release-prs.cjs'); - await closeStaleReleasePrs({ github, context, core }); + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: yarn tsx scripts/close-stale-release-prs.ts diff --git a/package.json b/package.json index 7bacdf2121e..069496a4f81 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,8 @@ "workspaces:list-versions": "./scripts/list-workspace-versions.sh" }, "devDependencies": { + "@actions/core": "^3.0.1", + "@actions/github": "^9.1.1", "@lavamoat/allow-scripts": "^3.0.4", "@lavamoat/preinstall-always-fail": "^2.1.0", "@metamask/create-release-branch": "^4.2.1", diff --git a/scripts/close-stale-release-prs.ts b/scripts/close-stale-release-prs.ts new file mode 100755 index 00000000000..53d6142140b --- /dev/null +++ b/scripts/close-stale-release-prs.ts @@ -0,0 +1,562 @@ +#!/usr/bin/env yarn tsx + +/** + * Close inactive same-repo `release/*` PRs, comment with the outcome, and + * delete the branch when the tip is unchanged. + * + * Usage (from GitHub Actions): + * GITHUB_TOKEN=... yarn tsx scripts/close-stale-release-prs.ts + */ + +import * as core from '@actions/core'; +import { context, getOctokit } from '@actions/github'; + +/** + * The label users can use to prevent stale release PRs from being auto-closed. + */ +const SKIP_LABEL = 'release:keep-open'; + +/** + * How long inactive release PRs stay open before auto-close (milliseconds). + */ +const STALE_DURATION_MS = 3 * 60 * 60 * 1000; + +const PULL_REQUEST_SNAPSHOT_QUERY = ` + query ($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + number + state + updatedAt + isInMergeQueue + autoMergeRequest { + enabledAt + } + labels(first: 100) { + nodes { + name + } + } + headRefName + headRefOid + headRepository { + isFork + } + } + } + } +`; + +type Octokit = ReturnType; + +type PullRequestSnapshot = { + number: number; + state: string; + updatedAt: string; + isInMergeQueue: boolean; + autoMergeRequest: { enabledAt: string } | null; + labels: { nodes: { name: string }[] }; + headRefName: string; + headRefOid: string; + headRepository: { isFork: boolean } | null; +}; + +type ListedPullRequest = { + number: number; + head: { + ref: string; + repo: { fork: boolean } | null; + }; + labels: { name: string }[]; +}; + +type BranchDeleteOutcome = { + outcome: + | 'deleted' + | 'kept-refresh-failed' + | 'kept-head-moved' + | 'kept-delete-failed'; + detail: string; +}; + +/** + * Stable fingerprint of a PR's label set for equality checks. + * + * @param labels - Label names. + * @returns Sorted, joined label names. + */ +function labelKey(labels: string[]): string { + return [...labels].sort().join('\0'); +} + +/** + * Label names from a GraphQL PR snapshot. + * + * @param snapshot - GraphQL pull request snapshot. + * @returns Label name list. + */ +function snapshotLabelNames(snapshot: PullRequestSnapshot): string[] { + return snapshot.labels.nodes.map((label) => label.name); +} + +/** + * Fetch PR state, labels, head tip, and merge-queue info in one GraphQL query. + * + * @param octokit - Authenticated Octokit client. + * @param pullNumber - Pull request number. + * @returns Pull request snapshot. + */ +async function getPullRequestSnapshot( + octokit: Octokit, + pullNumber: number, +): Promise { + const { owner, repo } = context.repo; + const response = await octokit.graphql<{ + repository: { pullRequest: PullRequestSnapshot | null }; + }>(PULL_REQUEST_SNAPSHOT_QUERY, { + owner, + repo, + number: pullNumber, + }); + + const { pullRequest } = response.repository; + if (!pullRequest) { + throw new Error(`Pull request #${pullNumber} was not found`); + } + + return pullRequest; +} + +/** + * Whether a listed PR head is a same-repo `release/*` branch that is not skipped. + * + * @param pullRequest - Pull request from `pulls.list`. + * @returns True when the PR is a candidate for stale close. + */ +function isReleasePrCandidate(pullRequest: ListedPullRequest): boolean { + if (!pullRequest.head.ref.startsWith('release/')) { + return false; + } + + // Only manage same-repo release branches (never forks). + if (!pullRequest.head.repo || pullRequest.head.repo.fork) { + return false; + } + + if (pullRequest.labels.some((label) => label.name === SKIP_LABEL)) { + core.info( + `Skipping #${pullRequest.number} (${pullRequest.head.ref}): skip label "${SKIP_LABEL}"`, + ); + return false; + } + + return true; +} + +/** + * Whether merge-queue or auto-merge is active for the PR. + * + * @param snapshot - GraphQL pull request snapshot. + * @returns True when a merge is already in progress. + */ +function isMergeInProgress(snapshot: PullRequestSnapshot): boolean { + return Boolean(snapshot.isInMergeQueue || snapshot.autoMergeRequest); +} + +/** + * Evaluate whether a PR snapshot is eligible to close as stale. + * + * Skip-label is re-checked on refreshed snapshots so a label added after the + * initial list filter still prevents auto-close. + * + * @param options - Evaluation inputs. + * @param options.snapshot - Fresh GraphQL pull request snapshot. + * @param options.staleBefore - Epoch ms; PRs updated at/after this are kept. + * @param options.phase - Optional log suffix describing the check phase. + * @returns Eligibility result. + */ +function evaluateStaleEligibility({ + snapshot, + staleBefore, + phase = '', +}: { + snapshot: PullRequestSnapshot; + staleBefore: number; + phase?: string; +}): { eligible: boolean; ageMs?: number } { + const suffix = phase ? ` ${phase}` : ''; + const ref = snapshot.headRefName; + + if (snapshot.state !== 'OPEN') { + core.info(`Skipping #${snapshot.number} (${ref}): no longer open${suffix}`); + return { eligible: false }; + } + + if (snapshotLabelNames(snapshot).includes(SKIP_LABEL)) { + core.info( + `Skipping #${snapshot.number} (${ref}): skip label "${SKIP_LABEL}"${suffix}`, + ); + return { eligible: false }; + } + + if (snapshot.headRepository?.isFork) { + core.info(`Skipping #${snapshot.number} (${ref}): fork head${suffix}`); + return { eligible: false }; + } + + const updatedAtMs = Date.parse(snapshot.updatedAt); + if (updatedAtMs >= staleBefore) { + core.info( + `Skipping #${snapshot.number} (${ref}): updated ${Math.round((Date.now() - updatedAtMs) / 60000)}m ago${suffix}`, + ); + return { eligible: false }; + } + + if (isMergeInProgress(snapshot)) { + core.info( + `Skipping #${snapshot.number} (${ref}): merge in progress${suffix}`, + ); + return { eligible: false }; + } + + return { eligible: true, ageMs: Date.now() - updatedAtMs }; +} + +/** + * Confirm the PR snapshot has not changed since the pre-close checks. + * + * @param options - Comparison inputs. + * @param options.latest - Most recent GraphQL snapshot. + * @param options.expected - Snapshot captured before the final refresh. + * @returns True when the snapshot is unchanged. + */ +function isUnchangedBeforeClose({ + latest, + expected, +}: { + latest: PullRequestSnapshot; + expected: PullRequestSnapshot; +}): boolean { + if ( + labelKey(snapshotLabelNames(latest)) !== + labelKey(snapshotLabelNames(expected)) + ) { + core.info( + `Skipping #${latest.number} (${latest.headRefName}): labels changed before close`, + ); + return false; + } + + if ( + latest.updatedAt !== expected.updatedAt || + latest.headRefOid !== expected.headRefOid || + latest.headRefName !== expected.headRefName + ) { + core.info( + `Skipping #${latest.number} (${latest.headRefName}): activity or head changed before close`, + ); + return false; + } + + return true; +} + +/** + * Close the pull request. + * + * @param octokit - Authenticated Octokit client. + * @param pullNumber - Pull request number. + * @param headRef - Head branch name for logging. + * @returns True when the close succeeded. + */ +async function closePullRequest( + octokit: Octokit, + pullNumber: number, + headRef: string, +): Promise { + const { owner, repo } = context.repo; + try { + await octokit.rest.pulls.update({ + owner, + repo, + pull_number: pullNumber, + state: 'closed', + }); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + core.warning(`Failed to close #${pullNumber} (${headRef}): ${message}`); + return false; + } +} + +/** + * Delete the release branch when its tip still matches the expected SHA. + * + * @param options - Delete inputs. + * @param options.octokit - Authenticated Octokit client. + * @param options.pullNumber - Closed pull request number. + * @param options.expectedHeadRef - Branch name to delete. + * @param options.expectedHeadSha - SHA that must still be the tip. + * @returns Branch deletion outcome. + */ +async function deleteBranchIfUnchanged({ + octokit, + pullNumber, + expectedHeadRef, + expectedHeadSha, +}: { + octokit: Octokit; + pullNumber: number; + expectedHeadRef: string; + expectedHeadSha: string; +}): Promise { + const { owner, repo } = context.repo; + let branchSha: string; + + try { + const { data: branchRef } = await octokit.rest.git.getRef({ + owner, + repo, + ref: `heads/${expectedHeadRef}`, + }); + branchSha = branchRef.object.sha; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + core.warning( + `Closed #${pullNumber} but failed to refresh ${expectedHeadRef} before delete: ${message}`, + ); + return { outcome: 'kept-refresh-failed', detail: message }; + } + + 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 }; + } + + try { + await octokit.rest.git.deleteRef({ + owner, + repo, + ref: `heads/${expectedHeadRef}`, + }); + core.info(`Closed #${pullNumber} and deleted branch ${expectedHeadRef}`); + return { outcome: 'deleted', detail: '' }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + core.warning( + `Closed #${pullNumber} but failed to delete ${expectedHeadRef}: ${message}`, + ); + return { outcome: 'kept-delete-failed', detail: message }; + } +} + +/** + * Build a link to the current workflow run. + * + * @returns Workflow run URL. + */ +function getWorkflowRunUrl(): string { + return `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; +} + +/** + * Build the PR comment body for a completed stale close. + * + * @param options - Comment inputs. + * @param options.inactiveHours - Formatted inactivity duration. + * @param options.outcome - Branch deletion outcome key. + * @returns Markdown comment body. + */ +function buildCloseComment({ + inactiveHours, + outcome, +}: { + inactiveHours: string; + outcome: BranchDeleteOutcome['outcome']; +}): string { + const staleHours = STALE_DURATION_MS / (60 * 60 * 1000); + const lines = [ + '## This pull request has been closed', + '', + `This release PR was automatically closed because it had no activity for ${staleHours} hours (last updated ${inactiveHours}h ago).`, + '', + 'Open release PRs are expected to merge promptly so they do not block others from starting a new release.', + '', + `To keep a release PR open longer in exceptional cases, add the \`${SKIP_LABEL}\` label.`, + ]; + + // GitHub already surfaces successful branch deletion on the closed PR. + // Only call out failures / intentional skips. + if (outcome !== 'deleted') { + lines.push( + '', + `> (A failed attempt was made to delete this branch. See more details here: ${getWorkflowRunUrl()})`, + ); + } + + lines.push('', ''); + return lines.join('\n'); +} + +/** + * Post the stale-close comment on the pull request. + * + * @param octokit - Authenticated Octokit client. + * @param pullNumber - Pull request number. + * @param body - Markdown comment body. + */ +async function commentOnPull( + octokit: Octokit, + pullNumber: number, + body: string, +): Promise { + const { owner, repo } = context.repo; + try { + await octokit.rest.issues.createComment({ + owner, + repo, + issue_number: pullNumber, + body, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + core.warning(`Closed #${pullNumber} but failed to comment: ${message}`); + } +} + +/** + * Process a single stale release PR candidate. + * + * @param options - Processing inputs. + * @param options.octokit - Authenticated Octokit client. + * @param options.candidate - Candidate from the initial open-PR list. + * @param options.staleBefore - Epoch ms; PRs updated at/after this are kept. + */ +async function processReleasePr({ + octokit, + candidate, + staleBefore, +}: { + octokit: Octokit; + candidate: ListedPullRequest; + staleBefore: number; +}): Promise { + let snapshot: PullRequestSnapshot; + try { + snapshot = await getPullRequestSnapshot(octokit, candidate.number); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + core.warning( + `Failed to refresh #${candidate.number} (${candidate.head.ref}): ${message}`, + ); + return; + } + + const eligibility = evaluateStaleEligibility({ snapshot, staleBefore }); + if (!eligibility.eligible || eligibility.ageMs === undefined) { + return; + } + + const inactiveHours = (eligibility.ageMs / (60 * 60 * 1000)).toFixed(1); + const expected = snapshot; + + let latest: PullRequestSnapshot; + try { + latest = await getPullRequestSnapshot(octokit, expected.number); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + core.warning( + `Failed final refresh for #${expected.number} (${expected.headRefName}): ${message}`, + ); + return; + } + + // Final pass uses a live cutoff so a PR that just became active is not closed. + const finalEligibility = evaluateStaleEligibility({ + snapshot: latest, + staleBefore: Date.now() - STALE_DURATION_MS, + phase: 'before close', + }); + if (!finalEligibility.eligible) { + return; + } + + if (!isUnchangedBeforeClose({ latest, expected })) { + return; + } + + // Close before commenting so a failed close does not bump updatedAt. + const closed = await closePullRequest( + octokit, + latest.number, + latest.headRefName, + ); + if (!closed) { + return; + } + + const branchResult = await deleteBranchIfUnchanged({ + octokit, + pullNumber: latest.number, + expectedHeadRef: expected.headRefName, + expectedHeadSha: expected.headRefOid, + }); + + const body = buildCloseComment({ + inactiveHours, + outcome: branchResult.outcome, + }); + + await commentOnPull(octokit, latest.number, body); +} + +/** + * Close inactive same-repo `release/*` PRs. + */ +async function main(): Promise { + // GitHub Actions provides the token via the environment for this workflow. + // eslint-disable-next-line n/no-process-env + const token = process.env.GITHUB_TOKEN; + if (!token) { + core.setFailed('GITHUB_TOKEN is required'); + return; + } + + const octokit = getOctokit(token); + const staleBefore = Date.now() - STALE_DURATION_MS; + const { owner, repo } = context.repo; + + const pullRequests = (await octokit.paginate(octokit.rest.pulls.list, { + owner, + repo, + state: 'open', + per_page: 100, + })) as ListedPullRequest[]; + + const releasePrs = pullRequests.filter((pullRequest) => + isReleasePrCandidate(pullRequest), + ); + + if (releasePrs.length === 0) { + core.info('No open release PRs to evaluate.'); + return; + } + + for (const candidate of releasePrs) { + await processReleasePr({ + octokit, + candidate, + staleBefore, + }); + } +} + +main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + core.setFailed(message); + process.exitCode = 1; +}); diff --git a/yarn.lock b/yarn.lock index 414dbc97704..011eb3403fe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,6 +5,67 @@ __metadata: version: 10 cacheKey: 10 +"@actions/core@npm:^3.0.1": + version: 3.0.1 + resolution: "@actions/core@npm:3.0.1" + dependencies: + "@actions/exec": "npm:^3.0.0" + "@actions/http-client": "npm:^4.0.0" + checksum: 10/e1295f6b81299cc5655ea571e7b3eea02889fdc479e71c783ad9ca48432c613f52a1fd01fecc973a64488b053083ea925a0d23ac7af0bcd8462afc4f4371918b + languageName: node + linkType: hard + +"@actions/exec@npm:^3.0.0": + version: 3.0.0 + resolution: "@actions/exec@npm:3.0.0" + dependencies: + "@actions/io": "npm:^3.0.2" + checksum: 10/c1904163e326cbe27f887514b4837e357d46e7a6c5eeda66c0e2efffd2772cb34d8ef0a2a48c65eb0e3b6ec72beb9b049eaba343c9f55978d3f45b09d09d2c54 + languageName: node + linkType: hard + +"@actions/github@npm:^9.1.1": + version: 9.1.1 + resolution: "@actions/github@npm:9.1.1" + dependencies: + "@actions/http-client": "npm:^3.0.2" + "@octokit/core": "npm:^7.0.6" + "@octokit/plugin-paginate-rest": "npm:^14.0.0" + "@octokit/plugin-rest-endpoint-methods": "npm:^17.0.0" + "@octokit/request": "npm:^10.0.7" + "@octokit/request-error": "npm:^7.1.0" + undici: "npm:^6.23.0" + checksum: 10/eb77846e506df107208ee6a57aa38c80ce6cdd9ab499ec3518a8e3000334def8f93fcf2b43c8b512fede9b093a1ca39d184551a9c50f37cb8fc17704d09c7e70 + languageName: node + linkType: hard + +"@actions/http-client@npm:^3.0.2": + version: 3.0.2 + resolution: "@actions/http-client@npm:3.0.2" + dependencies: + tunnel: "npm:^0.0.6" + undici: "npm:^6.23.0" + checksum: 10/36431245545cd54e2e2b25b333732801a904170a426cdcb6611423b9da70daeba2742d7258e7fb5a370e216082d3a416d04f47ea810d5e9d6cda8e6928466079 + languageName: node + linkType: hard + +"@actions/http-client@npm:^4.0.0": + version: 4.0.1 + resolution: "@actions/http-client@npm:4.0.1" + dependencies: + tunnel: "npm:^0.0.6" + undici: "npm:^6.23.0" + checksum: 10/4fab65bf488e15143db87ce200a9d1f6f81832adfb1cbdadc380bbe2a95c86b1f5daa0d89c029533ccea4cd2b811a84ce984dfd0d6530479b82bc9860e8be704 + languageName: node + linkType: hard + +"@actions/io@npm:^3.0.2": + version: 3.0.2 + resolution: "@actions/io@npm:3.0.2" + checksum: 10/ef17cb4ec0a2b640d5f4851446ad1c12bf4b2b1cf83741c5eecee4e8f50b3ca3ac9ae4084027dcaa1bf0c016d653dbc0e5fe20daedd39ee5fb6edb671f6e45b5 + languageName: node + linkType: hard + "@adraffy/ens-normalize@npm:1.10.1": version: 1.10.1 resolution: "@adraffy/ens-normalize@npm:1.10.1" @@ -6599,6 +6660,8 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/core-monorepo@workspace:." dependencies: + "@actions/core": "npm:^3.0.1" + "@actions/github": "npm:^9.1.1" "@lavamoat/allow-scripts": "npm:^3.0.4" "@lavamoat/preinstall-always-fail": "npm:^2.1.0" "@metamask/create-release-branch": "npm:^4.2.1" @@ -9929,6 +9992,13 @@ __metadata: languageName: node linkType: hard +"@octokit/auth-token@npm:^6.0.0": + version: 6.0.0 + resolution: "@octokit/auth-token@npm:6.0.0" + checksum: 10/a30f5c4c984964b57193de5b6f67169f74e4779fedbe716157dd3558dd9de3ca5c105cae521b7bd8ce1ae180773a2ef01afe2306ad5a329f4fd291eba2b7c7d1 + languageName: node + linkType: hard + "@octokit/core@npm:^5.0.2": version: 5.2.2 resolution: "@octokit/core@npm:5.2.2" @@ -9944,6 +10014,31 @@ __metadata: languageName: node linkType: hard +"@octokit/core@npm:^7.0.6": + version: 7.0.6 + resolution: "@octokit/core@npm:7.0.6" + dependencies: + "@octokit/auth-token": "npm:^6.0.0" + "@octokit/graphql": "npm:^9.0.3" + "@octokit/request": "npm:^10.0.6" + "@octokit/request-error": "npm:^7.0.2" + "@octokit/types": "npm:^16.0.0" + before-after-hook: "npm:^4.0.0" + universal-user-agent: "npm:^7.0.0" + checksum: 10/852d41fc3150d2a891156427dd0575c77889f1c7a109894ee541594e3fd47c0d4e0a93fee22966c507dfd6158b522e42846c2ac46b9d896078194c95fa81f4ae + languageName: node + linkType: hard + +"@octokit/endpoint@npm:^11.0.3": + version: 11.0.3 + resolution: "@octokit/endpoint@npm:11.0.3" + dependencies: + "@octokit/types": "npm:^16.0.0" + universal-user-agent: "npm:^7.0.2" + checksum: 10/21b67d76fb1ea28bd87ca467c12dfab648af55522b936760316d70f8ccdd638f170d636ee72606857f0cd8f343f40c8a4e8f55993d6b1f5b9ecf102e072044c5 + languageName: node + linkType: hard + "@octokit/endpoint@npm:^9.0.6": version: 9.0.6 resolution: "@octokit/endpoint@npm:9.0.6" @@ -9965,6 +10060,17 @@ __metadata: languageName: node linkType: hard +"@octokit/graphql@npm:^9.0.3": + version: 9.0.3 + resolution: "@octokit/graphql@npm:9.0.3" + dependencies: + "@octokit/request": "npm:^10.0.6" + "@octokit/types": "npm:^16.0.0" + universal-user-agent: "npm:^7.0.0" + checksum: 10/7b16f281f8571dce55280b3986fbb8d15465a7236164a5f6497ded7597ff9ee95d5796924555b979903fe8c6706fe6be1b3e140d807297f85ac8edeadc28f9fe + languageName: node + linkType: hard + "@octokit/openapi-types@npm:^24.2.0": version: 24.2.0 resolution: "@octokit/openapi-types@npm:24.2.0" @@ -9972,6 +10078,13 @@ __metadata: languageName: node linkType: hard +"@octokit/openapi-types@npm:^27.0.0": + version: 27.0.0 + resolution: "@octokit/openapi-types@npm:27.0.0" + checksum: 10/5cd2cdf4e41fdf522e15e3d53f3ece8380d98dda9173a6fc905828fb2c33e8733d5f5d2a757ae3a572525f4749748e66cb40e7939372132988d8eb4ba978d54f + languageName: node + linkType: hard + "@octokit/plugin-paginate-rest@npm:11.4.4-cjs.2": version: 11.4.4-cjs.2 resolution: "@octokit/plugin-paginate-rest@npm:11.4.4-cjs.2" @@ -9983,6 +10096,17 @@ __metadata: languageName: node linkType: hard +"@octokit/plugin-paginate-rest@npm:^14.0.0": + version: 14.0.0 + resolution: "@octokit/plugin-paginate-rest@npm:14.0.0" + dependencies: + "@octokit/types": "npm:^16.0.0" + peerDependencies: + "@octokit/core": ">=6" + checksum: 10/57ddd857528dad9c02431bc6254c2374c06057872cf9656a4a88b162ebe1c2bc9f34fbec360f2ccff72c940f29b120758ce14e8135bd027223d381eb1b8b6579 + languageName: node + linkType: hard + "@octokit/plugin-request-log@npm:^4.0.0": version: 4.0.1 resolution: "@octokit/plugin-request-log@npm:4.0.1" @@ -10003,6 +10127,17 @@ __metadata: languageName: node linkType: hard +"@octokit/plugin-rest-endpoint-methods@npm:^17.0.0": + version: 17.0.0 + resolution: "@octokit/plugin-rest-endpoint-methods@npm:17.0.0" + dependencies: + "@octokit/types": "npm:^16.0.0" + peerDependencies: + "@octokit/core": ">=6" + checksum: 10/e9d9ad4d9755cc7fb82fdcbfa870ddea8a432180f0f76c8469095557fd1e26f8caea8cae58401209be17c4f3d8cc48c0e16a3643e37e48f4d23c39e058bf2c55 + languageName: node + linkType: hard + "@octokit/request-error@npm:^5.1.1": version: 5.1.1 resolution: "@octokit/request-error@npm:5.1.1" @@ -10014,6 +10149,29 @@ __metadata: languageName: node linkType: hard +"@octokit/request-error@npm:^7.0.2, @octokit/request-error@npm:^7.1.0": + version: 7.1.0 + resolution: "@octokit/request-error@npm:7.1.0" + dependencies: + "@octokit/types": "npm:^16.0.0" + checksum: 10/c1d447ff7482382c69f7a4b2eaa44c672906dd111d8a9196a5d07f2adc4ae0f0e12ec4ce0063f14f9b2fb5f0cef4451c95ec961a7a711bd900e5d6441d546570 + languageName: node + linkType: hard + +"@octokit/request@npm:^10.0.6, @octokit/request@npm:^10.0.7": + version: 10.0.11 + resolution: "@octokit/request@npm:10.0.11" + dependencies: + "@octokit/endpoint": "npm:^11.0.3" + "@octokit/request-error": "npm:^7.0.2" + "@octokit/types": "npm:^16.0.0" + content-type: "npm:^2.0.0" + json-with-bigint: "npm:^3.5.3" + universal-user-agent: "npm:^7.0.2" + checksum: 10/036640f49e4ab63470130dccafb828822a18cf9ce060b8170e56ba9ce7173029db92b3c37ad7474acf2c097180d5127754b69d3ffbe60162489aed4324b3cb8f + languageName: node + linkType: hard + "@octokit/request@npm:^8.4.1": version: 8.4.1 resolution: "@octokit/request@npm:8.4.1" @@ -10047,6 +10205,15 @@ __metadata: languageName: node linkType: hard +"@octokit/types@npm:^16.0.0": + version: 16.0.0 + resolution: "@octokit/types@npm:16.0.0" + dependencies: + "@octokit/openapi-types": "npm:^27.0.0" + checksum: 10/03d5cfc29556a9b53eae8beb1bf15c0b704dc722db2c51b53f093f3c3ee6c1d8e20b682be8117a3a17034b458be7746d1b22aaefb959ceb5152ad7589b39e2c9 + languageName: node + linkType: hard + "@open-rpc/meta-schema@npm:^1.14.6, @open-rpc/meta-schema@npm:^1.14.9": version: 1.14.9 resolution: "@open-rpc/meta-schema@npm:1.14.9" @@ -13987,6 +14154,13 @@ __metadata: languageName: node linkType: hard +"before-after-hook@npm:^4.0.0": + version: 4.0.0 + resolution: "before-after-hook@npm:4.0.0" + checksum: 10/9fd52bc0c3cca0fb115e04dacbeeaacff38fa23e1af725d62392254c31ef433b15da60efcba61552e44d64e26f25ea259f72dba005115924389e88d2fd56e19f + languageName: node + linkType: hard + "better-sqlite3@npm:^12.9.0": version: 12.10.0 resolution: "better-sqlite3@npm:12.10.0" @@ -15142,6 +15316,13 @@ __metadata: languageName: node linkType: hard +"content-type@npm:^2.0.0": + version: 2.0.0 + resolution: "content-type@npm:2.0.0" + checksum: 10/0bbb276b790ba7e86c479c7d69fae1861b2e908ff3ce2cb01975b516f93eede2216d242902ed6c5b15cd554014611ce9dfdcf51cd35b16569e45a979e50d0cef + languageName: node + linkType: hard + "content-type@npm:~1.0.4, content-type@npm:~1.0.5": version: 1.0.5 resolution: "content-type@npm:1.0.5" @@ -20122,6 +20303,13 @@ __metadata: languageName: node linkType: hard +"json-with-bigint@npm:^3.5.3": + version: 3.5.10 + resolution: "json-with-bigint@npm:3.5.10" + checksum: 10/d9d5b41f30e7ac648296947162cb18bdc7030e46a264231e19561a93e8ee6fb127efe447c86ef6d571106ac32e4656ddeb10d8220a88b2a5edf5b7f263465003 + languageName: node + linkType: hard + "json5@npm:^2.1.2, json5@npm:^2.2.3": version: 2.2.3 resolution: "json5@npm:2.2.3" @@ -26542,6 +26730,13 @@ __metadata: languageName: node linkType: hard +"tunnel@npm:^0.0.6": + version: 0.0.6 + resolution: "tunnel@npm:0.0.6" + checksum: 10/cf1ffed5e67159b901a924dbf94c989f20b2b3b65649cfbbe4b6abb35955ce2cf7433b23498bdb2c5530ab185b82190fce531597b3b4a649f06a907fc8702405 + languageName: node + linkType: hard + "tweetnacl@npm:^1.0.3": version: 1.0.3 resolution: "tweetnacl@npm:1.0.3" @@ -26739,6 +26934,13 @@ __metadata: languageName: node linkType: hard +"undici@npm:^6.23.0": + version: 6.28.0 + resolution: "undici@npm:6.28.0" + checksum: 10/672a7a53bd1f6c80edb73b357ab36e98ef9e474024c442aab2b8ea2bc32f1103cab05525c78661ae724f3e1340e23d03efb9730fba28e76218ab0ec52e15d75a + languageName: node + linkType: hard + "undici@npm:^7.19.0": version: 7.26.0 resolution: "undici@npm:7.26.0" @@ -26908,6 +27110,13 @@ __metadata: languageName: node linkType: hard +"universal-user-agent@npm:^7.0.0, universal-user-agent@npm:^7.0.2": + version: 7.0.3 + resolution: "universal-user-agent@npm:7.0.3" + checksum: 10/c497e85f8b11eb8fa4dce584d7a39cc98710164959f494cafc3c269b51abb20fff269951838efd7424d15f6b3d001507f3cb8b52bb5676fdb642019dfd17e63e + languageName: node + linkType: hard + "universalify@npm:^2.0.0": version: 2.0.1 resolution: "universalify@npm:2.0.1" From a2ccde03786657dbc0dae8b13995850f2fb06a41 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Tue, 28 Jul 2026 10:54:57 +0100 Subject: [PATCH 12/19] fix: make stale release closer an ESM .mts module Rename to .mts so TypeScript treats the file as ESM and can import the ESM-only @actions packages, unblocking lint:tsc. --- .github/workflows/close-stale-release-prs.yml | 2 +- ...close-stale-release-prs.ts => close-stale-release-prs.mts} | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) rename scripts/{close-stale-release-prs.ts => close-stale-release-prs.mts} (99%) diff --git a/.github/workflows/close-stale-release-prs.yml b/.github/workflows/close-stale-release-prs.yml index 9f6520418e7..024e0757a6b 100644 --- a/.github/workflows/close-stale-release-prs.yml +++ b/.github/workflows/close-stale-release-prs.yml @@ -33,4 +33,4 @@ jobs: - name: Close inactive release PRs env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: yarn tsx scripts/close-stale-release-prs.ts + run: yarn tsx scripts/close-stale-release-prs.mts diff --git a/scripts/close-stale-release-prs.ts b/scripts/close-stale-release-prs.mts similarity index 99% rename from scripts/close-stale-release-prs.ts rename to scripts/close-stale-release-prs.mts index 53d6142140b..9ce4a214c5a 100755 --- a/scripts/close-stale-release-prs.ts +++ b/scripts/close-stale-release-prs.mts @@ -1,11 +1,9 @@ -#!/usr/bin/env yarn tsx - /** * Close inactive same-repo `release/*` PRs, comment with the outcome, and * delete the branch when the tip is unchanged. * * Usage (from GitHub Actions): - * GITHUB_TOKEN=... yarn tsx scripts/close-stale-release-prs.ts + * GITHUB_TOKEN=... yarn tsx scripts/close-stale-release-prs.mts */ import * as core from '@actions/core'; From 364d420f967fec755c28e2e9975046e09b382ed4 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Tue, 28 Jul 2026 10:58:33 +0100 Subject: [PATCH 13/19] fix: clarify tip-move skip vs delete failure in close comment Rename commentOnPull to commentOnPullRequest, and stop describing intentional tip-move skips as failed branch deletions. --- scripts/close-stale-release-prs.mts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/scripts/close-stale-release-prs.mts b/scripts/close-stale-release-prs.mts index 9ce4a214c5a..a7d18cab301 100755 --- a/scripts/close-stale-release-prs.mts +++ b/scripts/close-stale-release-prs.mts @@ -388,8 +388,13 @@ function buildCloseComment({ ]; // GitHub already surfaces successful branch deletion on the closed PR. - // Only call out failures / intentional skips. - if (outcome !== 'deleted') { + // Call out real failures and intentional tip move skips separately. + if (outcome === 'kept-head-moved') { + lines.push( + '', + `> (Branch was left in place because its tip changed after close. See more details here: ${getWorkflowRunUrl()})`, + ); + } else if (outcome !== 'deleted') { lines.push( '', `> (A failed attempt was made to delete this branch. See more details here: ${getWorkflowRunUrl()})`, @@ -407,7 +412,7 @@ function buildCloseComment({ * @param pullNumber - Pull request number. * @param body - Markdown comment body. */ -async function commentOnPull( +async function commentOnPullRequest( octokit: Octokit, pullNumber: number, body: string, @@ -509,7 +514,7 @@ async function processReleasePr({ outcome: branchResult.outcome, }); - await commentOnPull(octokit, latest.number, body); + await commentOnPullRequest(octokit, latest.number, body); } /** From f2e984d8d1e3a6b8cf0b25556b3153d3969aec62 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Tue, 28 Jul 2026 13:27:37 +0100 Subject: [PATCH 14/19] fix: type listed PRs from Octokit instead of casting --- scripts/close-stale-release-prs.mts | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/scripts/close-stale-release-prs.mts b/scripts/close-stale-release-prs.mts index a7d18cab301..ba3636f4457 100755 --- a/scripts/close-stale-release-prs.mts +++ b/scripts/close-stale-release-prs.mts @@ -59,14 +59,9 @@ type PullRequestSnapshot = { headRepository: { isFork: boolean } | null; }; -type ListedPullRequest = { - number: number; - head: { - ref: string; - repo: { fork: boolean } | null; - }; - labels: { name: string }[]; -}; +type ListedPullRequest = Awaited< + ReturnType +>['data'][number]; type BranchDeleteOutcome = { outcome: @@ -533,12 +528,15 @@ async function main(): Promise { const staleBefore = Date.now() - STALE_DURATION_MS; const { owner, repo } = context.repo; - const pullRequests = (await octokit.paginate(octokit.rest.pulls.list, { - owner, - repo, - state: 'open', - per_page: 100, - })) as ListedPullRequest[]; + const pullRequests: ListedPullRequest[] = await octokit.paginate( + octokit.rest.pulls.list, + { + owner, + repo, + state: 'open', + per_page: 100, + }, + ); const releasePrs = pullRequests.filter((pullRequest) => isReleasePrCandidate(pullRequest), From 12e7ee426841258ec20a25653df6dcb914daf503 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Tue, 28 Jul 2026 13:47:08 +0100 Subject: [PATCH 15/19] refactor: drop double-snapshot race dance before stale close One GraphQL eligibility check plus tip verification before delete is enough for this cron job. --- scripts/close-stale-release-prs.mts | 107 ++++------------------------ 1 file changed, 13 insertions(+), 94 deletions(-) diff --git a/scripts/close-stale-release-prs.mts b/scripts/close-stale-release-prs.mts index ba3636f4457..27f10d529c8 100755 --- a/scripts/close-stale-release-prs.mts +++ b/scripts/close-stale-release-prs.mts @@ -72,16 +72,6 @@ type BranchDeleteOutcome = { detail: string; }; -/** - * Stable fingerprint of a PR's label set for equality checks. - * - * @param labels - Label names. - * @returns Sorted, joined label names. - */ -function labelKey(labels: string[]): string { - return [...labels].sort().join('\0'); -} - /** * Label names from a GraphQL PR snapshot. * @@ -159,101 +149,56 @@ function isMergeInProgress(snapshot: PullRequestSnapshot): boolean { /** * Evaluate whether a PR snapshot is eligible to close as stale. * - * Skip-label is re-checked on refreshed snapshots so a label added after the - * initial list filter still prevents auto-close. + * Skip-label is re-checked so a label added after the initial list filter still + * prevents auto-close. * * @param options - Evaluation inputs. * @param options.snapshot - Fresh GraphQL pull request snapshot. * @param options.staleBefore - Epoch ms; PRs updated at/after this are kept. - * @param options.phase - Optional log suffix describing the check phase. * @returns Eligibility result. */ function evaluateStaleEligibility({ snapshot, staleBefore, - phase = '', }: { snapshot: PullRequestSnapshot; staleBefore: number; - phase?: string; }): { eligible: boolean; ageMs?: number } { - const suffix = phase ? ` ${phase}` : ''; const ref = snapshot.headRefName; if (snapshot.state !== 'OPEN') { - core.info(`Skipping #${snapshot.number} (${ref}): no longer open${suffix}`); + core.info(`Skipping #${snapshot.number} (${ref}): no longer open`); return { eligible: false }; } if (snapshotLabelNames(snapshot).includes(SKIP_LABEL)) { core.info( - `Skipping #${snapshot.number} (${ref}): skip label "${SKIP_LABEL}"${suffix}`, + `Skipping #${snapshot.number} (${ref}): skip label "${SKIP_LABEL}"`, ); return { eligible: false }; } if (snapshot.headRepository?.isFork) { - core.info(`Skipping #${snapshot.number} (${ref}): fork head${suffix}`); + core.info(`Skipping #${snapshot.number} (${ref}): fork head`); return { eligible: false }; } const updatedAtMs = Date.parse(snapshot.updatedAt); if (updatedAtMs >= staleBefore) { core.info( - `Skipping #${snapshot.number} (${ref}): updated ${Math.round((Date.now() - updatedAtMs) / 60000)}m ago${suffix}`, + `Skipping #${snapshot.number} (${ref}): updated ${Math.round((Date.now() - updatedAtMs) / 60000)}m ago`, ); return { eligible: false }; } if (isMergeInProgress(snapshot)) { - core.info( - `Skipping #${snapshot.number} (${ref}): merge in progress${suffix}`, - ); + core.info(`Skipping #${snapshot.number} (${ref}): merge in progress`); return { eligible: false }; } return { eligible: true, ageMs: Date.now() - updatedAtMs }; } -/** - * Confirm the PR snapshot has not changed since the pre-close checks. - * - * @param options - Comparison inputs. - * @param options.latest - Most recent GraphQL snapshot. - * @param options.expected - Snapshot captured before the final refresh. - * @returns True when the snapshot is unchanged. - */ -function isUnchangedBeforeClose({ - latest, - expected, -}: { - latest: PullRequestSnapshot; - expected: PullRequestSnapshot; -}): boolean { - if ( - labelKey(snapshotLabelNames(latest)) !== - labelKey(snapshotLabelNames(expected)) - ) { - core.info( - `Skipping #${latest.number} (${latest.headRefName}): labels changed before close`, - ); - return false; - } - - if ( - latest.updatedAt !== expected.updatedAt || - latest.headRefOid !== expected.headRefOid || - latest.headRefName !== expected.headRefName - ) { - core.info( - `Skipping #${latest.number} (${latest.headRefName}): activity or head changed before close`, - ); - return false; - } - - return true; -} - /** * Close the pull request. * @@ -460,38 +405,12 @@ async function processReleasePr({ } const inactiveHours = (eligibility.ageMs / (60 * 60 * 1000)).toFixed(1); - const expected = snapshot; - - let latest: PullRequestSnapshot; - try { - latest = await getPullRequestSnapshot(octokit, expected.number); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - core.warning( - `Failed final refresh for #${expected.number} (${expected.headRefName}): ${message}`, - ); - return; - } - - // Final pass uses a live cutoff so a PR that just became active is not closed. - const finalEligibility = evaluateStaleEligibility({ - snapshot: latest, - staleBefore: Date.now() - STALE_DURATION_MS, - phase: 'before close', - }); - if (!finalEligibility.eligible) { - return; - } - - if (!isUnchangedBeforeClose({ latest, expected })) { - return; - } // Close before commenting so a failed close does not bump updatedAt. const closed = await closePullRequest( octokit, - latest.number, - latest.headRefName, + snapshot.number, + snapshot.headRefName, ); if (!closed) { return; @@ -499,9 +418,9 @@ async function processReleasePr({ const branchResult = await deleteBranchIfUnchanged({ octokit, - pullNumber: latest.number, - expectedHeadRef: expected.headRefName, - expectedHeadSha: expected.headRefOid, + pullNumber: snapshot.number, + expectedHeadRef: snapshot.headRefName, + expectedHeadSha: snapshot.headRefOid, }); const body = buildCloseComment({ @@ -509,7 +428,7 @@ async function processReleasePr({ outcome: branchResult.outcome, }); - await commentOnPullRequest(octokit, latest.number, body); + await commentOnPullRequest(octokit, snapshot.number, body); } /** From 3f8f445cc6932fbfa749dbed483082efdf6771dc Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Tue, 28 Jul 2026 15:36:57 +0100 Subject: [PATCH 16/19] refactor: apply utils helpers and small closer review nits --- scripts/close-stale-release-prs.mts | 101 +++++++++++++++------------- 1 file changed, 53 insertions(+), 48 deletions(-) diff --git a/scripts/close-stale-release-prs.mts b/scripts/close-stale-release-prs.mts index 27f10d529c8..2cbaf029326 100755 --- a/scripts/close-stale-release-prs.mts +++ b/scripts/close-stale-release-prs.mts @@ -8,16 +8,22 @@ import * as core from '@actions/core'; import { context, getOctokit } from '@actions/github'; +import { Duration, getErrorMessage, inMilliseconds } from '@metamask/utils'; /** * The label users can use to prevent stale release PRs from being auto-closed. */ const SKIP_LABEL = 'release:keep-open'; +/** + * How long inactive release PRs stay open before auto-close. + */ +const STALE_DURATION_HOURS = 3; + /** * How long inactive release PRs stay open before auto-close (milliseconds). */ -const STALE_DURATION_MS = 3 * 60 * 60 * 1000; +const STALE_DURATION_MS = inMilliseconds(STALE_DURATION_HOURS, Duration.Hour); const PULL_REQUEST_SNAPSHOT_QUERY = ` query ($owner: String!, $repo: String!, $number: Int!) { @@ -72,14 +78,16 @@ type BranchDeleteOutcome = { detail: string; }; +type StaleEligibility = { eligible: true; ageMs: number } | { eligible: false }; + /** * Label names from a GraphQL PR snapshot. * - * @param snapshot - GraphQL pull request snapshot. + * @param pullRequest - GraphQL pull request snapshot. * @returns Label name list. */ -function snapshotLabelNames(snapshot: PullRequestSnapshot): string[] { - return snapshot.labels.nodes.map((label) => label.name); +function snapshotLabelNames(pullRequest: PullRequestSnapshot): string[] { + return pullRequest.labels.nodes.map((label) => label.name); } /** @@ -139,11 +147,11 @@ function isReleasePrCandidate(pullRequest: ListedPullRequest): boolean { /** * Whether merge-queue or auto-merge is active for the PR. * - * @param snapshot - GraphQL pull request snapshot. + * @param pullRequest - GraphQL pull request snapshot. * @returns True when a merge is already in progress. */ -function isMergeInProgress(snapshot: PullRequestSnapshot): boolean { - return Boolean(snapshot.isInMergeQueue || snapshot.autoMergeRequest); +function isMergeInProgress(pullRequest: PullRequestSnapshot): boolean { + return Boolean(pullRequest.isInMergeQueue || pullRequest.autoMergeRequest); } /** @@ -153,46 +161,46 @@ function isMergeInProgress(snapshot: PullRequestSnapshot): boolean { * prevents auto-close. * * @param options - Evaluation inputs. - * @param options.snapshot - Fresh GraphQL pull request snapshot. + * @param options.pullRequest - Fresh GraphQL pull request snapshot. * @param options.staleBefore - Epoch ms; PRs updated at/after this are kept. * @returns Eligibility result. */ function evaluateStaleEligibility({ - snapshot, + pullRequest, staleBefore, }: { - snapshot: PullRequestSnapshot; + pullRequest: PullRequestSnapshot; staleBefore: number; -}): { eligible: boolean; ageMs?: number } { - const ref = snapshot.headRefName; +}): StaleEligibility { + const ref = pullRequest.headRefName; - if (snapshot.state !== 'OPEN') { - core.info(`Skipping #${snapshot.number} (${ref}): no longer open`); + if (pullRequest.state !== 'OPEN') { + core.info(`Skipping #${pullRequest.number} (${ref}): no longer open`); return { eligible: false }; } - if (snapshotLabelNames(snapshot).includes(SKIP_LABEL)) { + if (snapshotLabelNames(pullRequest).includes(SKIP_LABEL)) { core.info( - `Skipping #${snapshot.number} (${ref}): skip label "${SKIP_LABEL}"`, + `Skipping #${pullRequest.number} (${ref}): skip label "${SKIP_LABEL}"`, ); return { eligible: false }; } - if (snapshot.headRepository?.isFork) { - core.info(`Skipping #${snapshot.number} (${ref}): fork head`); + if (pullRequest.headRepository?.isFork) { + core.info(`Skipping #${pullRequest.number} (${ref}): fork head`); return { eligible: false }; } - const updatedAtMs = Date.parse(snapshot.updatedAt); + const updatedAtMs = Date.parse(pullRequest.updatedAt); if (updatedAtMs >= staleBefore) { core.info( - `Skipping #${snapshot.number} (${ref}): updated ${Math.round((Date.now() - updatedAtMs) / 60000)}m ago`, + `Skipping #${pullRequest.number} (${ref}): updated ${Math.round((Date.now() - updatedAtMs) / Duration.Minute)}m ago`, ); return { eligible: false }; } - if (isMergeInProgress(snapshot)) { - core.info(`Skipping #${snapshot.number} (${ref}): merge in progress`); + if (isMergeInProgress(pullRequest)) { + core.info(`Skipping #${pullRequest.number} (${ref}): merge in progress`); return { eligible: false }; } @@ -222,8 +230,9 @@ async function closePullRequest( }); return true; } catch (error) { - const message = error instanceof Error ? error.message : String(error); - core.warning(`Failed to close #${pullNumber} (${headRef}): ${message}`); + core.warning( + `Failed to close #${pullNumber} (${headRef}): ${getErrorMessage(error)}`, + ); return false; } } @@ -260,7 +269,7 @@ async function deleteBranchIfUnchanged({ }); branchSha = branchRef.object.sha; } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = getErrorMessage(error); core.warning( `Closed #${pullNumber} but failed to refresh ${expectedHeadRef} before delete: ${message}`, ); @@ -284,7 +293,7 @@ async function deleteBranchIfUnchanged({ core.info(`Closed #${pullNumber} and deleted branch ${expectedHeadRef}`); return { outcome: 'deleted', detail: '' }; } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = getErrorMessage(error); core.warning( `Closed #${pullNumber} but failed to delete ${expectedHeadRef}: ${message}`, ); @@ -316,11 +325,10 @@ function buildCloseComment({ inactiveHours: string; outcome: BranchDeleteOutcome['outcome']; }): string { - const staleHours = STALE_DURATION_MS / (60 * 60 * 1000); const lines = [ '## This pull request has been closed', '', - `This release PR was automatically closed because it had no activity for ${staleHours} hours (last updated ${inactiveHours}h ago).`, + `This release PR was automatically closed because it had no activity for ${STALE_DURATION_HOURS} hours (last updated ${inactiveHours}h ago).`, '', 'Open release PRs are expected to merge promptly so they do not block others from starting a new release.', '', @@ -366,8 +374,9 @@ async function commentOnPullRequest( body, }); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - core.warning(`Closed #${pullNumber} but failed to comment: ${message}`); + core.warning( + `Closed #${pullNumber} but failed to comment: ${getErrorMessage(error)}`, + ); } } @@ -388,29 +397,28 @@ async function processReleasePr({ candidate: ListedPullRequest; staleBefore: number; }): Promise { - let snapshot: PullRequestSnapshot; + let pullRequest: PullRequestSnapshot; try { - snapshot = await getPullRequestSnapshot(octokit, candidate.number); + pullRequest = await getPullRequestSnapshot(octokit, candidate.number); } catch (error) { - const message = error instanceof Error ? error.message : String(error); core.warning( - `Failed to refresh #${candidate.number} (${candidate.head.ref}): ${message}`, + `Failed to refresh #${candidate.number} (${candidate.head.ref}): ${getErrorMessage(error)}`, ); return; } - const eligibility = evaluateStaleEligibility({ snapshot, staleBefore }); - if (!eligibility.eligible || eligibility.ageMs === undefined) { + const eligibility = evaluateStaleEligibility({ pullRequest, staleBefore }); + if (!eligibility.eligible) { return; } - const inactiveHours = (eligibility.ageMs / (60 * 60 * 1000)).toFixed(1); + const inactiveHours = (eligibility.ageMs / Duration.Hour).toFixed(1); // Close before commenting so a failed close does not bump updatedAt. const closed = await closePullRequest( octokit, - snapshot.number, - snapshot.headRefName, + pullRequest.number, + pullRequest.headRefName, ); if (!closed) { return; @@ -418,9 +426,9 @@ async function processReleasePr({ const branchResult = await deleteBranchIfUnchanged({ octokit, - pullNumber: snapshot.number, - expectedHeadRef: snapshot.headRefName, - expectedHeadSha: snapshot.headRefOid, + pullNumber: pullRequest.number, + expectedHeadRef: pullRequest.headRefName, + expectedHeadSha: pullRequest.headRefOid, }); const body = buildCloseComment({ @@ -428,7 +436,7 @@ async function processReleasePr({ outcome: branchResult.outcome, }); - await commentOnPullRequest(octokit, snapshot.number, body); + await commentOnPullRequest(octokit, pullRequest.number, body); } /** @@ -457,9 +465,7 @@ async function main(): Promise { }, ); - const releasePrs = pullRequests.filter((pullRequest) => - isReleasePrCandidate(pullRequest), - ); + const releasePrs = pullRequests.filter(isReleasePrCandidate); if (releasePrs.length === 0) { core.info('No open release PRs to evaluate.'); @@ -476,7 +482,6 @@ async function main(): Promise { } main().catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - core.setFailed(message); + core.setFailed(getErrorMessage(error)); process.exitCode = 1; }); From a9dfaae35c9033b62b5f5c07ab030cf9d3531734 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Tue, 28 Jul 2026 16:02:56 +0100 Subject: [PATCH 17/19] refactor: rename snapshotLabelNames to pullRequestLabelNames --- scripts/close-stale-release-prs.mts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/close-stale-release-prs.mts b/scripts/close-stale-release-prs.mts index 2cbaf029326..1170745d5b4 100755 --- a/scripts/close-stale-release-prs.mts +++ b/scripts/close-stale-release-prs.mts @@ -86,7 +86,7 @@ type StaleEligibility = { eligible: true; ageMs: number } | { eligible: false }; * @param pullRequest - GraphQL pull request snapshot. * @returns Label name list. */ -function snapshotLabelNames(pullRequest: PullRequestSnapshot): string[] { +function pullRequestLabelNames(pullRequest: PullRequestSnapshot): string[] { return pullRequest.labels.nodes.map((label) => label.name); } @@ -179,7 +179,7 @@ function evaluateStaleEligibility({ return { eligible: false }; } - if (snapshotLabelNames(pullRequest).includes(SKIP_LABEL)) { + if (pullRequestLabelNames(pullRequest).includes(SKIP_LABEL)) { core.info( `Skipping #${pullRequest.number} (${ref}): skip label "${SKIP_LABEL}"`, ); From 3457b779f3653cc43bf97c789b708b0b30b8accf Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Tue, 28 Jul 2026 16:06:17 +0100 Subject: [PATCH 18/19] refactor: make merge-in-progress check an explicit boolean --- scripts/close-stale-release-prs.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/close-stale-release-prs.mts b/scripts/close-stale-release-prs.mts index 1170745d5b4..3c3a04dcb66 100755 --- a/scripts/close-stale-release-prs.mts +++ b/scripts/close-stale-release-prs.mts @@ -151,7 +151,7 @@ function isReleasePrCandidate(pullRequest: ListedPullRequest): boolean { * @returns True when a merge is already in progress. */ function isMergeInProgress(pullRequest: PullRequestSnapshot): boolean { - return Boolean(pullRequest.isInMergeQueue || pullRequest.autoMergeRequest); + return pullRequest.isInMergeQueue || pullRequest.autoMergeRequest !== null; } /** From 7a05448336a79d766e6dd36e871a743ba1bdeec8 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Tue, 28 Jul 2026 16:44:44 +0100 Subject: [PATCH 19/19] refactor: include stale threshold in not-yet-stale skip log --- scripts/close-stale-release-prs.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/close-stale-release-prs.mts b/scripts/close-stale-release-prs.mts index 3c3a04dcb66..97f1bf42dd8 100755 --- a/scripts/close-stale-release-prs.mts +++ b/scripts/close-stale-release-prs.mts @@ -194,7 +194,7 @@ function evaluateStaleEligibility({ const updatedAtMs = Date.parse(pullRequest.updatedAt); if (updatedAtMs >= staleBefore) { core.info( - `Skipping #${pullRequest.number} (${ref}): updated ${Math.round((Date.now() - updatedAtMs) / Duration.Minute)}m ago`, + `Skipping #${pullRequest.number} (${ref}): has not reached stale age of ${STALE_DURATION_HOURS}h yet (updated ${Math.round((Date.now() - updatedAtMs) / Duration.Minute)}m ago)`, ); return { eligible: false }; }