Skip to content

Commit 0ca01c1

Browse files
Dumbrisclaude
andauthored
ci: thank external contributors on merge with share snippet (E5) (#1708)
## What Experiment E5: when an **external** contributor's PR is merged, a bot posts **one** thank-you comment with an optional, copy-paste share snippet, and release notes credit external contributors. - `.github/workflows/contributor-thanks.yml` — `pull_request_target: closed` (merged only) + `workflow_dispatch` for manual dry runs. - `scripts/contributors.sh` — `thanks <pr>` and `release <prev-tag> <ref>`; `scripts/test-contributors.sh` — 26 offline checks. - `release.yml` — new "Collect contributors" step; a `## Contributors` section is appended after the LLM-written notes (deterministic, so a name is never dropped or invented). Existing tooling did not credit anyone: notes are LLM-summarised commit subjects, git-cliff `CHANGELOG.md` has no author section. - `CONTRIBUTING.md` — short "Recognition" note. Skipped: author `Dumbris`, `algis-dumbris`, any `[bot]`/Bot type, and association OWNER/MEMBER. Posted as `github-actions[bot]`, never as Algis. Nothing is posted on the contributor's behalf. ## Comment wording — please approve (rendered for PR #1222, first-time author) ````text <!-- contributor-thanks:v1 --> (hidden marker, used for idempotency) Thank you @loloDawit! Your PR "fix(test): restore the registry SSRF allow-policy after loopback fixtures" is merged and will ship in the next release. This is your first merged contribution to MCPProxy. Welcome, and congratulations! <- only if first-time If you would like to mention it, here is a snippet you can copy and edit: ```text My PR to MCPProxy, an open-source MCP proxy for Claude Code, Codex and Cursor, just got merged: #1222 Repo: https://github.com/smart-mcp-proxy/mcpproxy-go ``` Sharing genuinely helps a small open-source project get found. It is entirely optional, and we are grateful either way. ```` 679 chars with the first-time line (~600 without); ASCII only. "next release" is used because the release isn't known at merge time. The PR title is sanitised (one line, `@` neutralised, backticks/brackets/angle brackets stripped, 120 chars max). Release-notes section (example, `release v0.70.0 v0.71.1` run read-only): ``` ## Contributors Thank you to our external contributors for this release: @sb123sb123, @mvanhorn, @AdamMagued, ... @rudycelekli ``` ## Rollout — nothing posts until Algis flips it Every run is a **dry run** (comment printed in the job log, nothing posted) until the repo variable `CONTRIBUTOR_THANKS_LIVE` is `true`: `gh variable set CONTRIBUTOR_THANKS_LIVE --body true -R smart-mcp-proxy/mcpproxy-go` Manual dry run for any merged PR: Actions > Contributor Thanks > Run workflow (it only posts if the variable is `true` AND `dry_run` is unchecked). ## Security (`pull_request_target`) Checks out only the default branch (`sparse-checkout: scripts/contributors.sh`, `persist-credentials: false`); never PR head code. PR data comes via the API, is passed through env/quoted args only, never interpolated into shell. `permissions: {}` at top, job gets `contents: read, pull-requests: write, issues: write`. Per-PR concurrency group + marker check (bot-authored comments only) for idempotency. ## Testing - `bash scripts/test-contributors.sh` — 26/26 (eligibility, template, length < 900, no non-ASCII, hostile titles). - Read-only dry runs against real PRs: #1222 (first-time=true), #1664 (repeat, first=false), #1690 (maintainer -> skipped). `release v0.70.0 v0.71.1` produced the list above. - `actionlint` clean on `contributor-thanks.yml`; `release.yml` has the same pre-existing 53 findings before and after (none from the new step). `shellcheck -x` clean. `yamllint` not installed. `act` not installed. **No comment was posted anywhere.** ## Cross-model review (opencode `github-copilot/gpt-6.1-sol`, high) - Round 1 (the first attempt exited without a verdict — treated as UNREVIEWED and re-run with a tighter brief): 2 P2 findings, both fixed: (1) any commenter could suppress the thanks by pasting the marker → only `github-actions[bot]` comments count; (2) search-index lag/`incomplete_results` could mis-flag first-time → incomplete = not-first (residual race for two first-timer PRs merged within index lag documented and accepted). - Round 2: **VERDICT: CLEAN** (also ran mocked-API scenarios: empty comments, human marker, bot marker, later page, incomplete search). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 9b8271f commit 0ca01c1

5 files changed

Lines changed: 267 additions & 0 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
name: Contributor Thanks
2+
3+
# Experiment E5: when an external contributor's PR is merged, post one
4+
# thank-you comment (as github-actions[bot]) with an optional, copy-paste
5+
# share snippet. Consent-based: nothing is ever posted on the contributor's
6+
# behalf. Logic lives in scripts/contributors.sh (unit-tested by
7+
# scripts/test-contributors.sh).
8+
#
9+
# SECURITY: pull_request_target runs with a write token. This workflow
10+
# checks out only the DEFAULT branch (trusted code), never the PR head, and
11+
# reads the PR through the API. PR text is data and is never interpolated
12+
# into a shell command.
13+
#
14+
# ROLLOUT: until the repository variable CONTRIBUTOR_THANKS_LIVE is set to
15+
# "true" (Settings > Secrets and variables > Actions > Variables), every run
16+
# is a dry run: the comment is printed in the job log and nothing is posted.
17+
# Manual dry run for any merged PR: Actions > Contributor Thanks > Run
18+
# workflow (posting from there needs live=true AND dry_run=false).
19+
20+
on:
21+
pull_request_target:
22+
types: [closed]
23+
workflow_dispatch:
24+
inputs:
25+
pr_number:
26+
description: Merged PR number to render the comment for
27+
required: true
28+
dry_run:
29+
description: Print the comment instead of posting it
30+
type: boolean
31+
default: true
32+
33+
permissions: {}
34+
35+
jobs:
36+
thank:
37+
# Cheap pre-filter; the script re-checks everything from the API.
38+
if: >
39+
github.event_name == 'workflow_dispatch' ||
40+
(github.event.pull_request.merged == true &&
41+
github.event.pull_request.user.type != 'Bot' &&
42+
!endsWith(github.event.pull_request.user.login, '[bot]') &&
43+
github.event.pull_request.user.login != 'Dumbris' &&
44+
github.event.pull_request.user.login != 'algis-dumbris' &&
45+
github.event.pull_request.author_association != 'OWNER' &&
46+
github.event.pull_request.author_association != 'MEMBER')
47+
runs-on: ubuntu-latest
48+
timeout-minutes: 5
49+
permissions:
50+
contents: read
51+
pull-requests: write
52+
issues: write
53+
concurrency:
54+
group: contributor-thanks-${{ github.event.pull_request.number || inputs.pr_number }}
55+
cancel-in-progress: false
56+
steps:
57+
- name: Checkout trusted scripts (default branch only)
58+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
59+
with:
60+
ref: ${{ github.event.repository.default_branch }}
61+
sparse-checkout: scripts/contributors.sh
62+
persist-credentials: false
63+
64+
- name: Post thank-you comment
65+
env:
66+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
67+
GITHUB_REPOSITORY: ${{ github.repository }}
68+
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }}
69+
# Live only when the repo variable is "true" AND this is not a manual dry run.
70+
DRY_RUN: ${{ (vars.CONTRIBUTOR_THANKS_LIVE == 'true' && inputs.dry_run != true) && 'false' || 'true' }}
71+
run: bash scripts/contributors.sh thanks "$PR_NUMBER"

