Skip to content
Merged
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
71 changes: 71 additions & 0 deletions .github/workflows/contributor-thanks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
name: Contributor Thanks

# Experiment E5: when an external contributor's PR is merged, post one
# thank-you comment (as github-actions[bot]) with an optional, copy-paste
# share snippet. Consent-based: nothing is ever posted on the contributor's
# behalf. Logic lives in scripts/contributors.sh (unit-tested by
# scripts/test-contributors.sh).
#
# SECURITY: pull_request_target runs with a write token. This workflow
# checks out only the DEFAULT branch (trusted code), never the PR head, and
# reads the PR through the API. PR text is data and is never interpolated
# into a shell command.
#
# ROLLOUT: until the repository variable CONTRIBUTOR_THANKS_LIVE is set to
# "true" (Settings > Secrets and variables > Actions > Variables), every run
# is a dry run: the comment is printed in the job log and nothing is posted.
# Manual dry run for any merged PR: Actions > Contributor Thanks > Run
# workflow (posting from there needs live=true AND dry_run=false).

on:
pull_request_target:
types: [closed]
workflow_dispatch:
inputs:
pr_number:
description: Merged PR number to render the comment for
required: true
dry_run:
description: Print the comment instead of posting it
type: boolean
default: true

permissions: {}

jobs:
thank:
# Cheap pre-filter; the script re-checks everything from the API.
if: >
github.event_name == 'workflow_dispatch' ||
(github.event.pull_request.merged == true &&
github.event.pull_request.user.type != 'Bot' &&
!endsWith(github.event.pull_request.user.login, '[bot]') &&
github.event.pull_request.user.login != 'Dumbris' &&
github.event.pull_request.user.login != 'algis-dumbris' &&
github.event.pull_request.author_association != 'OWNER' &&
github.event.pull_request.author_association != 'MEMBER')
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
pull-requests: write
issues: write
concurrency:
group: contributor-thanks-${{ github.event.pull_request.number || inputs.pr_number }}
cancel-in-progress: false
steps:
- name: Checkout trusted scripts (default branch only)
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.repository.default_branch }}
sparse-checkout: scripts/contributors.sh
persist-credentials: false

- name: Post thank-you comment
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }}
# Live only when the repo variable is "true" AND this is not a manual dry run.
DRY_RUN: ${{ (vars.CONTRIBUTOR_THANKS_LIVE == 'true' && inputs.dry_run != true) && 'false' || 'true' }}
run: bash scripts/contributors.sh thanks "$PR_NUMBER"
20 changes: 20 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ jobs:
outputs:
notes: ${{ steps.generate.outputs.notes }}
notes_file: ${{ steps.generate.outputs.notes_file }}
contributors: ${{ steps.contributors.outputs.section }}

steps:
- name: Checkout
Expand Down Expand Up @@ -79,6 +80,23 @@ jobs:

echo "Collected $(echo "$COMMITS" | wc -l) commits"

# Deterministic credit for outside contributors. Kept out of the LLM
# summary above so a name can never be dropped or invented. Lists the
# authors of PRs merged since the previous stable tag, excluding
# maintainers, bots and org members (scripts/contributors.sh).
- name: Collect contributors
id: contributors
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PREV_TAG: ${{ steps.prev_tag.outputs.tag }}
run: |
SECTION=$(bash scripts/contributors.sh release "$PREV_TAG" HEAD || true)
{
echo "section<<CONTRIBUTORS_EOF"
echo "$SECTION"
echo "CONTRIBUTORS_EOF"
} >> "$GITHUB_OUTPUT"

- name: Generate release notes with Claude
id: generate
env:
Expand Down Expand Up @@ -1531,6 +1549,8 @@ jobs:

${{ needs.generate-notes.outputs.notes }}

${{ needs.generate-notes.outputs.contributors }}

---

## Download Installers
Expand Down
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,7 @@ go test -race ./internal/... -v # With the race detector

