ci: auto-close PRs that try to publish servers via the repo (#1393)
## Summary We keep getting PRs (e.g. #1385, #1368, #1367, #1363, #1354) that try to "publish" an MCP server by adding files under `servers/` or editing `data/seed.json`. That isn't how publishing works — servers are published with the `mcp-publisher` CLI against the live registry API. This adds automation that detects those PRs, comments with a pointer to the publishing quickstart, labels them `invalid`, and closes them. ## How it works (two stages, no `pull_request_target`) `pull_request_target` is intentionally avoided. Instead: - **Stage 1 — `detect-invalid-publish-prs.yml`** runs on `pull_request` (opened/reopened) with a **read-only** token. It only reads the changed file paths and, if the PR *exclusively* touches `servers/**` and/or `data/seed.json` (and the author isn't a MEMBER/OWNER/COLLABORATOR), records just the PR number as a short-lived artifact. It can't comment or close anything. - **Stage 2 — `close-invalid-publish-prs.yml`** runs via `workflow_run` in the trusted base-repo context (write token). It picks up the flagged PR number, **independently re-validates** the PR from the API (still open, external author, files still match, not already labeled), then comments, labels `invalid`, and closes. ### Trust boundary The only thing crossing from the untrusted stage to the privileged stage is the **PR number**, sanitized to digits only. Stage 2 never trusts stage 1's verdict — it re-derives every decision. Neither stage ever checks out or runs PR code. ### Safety against false positives - Fires only when a PR touches *exclusively* `servers/**` and/or `data/seed.json` — a real code PR that also happens to touch seed data is left alone. - Skips `MEMBER`/`OWNER`/`COLLABORATOR` authors, so maintainers editing dev seed data aren't auto-closed. - Idempotent: skips PRs already labeled `invalid` (e.g. if a maintainer reopens one). ## Notes - Like any `pull_request`/`workflow_run` automation, this only takes effect **once merged to `main`** — it is not retroactive and won't run on this PR. The currently-open offending PRs should be closed manually. - Pinned action SHAs match the repo's existing convention (`upload-artifact` reuses the SHA already in `ci.yml`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
535941a30e
commit
65aab79997
2 changed files with 223 additions and 0 deletions
141
.github/workflows/close-invalid-publish-prs.yml
vendored
Normal file
141
.github/workflows/close-invalid-publish-prs.yml
vendored
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
name: Close invalid publish PRs
|
||||
|
||||
# Stage 2 of 2 (see detect-invalid-publish-prs.yml for stage 1).
|
||||
#
|
||||
# Runs in the trusted base-repo context (triggered by workflow_run, not by the
|
||||
# PR itself), so it has a write token. It picks up the PR number flagged by
|
||||
# stage 1 and, after independently re-validating the PR from the API, posts a
|
||||
# comment pointing at the publishing docs, labels it `invalid`, and closes it.
|
||||
#
|
||||
# Trust boundary: the ONLY thing crossing from the untrusted stage is the PR
|
||||
# number (sanitized to digits below). We re-derive every decision (state,
|
||||
# author, changed files) here, so a wrong/stale artifact can never cause us to
|
||||
# close a PR that doesn't actually match.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Detect invalid publish PRs"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write # labels on PRs go through the issues API
|
||||
actions: read # needed to list/download the stage-1 artifact
|
||||
|
||||
jobs:
|
||||
close:
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
COMMENT_BODY: |
|
||||
Hi @__AUTHOR__ 👋 — thanks for your interest in the MCP Registry!
|
||||
|
||||
It looks like this PR is trying to publish an MCP server by adding or editing files in this repository (under `servers/` or in `data/seed.json`). That isn't how servers get published, so I'm closing this PR automatically.
|
||||
|
||||
**Servers are published with the [`mcp-publisher`](https://github.com/modelcontextprotocol/registry/blob/main/docs/modelcontextprotocol-io/quickstart.mdx) CLI**, not by opening a pull request against this repo. The CLI verifies that you own your namespace and submits your `server.json` directly to the live registry API.
|
||||
|
||||
To publish your server, follow the **[Publishing Quickstart](https://github.com/modelcontextprotocol/registry/blob/main/docs/modelcontextprotocol-io/quickstart.mdx)**. In short:
|
||||
|
||||
```bash
|
||||
# Build the publisher CLI
|
||||
make publisher
|
||||
|
||||
# Authenticate (e.g. via GitHub) and publish your server.json
|
||||
./bin/mcp-publisher login github
|
||||
./bin/mcp-publisher publish
|
||||
```
|
||||
|
||||
Note: `data/seed.json` is seed data for **local development only** — adding entries there does not publish anything to the registry.
|
||||
|
||||
If you believe this was closed in error, please leave a comment and a maintainer will take a look. 🙇
|
||||
|
||||
_This is an automated message._
|
||||
|
||||
steps:
|
||||
- name: Check for flagged-pr artifact
|
||||
id: check
|
||||
run: |
|
||||
set -euo pipefail
|
||||
names="$(gh api "repos/$GH_REPO/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[].name' || true)"
|
||||
if printf '%s\n' "$names" | grep -qx "flagged-pr"; then
|
||||
echo "found=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No flagged-pr artifact on run $RUN_ID; nothing to do."
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Download flagged PR number
|
||||
if: steps.check.outputs.found == 'true'
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: flagged-pr
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Re-validate and close
|
||||
if: steps.check.outputs.found == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Sanitize: the only cross-boundary input. Keep digits only.
|
||||
PR_NUMBER="$(tr -dc '0-9' < pr-number.txt)"
|
||||
if [ -z "$PR_NUMBER" ]; then
|
||||
echo "No valid PR number in artifact; skipping."
|
||||
exit 0
|
||||
fi
|
||||
echo "Re-validating PR #$PR_NUMBER"
|
||||
|
||||
state="$(gh pr view "$PR_NUMBER" --json state --jq '.state')"
|
||||
if [ "$state" != "OPEN" ]; then
|
||||
echo "PR #$PR_NUMBER is $state, not OPEN; skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
assoc="$(gh pr view "$PR_NUMBER" --json authorAssociation --jq '.authorAssociation')"
|
||||
case "$assoc" in
|
||||
MEMBER|OWNER|COLLABORATOR)
|
||||
echo "Author association '$assoc' is trusted; skipping."
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# Idempotency: if a maintainer reopens after we labeled it, leave it be.
|
||||
# Capture into a var first so a transient gh failure can't fail open
|
||||
# (an errored fetch piped to grep would otherwise look "not labeled").
|
||||
labels="$(gh pr view "$PR_NUMBER" --json labels --jq '.labels[].name' || true)"
|
||||
if printf '%s\n' "$labels" | grep -qx "invalid"; then
|
||||
echo "PR #$PR_NUMBER already labeled 'invalid'; skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Paginated REST endpoint (gh pr view --json files caps at 100, no pagination).
|
||||
mapfile -t FILES < <(gh api --paginate "repos/$GH_REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')
|
||||
if [ "${#FILES[@]}" -eq 0 ]; then
|
||||
echo "No files for PR #$PR_NUMBER; skipping."
|
||||
exit 0
|
||||
fi
|
||||
publish_attempt=0
|
||||
other=0
|
||||
for f in "${FILES[@]}"; do
|
||||
if [[ "$f" == servers/* || "$f" == "data/seed.json" ]]; then
|
||||
publish_attempt=1
|
||||
else
|
||||
other=1
|
||||
fi
|
||||
done
|
||||
if [ "$publish_attempt" -ne 1 ] || [ "$other" -ne 0 ]; then
|
||||
echo "PR #$PR_NUMBER no longer matches the publish-attempt pattern; skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
author="$(gh pr view "$PR_NUMBER" --json author --jq '.author.login')"
|
||||
body="${COMMENT_BODY//__AUTHOR__/$author}"
|
||||
|
||||
echo "Commenting on, labeling, and closing PR #$PR_NUMBER"
|
||||
gh pr comment "$PR_NUMBER" --body "$body"
|
||||
gh pr edit "$PR_NUMBER" --add-label "invalid" || echo "Could not add label; continuing."
|
||||
gh pr close "$PR_NUMBER"
|
||||
82
.github/workflows/detect-invalid-publish-prs.yml
vendored
Normal file
82
.github/workflows/detect-invalid-publish-prs.yml
vendored
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
name: Detect invalid publish PRs
|
||||
|
||||
# Stage 1 of 2 (see close-invalid-publish-prs.yml for stage 2).
|
||||
#
|
||||
# Some contributors try to "publish" a server by opening a PR that adds files
|
||||
# under servers/ or edits data/seed.json. That is not how publishing works -
|
||||
# servers are published with the mcp-publisher CLI against the live registry
|
||||
# API. This job only DETECTS such PRs. It runs from the (untrusted) PR context
|
||||
# with a read-only token, so it cannot comment on or close anything. When it
|
||||
# finds a match it records just the PR number as an artifact; the privileged
|
||||
# stage-2 workflow picks that up via workflow_run and acts on it.
|
||||
#
|
||||
# We never check out or run the PR's code - we only read changed file paths.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
detect:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }}
|
||||
steps:
|
||||
- name: Classify PR
|
||||
id: classify
|
||||
run: |
|
||||
set -euo pipefail
|
||||
match=false
|
||||
|
||||
# Maintainers may legitimately touch these files (e.g. dev seed data),
|
||||
# so never flag their PRs.
|
||||
case "$AUTHOR_ASSOCIATION" in
|
||||
MEMBER|OWNER|COLLABORATOR)
|
||||
echo "Author association '$AUTHOR_ASSOCIATION' is trusted; not flagging."
|
||||
;;
|
||||
*)
|
||||
# Use the paginated REST endpoint: `gh pr view --json files` caps
|
||||
# at 100 files with no pagination, which could truncate a large PR
|
||||
# and cause a false match.
|
||||
mapfile -t FILES < <(gh api --paginate "repos/$GH_REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')
|
||||
if [ "${#FILES[@]}" -gt 0 ]; then
|
||||
# Flag only if the PR touches *exclusively* publish-attempt files
|
||||
# (servers/** and/or data/seed.json) and nothing else, so that
|
||||
# legit PRs touching seed data alongside real code are left alone.
|
||||
publish_attempt=0
|
||||
other=0
|
||||
for f in "${FILES[@]}"; do
|
||||
if [[ "$f" == servers/* || "$f" == "data/seed.json" ]]; then
|
||||
publish_attempt=1
|
||||
else
|
||||
echo "Non-publish file changed: $f"
|
||||
other=1
|
||||
fi
|
||||
done
|
||||
if [ "$publish_attempt" -eq 1 ] && [ "$other" -eq 0 ]; then
|
||||
match=true
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "PR #$PR_NUMBER match=$match"
|
||||
echo "match=$match" >> "$GITHUB_OUTPUT"
|
||||
if [ "$match" = "true" ]; then
|
||||
echo "$PR_NUMBER" > pr-number.txt
|
||||
fi
|
||||
|
||||
- name: Record flagged PR number
|
||||
if: steps.classify.outputs.match == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4
|
||||
with:
|
||||
name: flagged-pr
|
||||
path: pr-number.txt
|
||||
retention-days: 1
|
||||
Loading…
Reference in a new issue