Skip to content

Commit 65aab79

Browse files
rdimitrovclaude
andauthored
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>
1 parent 535941a commit 65aab79

2 files changed

Lines changed: 223 additions & 0 deletions

File tree

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
name: Close invalid publish PRs
2+
3+
# Stage 2 of 2 (see detect-invalid-publish-prs.yml for stage 1).
4+
#
5+
# Runs in the trusted base-repo context (triggered by workflow_run, not by the
6+
# PR itself), so it has a write token. It picks up the PR number flagged by
7+
# stage 1 and, after independently re-validating the PR from the API, posts a
8+
# comment pointing at the publishing docs, labels it `invalid`, and closes it.
9+
#
10+
# Trust boundary: the ONLY thing crossing from the untrusted stage is the PR
11+
# number (sanitized to digits below). We re-derive every decision (state,
12+
# author, changed files) here, so a wrong/stale artifact can never cause us to
13+
# close a PR that doesn't actually match.
14+
15+
on:
16+
workflow_run:
17+
workflows: ["Detect invalid publish PRs"]
18+
types: [completed]
19+
20+
permissions:
21+
contents: read
22+
pull-requests: write
23+
issues: write # labels on PRs go through the issues API
24+
actions: read # needed to list/download the stage-1 artifact
25+
26+
jobs:
27+
close:
28+
if: github.event.workflow_run.conclusion == 'success'
29+
runs-on: ubuntu-latest
30+
env:
31+
GH_TOKEN: ${{ github.token }}
32+
GH_REPO: ${{ github.repository }}
33+
RUN_ID: ${{ github.event.workflow_run.id }}
34+
COMMENT_BODY: |
35+
Hi @__AUTHOR__ 👋 — thanks for your interest in the MCP Registry!
36+
37+
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.
38+
39+
**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.
40+
41+
To publish your server, follow the **[Publishing Quickstart](https://github.com/modelcontextprotocol/registry/blob/main/docs/modelcontextprotocol-io/quickstart.mdx)**. In short:
42+
43+
```bash
44+
# Build the publisher CLI
45+
make publisher
46+
47+
# Authenticate (e.g. via GitHub) and publish your server.json
48+
./bin/mcp-publisher login github
49+
./bin/mcp-publisher publish
50+
```
51+
52+
Note: `data/seed.json` is seed data for **local development only** — adding entries there does not publish anything to the registry.
53+
54+
If you believe this was closed in error, please leave a comment and a maintainer will take a look. 🙇
55+
56+
_This is an automated message._
57+
58+
steps:
59+
- name: Check for flagged-pr artifact
60+
id: check
61+
run: |
62+
set -euo pipefail
63+
names="$(gh api "repos/$GH_REPO/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[].name' || true)"
64+
if printf '%s\n' "$names" | grep -qx "flagged-pr"; then
65+
echo "found=true" >> "$GITHUB_OUTPUT"
66+
else
67+
echo "No flagged-pr artifact on run $RUN_ID; nothing to do."
68+
echo "found=false" >> "$GITHUB_OUTPUT"
69+
fi
70+
71+
- name: Download flagged PR number
72+
if: steps.check.outputs.found == 'true'
73+
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
74+
with:
75+
name: flagged-pr
76+
run-id: ${{ github.event.workflow_run.id }}
77+
github-token: ${{ github.token }}
78+
79+
- name: Re-validate and close
80+
if: steps.check.outputs.found == 'true'
81+
run: |
82+
set -euo pipefail
83+
84+
# Sanitize: the only cross-boundary input. Keep digits only.
85+
PR_NUMBER="$(tr -dc '0-9' < pr-number.txt)"
86+
if [ -z "$PR_NUMBER" ]; then
87+
echo "No valid PR number in artifact; skipping."
88+
exit 0
89+
fi
90+
echo "Re-validating PR #$PR_NUMBER"
91+
92+
state="$(gh pr view "$PR_NUMBER" --json state --jq '.state')"
93+
if [ "$state" != "OPEN" ]; then
94+
echo "PR #$PR_NUMBER is $state, not OPEN; skipping."
95+
exit 0
96+
fi
97+
98+
assoc="$(gh pr view "$PR_NUMBER" --json authorAssociation --jq '.authorAssociation')"
99+
case "$assoc" in
100+
MEMBER|OWNER|COLLABORATOR)
101+
echo "Author association '$assoc' is trusted; skipping."
102+
exit 0
103+
;;
104+
esac
105+
106+
# Idempotency: if a maintainer reopens after we labeled it, leave it be.
107+
# Capture into a var first so a transient gh failure can't fail open
108+
# (an errored fetch piped to grep would otherwise look "not labeled").
109+
labels="$(gh pr view "$PR_NUMBER" --json labels --jq '.labels[].name' || true)"
110+
if printf '%s\n' "$labels" | grep -qx "invalid"; then
111+
echo "PR #$PR_NUMBER already labeled 'invalid'; skipping."
112+
exit 0
113+
fi
114+
115+
# Paginated REST endpoint (gh pr view --json files caps at 100, no pagination).
116+
mapfile -t FILES < <(gh api --paginate "repos/$GH_REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')
117+
if [ "${#FILES[@]}" -eq 0 ]; then
118+
echo "No files for PR #$PR_NUMBER; skipping."
119+
exit 0
120+
fi
121+
publish_attempt=0
122+
other=0
123+
for f in "${FILES[@]}"; do
124+
if [[ "$f" == servers/* || "$f" == "data/seed.json" ]]; then
125+
publish_attempt=1
126+
else
127+
other=1
128+
fi
129+
done
130+
if [ "$publish_attempt" -ne 1 ] || [ "$other" -ne 0 ]; then
131+
echo "PR #$PR_NUMBER no longer matches the publish-attempt pattern; skipping."
132+
exit 0
133+
fi
134+
135+
author="$(gh pr view "$PR_NUMBER" --json author --jq '.author.login')"
136+
body="${COMMENT_BODY//__AUTHOR__/$author}"
137+
138+
echo "Commenting on, labeling, and closing PR #$PR_NUMBER"
139+
gh pr comment "$PR_NUMBER" --body "$body"
140+
gh pr edit "$PR_NUMBER" --add-label "invalid" || echo "Could not add label; continuing."
141+
gh pr close "$PR_NUMBER"
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
name: Detect invalid publish PRs
2+
3+
# Stage 1 of 2 (see close-invalid-publish-prs.yml for stage 2).
4+
#
5+
# Some contributors try to "publish" a server by opening a PR that adds files
6+
# under servers/ or edits data/seed.json. That is not how publishing works -
7+
# servers are published with the mcp-publisher CLI against the live registry
8+
# API. This job only DETECTS such PRs. It runs from the (untrusted) PR context
9+
# with a read-only token, so it cannot comment on or close anything. When it
10+
# finds a match it records just the PR number as an artifact; the privileged
11+
# stage-2 workflow picks that up via workflow_run and acts on it.
12+
#
13+
# We never check out or run the PR's code - we only read changed file paths.
14+
15+
on:
16+
pull_request:
17+
types: [opened, reopened]
18+
19+
permissions:
20+
contents: read
21+
pull-requests: read
22+
23+
jobs:
24+
detect:
25+
runs-on: ubuntu-latest
26+
env:
27+
GH_TOKEN: ${{ github.token }}
28+
GH_REPO: ${{ github.repository }}
29+
PR_NUMBER: ${{ github.event.pull_request.number }}
30+
AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }}
31+
steps:
32+
- name: Classify PR
33+
id: classify
34+
run: |
35+
set -euo pipefail
36+
match=false
37+
38+
# Maintainers may legitimately touch these files (e.g. dev seed data),
39+
# so never flag their PRs.
40+
case "$AUTHOR_ASSOCIATION" in
41+
MEMBER|OWNER|COLLABORATOR)
42+
echo "Author association '$AUTHOR_ASSOCIATION' is trusted; not flagging."
43+
;;
44+
*)
45+
# Use the paginated REST endpoint: `gh pr view --json files` caps
46+
# at 100 files with no pagination, which could truncate a large PR
47+
# and cause a false match.
48+
mapfile -t FILES < <(gh api --paginate "repos/$GH_REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')
49+
if [ "${#FILES[@]}" -gt 0 ]; then
50+
# Flag only if the PR touches *exclusively* publish-attempt files
51+
# (servers/** and/or data/seed.json) and nothing else, so that
52+
# legit PRs touching seed data alongside real code are left alone.
53+
publish_attempt=0
54+
other=0
55+
for f in "${FILES[@]}"; do
56+
if [[ "$f" == servers/* || "$f" == "data/seed.json" ]]; then
57+
publish_attempt=1
58+
else
59+
echo "Non-publish file changed: $f"
60+
other=1
61+
fi
62+
done
63+
if [ "$publish_attempt" -eq 1 ] && [ "$other" -eq 0 ]; then
64+
match=true
65+
fi
66+
fi
67+
;;
68+
esac
69+
70+
echo "PR #$PR_NUMBER match=$match"
71+
echo "match=$match" >> "$GITHUB_OUTPUT"
72+
if [ "$match" = "true" ]; then
73+
echo "$PR_NUMBER" > pr-number.txt
74+
fi
75+
76+
- name: Record flagged PR number
77+
if: steps.classify.outputs.match == 'true'
78+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4
79+
with:
80+
name: flagged-pr
81+
path: pr-number.txt
82+
retention-days: 1

0 commit comments

Comments
 (0)