See [CLAUDE.md](CLAUDE.md) and the [docs](https://docs.mcpproxy.app) for the
full development and testing reference.

## Recognition

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`.
119 changes: 119 additions & 0 deletions scripts/contributors.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
#!/usr/bin/env bash
# Contributor recognition helpers (experiment E5).
#
# contributors.sh thanks <pr-number> # post (or dry-run) the thank-you comment
# contributors.sh release <prev-tag> <ref> # print a "Contributors" markdown section
#
# Environment: GH_TOKEN, GITHUB_REPOSITORY. DRY_RUN (thanks only) defaults to
# "true": the comment is printed, never posted. Set DRY_RUN=false to post.
#
# Safety: reads the GitHub API only. It never checks out, builds or executes
# anything from a pull request, so it is safe under pull_request_target.
# Untrusted PR text (title, login) is handled as data and never reaches eval.
set -euo pipefail

REPO="${GITHUB_REPOSITORY:-smart-mcp-proxy/mcpproxy-go}"
MARKER='<!-- contributor-thanks:v1 -->'
MAINTAINERS='Dumbris algis-dumbris'

# is_external <login> <user-type> <author-association>
# Succeeds when the PR author is an outside contributor worth thanking.
is_external() {
local login="$1" utype="$2" assoc="$3" m
[[ -n "$login" ]] || return 1
[[ "$utype" != "Bot" && "$login" != *"[bot]" ]] || return 1
local l; l="$(printf '%s' "$login" | tr '[:upper:]' '[:lower:]')"
for m in $MAINTAINERS; do
[[ "$l" != "$(printf '%s' "$m" | tr '[:upper:]' '[:lower:]')" ]] || return 1
done
[[ "$assoc" != "OWNER" && "$assoc" != "MEMBER" ]] || return 1
return 0
}

# neutralize <text>: one line, no @mentions, no backticks/markdown links (PR titles are untrusted).
neutralize() {
printf '%s' "$1" | tr '\r\n\t' ' ' | sed -e 's/@/@\xE2\x80\x8B/g' -e 's/[]`<>[]//g' | cut -c1-120
}

# render_comment <login> <title> <pr-url> <first-time:true|false>
render_comment() {
local login="$1" title url="$3" first="$4"
title="$(neutralize "$2")"
printf '%s\n' "$MARKER"
printf 'Thank you @%s! Your PR "%s" is merged and will ship in the next release.\n\n' "$login" "$title"
if [[ "$first" == "true" ]]; then
printf 'This is your first merged contribution to MCPProxy. Welcome, and congratulations!\n\n'
fi
printf 'If you would like to mention it, here is a snippet you can copy and edit:\n\n'
printf '```text\n'
printf 'My PR to MCPProxy, an open-source MCP proxy for Claude Code, Codex and Cursor, just got merged: %s\n' "$url"
printf 'Repo: https://github.com/%s\n' "$REPO"
printf '```\n\n'
printf 'Sharing genuinely helps a small open-source project get found. It is entirely optional, and we are grateful either way.\n'
}

cmd_thanks() {
local pr="$1" dry="${DRY_RUN:-true}" info login utype assoc title url merged others first body existing
info="$(gh api "repos/$REPO/pulls/$pr")"
merged="$(jq -r '.merged' <<<"$info")"
login="$(jq -r '.user.login' <<<"$info")"
utype="$(jq -r '.user.type' <<<"$info")"
assoc="$(jq -r '.author_association' <<<"$info")"
title="$(jq -r '.title' <<<"$info")"
url="$(jq -r '.html_url' <<<"$info")"

[[ "$merged" == "true" ]] || { echo "skip: PR #$pr is not merged"; return 0; }
is_external "$login" "$utype" "$assoc" || { echo "skip: $login ($utype, $assoc) is not an external contributor"; return 0; }

# Only the bot's own comments count (a commenter cannot suppress the thanks by
# pasting the marker). Read all comments first: piping into `grep -q` can SIGPIPE gh and, under
# pipefail, look like "marker not found" (a duplicate comment).
existing="$(gh api --paginate "repos/$REPO/issues/$pr/comments" --jq '.[] | select(.user.login == "github-actions[bot]") | .body')"
if grep -qF "$MARKER" <<<"$existing"; then
echo "skip: PR #$pr already has a thank-you comment"; return 0
fi

# First contribution = no OTHER merged PR by this author. Counting "others"
# (instead of total == 1) keeps this right while the search index lags behind
# the merge we are reacting to. A failed or incomplete search degrades to "not first". Known
# limit: two first-timer PRs merged within the index lag may both claim "first".
others="$(gh api -X GET search/issues -f q="repo:$REPO is:pr is:merged author:$login" -f per_page=5 \
--jq "if .incomplete_results then -1 else [.items[].number | select(. != $pr)] | length end" 2>/dev/null)" || others=-1
first=false; [[ "$others" == "0" ]] && first=true

body="$(render_comment "$login" "$title" "$url" "$first")"
echo "--- comment for PR #$pr (author=$login assoc=$assoc other_merged_prs=$others first=$first, ${#body} chars) ---"
printf '%s\n' "$body"
if [[ "$dry" == "true" ]]; then
echo "--- DRY_RUN=true: nothing posted ---"
return 0
fi
printf '%s\n' "$body" | gh api -X POST "repos/$REPO/issues/$pr/comments" -F body=@- >/dev/null
echo "--- posted ---"
}