‎.github/workflows/release.yml‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ jobs:
2121
outputs:
2222
notes: ${{ steps.generate.outputs.notes }}
2323
notes_file: ${{ steps.generate.outputs.notes_file }}
24+
contributors: ${{ steps.contributors.outputs.section }}
2425

2526
steps:
2627
- name: Checkout
@@ -79,6 +80,23 @@ jobs:
7980
8081
echo "Collected $(echo "$COMMITS" | wc -l) commits"
8182
83+
# Deterministic credit for outside contributors. Kept out of the LLM
84+
# summary above so a name can never be dropped or invented. Lists the
85+
# authors of PRs merged since the previous stable tag, excluding
86+
# maintainers, bots and org members (scripts/contributors.sh).
87+
- name: Collect contributors
88+
id: contributors
89+
env:
90+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
91+
PREV_TAG: ${{ steps.prev_tag.outputs.tag }}
92+
run: |
93+
SECTION=$(bash scripts/contributors.sh release "$PREV_TAG" HEAD || true)
94+
{
95+
echo "section<<CONTRIBUTORS_EOF"
96+
echo "$SECTION"
97+
echo "CONTRIBUTORS_EOF"
98+
} >> "$GITHUB_OUTPUT"
99+
82100
- name: Generate release notes with Claude
83101
id: generate
84102
env:
@@ -1531,6 +1549,8 @@ jobs:
15311549
15321550
${{ needs.generate-notes.outputs.notes }}
15331551
1552+
${{ needs.generate-notes.outputs.contributors }}
1553+
15341554
---
15351555
15361556
## Download Installers

