From 8478bb6726a34de1a4e1ac3dc5787cbf7784f4d2 Mon Sep 17 00:00:00 2001 From: Tomas Pereira de Vasconcelos Date: Thu, 30 Jul 2026 00:54:30 +0200 Subject: [PATCH 1/4] Replace broken `actions/first-interaction` with an inline script The v3 rewrite of actions/first-interaction dropped the per-author filter from its first-PR check and ORs the issue/PR checks together, so the "first pull request" greeting fires for every PR whose author has never opened an issue in this repo - i.e. on every single PR from pre-commit.ci, dependabot, and Copilot. Broken upstream since v3.1.0 with no fix available (actions/first-interaction#369). Replace it with an inline actions/github-script step that restores the correct v1 semantics, never greets bots, runs with a least-privilege token, and fails soft on API errors. --- .github/workflows/greet-new-users.yml | 85 +++++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 6 deletions(-) diff --git a/.github/workflows/greet-new-users.yml b/.github/workflows/greet-new-users.yml index 949a388b..356faaa4 100644 --- a/.github/workflows/greet-new-users.yml +++ b/.github/workflows/greet-new-users.yml @@ -6,21 +6,33 @@ on: pull_request_target: types: [ opened ] +# SECURITY: pull_request_target runs with a write token in the base-repo +# context. This workflow must never check out or execute anything from the +# PR head, and untrusted fields (titles, bodies, logins) must never be +# `${{ }}`-interpolated into the script source — read them via +# `context.payload` inside the script instead. +permissions: + issues: write + pull-requests: write + jobs: greeting: runs-on: ubuntu-latest - timeout-minutes: 1 + timeout-minutes: 2 steps: - - uses: actions/first-interaction@v3 - with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - issue_message: | + # Inline replacement for actions/first-interaction, which greets every + # PR from any author who never opened an issue in this repo (including + # bots like pre-commit.ci and dependabot). + # See: https://github.com/actions/first-interaction/issues/369 + - uses: actions/github-script@v9 + env: + ISSUE_MESSAGE: | **Thank you for submitting your first issue with us!** 🎉 Our response times may vary, but we'll get back to you as soon as we can! Welcome aboard! 🚀 - pr_message: | + PR_MESSAGE: | **Thank you for submitting your first pull request with us!** 🎉 Our response times may vary, but we'll get back to you as soon as we can! @@ -28,3 +40,64 @@ jobs: To help us help you, please make sure you have ticked all the boxes in the pull request template. Welcome aboard! 🚀 + with: + script: | + try { + const isIssue = context.eventName === 'issues' + const item = isIssue ? context.payload.issue : context.payload.pull_request + const author = item.user + + // Never greet bots (pre-commit.ci, dependabot, github-actions, Copilot, ...) + 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: isIssue ? process.env.ISSUE_MESSAGE : process.env.PR_MESSAGE, + }) + } 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}`) + } From e47ab564727f29213ede7de358ac540d9badcf9e Mon Sep 17 00:00:00 2001 From: Tomas Pereira de Vasconcelos Date: Thu, 30 Jul 2026 01:03:28 +0200 Subject: [PATCH 2/4] Add changelog entry for PR 398 --- docs/reference/changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/reference/changelog.md b/docs/reference/changelog.md index c08ef295..c23ca199 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 an inline `actions/github-script` step, so that first-time greetings are no longer posted on every PR opened by bots like pre-commit.ci and dependabot ({gh-pr}`398`) --- From 55a2557973a86baf2eb4c9025d360c050c0b77fb Mon Sep 17 00:00:00 2001 From: Tomas Pereira de Vasconcelos Date: Thu, 30 Jul 2026 19:57:33 +0200 Subject: [PATCH 3/4] Refactor the greeting logic into a reusable local action Move the inline github-script logic into a composite action under .github/actions/greet-new-users/ with the core logic in a standalone, directly-testable main.js module. This makes the action consumable from other repositories and easy to open source later by moving the directory to a standalone repository. The action exposes issue-message/pr-message/token inputs and a greeted output, and gains two reusability guards: it only acts on 'opened' events (preventing duplicate greetings if a consumer also triggers on 'edited') and it skips unsupported events gracefully. The workflow now checks out the base ref (never the PR head) to load the local action. --- .github/actions/greet-new-users/action.yml | 44 +++++++++++ .github/actions/greet-new-users/main.js | 90 ++++++++++++++++++++++ .github/workflows/greet-new-users.yml | 90 ++++------------------ docs/reference/changelog.md | 2 +- 4 files changed, 152 insertions(+), 74 deletions(-) create mode 100644 .github/actions/greet-new-users/action.yml create mode 100644 .github/actions/greet-new-users/main.js diff --git a/.github/actions/greet-new-users/action.yml b/.github/actions/greet-new-users/action.yml new file mode 100644 index 00000000..e873c007 --- /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 356faaa4..765336ce 100644 --- a/.github/workflows/greet-new-users.yml +++ b/.github/workflows/greet-new-users.yml @@ -7,11 +7,13 @@ on: types: [ opened ] # SECURITY: pull_request_target runs with a write token in the base-repo -# context. This workflow must never check out or execute anything from the -# PR head, and untrusted fields (titles, bodies, logins) must never be -# `${{ }}`-interpolated into the script source — read them via -# `context.payload` inside the script instead. +# 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 @@ -20,19 +22,22 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 2 steps: - # Inline replacement for actions/first-interaction, which greets every - # PR from any author who never opened an issue in this repo (including - # bots like pre-commit.ci and dependabot). - # See: https://github.com/actions/first-interaction/issues/369 - - uses: actions/github-script@v9 - env: - ISSUE_MESSAGE: | + # 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: + issue-message: | **Thank you for submitting your first issue with us!** 🎉 Our response times may vary, but we'll get back to you as soon as we can! Welcome aboard! 🚀 - PR_MESSAGE: | + pr-message: | **Thank you for submitting your first pull request with us!** 🎉 Our response times may vary, but we'll get back to you as soon as we can! @@ -40,64 +45,3 @@ jobs: To help us help you, please make sure you have ticked all the boxes in the pull request template. Welcome aboard! 🚀 - with: - script: | - try { - const isIssue = context.eventName === 'issues' - const item = isIssue ? context.payload.issue : context.payload.pull_request - const author = item.user - - // Never greet bots (pre-commit.ci, dependabot, github-actions, Copilot, ...) - 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: isIssue ? process.env.ISSUE_MESSAGE : process.env.PR_MESSAGE, - }) - } 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/docs/reference/changelog.md b/docs/reference/changelog.md index c23ca199..881e9a17 100644 --- a/docs/reference/changelog.md +++ b/docs/reference/changelog.md @@ -37,7 +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 an inline `actions/github-script` step, so that first-time greetings are no longer posted on every PR opened by bots like pre-commit.ci and dependabot ({gh-pr}`398`) +- 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`) --- From d49b150b81ee177126d823622af67ac7093494a5 Mon Sep 17 00:00:00 2001 From: Tomas Pereira de Vasconcelos Date: Thu, 30 Jul 2026 21:14:32 +0200 Subject: [PATCH 4/4] Rename message inputs to match actions/first-interaction Use issue_message/pr_message (snake_case) for the action's inputs, consistent with the actions/first-interaction action being replaced. --- .github/actions/greet-new-users/action.yml | 8 ++++---- .github/workflows/greet-new-users.yml | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/actions/greet-new-users/action.yml b/.github/actions/greet-new-users/action.yml index e873c007..eb8c37ba 100644 --- a/.github/actions/greet-new-users/action.yml +++ b/.github/actions/greet-new-users/action.yml @@ -8,10 +8,10 @@ description: >- https://github.com/actions/first-interaction/issues/369 for details. inputs: - issue-message: + issue_message: description: "Message to post on a user's first issue. Leave empty to not greet issues." required: false - pr-message: + pr_message: description: "Message to post on a user's first pull request. Leave empty to not greet PRs." required: false token: @@ -32,8 +32,8 @@ runs: - id: greet uses: actions/github-script@v9 env: - ISSUE_MESSAGE: ${{ inputs.issue-message }} - PR_MESSAGE: ${{ inputs.pr-message }} + 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 diff --git a/.github/workflows/greet-new-users.yml b/.github/workflows/greet-new-users.yml index 765336ce..d2493347 100644 --- a/.github/workflows/greet-new-users.yml +++ b/.github/workflows/greet-new-users.yml @@ -31,13 +31,13 @@ jobs: - uses: ./.github/actions/greet-new-users with: - issue-message: | + issue_message: | **Thank you for submitting your first issue with us!** 🎉 Our response times may vary, but we'll get back to you as soon as we can! Welcome aboard! 🚀 - pr-message: | + pr_message: | **Thank you for submitting your first pull request with us!** 🎉 Our response times may vary, but we'll get back to you as soon as we can!