diff --git a/.github/workflows/changes-file.yml b/.github/workflows/changes-file.yml deleted file mode 100644 index 76098443a1..0000000000 --- a/.github/workflows/changes-file.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: 📝 Change File Check - -on: - pull_request: - branches: - - dev - types: [opened, synchronize, reopened] - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - check: - name: 📝 Change File Check - if: github.repository == 'remix-run/react-router' - runs-on: ubuntu-latest - permissions: - issues: write - pull-requests: write - - steps: - - name: ⬇️ Checkout repo - uses: actions/checkout@v6 - - - name: 📦 Setup pnpm - uses: pnpm/action-setup@v6 - - - name: ⎔ Setup node - uses: actions/setup-node@v6 - with: - node-version-file: ".nvmrc" - cache: pnpm - - - name: 📥 Install deps - run: pnpm install --frozen-lockfile - - - name: 📝 Check change file and update PR comment - env: - GITHUB_TOKEN: ${{ github.token }} - run: node scripts/changes/check-pr.ts ${{ github.event.pull_request.number }} diff --git a/.github/workflows/close-feature-pr.yml b/.github/workflows/close-feature-pr.yml deleted file mode 100644 index 9937a8c448..0000000000 --- a/.github/workflows/close-feature-pr.yml +++ /dev/null @@ -1,28 +0,0 @@ -# Close a singular pull request that implements a feature that has not -# gone through the Proposal process -# Triggered by adding the `feature-request` label to an issue - -name: 🚪 Check Feature PR - -on: - pull_request_target: - types: [labeled] - -jobs: - close-feature-pr: - name: 🚪 Check Feature PR - if: github.repository == 'remix-run/react-router' && github.event.label.name == 'feature-request' - runs-on: ubuntu-latest - permissions: - pull-requests: write - steps: - - name: ⬇️ Checkout repo - uses: actions/checkout@v6 - - - name: 🚪 Close PR - env: - GH_TOKEN: ${{ github.token }} - run: | - gh pr comment ${{ github.event.pull_request.number }} -F ./scripts/close-feature-pr.md - gh pr edit ${{ github.event.pull_request.number }} --remove-label ${{ github.event.label.name }} - gh pr close ${{ github.event.pull_request.number }} diff --git a/.github/workflows/pr-actions.yml b/.github/workflows/pr-actions.yml new file mode 100644 index 0000000000..68d24d1e92 --- /dev/null +++ b/.github/workflows/pr-actions.yml @@ -0,0 +1,52 @@ +name: PR (Actions) + +# Triggered when "PR (Check)" completes. Downloads the artifact produced by +# the upstream workflow and applies the recorded actions (sticky comments, +# label changes, close, etc.) with write permissions. Never executes PR +# source code — only reads the JSON artifact. + +on: + workflow_run: + workflows: ["PR (Check)"] + types: [completed] + +jobs: + actions: + name: PR (Actions) + if: > + github.event.workflow_run.conclusion == 'success' && + github.repository == 'remix-run/react-router' + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + actions: read + + steps: + # Check's out the base (main) branch - not the PR branch + - name: ⬇️ Checkout repo + uses: actions/checkout@v6 + + - name: 📦 Setup pnpm + uses: pnpm/action-setup@v6 + + - name: ⎔ Setup node + uses: actions/setup-node@v6 + with: + node-version-file: ".nvmrc" + cache: pnpm + + - name: 📥 Install deps + run: pnpm install --frozen-lockfile + + - name: 📥 Download result from upstream workflow + uses: actions/download-artifact@v4 + with: + name: pr-checks-result + github-token: ${{ github.token }} + run-id: ${{ github.event.workflow_run.id }} + + - name: 💬 Apply actions + env: + GITHUB_TOKEN: ${{ github.token }} + run: node scripts/pr.ts actions pr-checks-result.json diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000000..ef7785e294 --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,53 @@ +name: PR (Check) + +# Read-only PR inspection. Computes a list of "actions" (sticky comments, +# label changes, close, etc.) and uploads them as an artifact. The PR (Actions) +# workflow consumes the artifact and applies the actions with write +# permissions — keeping that step out of the PR's untrusted code path. + +on: + pull_request: + types: [opened, synchronize, reopened, labeled] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.action }}-${{ github.event.label.name }} + cancel-in-progress: true + +jobs: + check: + name: 🔍 Check PR + if: github.repository == 'remix-run/react-router' + runs-on: ubuntu-latest + permissions: + pull-requests: read + + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v6 + + - name: 📦 Setup pnpm + uses: pnpm/action-setup@v6 + + - name: ⎔ Setup node + uses: actions/setup-node@v6 + with: + node-version-file: ".nvmrc" + cache: pnpm + + - name: 📥 Install deps + run: pnpm install --frozen-lockfile + + - name: 🔍 Run checks + env: + GITHUB_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_BASE: ${{ github.event.pull_request.base.ref }} + EVENT_ACTION: ${{ github.event.action }} + LABEL_NAME: ${{ github.event.label.name }} + run: node scripts/pr.ts check + + - name: 📤 Upload result + uses: actions/upload-artifact@v4 + with: + name: pr-checks-result + path: pr-checks-result.json diff --git a/scripts/changes/check-pr.ts b/scripts/changes/check-pr.ts deleted file mode 100644 index 62852d2942..0000000000 --- a/scripts/changes/check-pr.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Checks whether the current PR contains a change file and posts (or - * updates) a sticky comment on the PR with the result. - * - * Usage (called by the changes-file GitHub Actions workflow): - * node scripts/changes/check-pr.ts - * - * Usage: - * node scripts/changes/check-pr.ts - * - * Environment: - * GITHUB_TOKEN - Required. GitHub token with pull-requests:write permission. - */ -import { - createPrComment, - getPrComments, - getPrFiles, - updatePrComment, -} from "../utils/github.ts"; - -const COMMENT_MARKER = ""; - -const COMMENT_FOUND = `${COMMENT_MARKER} -### ✅ Change File Found - -A [change file](https://github.com/remix-run/react-router/blob/main/docs/community/contributing.md#change-files) file exists in this PR. Thanks!`; - -const COMMENT_MISSING = `${COMMENT_MARKER} -### ⚠️ No Change File Found - -This PR doesn't include a [change file](https://github.com/remix-run/react-router/blob/main/docs/community/contributing.md#change-files) which is used for automated release notes. -If your change affects users, please add one (or more) change files and commit the generated file(s). - -\`\`\`sh -pnpm run changes:add -\`\`\` - -> This script requires Node 24+. If you are on a lower version, please [add a file manually](https://github.com/remix-run/react-router/blob/main/docs/community/contributing.md#change-files) - -> Not every PR needs a change file — you can skip this step if the change is internal-only -> (tests, tooling, docs)`; - -// Matches packages/*/.changes/*.md but not .gitkeep -const CHANGE_FILE_RE = /^packages\/[^/]+\/\.changes\/[^/]+\.md$/; - -async function main() { - let arg = process.argv[2]; - let prNumber = arg ? parseInt(arg, 10) : NaN; - if (!arg || isNaN(prNumber)) { - console.error("Usage: node scripts/changes/check-pr.ts "); - process.exit(1); - } - - // Check for change files via the GitHub API — no git fetch needed - let files = await getPrFiles(prNumber); - let found = files.some((f) => CHANGE_FILE_RE.test(f.filename)); - let body = found ? COMMENT_FOUND : COMMENT_MISSING; - console.log(`Change files found: ${found}`); - - // Find existing sticky comment - let comments = await getPrComments(prNumber); - let existing = comments.find( - (c) => - c.user?.login === "github-actions[bot]" && - c.body?.includes(COMMENT_MARKER), - ); - - if (existing) { - console.log(`Updating existing comment #${existing.id}`); - await updatePrComment(existing.id, body); - } else { - console.log("Creating new comment"); - await createPrComment(prNumber, body); - } -} - -main().catch((err) => { - console.error("Error:", err.message); - process.exit(1); -}); diff --git a/scripts/pr.ts b/scripts/pr.ts new file mode 100644 index 0000000000..42b5339630 --- /dev/null +++ b/scripts/pr.ts @@ -0,0 +1,232 @@ +/** + * Runs a set of checks against a PR and applies the resulting actions. + * + * Two-phase to avoid running with write permissions on PRs from forks. + * See https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/ + * + * check Inspects the PR via the GitHub API and writes a list of + * "actions" to pr-checks-result.json. Safe to run with a + * read-only token (Workflow A: 🔍 Check PR). + * + * actions Reads pr-checks-result.json and applies each action. Runs + * in Workflow B (PR (Actions)) under `workflow_run` with + * write permissions but never executes any PR code. + * + * Usage: + * node scripts/pr.ts check + * node scripts/pr.ts actions + * + * Environment (check): + * GITHUB_TOKEN - Required (read-only PR scope is enough). + * PR_NUMBER - Required. github.event.pull_request.number + * PR_BASE - Required. github.event.pull_request.base.ref + * EVENT_ACTION - Required. github.event.action (opened|synchronize|reopened|labeled) + * LABEL_NAME - Optional. github.event.label.name (set when EVENT_ACTION=labeled) + * + * Environment (actions): + * GITHUB_TOKEN - Required (issues:write + pull-requests:write). + */ +import * as fs from "node:fs"; +import * as util from "node:util"; + +import { + closePr, + createPrComment, + getPrComments, + getPrFiles, + removePrLabel, + updatePrComment, +} from "./utils/github.ts"; + +type Action = + | { type: "upsert-sticky-comment"; marker: string; body: string } + | { type: "create-comment"; body: string } + | { type: "remove-label"; label: string } + | { type: "close-pr" }; + +type CheckContext = { + prNumber: number; + baseBranch: string; + eventAction: string; + labelName: string; +}; + +type Check = (ctx: CheckContext) => Promise; + +const CHANGE_FILE_MARKER = ""; + +const CHANGE_FILE_FOUND_COMMENT = `${CHANGE_FILE_MARKER} +### ✅ Change File Found + +A [change file](https://github.com/remix-run/react-router/blob/main/docs/community/contributing.md#change-files) file exists in this PR. Thanks!`; + +const CHANGE_FILE_MISSING_COMMENT = `${CHANGE_FILE_MARKER} +### ⚠️ No Change File Found + +This PR doesn't include a [change file](https://github.com/remix-run/react-router/blob/main/docs/community/contributing.md#change-files) which is used for automated release notes. +If your change affects users, please add one (or more) change files and commit the generated file(s). + +\`\`\`sh +pnpm run changes:add +\`\`\` + +> This script requires Node 24+. If you are on a lower version, please [add a file manually](https://github.com/remix-run/react-router/blob/main/docs/community/contributing.md#change-files) + +> Not every PR needs a change file — you can skip this step if the change is internal-only +> (tests, tooling, docs)`; + +const CLOSE_FEATURE_PR_COMMENT = `\ +To align with our new [Open Governance](https://remix.run/blog/rr-governance) model, we are now asking that all new features go through the [Proposal/RFC process](https://github.com/remix-run/react-router/blob/main/GOVERNANCE.md#new-feature-process) and that we don't open PRs until a proposal has been accepted and advanced to Stage 1. + +If this feature doesn't have a Proposal, please [open one](https://github.com/remix-run/react-router/discussions/new?category=proposals) so we can evaluate/discuss the proposed feature. You can link to this PR as an example of a potential implementation and we can re-open it if the proposal advances. + +If this PR already has a Proposal but it has not yet been accepted, let's continue the discussion in the Proposal until it gets accepted and then we can look to open a PR. Feel free to link to this PR or to a branch in a forked repo to show what a potential implementation might look like. + +If you have any questions, you can always reach out on [Discord](https://rmx.as/discord). Thanks again for providing feedback and helping us make React Router even better! +`; + +let { positionals } = util.parseArgs({ allowPositionals: true }); +let [mode, arg] = positionals; + +if (mode === "check") { + await runChecks(); +} else if (mode === "actions") { + if (!arg) usage(); + await runActions(arg); +} else { + usage(); +} + +// ---------- Checks ---------- + +async function runChecks() { + let prNumber = parseInt(requireEnv("PR_NUMBER"), 10); + if (isNaN(prNumber)) { + console.error("PR_NUMBER must be numeric"); + process.exit(1); + } + + let checks: Check[] = [changeFileCheck, featurePrCheck]; + + let ctx: CheckContext = { + prNumber, + baseBranch: requireEnv("PR_BASE"), + eventAction: requireEnv("EVENT_ACTION"), + labelName: process.env.LABEL_NAME ?? "", + }; + console.log("ctx:", ctx); + + let result: { prNumber: number; actions: Action[] } = { + prNumber, + actions: [], + }; + + for (let check of checks) { + result.actions.push(...(await check(ctx))); + } + + // Matches pr-checks.yml/pr-actions.yml workflow artifact name + let filename = "pr-checks-result.json"; + console.log(`Writing ${filename}:`, JSON.stringify(result)); + fs.writeFileSync(filename, JSON.stringify(result)); +} + +async function changeFileCheck(ctx: CheckContext): Promise { + if (ctx.baseBranch !== "dev") return []; + if (!["opened", "synchronize", "reopened"].includes(ctx.eventAction)) { + return []; + } + + let files = await getPrFiles(ctx.prNumber); + let regex = /^packages\/[^/]+\/\.changes\/[^/]+\.md$/; + let found = files.some((f) => regex.test(f.filename)); + console.log(`changeFileCheck: found=${found}`); + return [ + { + type: "upsert-sticky-comment", + marker: CHANGE_FILE_MARKER, + body: found ? CHANGE_FILE_FOUND_COMMENT : CHANGE_FILE_MISSING_COMMENT, + }, + ]; +} + +async function featurePrCheck(ctx: CheckContext): Promise { + if (ctx.eventAction !== "labeled") return []; + if (ctx.labelName !== "feature-request") return []; + + console.log(`featurePrCheck: closing PR ${ctx.prNumber}`); + return [ + { type: "create-comment", body: CLOSE_FEATURE_PR_COMMENT }, + { type: "remove-label", label: ctx.labelName }, + { type: "close-pr" }, + ]; +} + +// ---------- Action dispatch ---------- + +async function runActions(resultPath: string) { + let { prNumber, actions } = JSON.parse( + fs.readFileSync(resultPath, "utf8"), + ) as { prNumber: number; actions: Action[] }; + + if (actions.length === 0) { + console.log("No actions to apply"); + return; + } + + for (let action of actions) { + switch (action.type) { + case "upsert-sticky-comment": { + let comments = await getPrComments(prNumber); + let existing = comments.find( + (c) => + c.user?.login === "github-actions[bot]" && + c.body?.includes(action.marker), + ); + if (existing) { + console.log(`Updating sticky comment #${existing.id}`); + await updatePrComment(existing.id, action.body); + } else { + console.log("Creating sticky comment"); + await createPrComment(prNumber, action.body); + } + return; + } + case "create-comment": { + console.log("Creating comment"); + await createPrComment(prNumber, action.body); + return; + } + case "remove-label": { + console.log(`Removing label '${action.label}'`); + await removePrLabel(prNumber, action.label); + return; + } + case "close-pr": { + console.log(`Closing PR ${prNumber}`); + await closePr(prNumber); + return; + } + } + } +} + +// Utils + +function usage(): never { + console.error( + "Usage:\n" + + " node scripts/pr.ts check\n" + + " node scripts/pr.ts actions ", + ); + process.exit(1); +} + +function requireEnv(name: string): string { + let value = process.env[name]; + if (!value) { + console.error(`Missing required env var: ${name}`); + process.exit(1); + } + return value; +} diff --git a/scripts/utils/github.ts b/scripts/utils/github.ts index b100b0eeb3..4338937465 100644 --- a/scripts/utils/github.ts +++ b/scripts/utils/github.ts @@ -237,3 +237,17 @@ export async function deletePrComment(commentId: number) { comment_id: commentId, }); } + +/** + * Remove a label from a PR (or issue) + */ +export async function removePrLabel(prNumber: number, label: string) { + await request( + "DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels/{name}", + { + ...requestOptions(), + issue_number: prNumber, + name: label, + }, + ); +}