diff --git a/.github/actions/greet-new-users/action.yml b/.github/actions/greet-new-users/action.yml new file mode 100644 index 00000000..eb8c37ba --- /dev/null +++ b/.github/actions/greet-new-users/action.yml @@ -0,0 +1,44 @@ +name: Greet new users + +description: >- + Comment on a user's first issue or pull request. A drop-in replacement for + actions/first-interaction, whose v3 rewrite greets every PR from any author + who never opened an issue in the repository - including every PR from bots + like pre-commit.ci and dependabot. See + https://github.com/actions/first-interaction/issues/369 for details. + +inputs: + issue_message: + description: "Message to post on a user's first issue. Leave empty to not greet issues." + required: false + pr_message: + description: "Message to post on a user's first pull request. Leave empty to not greet PRs." + required: false + token: + description: >- + GitHub token used to list existing issues/PRs and to post the greeting + comment. Requires `issues: write` and `pull-requests: write` permissions. + required: false + default: ${{ github.token }} + +outputs: + greeted: + description: "Whether a greeting comment was posted ('true'/'false')." + value: ${{ steps.greet.outputs.greeted }} + +runs: + using: composite + steps: + - id: greet + uses: actions/github-script@v9 + env: + ISSUE_MESSAGE: ${{ inputs.issue_message }} + PR_MESSAGE: ${{ inputs.pr_message }} + with: + github-token: ${{ inputs.token }} + # The logic lives in main.js next to this file. The messages are + # passed through the environment so that no user-controlled content + # is ever `${{ }}`-interpolated into the script source. + script: | + const greet = require(`${process.env.GITHUB_ACTION_PATH}/main.js`) + await greet({ core, context, github }) diff --git a/.github/actions/greet-new-users/main.js b/.github/actions/greet-new-users/main.js new file mode 100644 index 00000000..f6ccc29f --- /dev/null +++ b/.github/actions/greet-new-users/main.js @@ -0,0 +1,90 @@ +// Core logic for the greet-new-users composite action (see action.yml). +// +// Kept in a standalone CommonJS module - rather than inline in action.yml - +// so it can be linted, syntax-checked, and unit-tested directly. It is +// invoked from actions/github-script, which injects its pre-authenticated +// Octokit client (`github`) plus the `core` and `context` helpers. +// +// The greeting messages are read from the ISSUE_MESSAGE / PR_MESSAGE +// environment variables (so that no user-controlled content is ever +// `${{ }}`-interpolated into script source), and a `greeted` output +// ("true"/"false") is always set. + +module.exports = async function greet({ core, context, github }) { + core.setOutput('greeted', 'false') + try { + // Only greet on newly created issues/PRs. Guards against consumers + // triggering this action on e.g. `edited` events, which would + // otherwise post duplicate greetings. + if (context.payload.action !== 'opened') { + return core.info(`Skipping: unsupported event action (${context.payload.action})`) + } + + const isIssue = context.eventName === 'issues' + const item = isIssue ? context.payload.issue : context.payload.pull_request + if (!item) { + return core.info(`Skipping: unsupported event (${context.eventName})`) + } + + const message = isIssue ? process.env.ISSUE_MESSAGE : process.env.PR_MESSAGE + if (!message) { + return core.info(`Skipping: no ${isIssue ? 'issue' : 'pull request'} message configured`) + } + + // Never greet bots (pre-commit.ci, dependabot, github-actions, Copilot, ...) + const author = item.user + if (author.type === 'Bot') { + return core.info(`Skipping: ${author.login} is a bot`) + } + + // Repo-affiliated authors are never "new users". This is only a + // shortcut: a missing/unreliable value falls through to the real + // check below, so it can never cause a wrong greeting. + if (['OWNER', 'MEMBER', 'COLLABORATOR'].includes(item.author_association)) { + return core.info(`Skipping: ${author.login} is ${item.author_association}`) + } + + let isFirst = true + if (isIssue) { + // Everything this author created (the endpoint returns PRs too, + // so keep only true issues that predate the current one). + const created = await github.paginate(github.rest.issues.listForRepo, { + ...context.repo, + creator: author.login, + state: 'all', + per_page: 100, + }) + isFirst = !created.some((i) => !i.pull_request && i.number < item.number) + } else { + // pulls.list cannot filter by author, so scan all PRs and stop + // as soon as an older PR by this author shows up. + await github.paginate( + github.rest.pulls.list, + { ...context.repo, state: 'all', per_page: 100 }, + (response, done) => { + if (response.data.some((p) => p.user?.login === author.login && p.number < item.number)) { + isFirst = false + done() + } + return [] + }, + ) + } + + if (!isFirst) { + return core.info(`Skipping: not ${author.login}'s first ${isIssue ? 'issue' : 'pull request'}`) + } + + core.info(`Greeting ${author.login} on their first ${isIssue ? 'issue' : 'pull request'}`) + await github.rest.issues.createComment({ + ...context.repo, + issue_number: item.number, + body: message, + }) + core.setOutput('greeted', 'true') + } catch (error) { + // The greeting is cosmetic: a red X on a newcomer's first PR is + // worse than a missing welcome, so warn instead of failing. + core.warning(`Skipping greeting: ${error.message}`) + } +} diff --git a/.github/workflows/greet-new-users.yml b/.github/workflows/greet-new-users.yml index 949a388b..d2493347 100644 --- a/.github/workflows/greet-new-users.yml +++ b/.github/workflows/greet-new-users.yml @@ -6,14 +6,31 @@ on: pull_request_target: types: [ opened ] +# SECURITY: pull_request_target runs with a write token in the base-repo +# context. Only the *base* ref may ever be checked out here (the checkout +# below uses the default ref, which on pull_request_target is the base +# branch) - never the PR head. Untrusted fields (titles, bodies, logins) +# must never be `${{ }}`-interpolated into scripts; the greet-new-users +# action reads them via `context.payload` instead. +permissions: + contents: read + issues: write + pull-requests: write + jobs: greeting: runs-on: ubuntu-latest - timeout-minutes: 1 + timeout-minutes: 2 steps: - - uses: actions/first-interaction@v3 + # Needed to load the local action. On pull_request_target this checks + # out the base branch, so PR authors cannot alter the code that runs + # in this privileged workflow. + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: ./.github/actions/greet-new-users with: - repo_token: ${{ secrets.GITHUB_TOKEN }} issue_message: | **Thank you for submitting your first issue with us!** 🎉 diff --git a/docs/reference/changelog.md b/docs/reference/changelog.md index c08ef295..881e9a17 100644 --- a/docs/reference/changelog.md +++ b/docs/reference/changelog.md @@ -37,6 +37,7 @@ Unreleased changes - Use the official `astral-sh/setup-uv` action to install and cache `uv` in CI ({gh-pr}`386`) - Let `uv` manage the Python interpreter and virtual environment in CI ({gh-pr}`393`) - Remove the stale `requirements/*.txt` glob from the CI cache key, left over from the migration to PEP 735 dependency groups ({gh-pr}`394`) +- Replace the broken `actions/first-interaction` action with a local reusable action (`.github/actions/greet-new-users`), so that first-time greetings are no longer posted on every PR opened by bots like pre-commit.ci and dependabot ({gh-pr}`398`) ---