#1524 tried to publish by adding `data/servers/io.decisionrules/server.json`. The detector ran fine and logged: ``` Non-publish file changed: data/servers/io.decisionrules/server.json PR #1524 match=false ``` The path list only knew `servers/**` at the repo root and exactly `data/seed.json`. Putting the file under `data/servers/` is a natural guess given `data/seed.json` exists, and it fell straight through. Adds `data/servers/**` plus root-level `server.json` / `servers.json`. `find` confirms nothing legitimate lives at any of those paths today. The list stays explicit rather than becoming a broad `**/server.json` glob. The classifier only flags a PR when *every* changed file matches, which makes widening asymmetric: too narrow silently misses spam, too broad auto-closes a legitimate single-file PR. Both copies of the list (stage 1 and stage 2) are updated identically, with a comment on each pointing at the other. ## Testing `actionlint` clean. macOS ships bash 3.2, so I extracted the literal `if [[ ... ]]` condition from each workflow file and ran it under bash 5 in Docker — testing the shipped text rather than a retyped copy. Both copies are byte-identical and agree: ``` MATCH data/servers/io.decisionrules/server.json # PR 1524 MATCH servers/com.aradia/x.json # PR 1549 MATCH data/seed.json MATCH server.json MATCH servers.json keep internal/api/handler.go keep internal/servers/service.go keep data/servers.md keep docs/README.md ``` Also dry-ran the full classifier against all 35 currently-open PRs from untrusted authors: **0 would be closed**. No legitimate open PR is affected. ## Known remaining gap (deliberately not fixed here) The detector does not run at all for authors new to GitHub. This repo's Actions approval policy is `first_time_contributors_new_to_github`, so their fork PRs sit in `action_required` until a maintainer approves the run. That is why #1549 was never flagged. I'm leaving that alone for now because the numbers don't justify the fix. Since the automation landed on 2026-07-09 it has had **71 detect runs: 70 ran, 1 was gated**. It has correctly closed 3 publish attempts (SAGE, Diagram Generation API, Kairos Signal) and missed 2 — one to the path bug this PR fixes, one to the gate. So the gate costs roughly one missed PR every six weeks, and those get closed manually within a day. The only ways around it are `pull_request_target` (runs untrusted PRs in a privileged context — one future `actions/checkout` from being a privilege-escalation hole) or a scheduled sweep (works, but trades the two-stage design for cron semantics and a ~15 min delay). Neither seems worth it at that rate. Happy to open a follow-up if you'd rather close the gap. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
94 lines
3.7 KiB
YAML
94 lines
3.7 KiB
YAML
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
|
|
# and nothing else, so that legit PRs touching seed data
|
|
# alongside real code are left alone.
|
|
#
|
|
# Keep this list in sync with the copy in stage 2. It stays an
|
|
# explicit list rather than a broad `**/server.json` glob:
|
|
# because we require *every* file to match, an over-broad
|
|
# pattern would auto-close a legitimate single-file PR.
|
|
# data/servers/** is here because #1524 put its server.json
|
|
# there - a natural guess next to data/seed.json - and slipped
|
|
# through the original servers/**-only list.
|
|
publish_attempt=0
|
|
other=0
|
|
for f in "${FILES[@]}"; do
|
|
if [[ "$f" == servers/* \
|
|
|| "$f" == data/servers/* \
|
|
|| "$f" == "data/seed.json" \
|
|
|| "$f" == "server.json" \
|
|
|| "$f" == "servers.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
|