diff --git a/.github/workflows/contributor-thanks.yml b/.github/workflows/contributor-thanks.yml new file mode 100644 index 000000000..e8f00af8a --- /dev/null +++ b/.github/workflows/contributor-thanks.yml @@ -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" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ab45668e..c00626fd7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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<> "$GITHUB_OUTPUT" + - name: Generate release notes with Claude id: generate env: @@ -1531,6 +1549,8 @@ jobs: ${{ needs.generate-notes.outputs.notes }} + ${{ needs.generate-notes.outputs.contributors }} + --- ## Download Installers diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 07832a19f..0622d2cb8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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`. diff --git a/scripts/contributors.sh b/scripts/contributors.sh new file mode 100755 index 000000000..e45046878 --- /dev/null +++ b/scripts/contributors.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# Contributor recognition helpers (experiment E5). +# +# contributors.sh thanks # post (or dry-run) the thank-you comment +# contributors.sh release # 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='' +MAINTAINERS='Dumbris algis-dumbris' + +# is_external +# 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 : 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 <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 diff --git a/scripts/test-contributors.sh b/scripts/test-contributors.sh new file mode 100755 index 000000000..e6df9e4d2 --- /dev/null +++ b/scripts/test-contributors.sh @@ -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