Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/actions/greet-new-users/action.yml
Original file line number Diff line number Diff line change
@@ -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 })
90 changes: 90 additions & 0 deletions .github/actions/greet-new-users/main.js
Original file line number Diff line number Diff line change
@@ -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}`)
}
}
23 changes: 20 additions & 3 deletions .github/workflows/greet-new-users.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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!** 🎉

Expand Down
1 change: 1 addition & 0 deletions docs/reference/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

---

Expand Down
Loading