diff --git a/.github/actions/is-release-pr/action.yml b/.github/actions/is-release-pr/action.yml new file mode 100644 index 00000000000..b9a19445324 --- /dev/null +++ b/.github/actions/is-release-pr/action.yml @@ -0,0 +1,79 @@ +name: Is release PR +description: > + Determine whether a pull request is a release PR using the same check as + update-changelogs (checkout head, merge-base, MetaMask/action-is-release). + +inputs: + pr-number: + description: Pull request number to evaluate. + required: true + commit-starts-with: + description: > + Comma-separated commit/PR title prefixes. Use `[version]` for the new root + package version (same as vars.RELEASE_COMMIT_PREFIX). + required: true + token: + description: GitHub token for gh/API access. + required: false + default: ${{ github.token }} + +outputs: + IS_RELEASE: + description: '"true" when the PR is a release PR, otherwise "false".' + value: ${{ steps.is-release.outputs.IS_RELEASE }} + head-sha: + description: Head commit SHA of the pull request. + value: ${{ steps.pr-info.outputs.pr-head-sha }} + head-ref: + description: Head branch name of the pull request. + value: ${{ steps.pr-info.outputs.pr-head-ref }} + base-ref: + description: Base branch name of the pull request. + value: ${{ steps.pr-info.outputs.pr-base-ref }} + merge-base: + description: Merge-base SHA between the PR head and base branch. + value: ${{ steps.merge-base.outputs.merge-base }} + +runs: + using: composite + steps: + - name: Get pull request info + id: pr-info + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + PR_NUMBER: ${{ inputs.pr-number }} + run: | + set -euo pipefail + gh pr view "$PR_NUMBER" \ + --repo "$GITHUB_REPOSITORY" \ + --json baseRefName,headRefOid,headRefName,title \ + --jq '"pr-base-ref=\(.baseRefName)\npr-head-sha=\(.headRefOid)\npr-head-ref=\(.headRefName)\npr-title=\(.title)"' \ + >> "$GITHUB_OUTPUT" + + - name: Checkout repository + uses: actions/checkout@v7 + with: + token: ${{ inputs.token }} + fetch-depth: 0 + persist-credentials: false + ref: ${{ steps.pr-info.outputs.pr-head-sha }} + + - name: Get merge base + id: merge-base + shell: bash + env: + BASE_REF: ${{ steps.pr-info.outputs.pr-base-ref }} + run: | + set -euo pipefail + MERGE_BASE=$(git merge-base HEAD "refs/remotes/origin/$BASE_REF") + echo "merge-base=$MERGE_BASE" >> "$GITHUB_OUTPUT" + + - name: Check if the pull request is a release + id: is-release + uses: MetaMask/action-is-release@v2 + with: + commit-starts-with: ${{ inputs.commit-starts-with }} + commit-message: ${{ steps.pr-info.outputs.pr-title }} + before: ${{ steps.merge-base.outputs.merge-base }} + skip-checkout: true diff --git a/.github/workflows/close-stale-release-prs.yml b/.github/workflows/close-stale-release-prs.yml index 024e0757a6b..aef3bbd43fb 100644 --- a/.github/workflows/close-stale-release-prs.yml +++ b/.github/workflows/close-stale-release-prs.yml @@ -1,8 +1,12 @@ 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. +# Release PRs 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. +# +# Release PR detection is shared with update-changelogs via +# `.github/actions/is-release-pr` (root version bump + RELEASE_COMMIT_PREFIX). +# `release/*` is only a cheap prefilter before that shared check. on: schedule: # Check twice an hour so the 3h window is reasonably precise. @@ -17,8 +21,82 @@ concurrency: cancel-in-progress: false jobs: + find-candidates: + name: Find release/* PR candidates + runs-on: ubuntu-latest + permissions: + pull-requests: read + outputs: + prs: ${{ steps.list.outputs.prs }} + steps: + - name: List same-repo open release/* PRs + id: list + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + # Paginate all open PRs; --limit 100 would miss older release/* heads + # once the repo has many open PRs. + PRS="$( + gh api --paginate \ + "/repos/${GITHUB_REPOSITORY}/pulls?state=open&per_page=100" \ + --jq '.[] | select(.head.repo != null and .head.repo.fork == false and (.head.ref | startswith("release/"))) | .number' \ + | jq --compact-output --slurp '.' + )" + echo "prs=$PRS" >> "$GITHUB_OUTPUT" + echo "Candidate PR numbers: $PRS" + + filter-release-prs: + name: Confirm release PR #${{ matrix.pr }} + needs: find-candidates + if: needs.find-candidates.outputs.prs != '[]' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + strategy: + fail-fast: false + matrix: + pr: ${{ fromJson(needs.find-candidates.outputs.prs) }} + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Check if the pull request is a release + id: is-release + uses: ./.github/actions/is-release-pr + with: + pr-number: ${{ matrix.pr }} + commit-starts-with: ${{ vars.RELEASE_COMMIT_PREFIX }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Record confirmed release PR + if: steps.is-release.outputs.IS_RELEASE == 'true' + env: + # Pass via env so the value is not expanded into the script text + # (zizmor template-injection). PR numbers come from open same-repo + # release/* PRs; write access is enough to create those. + PR_NUMBER: ${{ matrix.pr }} + run: | + set -euo pipefail + # Unique filename per matrix leg so merge-multiple keeps every PR. + echo "$PR_NUMBER" > "release-pr-${PR_NUMBER}.txt" + + - name: Upload confirmed release PR artifact + if: steps.is-release.outputs.IS_RELEASE == 'true' + uses: actions/upload-artifact@v4 + with: + name: release-pr-${{ matrix.pr }} + path: release-pr-${{ matrix.pr }}.txt + if-no-files-found: ignore + close-stale-release-prs: name: Close stale release PRs + needs: [find-candidates, filter-release-prs] + # Still close any PRs that were confirmed even if some matrix legs failed. + if: always() && needs.find-candidates.result == 'success' && needs.find-candidates.outputs.prs != '[]' && needs.filter-release-prs.result != 'cancelled' runs-on: ubuntu-latest permissions: contents: write @@ -30,7 +108,34 @@ jobs: is-high-risk-environment: false persist-credentials: false cache-node-modules: true + + - name: Download confirmed release PR numbers + uses: actions/download-artifact@v4 + continue-on-error: true + with: + pattern: release-pr-* + merge-multiple: true + path: release-pr-numbers + + - name: Build RELEASE_PR_NUMBERS + id: numbers + run: | + set -euo pipefail + if [ -d release-pr-numbers ]; then + NUMBERS="$(find release-pr-numbers -type f -print0 | xargs -0 cat | sort -n -u | paste -sd, -)" + else + NUMBERS='' + fi + echo "numbers=$NUMBERS" >> "$GITHUB_OUTPUT" + echo "Confirmed release PR numbers: ${NUMBERS:-}" + - name: Close inactive release PRs + if: steps.numbers.outputs.numbers != '' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_PR_NUMBERS: ${{ steps.numbers.outputs.numbers }} run: yarn tsx scripts/close-stale-release-prs.mts + + - name: No confirmed release PRs + if: steps.numbers.outputs.numbers == '' + run: echo 'No confirmed release PRs to evaluate.' diff --git a/.github/workflows/update-changelogs.yml b/.github/workflows/update-changelogs.yml index 2081554fafc..8b2c98db01a 100644 --- a/.github/workflows/update-changelogs.yml +++ b/.github/workflows/update-changelogs.yml @@ -44,50 +44,23 @@ jobs: pull-requests: read outputs: is-release: ${{ steps.is-release.outputs.IS_RELEASE }} - head-sha: ${{ steps.pr-info.outputs.pr-head-sha }} - head-ref: ${{ steps.pr-info.outputs.pr-head-ref }} - base-ref: ${{ steps.pr-info.outputs.pr-base-ref }} - merge-base: ${{ steps.merge-base.outputs.merge-base }} + head-sha: ${{ steps.is-release.outputs.head-sha }} + head-ref: ${{ steps.is-release.outputs.head-ref }} + base-ref: ${{ steps.is-release.outputs.base-ref }} + merge-base: ${{ steps.is-release.outputs.merge-base }} steps: - - name: Get pull request info - id: pr-info - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }} - run: | - gh pr view "$PR_NUMBER" \ - --repo "$GITHUB_REPOSITORY" \ - --json baseRefName,headRefOid,headRefName,title \ - --jq '"pr-base-ref=\(.baseRefName)\npr-head-sha=\(.headRefOid)\npr-head-ref=\(.headRefName)\npr-title=\(.title)"' \ - >> "$GITHUB_OUTPUT" - - name: Checkout repository uses: actions/checkout@v7 with: - token: ${{ secrets.GITHUB_TOKEN }} - fetch-depth: 0 persist-credentials: false - ref: ${{ steps.pr-info.outputs.pr-head-sha }} - - - name: Get merge base - id: merge-base - shell: bash - env: - BASE_REF: ${{ steps.pr-info.outputs.pr-base-ref }} - run: | - set -euo pipefail - - MERGE_BASE=$(git merge-base HEAD "refs/remotes/origin/$BASE_REF") - echo "merge-base=$MERGE_BASE" >> "$GITHUB_OUTPUT" - name: Check if the pull request is a release id: is-release - uses: MetaMask/action-is-release@v2 + uses: ./.github/actions/is-release-pr with: + pr-number: ${{ github.event.issue.number || github.event.pull_request.number }} commit-starts-with: ${{ vars.RELEASE_COMMIT_PREFIX }} - commit-message: ${{ steps.pr-info.outputs.pr-title }} - before: ${{ steps.merge-base.outputs.merge-base }} - skip-checkout: true + token: ${{ secrets.GITHUB_TOKEN }} react-to-comment: name: React to the comment diff --git a/docs/processes/releasing.md b/docs/processes/releasing.md index 0009ec21569..dbee929fd1f 100644 --- a/docs/processes/releasing.md +++ b/docs/processes/releasing.md @@ -4,7 +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. +- Release PRs that sit inactive for **3 hours** are automatically closed, with the branch deleted, so abandoned releases do not block others. Detection uses the same release check as changelog updates (`MetaMask/action-is-release` via `.github/actions/is-release-pr`), after a cheap `release/*` branch prefilter. 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. diff --git a/scripts/close-stale-release-prs.mts b/scripts/close-stale-release-prs.mts index 97f1bf42dd8..fcb88d61c5c 100755 --- a/scripts/close-stale-release-prs.mts +++ b/scripts/close-stale-release-prs.mts @@ -1,9 +1,13 @@ /** - * Close inactive same-repo `release/*` PRs, comment with the outcome, and - * delete the branch when the tip is unchanged. + * Close inactive release PRs, comment with the outcome, and delete the branch + * when the tip is unchanged. + * + * Release PR detection is performed by `.github/actions/is-release-pr` in the + * workflow. This script expects `RELEASE_PR_NUMBERS` (comma-separated) and + * only evaluates staleness / close / branch delete for those PRs. * * Usage (from GitHub Actions): - * GITHUB_TOKEN=... yarn tsx scripts/close-stale-release-prs.mts + * GITHUB_TOKEN=... RELEASE_PR_NUMBERS=1,2 yarn tsx scripts/close-stale-release-prs.mts */ import * as core from '@actions/core'; @@ -66,8 +70,8 @@ type PullRequestSnapshot = { }; type ListedPullRequest = Awaited< - ReturnType ->['data'][number]; + ReturnType +>['data']; type BranchDeleteOutcome = { outcome: @@ -119,18 +123,18 @@ async function getPullRequestSnapshot( } /** - * Whether a listed PR head is a same-repo `release/*` branch that is not skipped. + * Whether a listed PR should be considered for stale close. + * + * Release detection happens in `.github/actions/is-release-pr`; this only + * applies same-repo and skip-label guards. * - * @param pullRequest - Pull request from `pulls.list`. - * @returns True when the PR is a candidate for stale close. + * @param pullRequest - Pull request from `pulls.get`. + * @returns True when the PR may be closed if stale. */ -function isReleasePrCandidate(pullRequest: ListedPullRequest): boolean { - if (!pullRequest.head.ref.startsWith('release/')) { - return false; - } - +function isCloseableReleasePr(pullRequest: ListedPullRequest): boolean { // Only manage same-repo release branches (never forks). if (!pullRequest.head.repo || pullRequest.head.repo.fork) { + core.info(`Skipping #${pullRequest.number}: fork head`); return false; } @@ -440,10 +444,27 @@ async function processReleasePr({ } /** - * Close inactive same-repo `release/*` PRs. + * Parse the comma-separated PR numbers supplied by the workflow. + * + * @param raw - `RELEASE_PR_NUMBERS` env value. + * @returns PR numbers. + */ +function parseReleasePrNumbers(raw: string): number[] { + if (raw.trim() === '') { + return []; + } + + return raw + .split(',') + .map((part) => Number(part.trim())) + .filter((value) => Number.isSafeInteger(value) && value > 0); +} + +/** + * Close inactive release PRs confirmed by `.github/actions/is-release-pr`. */ async function main(): Promise { - // GitHub Actions provides the token via the environment for this workflow. + // GitHub Actions provides these via the environment for this workflow. // eslint-disable-next-line n/no-process-env const token = process.env.GITHUB_TOKEN; if (!token) { @@ -451,28 +472,41 @@ async function main(): Promise { return; } + // eslint-disable-next-line n/no-process-env + const releasePrNumbersRaw = process.env.RELEASE_PR_NUMBERS; + if (releasePrNumbersRaw === undefined) { + core.setFailed('RELEASE_PR_NUMBERS is required'); + return; + } + + const pullNumbers = parseReleasePrNumbers(releasePrNumbersRaw); + if (pullNumbers.length === 0) { + core.info('No release PRs to evaluate.'); + return; + } + const octokit = getOctokit(token); const staleBefore = Date.now() - STALE_DURATION_MS; const { owner, repo } = context.repo; - const pullRequests: ListedPullRequest[] = await octokit.paginate( - octokit.rest.pulls.list, - { - owner, - repo, - state: 'open', - per_page: 100, - }, - ); - - const releasePrs = pullRequests.filter(isReleasePrCandidate); + for (const pullNumber of pullNumbers) { + let candidate: ListedPullRequest; + try { + const { data } = await octokit.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + candidate = data; + } catch (error) { + core.warning(`Failed to load #${pullNumber}: ${getErrorMessage(error)}`); + continue; + } - if (releasePrs.length === 0) { - core.info('No open release PRs to evaluate.'); - return; - } + if (!isCloseableReleasePr(candidate)) { + continue; + } - for (const candidate of releasePrs) { await processReleasePr({ octokit, candidate,