‎CONTRIBUTING.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,7 @@ go test -race ./internal/... -v # With the race detector
3232

3333
See [CLAUDE.md](CLAUDE.md) and the [docs](https://docs.mcpproxy.app) for the
3434
full development and testing reference.
35+
36+
## Recognition
37+
38+
When your pull request is merged, a bot comments with a thank-you and a short snippet you can copy if you would like to mention it. Sharing is entirely optional; it does help a small open-source project get found. Contributors from outside the maintainer team are also credited in a "Contributors" section of the release notes. The logic lives in `scripts/contributors.sh` (tests: `scripts/test-contributors.sh`) and `.github/workflows/contributor-thanks.yml`.

‎scripts/contributors.sh‎

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
#!/usr/bin/env bash
2+
# Contributor recognition helpers (experiment E5).
3+
#
4+
# contributors.sh thanks <pr-number> # post (or dry-run) the thank-you comment
5+
# contributors.sh release <prev-tag> <ref> # print a "Contributors" markdown section
6+
#
7+
# Environment: GH_TOKEN, GITHUB_REPOSITORY. DRY_RUN (thanks only) defaults to
8+
# "true": the comment is printed, never posted. Set DRY_RUN=false to post.
9+
#
10+
# Safety: reads the GitHub API only. It never checks out, builds or executes
11+
# anything from a pull request, so it is safe under pull_request_target.
12+
# Untrusted PR text (title, login) is handled as data and never reaches eval.
13+
set -euo pipefail
14+
15+
REPO="${GITHUB_REPOSITORY:-smart-mcp-proxy/mcpproxy-go}"
16+
MARKER='<!-- contributor-thanks:v1 -->'
17+
MAINTAINERS='Dumbris algis-dumbris'
18+
19+
# is_external <login> <user-type> <author-association>
20+
# Succeeds when the PR author is an outside contributor worth thanking.
21+
is_external() {
22+
local login="$1" utype="$2" assoc="$3" m
23+
[[ -n "$login" ]] || return 1
24+
[[ "$utype" != "Bot" && "$login" != *"[bot]" ]] || return 1
25+
local l; l="$(printf '%s' "$login" | tr '[:upper:]' '[:lower:]')"
26+
for m in $MAINTAINERS; do
27+
[[ "$l" != "$(printf '%s' "$m" | tr '[:upper:]' '[:lower:]')" ]] || return 1
28+
done
29+
[[ "$assoc" != "OWNER" && "$assoc" != "MEMBER" ]] || return 1
30+
return 0
31+
}
32+
33+
# neutralize <text>: one line, no @mentions, no backticks/markdown links (PR titles are untrusted).
34+
neutralize() {
35+
printf '%s' "$1" | tr '\r\n\t' ' ' | sed -e 's/@/@\xE2\x80\x8B/g' -e 's/[]`<>[]//g' | cut -c1-120
36+
}
37+
38+
# render_comment <login> <title> <pr-url> <first-time:true|false>
39+
render_comment() {
40+
local login="$1" title url="$3" first="$4"
41+
title="$(neutralize "$2")"
42+
printf '%s\n' "$MARKER"
43+
printf 'Thank you @%s! Your PR "%s" is merged and will ship in the next release.\n\n' "$login" "$title"
44+
if [[ "$first" == "true" ]]; then
45+
printf 'This is your first merged contribution to MCPProxy. Welcome, and congratulations!\n\n'
46+
fi
47+
printf 'If you would like to mention it, here is a snippet you can copy and edit:\n\n'
48+
printf '```text\n'
49+
printf 'My PR to MCPProxy, an open-source MCP proxy for Claude Code, Codex and Cursor, just got merged: %s\n' "$url"
50+
printf 'Repo: https://github.com/%s\n' "$REPO"
51+
printf '```\n\n'
52+
printf 'Sharing genuinely helps a small open-source project get found. It is entirely optional, and we are grateful either way.\n'
53+
}
54+
55+
cmd_thanks() {
56+
local pr="$1" dry="${DRY_RUN:-true}" info login utype assoc title url merged others first body existing
57+
info="$(gh api "repos/$REPO/pulls/$pr")"
58+
merged="$(jq -r '.merged' <<<"$info")"
59+
login="$(jq -r '.user.login' <<<"$info")"
60+
utype="$(jq -r '.user.type' <<<"$info")"
61+
assoc="$(jq -r '.author_association' <<<"$info")"
62+
title="$(jq -r '.title' <<<"$info")"
63+
url="$(jq -r '.html_url' <<<"$info")"
64+
65+
[[ "$merged" == "true" ]] || { echo "skip: PR #$pr is not merged"; return 0; }
66+
is_external "$login" "$utype" "$assoc" || { echo "skip: $login ($utype, $assoc) is not an external contributor"; return 0; }
67+
68+
# Only the bot's own comments count (a commenter cannot suppress the thanks by
69+
# pasting the marker). Read all comments first: piping into `grep -q` can SIGPIPE gh and, under
70+
# pipefail, look like "marker not found" (a duplicate comment).
71+
existing="$(gh api --paginate "repos/$REPO/issues/$pr/comments" --jq '.[] | select(.user.login == "github-actions[bot]") | .body')"
72+
if grep -qF "$MARKER" <<<"$existing"; then
73+
echo "skip: PR #$pr already has a thank-you comment"; return 0
74+
fi
75+
76+
# First contribution = no OTHER merged PR by this author. Counting "others"
77+
# (instead of total == 1) keeps this right while the search index lags behind
78+
# the merge we are reacting to. A failed or incomplete search degrades to "not first". Known
79+
# limit: two first-timer PRs merged within the index lag may both claim "first".
80+
others="$(gh api -X GET search/issues -f q="repo:$REPO is:pr is:merged author:$login" -f per_page=5 \
81+
--jq "if .incomplete_results then -1 else [.items[].number | select(. != $pr)] | length end" 2>/dev/null)" || others=-1
82+
first=false; [[ "$others" == "0" ]] && first=true
83+
84+
body="$(render_comment "$login" "$title" "$url" "$first")"
85+
echo "--- comment for PR #$pr (author=$login assoc=$assoc other_merged_prs=$others first=$first, ${#body} chars) ---"
86+
printf '%s\n' "$body"
87+
if [[ "$dry" == "true" ]]; then
88+
echo "--- DRY_RUN=true: nothing posted ---"
89+
return 0
90+
fi
91+
printf '%s\n' "$body" | gh api -X POST "repos/$REPO/issues/$pr/comments" -F body=@- >/dev/null
92+
echo "--- posted ---"
93+
}
94+
95+
cmd_release() {
96+
local prev="$1" ref="$2" range nums n info login utype assoc seen=' ' list=
97+
range="${prev:+$prev..}$ref"
98+
# Squash-merge subjects end in "(#N)"; one PR number per commit.
99+
nums="$(git log "$range" --no-merges --pretty=format:%s | sed -n 's/.*(#\([0-9][0-9]*\))$/\1/p' | sort -un)"
100+
for n in $nums; do
101+
info="$(gh api "repos/$REPO/pulls/$n" 2>/dev/null)" || continue
102+
login="$(jq -r '.user.login' <<<"$info")"
103+
utype="$(jq -r '.user.type' <<<"$info")"
104+
assoc="$(jq -r '.author_association' <<<"$info")"
105+
is_external "$login" "$utype" "$assoc" || continue
106+
[[ "$seen" == *" $login "* ]] && continue
107+
seen+="$login "; list="${list:+$list, }@$login"
108+
done
109+
[[ -n "$list" ]] || return 0
110+
printf '## Contributors\n\nThank you to our external contributors for this release: %s\n' "$list"
111+
}
112+
113+
if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then
114+
case "${1:-}" in
115+
thanks) cmd_thanks "${2:?pr number}" ;;
116+
release) cmd_release "${2-}" "${3:?ref}" ;;
117+
*) echo "usage: $0 thanks <pr> | release <prev-tag> <ref>" >&2; exit 2 ;;
118+
esac
119+
fi