cmd_release() {
local prev="$1" ref="$2" range nums n info login utype assoc seen=' ' list=
range="${prev:+$prev..}$ref"
# Squash-merge subjects end in "(#N)"; one PR number per commit.
nums="$(git log "$range" --no-merges --pretty=format:%s | sed -n 's/.*(#\([0-9][0-9]*\))$/\1/p' | sort -un)"
for n in $nums; do
info="$(gh api "repos/$REPO/pulls/$n" 2>/dev/null)" || continue
login="$(jq -r '.user.login' <<<"$info")"
utype="$(jq -r '.user.type' <<<"$info")"
assoc="$(jq -r '.author_association' <<<"$info")"
is_external "$login" "$utype" "$assoc" || continue
[[ "$seen" == *" $login "* ]] && continue
seen+="$login "; list="${list:+$list, }@$login"
done
[[ -n "$list" ]] || return 0
printf '## Contributors\n\nThank you to our external contributors for this release: %s\n' "$list"
}

if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then
case "${1:-}" in
thanks) cmd_thanks "${2:?pr number}" ;;
release) cmd_release "${2-}" "${3:?ref}" ;;
*) echo "usage: $0 thanks <pr> | release <prev-tag> <ref>" >&2; exit 2 ;;
esac
fi
53 changes: 53 additions & 0 deletions scripts/test-contributors.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Offline tests for scripts/contributors.sh (no network, no gh calls).
set -uo pipefail
cd "$(dirname "$0")/.." || exit 1
export GITHUB_REPOSITORY=smart-mcp-proxy/mcpproxy-go
# shellcheck source=scripts/contributors.sh
source scripts/contributors.sh
set +e
fail=0
ok() { printf 'ok %s\n' "$1"; }
bad() { printf 'FAIL %s\n' "$1"; fail=1; }
check() { local name="$1"; shift; if "$@"; then ok "$name"; else bad "$name"; fi; }
refute() { local name="$1"; shift; if "$@"; then bad "$name"; else ok "$name"; fi; }

# eligibility
check "external contributor" is_external loloDawit User CONTRIBUTOR
check "first-time contributor" is_external newbie User FIRST_TIME_CONTRIBUTOR
check "no association" is_external someone User NONE
refute "maintainer Dumbris" is_external Dumbris User MEMBER
refute "maintainer case-insensitive" is_external DUMBRIS User CONTRIBUTOR
refute "maintainer algis-dumbris" is_external algis-dumbris User CONTRIBUTOR
refute "dependabot" is_external 'dependabot[bot]' Bot NONE
refute "any [bot] login" is_external 'renovate[bot]' User NONE
refute "type Bot" is_external copilot Bot NONE
refute "org member" is_external teammate User MEMBER
refute "org owner" is_external boss User OWNER
refute "empty login" is_external '' User NONE

# rendering
url=https://github.com/smart-mcp-proxy/mcpproxy-go/pull/1
first="$(render_comment alice 'fix: a thing' "$url" true)"
repeat="$(render_comment alice 'fix: a thing' "$url" false)"
check "marker present" grep -qF "$MARKER" <<<"$first"
check "first-time line (first)" grep -q 'first merged contribution' <<<"$first"
refute "first-time line (repeat)" grep -q 'first merged contribution' <<<"$repeat"
check "snippet has PR url" grep -qF "just got merged: $url" <<<"$first"
check "snippet has repo link" grep -qF "Repo: https://github.com/$GITHUB_REPOSITORY" <<<"$first"
check "says sharing helps" grep -q 'helps a small open-source project get found' <<<"$first"
check "says optional" grep -q 'entirely optional' <<<"$first"
check "first under 900 chars" test "${#first}" -lt 900
check "repeat under 900 chars" test "${#repeat}" -lt 900
# shellcheck disable=SC2016
refute "no emoji / non-ascii" bash -c 'LC_ALL=C grep -q "[^ -~]" <<<"$1"' _ "$first"

# hostile title
evil="$(render_comment alice $'x @everyone `rm -rf` [a](http://evil) <img src=x>\nINJECT' "$url" false)"
refute "title: raw @mention" grep -qE '@everyone' <<<"$evil"
refute "title: backtick/link/html" grep -qE 'rm -rf`|\]\(http|<img' <<<"$evil"
check "title: newline flattened" test "$(grep -c '^INJECT' <<<"$evil")" -eq 0
long="$(render_comment alice "$(printf 'a%.0s' {1..500})" "$url" true)"
check "long title still under 900" test "${#long}" -lt 900

exit $fail
Loading