‎scripts/test-contributors.sh‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
#!/usr/bin/env bash
2+
# Offline tests for scripts/contributors.sh (no network, no gh calls).
3+
set -uo pipefail
4+
cd "$(dirname "$0")/.." || exit 1
5+
export GITHUB_REPOSITORY=smart-mcp-proxy/mcpproxy-go
6+
# shellcheck source=scripts/contributors.sh
7+
source scripts/contributors.sh
8+
set +e
9+
fail=0
10+
ok() { printf 'ok %s\n' "$1"; }
11+
bad() { printf 'FAIL %s\n' "$1"; fail=1; }
12+
check() { local name="$1"; shift; if "$@"; then ok "$name"; else bad "$name"; fi; }
13+
refute() { local name="$1"; shift; if "$@"; then bad "$name"; else ok "$name"; fi; }
14+
15+
# eligibility
16+
check "external contributor" is_external loloDawit User CONTRIBUTOR
17+
check "first-time contributor" is_external newbie User FIRST_TIME_CONTRIBUTOR
18+
check "no association" is_external someone User NONE
19+
refute "maintainer Dumbris" is_external Dumbris User MEMBER
20+
refute "maintainer case-insensitive" is_external DUMBRIS User CONTRIBUTOR
21+
refute "maintainer algis-dumbris" is_external algis-dumbris User CONTRIBUTOR
22+
refute "dependabot" is_external 'dependabot[bot]' Bot NONE
23+
refute "any [bot] login" is_external 'renovate[bot]' User NONE
24+
refute "type Bot" is_external copilot Bot NONE
25+
refute "org member" is_external teammate User MEMBER
26+
refute "org owner" is_external boss User OWNER
27+
refute "empty login" is_external '' User NONE
28+
29+
# rendering
30+
url=https://github.com/smart-mcp-proxy/mcpproxy-go/pull/1
31+
first="$(render_comment alice 'fix: a thing' "$url" true)"
32+
repeat="$(render_comment alice 'fix: a thing' "$url" false)"
33+
check "marker present" grep -qF "$MARKER" <<<"$first"
34+
check "first-time line (first)" grep -q 'first merged contribution' <<<"$first"
35+
refute "first-time line (repeat)" grep -q 'first merged contribution' <<<"$repeat"
36+
check "snippet has PR url" grep -qF "just got merged: $url" <<<"$first"
37+
check "snippet has repo link" grep -qF "Repo: https://github.com/$GITHUB_REPOSITORY" <<<"$first"
38+
check "says sharing helps" grep -q 'helps a small open-source project get found' <<<"$first"
39+
check "says optional" grep -q 'entirely optional' <<<"$first"
40+
check "first under 900 chars" test "${#first}" -lt 900
41+
check "repeat under 900 chars" test "${#repeat}" -lt 900
42+
# shellcheck disable=SC2016
43+
refute "no emoji / non-ascii" bash -c 'LC_ALL=C grep -q "[^ -~]" <<<"$1"' _ "$first"
44+
45+
# hostile title
46+
evil="$(render_comment alice $'x @everyone `rm -rf` [a](http://evil) <img src=x>\nINJECT' "$url" false)"
47+
refute "title: raw @mention" grep -qE '@everyone' <<<"$evil"
48+
refute "title: backtick/link/html" grep -qE 'rm -rf`|\]\(http|<img' <<<"$evil"
49+
check "title: newline flattened" test "$(grep -c '^INJECT' <<<"$evil")" -eq 0
50+
long="$(render_comment alice "$(printf 'a%.0s' {1..500})" "$url" true)"
51+
check "long title still under 900" test "${#long}" -lt 900
52+
53+
exit $fail

0 commit comments

Comments
 (0)