Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6affe5c0d3 |
||
|
|
9972c21aa4 |
||
|
|
fd66270926 |
||
|
|
08a3bc8eaf |
||
|
|
7bb486a10f |
||
|
|
0c9136841f |
||
|
|
9771e6b59c |
||
|
|
a925e55893 |
||
|
|
e8b94867ac |
||
|
|
c6762e821c |
||
|
|
5fd3abc548 |
||
|
|
a2dab7e2dd |
||
|
|
2e0a18199a |
||
|
|
10dc173637 |
||
|
|
979208c115 |
||
|
|
d060b36e1d |
||
|
|
d2290ca343 |
||
|
|
6705402e24 |
||
|
|
e7284ed130 |
||
|
|
0921d94a74 |
||
|
|
4d6f87e8d2 |
||
|
|
c5d7d0b6d0 |
||
|
|
d8b638304e |
||
|
|
56af44727b |
||
|
|
f1c40b062f |
||
|
|
57394b0548 |
||
|
|
0cee6249ba |
||
|
|
0d92192765 |
||
|
|
b2025ab815 |
||
|
|
9057285683 |
||
|
|
2a1cc94493 |
||
|
|
e473cca6a1 |
||
|
|
fb443cc4b2 |
||
|
|
37b3cb1ef7 |
||
|
|
31b76cbeed |
||
|
|
959569ba15 |
||
|
|
fc41dd56c8 |
||
|
|
5285e936a9 |
||
|
|
52ad0a8876 |
||
|
|
14aa889992 |
||
|
|
2378e560fb |
||
|
|
6e304527a5 |
||
|
|
a4f4ccd091 |
||
|
|
fe47969fb9 |
||
|
|
d82ed88eb5 |
||
|
|
b31ddf37ed |
||
|
|
6f69a3758e |
||
|
|
78e6fbb7e4 |
||
|
|
af06330a31 |
||
|
|
68ca87e20b |
||
|
|
c9c431b71a |
||
|
|
528e366558 |
||
|
|
27f5cc7a46 |
||
|
|
89c5e700f2 |
||
|
|
b61ce388dd |
||
|
|
b7c9a916d6 |
||
|
|
923341c98a |
||
|
|
e8ef138153 |
||
|
|
d3ffe87960 |
||
|
|
b9422f1c9b |
1081 changed files with 107610 additions and 5030 deletions
17
.github/ISSUE_TEMPLATE/bug.yaml
vendored
17
.github/ISSUE_TEMPLATE/bug.yaml
vendored
|
|
@ -11,13 +11,26 @@ body:
|
||||||
id: checks
|
id: checks
|
||||||
attributes:
|
attributes:
|
||||||
label: Initial Checks
|
label: Initial Checks
|
||||||
description: Just making sure you're using the latest version of MCP Python SDK.
|
description: >
|
||||||
|
Both the 2.x stable line and the 1.x maintenance line are supported, and only
|
||||||
|
the newest release of each line receives fixes.
|
||||||
options:
|
options:
|
||||||
- label: I confirm that I'm using the latest version of MCP Python SDK
|
- label: I confirm that I'm using the newest release of my line (the latest 2.x, or the latest 1.x if I'm still on v1)
|
||||||
required: true
|
required: true
|
||||||
- label: I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
|
- label: I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: release-line
|
||||||
|
attributes:
|
||||||
|
label: Release line
|
||||||
|
description: Which major version of the SDK are you using?
|
||||||
|
options:
|
||||||
|
- 2.x (current stable)
|
||||||
|
- 1.x (maintenance line, v1.x branch)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: description
|
id: description
|
||||||
attributes:
|
attributes:
|
||||||
|
|
|
||||||
6
.github/ISSUE_TEMPLATE/v2-feedback.yaml
vendored
6
.github/ISSUE_TEMPLATE/v2-feedback.yaml
vendored
|
|
@ -1,15 +1,15 @@
|
||||||
name: v2 feedback
|
name: v2 feedback
|
||||||
description: Bugs, API friction, or docs gaps in v2 of the SDK
|
description: Bugs, API friction, or docs gaps in v2 of the SDK
|
||||||
title: "[v2] "
|
title: "[v2] "
|
||||||
labels: ["v2-alpha"]
|
labels: ["v2"]
|
||||||
|
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Thanks for trying v2. Anything that broke, surprised you, or slowed you down is useful — API feedback is explicitly welcome while v2 is in pre-release.
|
Anything in v2 that broke, surprised you, or slowed you down is useful, including API friction and docs gaps.
|
||||||
|
|
||||||
Docs: https://py.sdk.modelcontextprotocol.io/v2/ · Migration from v1: https://py.sdk.modelcontextprotocol.io/v2/migration/
|
Docs: https://py.sdk.modelcontextprotocol.io/ · Migration from v1: https://py.sdk.modelcontextprotocol.io/migration/
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: what
|
id: what
|
||||||
|
|
|
||||||
42
.github/actions/conformance/client.py
vendored
42
.github/actions/conformance/client.py
vendored
|
|
@ -11,16 +11,18 @@ Contract:
|
||||||
--spec-version is omitted the harness picks per-scenario (LATEST_SPEC_VERSION
|
--spec-version is omitted the harness picks per-scenario (LATEST_SPEC_VERSION
|
||||||
for active scenarios, DRAFT_PROTOCOL_VERSION for draft-only ones).
|
for active scenarios, DRAFT_PROTOCOL_VERSION for draft-only ones).
|
||||||
- Server URL as last CLI argument (sys.argv[1])
|
- Server URL as last CLI argument (sys.argv[1])
|
||||||
- Must exit 0 within 30 seconds
|
- Must exit 0 within the harness --timeout (CI passes 60s; the default is 30s)
|
||||||
|
|
||||||
Scenarios:
|
Scenarios:
|
||||||
initialize - Connect, initialize, list tools, close
|
initialize - Connect, initialize, list tools, close
|
||||||
tools_call - Connect, call add_numbers(a=5, b=3), close
|
tools_call - Connect, call add_numbers(a=5, b=3), close
|
||||||
sse-retry - Connect, call test_reconnection, close
|
sse-retry - Connect, call test_reconnection, close
|
||||||
json-schema-ref-no-deref - Connect, list tools (no $ref deref)
|
json-schema-ref-no-deref - Connect, list tools (no $ref deref)
|
||||||
|
json-schema-2020-12-preservation - List tools, echo the focal inputSchema back verbatim
|
||||||
request-metadata - Connect with all callbacks; client stamps _meta
|
request-metadata - Connect with all callbacks; client stamps _meta
|
||||||
http-standard-headers - Connect, call a tool (Mcp-* headers checked)
|
http-standard-headers - Tool, resource and prompt round-trips (Mcp-* headers checked)
|
||||||
http-invalid-tool-headers - List tools, call every surfaced tool (x-mcp-header filter)
|
http-invalid-tool-headers - List tools, call every surfaced tool (x-mcp-header filter)
|
||||||
|
http-custom-headers - Replay the harness's toolCalls (x-mcp-header -> Mcp-Param-*)
|
||||||
elicitation-sep1034-client-defaults - Elicitation with default accept callback
|
elicitation-sep1034-client-defaults - Elicitation with default accept callback
|
||||||
sep-2322-client-request-state - Drive the MRTR auto-loop (SEP-2322)
|
sep-2322-client-request-state - Drive the MRTR auto-loop (SEP-2322)
|
||||||
auth/client-credentials-jwt - Client credentials with private_key_jwt
|
auth/client-credentials-jwt - Client credentials with private_key_jwt
|
||||||
|
|
@ -252,6 +254,21 @@ async def run_json_schema_ref_no_deref(server_url: str) -> None:
|
||||||
await client.list_tools()
|
await client.list_tools()
|
||||||
|
|
||||||
|
|
||||||
|
@register("json-schema-2020-12-preservation")
|
||||||
|
async def run_json_schema_2020_12_preservation(server_url: str) -> None:
|
||||||
|
"""List tools, then echo the focal tool's inputSchema back verbatim (SEP-1613 / SEP-2106).
|
||||||
|
|
||||||
|
The harness diffs what the client round-trips through `json_schema_echo` against its
|
||||||
|
fixture to detect 2020-12 keywords ($schema, $defs, $anchor, additionalProperties,
|
||||||
|
allOf/anyOf, if/then/else) being stripped while parsing tools/list. Unlike
|
||||||
|
json-schema-ref-no-deref, this mock is version-aware, so client_mode() applies.
|
||||||
|
"""
|
||||||
|
async with Client(server_url, mode=client_mode()) as client:
|
||||||
|
listed = await client.list_tools()
|
||||||
|
focal = next(tool for tool in listed.tools if tool.name == "json_schema_2020_12_tool")
|
||||||
|
await client.call_tool("json_schema_echo", {"schema": focal.input_schema})
|
||||||
|
|
||||||
|
|
||||||
@register("tools_call")
|
@register("tools_call")
|
||||||
async def run_tools_call(server_url: str) -> None:
|
async def run_tools_call(server_url: str) -> None:
|
||||||
"""Connect, list tools, call add_numbers(a=5, b=3), close."""
|
"""Connect, list tools, call add_numbers(a=5, b=3), close."""
|
||||||
|
|
@ -294,11 +311,24 @@ async def run_request_metadata(server_url: str) -> None:
|
||||||
|
|
||||||
@register("http-standard-headers")
|
@register("http-standard-headers")
|
||||||
async def run_http_standard_headers(server_url: str) -> None:
|
async def run_http_standard_headers(server_url: str) -> None:
|
||||||
"""Connect on the modern path so Mcp-Method / Mcp-Name / MCP-Protocol-Version are sent (SEP-2243)."""
|
"""Touch tools, resources and prompts on the modern path so each standard header is checked (SEP-2243).
|
||||||
|
|
||||||
|
The scenario inspects Mcp-Method on each request and Mcp-Name on tools/call,
|
||||||
|
resources/read and prompts/get, and reports methods the client never sent as
|
||||||
|
SKIPPED rather than failed, so exercise one of each. initialize and
|
||||||
|
notifications/initialized stay SKIPPED: the modern path discovers via
|
||||||
|
server/discover and never sends them.
|
||||||
|
"""
|
||||||
async with Client(server_url, mode=client_mode()) as client:
|
async with Client(server_url, mode=client_mode()) as client:
|
||||||
await client.list_tools()
|
tools = await client.list_tools()
|
||||||
result = await client.call_tool("add_numbers", {"a": 5, "b": 3})
|
if tools.tools:
|
||||||
logger.debug(f"add_numbers result: {result}")
|
await client.call_tool(tools.tools[0].name, _stub_required_args(tools.tools[0].input_schema))
|
||||||
|
resources = await client.list_resources()
|
||||||
|
if resources.resources:
|
||||||
|
await client.read_resource(resources.resources[0].uri)
|
||||||
|
prompts = await client.list_prompts()
|
||||||
|
if prompts.prompts:
|
||||||
|
await client.get_prompt(prompts.prompts[0].name)
|
||||||
|
|
||||||
|
|
||||||
def _stub_required_args(input_schema: dict[str, Any]) -> dict[str, Any]:
|
def _stub_required_args(input_schema: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
|
|
||||||
26
.github/actions/conformance/expected-failures.2025-11-25.yml
vendored
Normal file
26
.github/actions/conformance/expected-failures.2025-11-25.yml
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# Expected failures at the 2025-11-25 wire, read by the `--suite all
|
||||||
|
# --spec-version 2025-11-25` server and client legs: every scenario the pinned
|
||||||
|
# harness marks applicable at 2025-11-25, run over the stateful handshake.
|
||||||
|
#
|
||||||
|
# That selection is a superset of the frozen 2025-11-25 requirement set (`npx
|
||||||
|
# $CONFORMANCE_PKG list --requirements 2025-11-25`), so an entry here for a
|
||||||
|
# scenario in that set means `conformance tier-check` scores the SDK below 100%
|
||||||
|
# for 2025-11-25 even though CI is green; link the tracking issue next to any
|
||||||
|
# such entry.
|
||||||
|
#
|
||||||
|
# Kept separate from expected-failures.yml even though the bare `--suite all`
|
||||||
|
# legs currently run these same scenarios at 2025-11-25 too: the harness's
|
||||||
|
# default wire is due to move (modelcontextprotocol/conformance#426) and this
|
||||||
|
# file must keep meaning 2025-11-25. Until then a failure here also needs an
|
||||||
|
# entry there. The file is passed even while empty: run-client.sh keys its solo
|
||||||
|
# re-verification off the harness's baseline output.
|
||||||
|
#
|
||||||
|
# Baseline established against the harness pinned via CONFORMANCE_PKG in
|
||||||
|
# .github/workflows/conformance.yml. New conformance releases are adopted by
|
||||||
|
# deliberately bumping that pin and reconciling all three expected-failures
|
||||||
|
# files in the same change. Entries MUST be removed as they start passing (the
|
||||||
|
# runner fails on stale entries).
|
||||||
|
|
||||||
|
client: []
|
||||||
|
|
||||||
|
server: []
|
||||||
|
|
@ -1,19 +1,23 @@
|
||||||
# Expected failures for the carried-forward 2026-07-28 legs
|
# Expected failures at the 2026-07-28 wire, read by the `--suite all
|
||||||
# (`--suite all --spec-version 2026-07-28` for both server and client).
|
# --spec-version 2026-07-28` server and client legs: every scenario the pinned
|
||||||
|
# harness marks applicable at 2026-07-28, run stateless with per-request _meta.
|
||||||
|
#
|
||||||
|
# That selection is a superset of the frozen 2026-07-28 requirement set (`npx
|
||||||
|
# $CONFORMANCE_PKG list --requirements 2026-07-28`), so an entry here for a
|
||||||
|
# scenario in that set means `conformance tier-check` scores the SDK below 100%
|
||||||
|
# for 2026-07-28 even though CI is green; link the tracking issue next to any
|
||||||
|
# such entry.
|
||||||
#
|
#
|
||||||
# This baseline is separate from expected-failures.yml because entries are
|
# This baseline is separate from expected-failures.yml because entries are
|
||||||
# keyed by scenario name only: a scenario that passes at its default version
|
# keyed by scenario name only: a scenario that passes at the harness default
|
||||||
# in the 2025 legs but fails when forced to 2026-07-28 (or vice versa) cannot
|
# wire in the bare `--suite all` legs but fails when forced to 2026-07-28 (or
|
||||||
# be expressed in a shared file (the passing leg would flag the entry as
|
# vice versa) cannot be expressed in a shared file. Where the bare legs already
|
||||||
# stale). Like expected-failures.yml, this single file covers both
|
# default a scenario to 2026-07-28, a failure here also needs an entry there.
|
||||||
# directions: the client 2026 leg reads the `client:` section and the server
|
|
||||||
# 2026 leg reads the `server:` section. Both burn down independently of the
|
|
||||||
# 2025 legs.
|
|
||||||
#
|
#
|
||||||
# Baseline established against the harness pinned via CONFORMANCE_PKG in
|
# Baseline established against the harness pinned via CONFORMANCE_PKG in
|
||||||
# .github/workflows/conformance.yml. New conformance releases are adopted by
|
# .github/workflows/conformance.yml. New conformance releases are adopted by
|
||||||
# deliberately bumping that pin and reconciling both this file and
|
# deliberately bumping that pin and reconciling all three expected-failures
|
||||||
# expected-failures.yml in the same change.
|
# files in the same change.
|
||||||
#
|
#
|
||||||
# Entries are grouped by what unblocks them. As each gap closes the
|
# Entries are grouped by what unblocks them. As each gap closes the
|
||||||
# corresponding scenarios start passing and MUST be removed from this list
|
# corresponding scenarios start passing and MUST be removed from this list
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,16 @@
|
||||||
# Conformance scenarios not yet passing against the Python SDK on main.
|
# Conformance scenarios not yet passing against the Python SDK on main, read by
|
||||||
# CI exits 0 if only these fail, exits 1 on unexpected failures or stale entries.
|
# the bare `--suite all` server and client legs (every scenario shipped with the
|
||||||
|
# pinned harness, each at the harness's default wire for it). CI exits 0 if only
|
||||||
|
# these fail, exits 1 on unexpected failures or stale entries.
|
||||||
|
#
|
||||||
|
# The wire-pinned legs read expected-failures.2025-11-25.yml and
|
||||||
|
# expected-failures.2026-07-28.yml instead; see the header of
|
||||||
|
# .github/workflows/conformance.yml for the leg layout.
|
||||||
#
|
#
|
||||||
# Baseline established against the harness pinned via CONFORMANCE_PKG in
|
# Baseline established against the harness pinned via CONFORMANCE_PKG in
|
||||||
# .github/workflows/conformance.yml. New conformance releases are adopted by
|
# .github/workflows/conformance.yml. New conformance releases are adopted by
|
||||||
# deliberately bumping that pin and reconciling both this file and
|
# deliberately bumping that pin and reconciling all three expected-failures
|
||||||
# expected-failures.2026-07-28.yml in the same change.
|
# files in the same change.
|
||||||
#
|
#
|
||||||
# Entries are grouped by SEP. As each SEP lands in the SDK the corresponding
|
# Entries are grouped by SEP. As each SEP lands in the SDK the corresponding
|
||||||
# scenarios start passing and MUST be removed from this list (the runner fails
|
# scenarios start passing and MUST be removed from this list (the runner fails
|
||||||
|
|
@ -30,10 +36,9 @@ client:
|
||||||
|
|
||||||
server:
|
server:
|
||||||
# SEP-2663 (io.modelcontextprotocol/tasks): the SDK does not implement the
|
# SEP-2663 (io.modelcontextprotocol/tasks): the SDK does not implement the
|
||||||
# tasks extension yet. These extension-tagged scenarios are selected only by
|
# tasks extension yet. The bare `--suite all` leg is the only one that
|
||||||
# the bare `--suite all` leg — extension scenarios never match a
|
# reaches these extension-tagged scenarios (they never match a
|
||||||
# --spec-version filter and the active/draft suites exclude them — so these
|
# --spec-version filter), so these entries live here only.
|
||||||
# entries are inert for the other legs that read this file.
|
|
||||||
#
|
#
|
||||||
# `tasks-status-notifications` is intentionally NOT listed: the harness
|
# `tasks-status-notifications` is intentionally NOT listed: the harness
|
||||||
# skips it unconditionally (pending its rewrite against subscriptions/
|
# skips it unconditionally (pending its rewrite against subscriptions/
|
||||||
|
|
|
||||||
6
.github/actions/conformance/run-client.sh
vendored
6
.github/actions/conformance/run-client.sh
vendored
|
|
@ -1,6 +1,6 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Run a client conformance suite, re-verifying unexpected failures solo.
|
# Run a client conformance suite, re-verifying unexpected failures solo.
|
||||||
# Concurrent suite runs on a 2-vCPU runner can push scenarios with real-time
|
# Concurrent suite runs on a hosted runner can push scenarios with real-time
|
||||||
# waits past tolerance; solo, a real failure fails again while a contention
|
# waits past tolerance; solo, a real failure fails again while a contention
|
||||||
# artifact passes. Failures that only reproduce under concurrency are excused.
|
# artifact passes. Failures that only reproduce under concurrency are excused.
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
@ -101,4 +101,8 @@ if [ -n "$output_dir" ]; then
|
||||||
printf '%s\n' "${scenarios[@]}" > "$output_dir/FLAKE_RESCUED"
|
printf '%s\n' "${scenarios[@]}" > "$output_dir/FLAKE_RESCUED"
|
||||||
fi
|
fi
|
||||||
echo "All ${#scenarios[@]} unexpected failure(s) passed when re-run solo; the suite failures were parallel-run contention."
|
echo "All ${#scenarios[@]} unexpected failure(s) passed when re-run solo; the suite failures were parallel-run contention."
|
||||||
|
# Surface the rescue on the run summary; the log line above and the
|
||||||
|
# FLAKE_RESCUED marker in the uploaded results are otherwise easy to miss on a
|
||||||
|
# green run.
|
||||||
|
echo "::warning title=Conformance flake rescued::${scenarios[*]} failed under suite concurrency and passed solo${output_dir:+ (see ${output_dir}-solo and FLAKE_RESCUED in the uploaded results)}."
|
||||||
exit 0
|
exit 0
|
||||||
|
|
|
||||||
38
.github/pull_request_template.md
vendored
Normal file
38
.github/pull_request_template.md
vendored
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<!--
|
||||||
|
Pull requests from outside the maintainer team need to link an open issue that
|
||||||
|
a maintainer has assigned to you (or one labeled `help wanted`); others are
|
||||||
|
closed automatically until that's in place. See CONTRIBUTING.md for details.
|
||||||
|
-->
|
||||||
|
|
||||||
|
Fixes #
|
||||||
|
|
||||||
|
<!-- Provide a brief summary of your changes -->
|
||||||
|
|
||||||
|
## Motivation and Context
|
||||||
|
<!-- Why is this change needed? What problem does it solve? -->
|
||||||
|
|
||||||
|
## How Has This Been Tested?
|
||||||
|
<!-- Have you tested this in a real application? Which scenarios were tested? -->
|
||||||
|
|
||||||
|
## Breaking Changes
|
||||||
|
<!-- Will users need to update their code or configurations? -->
|
||||||
|
|
||||||
|
## Types of changes
|
||||||
|
<!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->
|
||||||
|
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||||
|
- [ ] New feature (non-breaking change which adds functionality)
|
||||||
|
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
|
||||||
|
- [ ] Documentation update
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
<!-- Go over all the following points, and put an `x` in all the boxes that apply. -->
|
||||||
|
- [ ] I am assigned to the linked issue (or it is labeled `help wanted`, or I'm a maintainer)
|
||||||
|
- [ ] I have disclosed any AI assistance and can explain the change in my own words
|
||||||
|
- [ ] I have read the [MCP Documentation](https://modelcontextprotocol.io)
|
||||||
|
- [ ] My code follows the repository's style guidelines
|
||||||
|
- [ ] New and existing tests pass locally
|
||||||
|
- [ ] I have added appropriate error handling
|
||||||
|
- [ ] I have added or updated documentation as needed
|
||||||
|
|
||||||
|
## Additional context
|
||||||
|
<!-- Add any other context, implementation notes, or design decisions -->
|
||||||
117
.github/scripts/docs_preview.js
vendored
Normal file
117
.github/scripts/docs_preview.js
vendored
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
// Docs preview gate and PR comment. .github/workflows/docs-preview.yml wires
|
||||||
|
// these up: `authorize` decides whether a run may build and deploy a preview
|
||||||
|
// (and for which commit), `comment` reports the outcome on the pull request.
|
||||||
|
// The security model is described in the workflow's header.
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const MARKER = '<!-- docs-preview -->';
|
||||||
|
const BOT_LOGIN = 'github-actions[bot]';
|
||||||
|
|
||||||
|
// Sets the job outputs `authorized`, `pr_number`, `head_sha` and
|
||||||
|
// `slash_attempt` for a pull_request_target or /preview-docs issue_comment run.
|
||||||
|
async function authorize({ github, context, core }) {
|
||||||
|
const { owner, repo } = context.repo;
|
||||||
|
|
||||||
|
async function permissionFor(username) {
|
||||||
|
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username });
|
||||||
|
return { level: data.permission, role: data.role_name };
|
||||||
|
}
|
||||||
|
|
||||||
|
let authorized = false;
|
||||||
|
let prNumber = '';
|
||||||
|
let headSha = '';
|
||||||
|
let slashAttempt = false;
|
||||||
|
|
||||||
|
if (context.eventName === 'pull_request_target') {
|
||||||
|
const pr = context.payload.pull_request;
|
||||||
|
prNumber = String(pr.number);
|
||||||
|
headSha = pr.head.sha;
|
||||||
|
// No automatic preview for fork PRs: actions/checkout refuses to fetch a
|
||||||
|
// fork's head in a pull_request_target run. A maintainer can still request
|
||||||
|
// one with /preview-docs. (head.repo is null once the fork is deleted.)
|
||||||
|
const headRepo = pr.head.repo;
|
||||||
|
if (!headRepo || headRepo.id !== context.payload.repository.id) {
|
||||||
|
core.info(`PR #${prNumber} head is on ${headRepo ? headRepo.full_name : 'a deleted fork'}; fork PRs are previewed via /preview-docs only.`);
|
||||||
|
} else {
|
||||||
|
// Gate on the *sender* (whoever caused this run — on synchronize that
|
||||||
|
// is the pusher), not the PR author, so a non-admin pushing to an
|
||||||
|
// admin-opened branch does not get an automatic build.
|
||||||
|
const actor = context.payload.sender.login;
|
||||||
|
const perm = await permissionFor(actor);
|
||||||
|
authorized = perm.level === 'admin';
|
||||||
|
core.info(`pull_request_target by ${actor} (level=${perm.level}, role=${perm.role}) → authorized=${authorized}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// issue_comment: the job-level `if:` already guarantees this is a PR
|
||||||
|
// comment starting with /preview-docs.
|
||||||
|
slashAttempt = true;
|
||||||
|
const actor = context.payload.comment.user.login;
|
||||||
|
prNumber = String(context.payload.issue.number);
|
||||||
|
const perm = await permissionFor(actor);
|
||||||
|
authorized = perm.level === 'admin' || perm.role === 'maintain';
|
||||||
|
if (authorized) {
|
||||||
|
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: Number(prNumber) });
|
||||||
|
if (pr.state !== 'open') {
|
||||||
|
authorized = false;
|
||||||
|
core.info(`PR #${prNumber} is ${pr.state}; refusing to preview.`);
|
||||||
|
} else {
|
||||||
|
headSha = pr.head.sha;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
core.info(`/preview-docs by ${actor} (level=${perm.level}, role=${perm.role}) → authorized=${authorized}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
core.setOutput('authorized', String(authorized));
|
||||||
|
core.setOutput('pr_number', prNumber);
|
||||||
|
core.setOutput('head_sha', headSha);
|
||||||
|
core.setOutput('slash_attempt', String(slashAttempt));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Posts or updates the preview comment on the PR. Reads the outcome of the
|
||||||
|
// earlier jobs from the step's env: AUTHORIZED, PR_NUMBER, HEAD_SHA,
|
||||||
|
// DEPLOY_RESULT, DEPLOYMENT_URL, ALIAS_URL, RUN_URL.
|
||||||
|
async function comment({ github, context }) {
|
||||||
|
const { owner, repo } = context.repo;
|
||||||
|
const env = process.env;
|
||||||
|
const issue_number = Number(env.PR_NUMBER);
|
||||||
|
|
||||||
|
async function upsert(body) {
|
||||||
|
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 });
|
||||||
|
const existing = comments.find((c) => c.user?.login === BOT_LOGIN && c.body?.includes(MARKER));
|
||||||
|
if (existing) {
|
||||||
|
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
|
||||||
|
} else {
|
||||||
|
await github.rest.issues.createComment({ owner, repo, issue_number, body });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env.AUTHORIZED !== 'true') {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner, repo, issue_number,
|
||||||
|
body: `@${context.actor} — only repository admins or maintainers can run \`/preview-docs\` (and the PR must be open).`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env.DEPLOY_RESULT !== 'success') {
|
||||||
|
await upsert(
|
||||||
|
`${MARKER}\n### 📚 Documentation preview\n\n` +
|
||||||
|
`❌ Preview build **failed** for \`${env.HEAD_SHA.slice(0, 7)}\` — [workflow logs](${env.RUN_URL}).`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewUrl = env.ALIAS_URL || env.DEPLOYMENT_URL;
|
||||||
|
const ts = new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
|
||||||
|
await upsert(
|
||||||
|
`${MARKER}\n### 📚 Documentation preview\n\n` +
|
||||||
|
`| | |\n|---|---|\n` +
|
||||||
|
`| **Preview** | ${previewUrl} |\n` +
|
||||||
|
`| **Deployment** | ${env.DEPLOYMENT_URL} |\n` +
|
||||||
|
`| **Commit** | \`${env.HEAD_SHA.slice(0, 7)}\` |\n` +
|
||||||
|
`| **Triggered by** | @${context.actor} |\n` +
|
||||||
|
`| **Updated** | ${ts} |\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { authorize, comment };
|
||||||
241
.github/scripts/docs_preview.test.js
vendored
Normal file
241
.github/scripts/docs_preview.test.js
vendored
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
// Scenario tests for docs_preview.js: who gets a preview build (`authorize`)
|
||||||
|
// and what the pull request shows afterwards (`comment`).
|
||||||
|
//
|
||||||
|
// node --test .github/scripts/docs_preview.test.js
|
||||||
|
//
|
||||||
|
// No dependencies; the GitHub client is a small fake defined at the bottom.
|
||||||
|
// CI runs it in the checks job (.github/workflows/shared.yml).
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { authorize, comment } = require('./docs_preview.js');
|
||||||
|
|
||||||
|
const REPO = { owner: 'modelcontextprotocol', repo: 'python-sdk' };
|
||||||
|
const BASE_REPO = { id: 1, full_name: 'modelcontextprotocol/python-sdk' };
|
||||||
|
const HEAD = 'e4dfda7baa127ab00ebcd1d5324560cbe3cdfe42';
|
||||||
|
const MARKER = '<!-- docs-preview -->';
|
||||||
|
|
||||||
|
// `permission` / `role_name` as the collaborators API reports them.
|
||||||
|
const PEOPLE = {
|
||||||
|
admin: { permission: 'admin', role_name: 'admin' },
|
||||||
|
maintainer: { permission: 'write', role_name: 'maintain' },
|
||||||
|
writer: { permission: 'write', role_name: 'write' },
|
||||||
|
outsider: { permission: 'read', role_name: 'read' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── authorize ──────────────────────────────────────────────────────────────
|
||||||
|
// `expect` is the full set of job outputs the step writes.
|
||||||
|
|
||||||
|
const authorizeScenarios = [
|
||||||
|
{
|
||||||
|
name: 'admin pushes to (or opens) a PR → automatic preview of that head',
|
||||||
|
event: pushed(7, 'admin'),
|
||||||
|
expect: { authorized: 'true', pr_number: '7', head_sha: HEAD, slash_attempt: 'false' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'someone with write but not admin pushes → no automatic preview',
|
||||||
|
event: pushed(7, 'writer'),
|
||||||
|
expect: { authorized: 'false', pr_number: '7', head_sha: HEAD, slash_attempt: 'false' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// actions/checkout refuses a fork's head under pull_request_target, so
|
||||||
|
// the run stops here instead of failing in `build`; /preview-docs still works.
|
||||||
|
name: 'admin pushes to or reopens a fork PR → no automatic preview',
|
||||||
|
event: pushed(7, 'admin', { fork: 'someone/python-sdk' }),
|
||||||
|
expect: { authorized: 'false', pr_number: '7', head_sha: HEAD, slash_attempt: 'false' },
|
||||||
|
permissionLookups: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'fork PR whose fork has since been deleted → no automatic preview',
|
||||||
|
event: pushed(7, 'admin', { fork: null }),
|
||||||
|
expect: { authorized: 'false', pr_number: '7', head_sha: HEAD, slash_attempt: 'false' },
|
||||||
|
permissionLookups: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'maintainer comments /preview-docs on a fork PR → previewed like any other',
|
||||||
|
pr: { fork: 'someone/python-sdk' },
|
||||||
|
event: slash(7, 'maintainer'),
|
||||||
|
expect: { authorized: 'true', pr_number: '7', head_sha: HEAD, slash_attempt: 'true' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'maintainer comments /preview-docs on an open PR → preview of its current head',
|
||||||
|
event: slash(7, 'maintainer'),
|
||||||
|
expect: { authorized: 'true', pr_number: '7', head_sha: HEAD, slash_attempt: 'true' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'admin comments /preview-docs → authorized as well',
|
||||||
|
event: slash(7, 'admin'),
|
||||||
|
expect: { authorized: 'true', pr_number: '7', head_sha: HEAD, slash_attempt: 'true' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'writer without the maintain role comments /preview-docs → refused, recorded as an attempt',
|
||||||
|
event: slash(7, 'writer'),
|
||||||
|
expect: { authorized: 'false', pr_number: '7', head_sha: '', slash_attempt: 'true' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'outsider comments /preview-docs → refused, recorded as an attempt',
|
||||||
|
event: slash(7, 'outsider'),
|
||||||
|
expect: { authorized: 'false', pr_number: '7', head_sha: '', slash_attempt: 'true' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '/preview-docs on a closed PR → refused even for a maintainer',
|
||||||
|
pr: { state: 'closed' },
|
||||||
|
event: slash(7, 'maintainer'),
|
||||||
|
expect: { authorized: 'false', pr_number: '7', head_sha: '', slash_attempt: 'true' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const s of authorizeScenarios) {
|
||||||
|
test(`authorize: ${s.name}`, async () => {
|
||||||
|
const world = makeWorld({ pr: { number: 7, ...s.pr } });
|
||||||
|
assert.deepEqual(await runAuthorize(world, s.event), s.expect);
|
||||||
|
assert.equal(world.writes.length, 0);
|
||||||
|
if (s.permissionLookups !== undefined) assert.equal(world.permissionLookups, s.permissionLookups);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('authorize: a failing permission lookup fails the step instead of deciding either way', async () => {
|
||||||
|
const world = makeWorld({ pr: { number: 7 } });
|
||||||
|
world.failPermissionLookup = true;
|
||||||
|
await assert.rejects(runAuthorize(world, pushed(7, 'admin')), /boom/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── comment ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DEPLOYED = {
|
||||||
|
AUTHORIZED: 'true',
|
||||||
|
PR_NUMBER: '7',
|
||||||
|
HEAD_SHA: HEAD,
|
||||||
|
DEPLOY_RESULT: 'success',
|
||||||
|
DEPLOYMENT_URL: 'https://1a2b3c.mcp-python-sdk-docs.pages.dev',
|
||||||
|
ALIAS_URL: 'https://pr-7.mcp-python-sdk-docs.pages.dev',
|
||||||
|
RUN_URL: 'https://github.com/modelcontextprotocol/python-sdk/actions/runs/1',
|
||||||
|
};
|
||||||
|
|
||||||
|
test('comment: a refused /preview-docs gets a plain reply to the commenter, not a preview comment', async () => {
|
||||||
|
const world = makeWorld({ pr: { number: 7 } });
|
||||||
|
await runComment(world, { ...DEPLOYED, AUTHORIZED: 'false', HEAD_SHA: '', DEPLOY_RESULT: 'skipped' }, 'outsider');
|
||||||
|
assert.equal(world.comments.length, 1);
|
||||||
|
assert.match(world.comments[0].body, /^@outsider — only repository admins or maintainers can run `\/preview-docs`/);
|
||||||
|
assert.ok(!world.comments[0].body.includes(MARKER));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('comment: first successful deploy posts one preview comment linking the alias URL and the commit', async () => {
|
||||||
|
const world = makeWorld({ pr: { number: 7 } });
|
||||||
|
await runComment(world, DEPLOYED, 'admin');
|
||||||
|
assert.equal(world.comments.length, 1);
|
||||||
|
const body = world.comments[0].body;
|
||||||
|
assert.ok(body.startsWith(`${MARKER}\n### 📚 Documentation preview`));
|
||||||
|
assert.match(body, /\| \*\*Preview\*\* \| https:\/\/pr-7\.mcp-python-sdk-docs\.pages\.dev \|/);
|
||||||
|
assert.match(body, /\| \*\*Deployment\*\* \| https:\/\/1a2b3c\.mcp-python-sdk-docs\.pages\.dev \|/);
|
||||||
|
assert.match(body, /\| \*\*Commit\*\* \| `e4dfda7` \|/);
|
||||||
|
assert.match(body, /\| \*\*Triggered by\*\* \| @admin \|/);
|
||||||
|
assert.deepEqual(world.writes, ['comment on #7']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('comment: a later deploy edits the existing preview comment instead of adding another', async () => {
|
||||||
|
const world = makeWorld({ pr: { number: 7 }, comments: [{ user: 'someone', body: 'LGTM' }, { user: 'github-actions[bot]', body: `${MARKER}\nold table` }] });
|
||||||
|
await runComment(world, { ...DEPLOYED, HEAD_SHA: 'f'.repeat(40) }, 'admin');
|
||||||
|
assert.equal(world.comments.length, 2);
|
||||||
|
assert.match(world.comments[1].body, /\| \*\*Commit\*\* \| `fffffff` \|/);
|
||||||
|
assert.deepEqual(world.writes, ['edit comment 101']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("comment: someone else's comment that happens to contain the marker is left alone", async () => {
|
||||||
|
const world = makeWorld({ pr: { number: 7 }, comments: [{ user: 'someone', body: `quoting ${MARKER} here` }] });
|
||||||
|
await runComment(world, DEPLOYED, 'admin');
|
||||||
|
assert.equal(world.comments.length, 2);
|
||||||
|
assert.equal(world.comments[0].body, `quoting ${MARKER} here`);
|
||||||
|
assert.deepEqual(world.writes, ['comment on #7']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('comment: with no alias URL the preview link falls back to the deployment URL', async () => {
|
||||||
|
const world = makeWorld({ pr: { number: 7 } });
|
||||||
|
await runComment(world, { ...DEPLOYED, ALIAS_URL: '' }, 'admin');
|
||||||
|
assert.match(world.comments[0].body, /\| \*\*Preview\*\* \| https:\/\/1a2b3c\.mcp-python-sdk-docs\.pages\.dev \|/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('comment: a build or deploy that did not succeed is reported with the short SHA and a link to the run', async () => {
|
||||||
|
const world = makeWorld({ pr: { number: 7 }, comments: [{ user: 'github-actions[bot]', body: `${MARKER}\nold table` }] });
|
||||||
|
await runComment(world, { ...DEPLOYED, DEPLOY_RESULT: 'skipped', DEPLOYMENT_URL: '', ALIAS_URL: '' }, 'admin');
|
||||||
|
assert.equal(world.comments.length, 1);
|
||||||
|
assert.equal(
|
||||||
|
world.comments[0].body,
|
||||||
|
`${MARKER}\n### 📚 Documentation preview\n\n❌ Preview build **failed** for \`e4dfda7\` — [workflow logs](${DEPLOYED.RUN_URL}).`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Harness ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// `fork`: full name of the fork the head lives on; null for a deleted fork; omitted for a same-repo branch.
|
||||||
|
function pushed(number, sender, { fork } = {}) {
|
||||||
|
const repo = fork === undefined ? BASE_REPO : fork === null ? null : { id: 2, full_name: fork };
|
||||||
|
return { eventName: 'pull_request_target', actor: sender, payload: { action: 'synchronize', repository: BASE_REPO, pull_request: { number, head: { sha: HEAD, repo } }, sender: { login: sender } } };
|
||||||
|
}
|
||||||
|
function slash(number, commenter) {
|
||||||
|
return { eventName: 'issue_comment', actor: commenter, payload: { action: 'created', issue: { number, pull_request: {} }, comment: { body: '/preview-docs', user: { login: commenter } } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAuthorize(world, event) {
|
||||||
|
const outputs = {};
|
||||||
|
const core = { info: () => {}, setOutput: (k, v) => { outputs[k] = v; } };
|
||||||
|
await authorize({ github: world.github, context: { repo: REPO, ...event }, core });
|
||||||
|
return outputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runComment(world, env, actor) {
|
||||||
|
const saved = {};
|
||||||
|
for (const [k, v] of Object.entries(env)) { saved[k] = process.env[k]; process.env[k] = v; }
|
||||||
|
try {
|
||||||
|
await comment({ github: world.github, context: { repo: REPO, actor }, core: {} });
|
||||||
|
} finally {
|
||||||
|
for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── A tiny in-memory GitHub ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeWorld({ pr, comments = [] }) {
|
||||||
|
const world = { pr: { state: 'open', ...pr }, comments: [], writes: [], failPermissionLookup: false, permissionLookups: 0, nextCommentId: 100 };
|
||||||
|
for (const c of comments) world.comments.push({ id: world.nextCommentId++, ...c });
|
||||||
|
|
||||||
|
const err = (status, message = 'fake error') => Object.assign(new Error(message), { status });
|
||||||
|
const write = (what) => world.writes.push(what);
|
||||||
|
const checkPr = (n) => { if (n !== world.pr.number) throw err(404); };
|
||||||
|
|
||||||
|
const rest = {
|
||||||
|
repos: {
|
||||||
|
getCollaboratorPermissionLevel: async ({ username }) => {
|
||||||
|
world.permissionLookups++;
|
||||||
|
if (world.failPermissionLookup) throw err(500, 'boom');
|
||||||
|
const person = PEOPLE[username];
|
||||||
|
if (!person) throw err(404, 'not a user');
|
||||||
|
return { data: { ...person, user: { login: username } } };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pulls: {
|
||||||
|
get: async ({ pull_number }) => {
|
||||||
|
checkPr(pull_number);
|
||||||
|
const repo = world.pr.fork ? { id: 2, full_name: world.pr.fork } : BASE_REPO;
|
||||||
|
return { data: { number: pull_number, state: world.pr.state, head: { sha: HEAD, repo } } };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
issues: {
|
||||||
|
listComments: async ({ issue_number }) => { checkPr(issue_number); return { data: world.comments.map((c) => ({ id: c.id, body: c.body, user: { login: c.user } })) }; },
|
||||||
|
createComment: async ({ issue_number, body }) => {
|
||||||
|
checkPr(issue_number);
|
||||||
|
write(`comment on #${issue_number}`);
|
||||||
|
world.comments.push({ id: world.nextCommentId++, user: 'github-actions[bot]', body });
|
||||||
|
},
|
||||||
|
updateComment: async ({ comment_id, body }) => {
|
||||||
|
write(`edit comment ${comment_id}`);
|
||||||
|
const c = world.comments.find((x) => x.id === comment_id);
|
||||||
|
if (!c) throw err(404);
|
||||||
|
c.body = body;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
world.github = { rest, paginate: async (fn, args) => (await fn(args)).data };
|
||||||
|
return world;
|
||||||
|
}
|
||||||
312
.github/scripts/pr_intake_gate.js
vendored
Normal file
312
.github/scripts/pr_intake_gate.js
vendored
Normal file
|
|
@ -0,0 +1,312 @@
|
||||||
|
// PR intake gate. The policy lives in CONTRIBUTING.md ("How pull requests get
|
||||||
|
// in"); .github/workflows/require-linked-issue.yml wires this up to events.
|
||||||
|
//
|
||||||
|
// A pull request from someone without triage rights stays open only if its
|
||||||
|
// description links (Fixes/Closes/Resolves #N) an open issue in this repo that
|
||||||
|
// is either assigned to the PR author or labeled `help wanted`. Otherwise the
|
||||||
|
// gate labels it `missing-issue-link`, leaves one comment, and closes it. It
|
||||||
|
// re-evaluates — and reopens — the PR when the description is edited or the
|
||||||
|
// author is assigned to the issue. A triage+ user reopening the PR, removing
|
||||||
|
// the label, or adding `bypass-issue-check` overrides it, and the override
|
||||||
|
// sticks.
|
||||||
|
//
|
||||||
|
// Everything that writes goes through mutate(); when the workflow passes
|
||||||
|
// ENFORCE=false (its kill switch) the run only logs what it would have done.
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const LABEL = 'missing-issue-link'; // marks PRs the gate has closed
|
||||||
|
const BYPASS_LABEL = 'bypass-issue-check'; // sticky maintainer override
|
||||||
|
const OPEN_LABEL = 'help wanted'; // issue label that waives assignment
|
||||||
|
const MARKER = '<!-- require-linked-issue -->';
|
||||||
|
const BOT_LOGIN = 'github-actions[bot]';
|
||||||
|
const MAX_ISSUES = 5;
|
||||||
|
|
||||||
|
module.exports = async function run({ github, context, core }) {
|
||||||
|
const { owner, repo } = context.repo;
|
||||||
|
const enforce = process.env.ENFORCE === 'true';
|
||||||
|
const contributingUrl = `https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md#how-pull-requests-get-in`;
|
||||||
|
|
||||||
|
// ── Entry points ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (context.eventName === 'issues') {
|
||||||
|
// Someone was assigned an issue: re-evaluate their gate-closed PRs that
|
||||||
|
// reference it (they may pass now).
|
||||||
|
const issueNumber = context.payload.issue.number;
|
||||||
|
const assignee = context.payload.assignee.login;
|
||||||
|
const closed = await github.paginate(github.rest.issues.listForRepo, {
|
||||||
|
owner, repo, state: 'closed', creator: assignee, labels: LABEL, per_page: 100,
|
||||||
|
});
|
||||||
|
const prs = closed.filter((i) => i.pull_request && closingRefs(i.body).includes(issueNumber));
|
||||||
|
console.log(`#${issueNumber} assigned to ${assignee}: ${prs.length} gate-closed PR(s) reference it`);
|
||||||
|
// Evaluate each independently so one transient failure doesn't strand
|
||||||
|
// the rest (this event won't fire again for the same assignment).
|
||||||
|
const failures = [];
|
||||||
|
for (const pr of prs) {
|
||||||
|
try {
|
||||||
|
await evaluate(pr.number, 'assigned', context.payload.sender?.login, issueNumber);
|
||||||
|
} catch (e) {
|
||||||
|
failures.push(`#${pr.number}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failures.length) throw new Error(`Could not re-evaluate ${failures.join('; ')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.eventName === 'workflow_dispatch') {
|
||||||
|
const n = parseInt(process.env.PR_NUMBER_INPUT, 10);
|
||||||
|
if (!Number.isInteger(n) || n <= 0) throw new Error(`Bad pr_number input: ${process.env.PR_NUMBER_INPUT}`);
|
||||||
|
await evaluate(n, 'dispatch', context.payload.sender?.login);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await evaluate(context.payload.pull_request.number, context.payload.action, context.payload.sender?.login);
|
||||||
|
|
||||||
|
// ── The rules ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function evaluate(prNumber, action, sender, hintIssue = null) {
|
||||||
|
// Always read the PR live; the event payload can be stale by the time a
|
||||||
|
// queued run starts.
|
||||||
|
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
||||||
|
const labels = pr.labels.map((l) => l.name);
|
||||||
|
// An `unlabeled` run only fires for LABEL (see the workflow `if:`), so the
|
||||||
|
// event itself proves the label was there a moment ago.
|
||||||
|
const gated = action === 'unlabeled' || labels.includes(LABEL);
|
||||||
|
console.log(`PR #${prNumber} by ${pr.user.login} (${pr.state}${pr.draft ? ', draft' : ''}) — ${action} by ${sender ?? '-'}, enforce=${enforce}`);
|
||||||
|
|
||||||
|
// 0. Scope: open PRs, plus closed PRs the gate closed itself. Merged PRs
|
||||||
|
// and PRs someone closed for other reasons are left alone.
|
||||||
|
if (pr.merged_at) return log('merged — nothing to do');
|
||||||
|
if (pr.state === 'closed' && !gated) return log('closed by someone else — not ours');
|
||||||
|
|
||||||
|
// 1. Exempt authors: bots and anyone with triage or better. Drafts are
|
||||||
|
// gated like any other PR.
|
||||||
|
if (pr.user.type === 'Bot') return log('author is a bot — exempt');
|
||||||
|
if (await isTrusted(pr.user.login)) return pass('author has triage+ on this repo');
|
||||||
|
|
||||||
|
// 2. Overrides: a triage+ user reopening the PR or removing the label wants
|
||||||
|
// it open. Anyone else doing so just triggers a re-check.
|
||||||
|
if ((action === 'reopened' || action === 'unlabeled') && sender && (await isTrusted(sender))) {
|
||||||
|
return pass(`${sender} ${action === 'reopened' ? 'reopened it' : 'removed the label'} — override`, { sticky: true });
|
||||||
|
}
|
||||||
|
if (labels.includes(BYPASS_LABEL)) return pass(`carries ${BYPASS_LABEL}`);
|
||||||
|
|
||||||
|
// 3. The rule: the description links an open issue in this repo that is
|
||||||
|
// labeled `help wanted` or assigned to the author. Only the first few
|
||||||
|
// references are fetched; a just-assigned issue is checked first.
|
||||||
|
const author = pr.user.login.toLowerCase();
|
||||||
|
const refs = closingRefs(pr.body);
|
||||||
|
if (hintIssue && refs.includes(hintIssue)) refs.unshift(...refs.splice(refs.indexOf(hintIssue), 1));
|
||||||
|
const linked = [];
|
||||||
|
for (const num of refs.slice(0, MAX_ISSUES)) {
|
||||||
|
const issue = await getIssue(num);
|
||||||
|
if (!issue) continue; // missing, a PR, closed, or transferred away
|
||||||
|
linked.push(num);
|
||||||
|
if (issue.labels.some((l) => l.name.toLowerCase() === OPEN_LABEL)) return pass(`#${num} is labeled "${OPEN_LABEL}"`);
|
||||||
|
if (issue.assignees.some((a) => a.login.toLowerCase() === author)) return pass(`author is assigned to #${num}`);
|
||||||
|
}
|
||||||
|
return fail(linked);
|
||||||
|
|
||||||
|
// ── Outcomes ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function pass(reason, { sticky = false } = {}) {
|
||||||
|
console.log(`PASS: ${reason}`);
|
||||||
|
if (sticky) await addLabel(prNumber, BYPASS_LABEL);
|
||||||
|
if (pr.state === 'closed' && !(await reopen(pr, reason))) return;
|
||||||
|
if (gated) {
|
||||||
|
await removeLabel(prNumber, LABEL);
|
||||||
|
await deleteGateComment(prNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fail(linkedIssues) {
|
||||||
|
console.log(`FAIL: ${linkedIssues.length ? `not assigned to ${linkedIssues.map((n) => `#${n}`).join(', ')}` : 'no usable issue link'}`);
|
||||||
|
await addLabel(prNumber, LABEL);
|
||||||
|
await upsertGateComment(prNumber, closedComment(pr.draft, linkedIssues));
|
||||||
|
if (pr.state === 'open') {
|
||||||
|
await mutate(`close PR #${prNumber}`, () => github.rest.pulls.update({ owner, repo, pull_number: prNumber, state: 'closed' }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(msg) {
|
||||||
|
console.log(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Comment text ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function closedComment(draft, linkedIssues) {
|
||||||
|
const issues = linkedIssues.map((n) => `#${n}`).join(', ');
|
||||||
|
const opener = draft
|
||||||
|
? "This PR has been closed automatically. It's still a draft, but we close those early so you don't put in more time only to have it closed the moment you mark it ready.\n\n"
|
||||||
|
: 'This PR has been closed automatically. ';
|
||||||
|
const rule = `${opener}This repo only keeps pull requests open when they come from a maintainer, or from a contributor a maintainer has assigned to the linked issue`;
|
||||||
|
const situation = linkedIssues.length
|
||||||
|
? [
|
||||||
|
`${rule}, and you aren't currently assigned to ${issues}.`,
|
||||||
|
'',
|
||||||
|
`If a maintainer assigns you to ${issues}, this PR reopens on its own and there's nothing more you need to do here. Assignment is a maintainer call based on capacity; comments that only ask to be assigned don't factor in. What does help is engaging on the issue itself by confirming the repro, explaining why it matters for your use case, or describing the approach you'd take.`,
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
`${rule}, and this PR doesn't link an open issue yet.`,
|
||||||
|
'',
|
||||||
|
"- **If you're already assigned to an issue for this**, add `Fixes #<n>` to the description and the PR will reopen on its own.",
|
||||||
|
`- **If there's no issue yet**, please [open one](https://github.com/${owner}/${repo}/issues/new/choose) instead: what you ran into, why it matters for your use case, and a minimal reproduction. That context is super important to us and is what we use to decide what to prioritise.`,
|
||||||
|
"- **If there's an issue but you're not assigned**, add `Fixes #<n>` anyway so they're linked, then engage on the issue itself by confirming the repro or describing the approach you'd take. Assignment is a maintainer call based on capacity; comments that only ask to be assigned don't factor in. If you are assigned, this PR reopens automatically.",
|
||||||
|
];
|
||||||
|
return [
|
||||||
|
MARKER,
|
||||||
|
...situation,
|
||||||
|
'',
|
||||||
|
"You're welcome to keep pushing commits here (just avoid force-pushing, since GitHub can't reopen a rewritten branch), but that on its own won't get the PR reviewed or the issue assigned, and realistically most auto-closed PRs stay closed. There's no need to open a new PR either way.",
|
||||||
|
'',
|
||||||
|
`[CONTRIBUTING.md](${contributingUrl}) has the full reasoning, but in short:`,
|
||||||
|
'',
|
||||||
|
"- We're a small team with very little capacity to review community PRs right now.",
|
||||||
|
'- Many recent PRs are AI-generated with little human review, and reviewing one carefully still costs a maintainer as much time as it ever did. A well-described issue is usually more useful to us than the code.',
|
||||||
|
'',
|
||||||
|
`*Maintainers: reopen, remove \`${LABEL}\`, or add \`${BYPASS_LABEL}\` to override.*`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function cannotReopenComment(pr, reason) {
|
||||||
|
return [
|
||||||
|
MARKER,
|
||||||
|
`This PR now passes the intake check (${reason}), but GitHub won't let it be reopened — usually because the branch was force-pushed or deleted while the PR was closed, or because another open PR uses the same branch.`,
|
||||||
|
'',
|
||||||
|
`If you have another open PR from this branch, please continue there. Otherwise, either push the branch back to \`${pr.head.sha.slice(0, 7)}\` and edit this PR's description to retry, or open a new PR that links the same issue (if a maintainer had waved this one through, mention that so they can do the same there).`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function mutate(description, fn) {
|
||||||
|
if (!enforce) {
|
||||||
|
console.log(`[dry-run] would ${description}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Triage-or-better on this repo, from the permission endpoint's capability
|
||||||
|
// flags (role names can be custom; author_association hides private org
|
||||||
|
// members). Only a nonexistent user 404s; any other error must throw rather
|
||||||
|
// than be read as "untrusted", or a maintainer's PR could be closed.
|
||||||
|
async function isTrusted(username) {
|
||||||
|
try {
|
||||||
|
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username });
|
||||||
|
const p = data.user?.permissions;
|
||||||
|
if (!p) throw new Error(`permission response for ${username} has no capability flags`);
|
||||||
|
const trusted = Boolean(p.triage || p.push || p.maintain || p.admin);
|
||||||
|
console.log(` ${username}: ${trusted ? 'trusted' : 'not trusted'} (role ${data.role_name || '-'})`);
|
||||||
|
return trusted;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status === 404) return false;
|
||||||
|
throw new Error(`Permission check failed for ${username} (HTTP ${e.status ?? '?'}): ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue numbers referenced with a closing keyword, in the forms GitHub itself
|
||||||
|
// honors: `Fixes #1`, `closes owner/repo#1`, `Resolved https://github.com/owner/repo/issues/1`.
|
||||||
|
function closingRefs(body) {
|
||||||
|
const repoRef = `${owner}/${repo}`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const re = new RegExp(
|
||||||
|
`\\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\s*:?\\s*(?:${repoRef}#|#|https?://github\\.com/${repoRef}/issues/)(\\d+)`,
|
||||||
|
'gi',
|
||||||
|
);
|
||||||
|
return [...new Set([...(body || '').matchAll(re)].map((m) => parseInt(m[1], 10)))];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The linked issue, or null if it doesn't exist, is actually a PR, isn't
|
||||||
|
// open, or has been transferred to another repository.
|
||||||
|
async function getIssue(num) {
|
||||||
|
let issue;
|
||||||
|
try {
|
||||||
|
({ data: issue } = await github.rest.issues.get({ owner, repo, issue_number: num }));
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status === 404 || e.status === 410) return null;
|
||||||
|
throw new Error(`Cannot fetch issue #${num} (HTTP ${e.status ?? '?'}): ${e.message}`);
|
||||||
|
}
|
||||||
|
if (issue.pull_request || issue.state !== 'open') return null;
|
||||||
|
if (!issue.repository_url?.endsWith(`/${owner}/${repo}`)) return null;
|
||||||
|
return issue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reopen a gate-closed PR. GitHub refuses (422) if the branch was rewritten
|
||||||
|
// or deleted while closed, or another open PR uses it. Explain that in the
|
||||||
|
// comment and make sure the control label is (still) on, so the PR stays
|
||||||
|
// gate-managed and a later edit or override retries the reopen.
|
||||||
|
async function reopen(pr, reason) {
|
||||||
|
try {
|
||||||
|
await mutate(`reopen PR #${pr.number}`, () => github.rest.pulls.update({ owner, repo, pull_number: pr.number, state: 'open' }));
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status !== 422) throw e;
|
||||||
|
core.warning(`GitHub refused to reopen PR #${pr.number}: ${e.message}`);
|
||||||
|
await addLabel(pr.number, LABEL);
|
||||||
|
await upsertGateComment(pr.number, cannotReopenComment(pr, reason));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addLabel(prNumber, name) {
|
||||||
|
await mutate(`add "${name}" to PR #${prNumber}`, async () => {
|
||||||
|
await ensureLabelExists(name);
|
||||||
|
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [name] });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeLabel(prNumber, name) {
|
||||||
|
await mutate(`remove "${name}" from PR #${prNumber}`, async () => {
|
||||||
|
try {
|
||||||
|
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name });
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status !== 404) throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureLabelExists(name) {
|
||||||
|
const meta = {
|
||||||
|
[LABEL]: ['b76e79', 'Auto-closed: PR needs a linked issue assigned to its author (see CONTRIBUTING.md)'],
|
||||||
|
[BYPASS_LABEL]: ['0e8a16', 'Maintainer override for the linked-issue intake gate'],
|
||||||
|
}[name];
|
||||||
|
try {
|
||||||
|
await github.rest.issues.getLabel({ owner, repo, name });
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status !== 404) throw e;
|
||||||
|
try {
|
||||||
|
await github.rest.issues.createLabel({ owner, repo, name, color: meta[0], description: meta[1] });
|
||||||
|
} catch (createErr) {
|
||||||
|
if (createErr.status !== 422) throw createErr; // created concurrently
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The gate keeps at most one comment per PR: authored by the Actions bot and
|
||||||
|
// carrying MARKER. It's created or updated on failure and deleted on pass.
|
||||||
|
async function findGateComment(prNumber) {
|
||||||
|
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: prNumber, per_page: 100 });
|
||||||
|
return comments.find((c) => c.user?.login === BOT_LOGIN && c.body?.includes(MARKER));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertGateComment(prNumber, body) {
|
||||||
|
const existing = await findGateComment(prNumber);
|
||||||
|
if (!existing) {
|
||||||
|
await mutate(`comment on PR #${prNumber}`, () => github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body }));
|
||||||
|
} else if (existing.body !== body) {
|
||||||
|
await mutate(`update the gate comment on PR #${prNumber}`, () => github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteGateComment(prNumber) {
|
||||||
|
const existing = await findGateComment(prNumber);
|
||||||
|
if (!existing) return;
|
||||||
|
await mutate(`delete the gate comment on PR #${prNumber}`, async () => {
|
||||||
|
try {
|
||||||
|
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status !== 404) throw e; // already deleted by a concurrent run
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
393
.github/scripts/pr_intake_gate.test.js
vendored
Normal file
393
.github/scripts/pr_intake_gate.test.js
vendored
Normal file
|
|
@ -0,0 +1,393 @@
|
||||||
|
// Scenario tests for pr_intake_gate.js. Each case sets up a tiny in-memory
|
||||||
|
// repository (pull requests, issues, people), fires one event at the gate, and
|
||||||
|
// checks what the gate left behind: PR state, labels, and its comment.
|
||||||
|
//
|
||||||
|
// node --test .github/scripts/pr_intake_gate.test.js
|
||||||
|
//
|
||||||
|
// No dependencies; the GitHub client is a small fake defined at the bottom.
|
||||||
|
// CI runs it in the checks job (.github/workflows/shared.yml).
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const gate = require('./pr_intake_gate.js');
|
||||||
|
|
||||||
|
const LABEL = 'missing-issue-link';
|
||||||
|
const BYPASS = 'bypass-issue-check';
|
||||||
|
const REPO = { owner: 'modelcontextprotocol', repo: 'python-sdk' };
|
||||||
|
|
||||||
|
// People. Only the capability flags matter to the gate.
|
||||||
|
const PEOPLE = {
|
||||||
|
maintainer: { type: 'User', perms: { admin: true, maintain: true, push: true, triage: true, pull: true } },
|
||||||
|
triager: { type: 'User', perms: { triage: true, pull: true } }, // e.g. a trusted-contributors team
|
||||||
|
outsider: { type: 'User', perms: { pull: true } },
|
||||||
|
'dependabot[bot]': { type: 'Bot', perms: {} },
|
||||||
|
'somebot[bot]': { type: 'Bot', perms: {} },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Scenarios ──────────────────────────────────────────────────────────────
|
||||||
|
// `prs` / `issues` describe the world before the event; `expect` describes each
|
||||||
|
// PR afterwards: state, labels, and comment ('closed' = the "this PR has been
|
||||||
|
// closed" comment, 'closed-draft' = its draft wording, 'cannot-reopen' = the
|
||||||
|
// refused-reopen comment, null = none).
|
||||||
|
// `writes: 0` additionally asserts the gate touched nothing at all.
|
||||||
|
|
||||||
|
const scenarios = [
|
||||||
|
{
|
||||||
|
name: 'outsider opens a PR with no issue link → labeled, commented, closed',
|
||||||
|
prs: [pr(3300, 'outsider')],
|
||||||
|
event: opened(3300, 'outsider'),
|
||||||
|
expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'outsider links an open issue reported by someone else, not assigned → closed',
|
||||||
|
prs: [pr(3300, 'outsider', { body: 'Fixes #10' })],
|
||||||
|
issues: [issue(10)],
|
||||||
|
event: opened(3300, 'outsider'),
|
||||||
|
expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'outsider links an issue they are assigned to → stays open, untouched',
|
||||||
|
prs: [pr(3300, 'outsider', { body: 'closes #10' })],
|
||||||
|
issues: [issue(10, { assignees: ['outsider'] })],
|
||||||
|
event: opened(3300, 'outsider'),
|
||||||
|
expect: { 3300: { state: 'open', labels: [], comment: null } },
|
||||||
|
writes: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'outsider links a `help wanted` issue → stays open without assignment',
|
||||||
|
prs: [pr(3300, 'outsider', { body: 'Resolves modelcontextprotocol/python-sdk#10' })],
|
||||||
|
issues: [issue(10, { labels: ['help wanted'] })],
|
||||||
|
event: opened(3300, 'outsider'),
|
||||||
|
expect: { 3300: { state: 'open', labels: [], comment: null } },
|
||||||
|
writes: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'links only count when they use a closing keyword and point at an open issue here',
|
||||||
|
prs: [pr(3300, 'outsider', { body: 'Related to #10, fixes owner/other#10, prefixes #10, Fixes #11, Fixes #12' })],
|
||||||
|
issues: [
|
||||||
|
issue(10, { assignees: ['outsider'] }), // referenced, but never with a closing keyword for this repo
|
||||||
|
issue(11, { assignees: ['outsider'], state: 'closed' }), // closed issues don't count
|
||||||
|
issue(12, { assignees: ['outsider'], repository_url: 'https://api.github.com/repos/modelcontextprotocol/typescript-sdk' }), // transferred away
|
||||||
|
],
|
||||||
|
event: opened(3300, 'outsider'),
|
||||||
|
expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'gate-closed PR: author edits the description to link a qualifying issue → reopened, label and comment removed',
|
||||||
|
prs: [pr(3300, 'outsider', { state: 'closed', labels: [LABEL], body: 'Fixes #10', gateComment: true })],
|
||||||
|
issues: [issue(10, { labels: ['help wanted'] })],
|
||||||
|
event: edited(3300, 'outsider'),
|
||||||
|
expect: { 3300: { state: 'open', labels: [], comment: null } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'gate-closed PR: maintainer assigns the author on the linked issue → reopened',
|
||||||
|
prs: [
|
||||||
|
pr(3300, 'outsider', { state: 'closed', labels: [LABEL], body: 'Fixes #10', gateComment: true }),
|
||||||
|
pr(3301, 'outsider', { state: 'closed', labels: [LABEL], body: 'Fixes #99', gateComment: true }), // different issue: untouched
|
||||||
|
],
|
||||||
|
issues: [issue(10, { assignees: ['outsider'] }), issue(99)],
|
||||||
|
event: assigned(10, 'outsider', 'maintainer'),
|
||||||
|
expect: {
|
||||||
|
3300: { state: 'open', labels: [], comment: null },
|
||||||
|
3301: { state: 'closed', labels: [LABEL], comment: 'closed' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'gate-closed PR: maintainer reopens it → stays open with the sticky bypass label',
|
||||||
|
prs: [pr(3300, 'outsider', { state: 'open', labels: [LABEL], gateComment: true })], // payload arrives post-reopen
|
||||||
|
event: reopened(3300, 'maintainer'),
|
||||||
|
expect: { 3300: { state: 'open', labels: [BYPASS], comment: null } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'gate-closed PR: triage-role user removes the label → reopened with the sticky bypass label',
|
||||||
|
prs: [pr(3300, 'outsider', { state: 'closed', labels: [], gateComment: true })], // payload arrives post-unlabel
|
||||||
|
event: unlabeled(3300, 'triager'),
|
||||||
|
expect: { 3300: { state: 'open', labels: [BYPASS], comment: null } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'gate-closed PR: some other bot strips the label → re-checked, label restored, still closed',
|
||||||
|
prs: [pr(3300, 'outsider', { state: 'closed', labels: [], gateComment: true })],
|
||||||
|
event: unlabeled(3300, 'somebot[bot]'),
|
||||||
|
expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'PR carrying the bypass label is never re-closed, even after edits that would fail',
|
||||||
|
prs: [pr(3300, 'outsider', { labels: [BYPASS], body: 'no link at all' })],
|
||||||
|
event: edited(3300, 'outsider'),
|
||||||
|
expect: { 3300: { state: 'open', labels: [BYPASS], comment: null } },
|
||||||
|
writes: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'previously passing PR edited to drop its link → closed again',
|
||||||
|
prs: [pr(3300, 'outsider', { body: 'see #10' })],
|
||||||
|
issues: [issue(10, { assignees: ['outsider'] })],
|
||||||
|
event: edited(3300, 'outsider'),
|
||||||
|
expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "maintainer's own PR is exempt (and reopening it adds no labels)",
|
||||||
|
prs: [pr(3300, 'maintainer')],
|
||||||
|
event: reopened(3300, 'maintainer'),
|
||||||
|
expect: { 3300: { state: 'open', labels: [], comment: null } },
|
||||||
|
writes: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'triage-role author is exempt',
|
||||||
|
prs: [pr(3300, 'triager')],
|
||||||
|
event: opened(3300, 'triager'),
|
||||||
|
expect: { 3300: { state: 'open', labels: [], comment: null } },
|
||||||
|
writes: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'bot-authored PR (Dependabot) is exempt',
|
||||||
|
prs: [pr(3300, 'dependabot[bot]')],
|
||||||
|
event: opened(3300, 'dependabot[bot]'),
|
||||||
|
expect: { 3300: { state: 'open', labels: [], comment: null } },
|
||||||
|
writes: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'draft PR with no issue link is closed like any other, with a note about why drafts are closed early',
|
||||||
|
prs: [pr(3300, 'outsider', { draft: true })],
|
||||||
|
event: opened(3300, 'outsider'),
|
||||||
|
expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed-draft' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'pre-existing draft marked ready for review with no link → closed',
|
||||||
|
prs: [pr(3300, 'outsider')],
|
||||||
|
event: readyForReview(3300, 'outsider'),
|
||||||
|
expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'PR a maintainer closed by hand (no gate label) is left alone when edited',
|
||||||
|
prs: [pr(3300, 'outsider', { state: 'closed' })],
|
||||||
|
event: edited(3300, 'outsider'),
|
||||||
|
expect: { 3300: { state: 'closed', labels: [], comment: null } },
|
||||||
|
writes: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'merged PR still carrying the label is left alone',
|
||||||
|
prs: [pr(3300, 'outsider', { state: 'closed', merged_at: '2026-08-15T00:00:00Z', labels: [LABEL] })],
|
||||||
|
event: edited(3300, 'outsider'),
|
||||||
|
expect: { 3300: { state: 'closed', labels: [LABEL], comment: null } },
|
||||||
|
writes: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'GitHub refuses to reopen (branch rewritten while closed) → stays closed, comment explains',
|
||||||
|
prs: [pr(3300, 'outsider', { state: 'closed', labels: [LABEL], body: 'Fixes #10', gateComment: true, refuseReopen: true })],
|
||||||
|
issues: [issue(10, { assignees: ['outsider'] })],
|
||||||
|
event: edited(3300, 'outsider'),
|
||||||
|
expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'cannot-reopen' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'gate-closed PR: maintainer adds the bypass label → reopened, and the label sticks',
|
||||||
|
prs: [pr(3300, 'outsider', { state: 'closed', labels: [LABEL, BYPASS], gateComment: true })], // payload arrives post-label
|
||||||
|
event: labeled(3300, 'maintainer', BYPASS),
|
||||||
|
expect: { 3300: { state: 'open', labels: [BYPASS], comment: null } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'refused reopen after a label-removal override → both labels on, so the PR stays gate-managed',
|
||||||
|
prs: [pr(3300, 'outsider', { state: 'closed', labels: [], gateComment: true, refuseReopen: true })],
|
||||||
|
event: unlabeled(3300, 'triager'),
|
||||||
|
expect: { 3300: { state: 'closed', labels: [BYPASS, LABEL], comment: 'cannot-reopen' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'after a refused reopen, once the branch is restored an edit retries and reopens',
|
||||||
|
prs: [pr(3300, 'outsider', { state: 'closed', labels: [BYPASS, LABEL], comments: [{ user: 'github-actions[bot]', body: "<!-- require-linked-issue -->\n…GitHub won't let it be reopened…" }] })],
|
||||||
|
event: edited(3300, 'outsider'),
|
||||||
|
expect: { 3300: { state: 'open', labels: [BYPASS], comment: null } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'assignment reopens the PR even when the assigned issue is referenced after several others',
|
||||||
|
prs: [pr(3300, 'outsider', { state: 'closed', labels: [LABEL], body: 'Fixes #1 fixes #2 fixes #3 fixes #4 fixes #5 fixes #6', gateComment: true })],
|
||||||
|
issues: [issue(1), issue(2), issue(3), issue(4), issue(5), issue(6, { assignees: ['outsider'] })],
|
||||||
|
event: assigned(6, 'outsider', 'maintainer'),
|
||||||
|
expect: { 3300: { state: 'open', labels: [], comment: null } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'a comment planted by the author with the marker is ignored; the gate posts its own',
|
||||||
|
prs: [pr(3300, 'outsider', { comments: [{ user: 'outsider', body: '<!-- require-linked-issue -->\nnice try' }] })],
|
||||||
|
event: opened(3300, 'outsider'),
|
||||||
|
expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed', foreignComments: 1 } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'manual dispatch evaluates an old, never-gated PR like a new one',
|
||||||
|
prs: [pr(3150, 'outsider')],
|
||||||
|
event: dispatch(3150, 'maintainer'),
|
||||||
|
expect: { 3150: { state: 'closed', labels: [LABEL], comment: 'closed' } },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const s of scenarios) {
|
||||||
|
test(s.name, async () => {
|
||||||
|
const world = makeWorld(s);
|
||||||
|
await run(world, s.event, { enforce: true });
|
||||||
|
assert.deepEqual(observe(world, s.expect), s.expect);
|
||||||
|
if (s.writes !== undefined) assert.equal(world.writes.length, s.writes, `writes: ${world.writes.join(', ')}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('kill switch (ENFORCE=false) reaches the same verdicts but writes nothing', async () => {
|
||||||
|
for (const s of scenarios) {
|
||||||
|
const world = makeWorld(s);
|
||||||
|
await run(world, s.event, { enforce: false });
|
||||||
|
assert.equal(world.writes.length, 0, `${s.name}: ${world.writes.join(', ')}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('on assignment, one PR failing to re-evaluate does not stop the others (run still fails)', async () => {
|
||||||
|
const world = makeWorld({
|
||||||
|
prs: [
|
||||||
|
pr(3300, 'outsider', { state: 'closed', labels: [LABEL], body: 'Fixes #10', gateComment: true }),
|
||||||
|
pr(3301, 'outsider', { state: 'closed', labels: [LABEL], body: 'Fixes #10', gateComment: true }),
|
||||||
|
],
|
||||||
|
issues: [issue(10, { assignees: ['outsider'] })],
|
||||||
|
});
|
||||||
|
world.failPullsGet = 3300;
|
||||||
|
await assert.rejects(run(world, assigned(10, 'outsider', 'maintainer'), { enforce: true }), /Could not re-evaluate #3300/);
|
||||||
|
assert.equal(world.prs.get(3301).state, 'open');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an API error while checking permissions fails the run rather than closing the PR', async () => {
|
||||||
|
const world = makeWorld({ prs: [pr(3300, 'outsider')] });
|
||||||
|
world.failPermissionLookup = true;
|
||||||
|
await assert.rejects(run(world, opened(3300, 'outsider'), { enforce: true }), /Permission check failed/);
|
||||||
|
assert.equal(world.writes.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Scenario helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function pr(number, author, o = {}) {
|
||||||
|
return { number, author, state: 'open', draft: false, body: '', labels: [], merged_at: null, comments: [], gateComment: false, refuseReopen: false, ...o };
|
||||||
|
}
|
||||||
|
function issue(number, o = {}) {
|
||||||
|
return { number, state: 'open', labels: [], assignees: [], repository_url: `https://api.github.com/repos/${REPO.owner}/${REPO.repo}`, ...o };
|
||||||
|
}
|
||||||
|
function prEvent(action, number, sender, extra = {}) {
|
||||||
|
return { eventName: 'pull_request_target', payload: { action, pull_request: { number }, sender: { login: sender }, ...extra } };
|
||||||
|
}
|
||||||
|
function opened(number, sender) { return prEvent('opened', number, sender); }
|
||||||
|
function edited(number, sender) { return prEvent('edited', number, sender); }
|
||||||
|
function reopened(number, sender) { return prEvent('reopened', number, sender); }
|
||||||
|
function readyForReview(number, sender) { return prEvent('ready_for_review', number, sender); }
|
||||||
|
function unlabeled(number, sender) { return prEvent('unlabeled', number, sender, { label: { name: LABEL } }); }
|
||||||
|
function labeled(number, sender, name) { return prEvent('labeled', number, sender, { label: { name } }); }
|
||||||
|
function assigned(issueNumber, assignee, sender) {
|
||||||
|
return { eventName: 'issues', payload: { action: 'assigned', issue: { number: issueNumber }, assignee: { login: assignee }, sender: { login: sender } } };
|
||||||
|
}
|
||||||
|
function dispatch(number, sender) {
|
||||||
|
return { eventName: 'workflow_dispatch', prNumber: number, payload: { inputs: { pr_number: String(number) }, sender: { login: sender } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(world, event, { enforce }) {
|
||||||
|
process.env.ENFORCE = enforce ? 'true' : 'false';
|
||||||
|
process.env.PR_NUMBER_INPUT = event.prNumber ? String(event.prNumber) : '';
|
||||||
|
const core = { warning: () => {}, setFailed: (m) => { throw new Error(`setFailed: ${m}`); } };
|
||||||
|
const quiet = console.log;
|
||||||
|
console.log = () => {};
|
||||||
|
try {
|
||||||
|
await gate({ github: world.github, context: { repo: REPO, ...event }, core });
|
||||||
|
} finally {
|
||||||
|
console.log = quiet;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the gate left behind, in the same shape as a scenario's `expect`.
|
||||||
|
function observe(world, expect) {
|
||||||
|
const out = {};
|
||||||
|
for (const num of Object.keys(expect)) {
|
||||||
|
const p = world.prs.get(Number(num));
|
||||||
|
const gateComments = p.comments.filter((c) => c.user === 'github-actions[bot]' && c.body.includes('<!-- require-linked-issue -->'));
|
||||||
|
assert.ok(gateComments.length <= 1, `PR #${num} has ${gateComments.length} gate comments`);
|
||||||
|
const body = gateComments[0]?.body;
|
||||||
|
const kind = !body ? null : body.includes("won't let it be reopened") ? 'cannot-reopen' : body.includes('still a draft') ? 'closed-draft' : 'closed';
|
||||||
|
out[num] = { state: p.state, labels: [...p.labels].sort(), comment: kind };
|
||||||
|
if ('foreignComments' in expect[num]) out[num].foreignComments = p.comments.length - gateComments.length;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── A tiny in-memory GitHub ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeWorld({ prs = [], issues = [] }) {
|
||||||
|
const world = { prs: new Map(), issues: new Map(), writes: [], failPermissionLookup: false, failPullsGet: null, nextCommentId: 100 };
|
||||||
|
for (const p of prs) {
|
||||||
|
const copy = structuredClone(p);
|
||||||
|
copy.comments = copy.comments.map((c) => ({ id: world.nextCommentId++, ...c }));
|
||||||
|
if (copy.gateComment) copy.comments.push({ id: world.nextCommentId++, user: 'github-actions[bot]', body: '<!-- require-linked-issue -->\nThanks for the contribution. …closed for now…' });
|
||||||
|
world.prs.set(p.number, copy);
|
||||||
|
}
|
||||||
|
for (const i of issues) world.issues.set(i.number, structuredClone(i));
|
||||||
|
|
||||||
|
const err = (status, message = 'fake error') => Object.assign(new Error(message), { status });
|
||||||
|
const write = (what) => world.writes.push(what);
|
||||||
|
const getPr = (n) => { const p = world.prs.get(n); if (!p) throw err(404); return p; };
|
||||||
|
const apiPr = (p) => ({
|
||||||
|
number: p.number, state: p.state, draft: p.draft, body: p.body, merged_at: p.merged_at,
|
||||||
|
labels: p.labels.map((name) => ({ name })),
|
||||||
|
user: { login: p.author, type: PEOPLE[p.author].type },
|
||||||
|
head: { sha: 'abc1234def', ref: 'patch-1', repo: { name: REPO.repo, owner: { login: p.author } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const rest = {
|
||||||
|
repos: {
|
||||||
|
getCollaboratorPermissionLevel: async ({ username }) => {
|
||||||
|
if (world.failPermissionLookup) throw err(500, 'boom');
|
||||||
|
const person = PEOPLE[username];
|
||||||
|
if (!person) throw err(404, 'not a user');
|
||||||
|
return { data: { role_name: 'custom', user: { permissions: { admin: false, maintain: false, push: false, triage: false, pull: false, ...person.perms } } } };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pulls: {
|
||||||
|
get: async ({ pull_number }) => { if (world.failPullsGet === pull_number) throw err(502, 'bad gateway'); return { data: apiPr(getPr(pull_number)) }; },
|
||||||
|
update: async ({ pull_number, state }) => {
|
||||||
|
const p = getPr(pull_number);
|
||||||
|
write(`${state === 'open' ? 'reopen' : 'close'} #${pull_number}`);
|
||||||
|
if (state === 'open' && p.refuseReopen) throw err(422, 'Validation Failed: state cannot be changed');
|
||||||
|
p.state = state;
|
||||||
|
return { data: apiPr(p) };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
issues: {
|
||||||
|
get: async ({ issue_number }) => {
|
||||||
|
if (world.prs.has(issue_number)) return { data: { ...apiPr(getPr(issue_number)), pull_request: {} } };
|
||||||
|
const i = world.issues.get(issue_number);
|
||||||
|
if (!i) throw err(404);
|
||||||
|
return { data: { ...i, labels: i.labels.map((name) => ({ name })), assignees: i.assignees.map((login) => ({ login })) } };
|
||||||
|
},
|
||||||
|
listForRepo: async ({ state, creator, labels }) => ({
|
||||||
|
data: [...world.prs.values()]
|
||||||
|
.filter((p) => p.state === state && p.author === creator && p.labels.includes(labels))
|
||||||
|
.map((p) => ({ number: p.number, body: p.body, pull_request: {} })),
|
||||||
|
}),
|
||||||
|
getLabel: async () => ({ data: {} }),
|
||||||
|
createLabel: async ({ name }) => { write(`create label ${name}`); },
|
||||||
|
addLabels: async ({ issue_number, labels }) => {
|
||||||
|
const p = getPr(issue_number);
|
||||||
|
write(`label #${issue_number} ${labels}`);
|
||||||
|
for (const l of labels) if (!p.labels.includes(l)) p.labels.push(l);
|
||||||
|
},
|
||||||
|
removeLabel: async ({ issue_number, name }) => {
|
||||||
|
const p = getPr(issue_number);
|
||||||
|
write(`unlabel #${issue_number} ${name}`);
|
||||||
|
if (!p.labels.includes(name)) throw err(404);
|
||||||
|
p.labels = p.labels.filter((l) => l !== name);
|
||||||
|
},
|
||||||
|
listComments: async ({ issue_number }) => ({ data: getPr(issue_number).comments.map((c) => ({ id: c.id, body: c.body, user: { login: c.user } })) }),
|
||||||
|
createComment: async ({ issue_number, body }) => {
|
||||||
|
write(`comment on #${issue_number}`);
|
||||||
|
getPr(issue_number).comments.push({ id: world.nextCommentId++, user: 'github-actions[bot]', body });
|
||||||
|
},
|
||||||
|
updateComment: async ({ comment_id, body }) => {
|
||||||
|
write(`edit comment ${comment_id}`);
|
||||||
|
for (const p of world.prs.values()) for (const c of p.comments) if (c.id === comment_id) c.body = body;
|
||||||
|
},
|
||||||
|
deleteComment: async ({ comment_id }) => {
|
||||||
|
write(`delete comment ${comment_id}`);
|
||||||
|
for (const p of world.prs.values()) p.comments = p.comments.filter((c) => c.id !== comment_id);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
world.github = { rest, paginate: async (fn, args) => (await fn(args)).data };
|
||||||
|
return world;
|
||||||
|
}
|
||||||
4
.github/workflows/claude.yml
vendored
4
.github/workflows/claude.yml
vendored
|
|
@ -27,14 +27,14 @@ jobs:
|
||||||
actions: read # Required for Claude to read CI results on PRs
|
actions: read # Required for Claude to read CI results on PRs
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Run Claude Code
|
- name: Run Claude Code
|
||||||
id: claude
|
id: claude
|
||||||
uses: anthropics/claude-code-action@d5726de019ec4498aa667642bc3a80fca83aa102 # v1.0.148
|
uses: anthropics/claude-code-action@d40ddef4c030e508327d6e35a9c45f3368482c50 # v1.0.195
|
||||||
with:
|
with:
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # zizmor: ignore[secrets-outside-env]
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # zizmor: ignore[secrets-outside-env]
|
||||||
use_commit_signing: true
|
use_commit_signing: true
|
||||||
|
|
|
||||||
98
.github/workflows/conformance.yml
vendored
98
.github/workflows/conformance.yml
vendored
|
|
@ -15,56 +15,55 @@ permissions:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
# Pinned conformance harness package spec (passed verbatim to `npx --yes`).
|
# Pinned conformance harness package spec (passed verbatim to `npx --yes`).
|
||||||
# Bump deliberately and reconcile both
|
# Bump deliberately and reconcile the three
|
||||||
# .github/actions/conformance/expected-failures*.yml files in the same change.
|
# .github/actions/conformance/expected-failures*.yml files in the same change.
|
||||||
CONFORMANCE_PKG: "@modelcontextprotocol/conformance@0.2.0-alpha.10"
|
CONFORMANCE_PKG: "@modelcontextprotocol/conformance@0.2.0-alpha.11"
|
||||||
|
|
||||||
|
# Each job runs three `--suite all` legs, one per baseline file. The two
|
||||||
|
# `--spec-version <rev>` legs run every scenario the pinned harness marks
|
||||||
|
# applicable at that spec revision, at that revision's wire (stateful
|
||||||
|
# handshake for 2025-11-25, stateless per-request _meta for 2026-07-28). Each
|
||||||
|
# is a superset of the frozen requirement set that `conformance tier-check`
|
||||||
|
# scores for that revision (`npx $CONFORMANCE_PKG list --requirements <rev>`
|
||||||
|
# prints it), so an expected-failures.<rev>.yml entry for a scenario in that
|
||||||
|
# set means tier-check reports the SDK below 100% for <rev>. The bare leg runs
|
||||||
|
# every shipped scenario at whichever wire the harness defaults it to; it is
|
||||||
|
# the only leg that reaches extension-tagged scenarios (tasks-*, auth/dpop*,
|
||||||
|
# ...), which never match a --spec-version filter. For everything else it
|
||||||
|
# overlaps one of the pinned legs, so a failure it also sees needs its own
|
||||||
|
# expected-failures.yml entry besides the pinned file's.
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
server-conformance:
|
server-conformance:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
version: 0.9.5
|
version: 0.9.5
|
||||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
- run: uv sync --frozen --all-extras --package mcp-everything-server
|
- run: uv sync --frozen --all-extras --package mcp-everything-server
|
||||||
- name: Run server conformance (active suite)
|
- name: Run server conformance (all suite, 2025-11-25 wire)
|
||||||
run: >-
|
run: >-
|
||||||
./.github/actions/conformance/run-server.sh
|
./.github/actions/conformance/run-server.sh
|
||||||
--suite active
|
--suite all
|
||||||
--expected-failures ./.github/actions/conformance/expected-failures.yml
|
--spec-version 2025-11-25
|
||||||
--output-dir conformance-results/server-active
|
--expected-failures ./.github/actions/conformance/expected-failures.2025-11-25.yml
|
||||||
- name: Run server conformance (draft suite)
|
--output-dir conformance-results/server-2025-11-25
|
||||||
run: >-
|
- name: Run server conformance (all suite, 2026-07-28 wire)
|
||||||
./.github/actions/conformance/run-server.sh
|
|
||||||
--suite draft
|
|
||||||
--expected-failures ./.github/actions/conformance/expected-failures.yml
|
|
||||||
--output-dir conformance-results/server-draft
|
|
||||||
- name: Run server conformance (2026-07-28 wire, all suite)
|
|
||||||
run: >-
|
run: >-
|
||||||
./.github/actions/conformance/run-server.sh
|
./.github/actions/conformance/run-server.sh
|
||||||
--suite all
|
--suite all
|
||||||
--spec-version 2026-07-28
|
--spec-version 2026-07-28
|
||||||
--expected-failures ./.github/actions/conformance/expected-failures.2026-07-28.yml
|
--expected-failures ./.github/actions/conformance/expected-failures.2026-07-28.yml
|
||||||
--output-dir conformance-results/server-2026-07-28
|
--output-dir conformance-results/server-2026-07-28
|
||||||
- name: Run server conformance (all suite, extension scenarios)
|
- name: Run server conformance (all suite, default wire)
|
||||||
# A bare `--suite all` (no --spec-version) selects every scenario
|
|
||||||
# shipped with the pinned harness — including the extension-tagged
|
|
||||||
# tasks-* scenarios and pending-listed ones like server-sse-polling,
|
|
||||||
# which no other leg reaches (extension scenarios never match a
|
|
||||||
# --spec-version filter, and the pending list keeps them out of the
|
|
||||||
# active suite). Running the full set keeps unimplemented surfaces
|
|
||||||
# visible as baselined known failures in expected-failures.yml instead
|
|
||||||
# of silent exclusions, and stays robust to scenarios moving between
|
|
||||||
# harness suite lists across pin bumps. `--suite pending` would cover
|
|
||||||
# the same union slightly faster; the full set is preferred for the
|
|
||||||
# self-contained run and for parity with typescript-sdk's CI.
|
|
||||||
run: >-
|
run: >-
|
||||||
./.github/actions/conformance/run-server.sh
|
./.github/actions/conformance/run-server.sh
|
||||||
--suite all
|
--suite all
|
||||||
|
|
@ -72,7 +71,9 @@ jobs:
|
||||||
--output-dir conformance-results/server-all
|
--output-dir conformance-results/server-all
|
||||||
- name: Upload conformance results
|
- name: Upload conformance results
|
||||||
# The log has only summary counts; per-check data is in checks.json.
|
# The log has only summary counts; per-check data is in checks.json.
|
||||||
if: failure()
|
# always(), not !cancelled(): a timeout-minutes kill is a cancellation,
|
||||||
|
# and that is when the partial results matter most.
|
||||||
|
if: always()
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: server-conformance-results
|
name: server-conformance-results
|
||||||
|
|
@ -81,15 +82,16 @@ jobs:
|
||||||
|
|
||||||
client-conformance:
|
client-conformance:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
version: 0.9.5
|
version: 0.9.5
|
||||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
# --compile-bytecode: without it, ~40 concurrently spawned interpreters
|
# --compile-bytecode: without it, ~40 concurrently spawned interpreters
|
||||||
|
|
@ -97,20 +99,21 @@ jobs:
|
||||||
- run: uv sync --frozen --all-extras --package mcp --compile-bytecode
|
- run: uv sync --frozen --all-extras --package mcp --compile-bytecode
|
||||||
- name: Pre-compile bytecode (editable sources)
|
- name: Pre-compile bytecode (editable sources)
|
||||||
run: uv run --frozen python -m compileall -q src .github/actions/conformance
|
run: uv run --frozen python -m compileall -q src .github/actions/conformance
|
||||||
- name: Run client conformance (all suite)
|
# The harness runs a leg's scenarios via unbounded Promise.all; under
|
||||||
# The harness runs all scenarios via unbounded Promise.all; with 40
|
# that concurrency the slowest one (sse-retry, which has a real-time SSE
|
||||||
# scenarios on a 2-core runner the slowest one (sse-retry, which has a
|
# reconnect wait) needs more than the 30s default budget.
|
||||||
# real-time SSE reconnect wait) needs more than the 30s default budget.
|
# `.venv/bin/python` (not `uv run`) avoids lockfile re-checks in ~40
|
||||||
# `.venv/bin/python` (not `uv run`) avoids lockfile re-checks in ~40
|
# concurrent spawns; run-client.sh re-runs unexpected failures solo.
|
||||||
# concurrent spawns; run-client.sh re-runs unexpected failures solo.
|
- name: Run client conformance (all suite, 2025-11-25 wire)
|
||||||
run: >-
|
run: >-
|
||||||
./.github/actions/conformance/run-client.sh
|
./.github/actions/conformance/run-client.sh
|
||||||
--command '.venv/bin/python .github/actions/conformance/client.py'
|
--command '.venv/bin/python .github/actions/conformance/client.py'
|
||||||
--suite all
|
--suite all
|
||||||
--timeout 60000
|
--timeout 60000
|
||||||
--expected-failures ./.github/actions/conformance/expected-failures.yml
|
--spec-version 2025-11-25
|
||||||
--output-dir conformance-results/client-all
|
--expected-failures ./.github/actions/conformance/expected-failures.2025-11-25.yml
|
||||||
- name: Run client conformance (2026-07-28 wire, all suite)
|
--output-dir conformance-results/client-2025-11-25
|
||||||
|
- name: Run client conformance (all suite, 2026-07-28 wire)
|
||||||
run: >-
|
run: >-
|
||||||
./.github/actions/conformance/run-client.sh
|
./.github/actions/conformance/run-client.sh
|
||||||
--command '.venv/bin/python .github/actions/conformance/client.py'
|
--command '.venv/bin/python .github/actions/conformance/client.py'
|
||||||
|
|
@ -119,10 +122,19 @@ jobs:
|
||||||
--spec-version 2026-07-28
|
--spec-version 2026-07-28
|
||||||
--expected-failures ./.github/actions/conformance/expected-failures.2026-07-28.yml
|
--expected-failures ./.github/actions/conformance/expected-failures.2026-07-28.yml
|
||||||
--output-dir conformance-results/client-2026-07-28
|
--output-dir conformance-results/client-2026-07-28
|
||||||
|
- name: Run client conformance (all suite, default wire)
|
||||||
|
run: >-
|
||||||
|
./.github/actions/conformance/run-client.sh
|
||||||
|
--command '.venv/bin/python .github/actions/conformance/client.py'
|
||||||
|
--suite all
|
||||||
|
--timeout 60000
|
||||||
|
--expected-failures ./.github/actions/conformance/expected-failures.yml
|
||||||
|
--output-dir conformance-results/client-all
|
||||||
- name: Upload conformance results
|
- name: Upload conformance results
|
||||||
# The log has only summary counts; per-check data is in checks.json.
|
# The log has only summary counts; per-check data is in checks.json.
|
||||||
# Also on FLAKE_RESCUED: rescued-flake evidence is otherwise discarded.
|
# always(), not !cancelled(): a timeout-minutes kill is a cancellation,
|
||||||
if: failure() || hashFiles('conformance-results/**/FLAKE_RESCUED') != ''
|
# and that is when the partial results matter most.
|
||||||
|
if: always()
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: client-conformance-results
|
name: client-conformance-results
|
||||||
|
|
|
||||||
12
.github/workflows/deploy-docs.yml
vendored
12
.github/workflows/deploy-docs.yml
vendored
|
|
@ -3,13 +3,17 @@ name: Deploy Docs
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
|
# main is the sole deployer of the combined site (v2 at / and /v2/, v1.x
|
||||||
|
# at /v1/); the v1.x branch has no deploy workflow. A v1.x docs change is
|
||||||
|
# published by the next main deploy or a manual workflow_dispatch here.
|
||||||
- main
|
- main
|
||||||
- v1.x
|
|
||||||
paths:
|
paths:
|
||||||
- docs/**
|
- docs/**
|
||||||
# docs pages include their code blocks from these files via `--8<--`, so a
|
# docs pages include their code blocks from these files via `--8<--`, so a
|
||||||
# change here changes the rendered site even when no .md file moves.
|
# change here changes the rendered site even when no .md file moves.
|
||||||
- docs_src/**
|
- docs_src/**
|
||||||
|
# translated pages and the language registry feed the site/<code>/ sites
|
||||||
|
- i18n/**
|
||||||
- mkdocs.yml
|
- mkdocs.yml
|
||||||
- src/mcp/**
|
- src/mcp/**
|
||||||
- src/mcp-types/**
|
- src/mcp-types/**
|
||||||
|
|
@ -38,17 +42,17 @@ jobs:
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
version: 0.9.5
|
version: 0.9.5
|
||||||
|
|
||||||
- name: Build combined docs (v1.x at /, main at /v2/)
|
- name: Build combined docs (main at / and /v2/, v1.x at /v1/)
|
||||||
run: bash scripts/build-docs.sh site
|
run: bash scripts/build-docs.sh site
|
||||||
|
|
||||||
- name: Configure Pages
|
- name: Configure Pages
|
||||||
|
|
|
||||||
152
.github/workflows/docs-preview.yml
vendored
152
.github/workflows/docs-preview.yml
vendored
|
|
@ -4,11 +4,16 @@ name: Docs Preview
|
||||||
#
|
#
|
||||||
# Security: the build executes Python from the PR (mkdocstrings imports
|
# Security: the build executes Python from the PR (mkdocstrings imports
|
||||||
# src/mcp, `!!python/name:` config directives run, and heads may ship their
|
# src/mcp, `!!python/name:` config directives run, and heads may ship their
|
||||||
# own build scripts). The build is gated by `authorize` (admin sender for
|
# own build scripts). The build is gated by `authorize` (admin sender on a
|
||||||
# auto-preview, admin/maintainer commenter for /preview-docs) and isolated
|
# same-repo branch for auto-preview, admin/maintainer commenter for
|
||||||
# from Cloudflare secrets — `build` runs PR code with no secrets and hands
|
# /preview-docs) and isolated from Cloudflare secrets — `build` runs PR code
|
||||||
# the static site to `deploy` via an artifact, so PR code never shares a
|
# with no secrets and hands the static site to `deploy` via an artifact, so
|
||||||
# runner with the Cloudflare token.
|
# PR code never shares a runner with the Cloudflare token. Fork PRs get no
|
||||||
|
# automatic preview: actions/checkout refuses to fetch a fork's head in a
|
||||||
|
# pull_request_target run, so a maintainer requests one with /preview-docs,
|
||||||
|
# which runs under issue_comment with the same gating and isolation.
|
||||||
|
# `authorize` and `comment` run .github/scripts/docs_preview.js, checked out
|
||||||
|
# from the default branch only; those two checkouts must never take a `ref:`.
|
||||||
#
|
#
|
||||||
# Required configuration:
|
# Required configuration:
|
||||||
# - secrets.CLOUDFLARE_API_TOKEN (scope: Account → Cloudflare Pages → Edit)
|
# - secrets.CLOUDFLARE_API_TOKEN (scope: Account → Cloudflare Pages → Edit)
|
||||||
|
|
@ -21,6 +26,7 @@ on:
|
||||||
paths:
|
paths:
|
||||||
- docs/**
|
- docs/**
|
||||||
- docs_src/**
|
- docs_src/**
|
||||||
|
- i18n/**
|
||||||
- mkdocs.yml
|
- mkdocs.yml
|
||||||
- scripts/docs/**
|
- scripts/docs/**
|
||||||
- pyproject.toml
|
- pyproject.toml
|
||||||
|
|
@ -57,57 +63,20 @@ jobs:
|
||||||
head_sha: ${{ steps.check.outputs.head_sha }}
|
head_sha: ${{ steps.check.outputs.head_sha }}
|
||||||
slash_attempt: ${{ steps.check.outputs.slash_attempt }}
|
slash_attempt: ${{ steps.check.outputs.slash_attempt }}
|
||||||
steps:
|
steps:
|
||||||
|
- name: Check out the scripts (default branch)
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
# No `ref:` here, ever: this job trusts what it checks out (see header).
|
||||||
|
persist-credentials: false
|
||||||
|
sparse-checkout: .github/scripts
|
||||||
|
|
||||||
- name: Determine authorization
|
- name: Determine authorization
|
||||||
id: check
|
id: check
|
||||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const { owner, repo } = context.repo;
|
const { authorize } = require('./.github/scripts/docs_preview.js');
|
||||||
|
await authorize({ github, context, core });
|
||||||
async function permissionFor(username) {
|
|
||||||
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username });
|
|
||||||
return { level: data.permission, role: data.role_name };
|
|
||||||
}
|
|
||||||
|
|
||||||
let authorized = false;
|
|
||||||
let prNumber = '';
|
|
||||||
let headSha = '';
|
|
||||||
let slashAttempt = false;
|
|
||||||
|
|
||||||
if (context.eventName === 'pull_request_target') {
|
|
||||||
// Gate on the *sender* (whoever caused this run — on synchronize that
|
|
||||||
// is the pusher), not the PR author, so a non-admin pushing to an
|
|
||||||
// admin-opened branch does not get an automatic build.
|
|
||||||
const actor = context.payload.sender.login;
|
|
||||||
prNumber = String(context.payload.pull_request.number);
|
|
||||||
headSha = context.payload.pull_request.head.sha;
|
|
||||||
const perm = await permissionFor(actor);
|
|
||||||
authorized = perm.level === 'admin';
|
|
||||||
core.info(`pull_request_target by ${actor} (level=${perm.level}, role=${perm.role}) → authorized=${authorized}`);
|
|
||||||
} else {
|
|
||||||
// issue_comment: the job-level `if:` already guarantees this is a PR
|
|
||||||
// comment starting with /preview-docs.
|
|
||||||
slashAttempt = true;
|
|
||||||
const actor = context.payload.comment.user.login;
|
|
||||||
prNumber = String(context.payload.issue.number);
|
|
||||||
const perm = await permissionFor(actor);
|
|
||||||
authorized = perm.level === 'admin' || perm.role === 'maintain';
|
|
||||||
if (authorized) {
|
|
||||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: Number(prNumber) });
|
|
||||||
if (pr.state !== 'open') {
|
|
||||||
authorized = false;
|
|
||||||
core.info(`PR #${prNumber} is ${pr.state}; refusing to preview.`);
|
|
||||||
} else {
|
|
||||||
headSha = pr.head.sha;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
core.info(`/preview-docs by ${actor} (level=${perm.level}, role=${perm.role}) → authorized=${authorized}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
core.setOutput('authorized', String(authorized));
|
|
||||||
core.setOutput('pr_number', prNumber);
|
|
||||||
core.setOutput('head_sha', headSha);
|
|
||||||
core.setOutput('slash_attempt', String(slashAttempt));
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
needs: authorize
|
needs: authorize
|
||||||
|
|
@ -116,27 +85,28 @@ jobs:
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.authorize.outputs.head_sha }}
|
ref: ${{ needs.authorize.outputs.head_sha }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||||
with:
|
with:
|
||||||
# pull_request_target runs share the base-branch Actions cache; saving
|
# Keep the untrusted build away from the shared Actions cache. GitHub
|
||||||
# a cache populated while untrusted PR code ran would let it poison
|
# already limits pull_request_target and issue_comment runs to
|
||||||
# later trusted workflows. Mirrors publish-pypi.yml.
|
# read-only access in the default branch's cache scope, so this is
|
||||||
|
# defence in depth (and avoids a refused save in the post step).
|
||||||
|
# Mirrors publish-pypi.yml.
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
version: 0.9.5
|
version: 0.9.5
|
||||||
|
|
||||||
# pull_request_target runs this workflow file from the base branch, so
|
# Both triggers run this workflow file from the default branch (whatever
|
||||||
# the whole recipe — dependency sync included — must come from the
|
# the PR targets), so the whole recipe — dependency sync included — must
|
||||||
# checkout itself: heads that ship scripts/docs/build.sh (the Zensical
|
# come from the checkout itself: heads that ship scripts/docs/build.sh
|
||||||
# toolchain) build with it; older heads, and v1.x heads previewed via
|
# (the Zensical toolchain) build with it; older heads and v1.x heads still
|
||||||
# /preview-docs, still build with MkDocs. Both arms must write the site
|
# build with MkDocs. Both arms must write the site to site/. Keep the
|
||||||
# to site/. Keep the detection in sync with build_site() in
|
# detection in sync with build_site() in scripts/build-docs.sh.
|
||||||
# scripts/build-docs.sh.
|
|
||||||
- run: |
|
- run: |
|
||||||
if [ -f scripts/docs/build.sh ]; then
|
if [ -f scripts/docs/build.sh ]; then
|
||||||
bash scripts/docs/build.sh
|
bash scripts/docs/build.sh
|
||||||
|
|
@ -191,10 +161,18 @@ jobs:
|
||||||
(needs.authorize.outputs.authorized == 'true' || needs.authorize.outputs.slash_attempt == 'true')
|
(needs.authorize.outputs.authorized == 'true' || needs.authorize.outputs.slash_attempt == 'true')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
|
contents: read # check out the script from the default branch
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
|
- name: Check out the scripts (default branch)
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
# No `ref:` here, ever: this job trusts what it checks out (see header).
|
||||||
|
persist-credentials: false
|
||||||
|
sparse-checkout: .github/scripts
|
||||||
|
|
||||||
- name: Post or update preview comment
|
- name: Post or update preview comment
|
||||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
env:
|
env:
|
||||||
AUTHORIZED: ${{ needs.authorize.outputs.authorized }}
|
AUTHORIZED: ${{ needs.authorize.outputs.authorized }}
|
||||||
PR_NUMBER: ${{ needs.authorize.outputs.pr_number }}
|
PR_NUMBER: ${{ needs.authorize.outputs.pr_number }}
|
||||||
|
|
@ -205,45 +183,5 @@ jobs:
|
||||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const { owner, repo } = context.repo;
|
const { comment } = require('./.github/scripts/docs_preview.js');
|
||||||
const env = process.env;
|
await comment({ github, context, core });
|
||||||
const issue_number = Number(env.PR_NUMBER);
|
|
||||||
const marker = '<!-- docs-preview -->';
|
|
||||||
|
|
||||||
async function upsert(body) {
|
|
||||||
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 });
|
|
||||||
const existing = comments.find(c => c.user?.login === 'github-actions[bot]' && c.body?.includes(marker));
|
|
||||||
if (existing) {
|
|
||||||
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
|
|
||||||
} else {
|
|
||||||
await github.rest.issues.createComment({ owner, repo, issue_number, body });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (env.AUTHORIZED !== 'true') {
|
|
||||||
await github.rest.issues.createComment({
|
|
||||||
owner, repo, issue_number,
|
|
||||||
body: `@${context.actor} — only repository admins or maintainers can run \`/preview-docs\` (and the PR must be open).`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (env.DEPLOY_RESULT !== 'success') {
|
|
||||||
await upsert(
|
|
||||||
`${marker}\n### 📚 Documentation preview\n\n` +
|
|
||||||
`❌ Preview build **failed** for \`${env.HEAD_SHA.slice(0, 7)}\` — [workflow logs](${env.RUN_URL}).`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const previewUrl = env.ALIAS_URL || env.DEPLOYMENT_URL;
|
|
||||||
const ts = new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
|
|
||||||
await upsert(
|
|
||||||
`${marker}\n### 📚 Documentation preview\n\n` +
|
|
||||||
`| | |\n|---|---|\n` +
|
|
||||||
`| **Preview** | ${previewUrl} |\n` +
|
|
||||||
`| **Deployment** | ${env.DEPLOYMENT_URL} |\n` +
|
|
||||||
`| **Commit** | \`${env.HEAD_SHA.slice(0, 7)}\` |\n` +
|
|
||||||
`| **Triggered by** | @${context.actor} |\n` +
|
|
||||||
`| **Updated** | ${ts} |\n`
|
|
||||||
);
|
|
||||||
|
|
|
||||||
8
.github/workflows/publish-pypi.yml
vendored
8
.github/workflows/publish-pypi.yml
vendored
|
|
@ -13,15 +13,15 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [checks]
|
needs: [checks]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||||
with:
|
with:
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
version: 0.9.5
|
version: 0.12.5
|
||||||
|
|
||||||
- name: Set up Python 3.12
|
- name: Set up Python 3.12
|
||||||
run: uv python install 3.12
|
run: uv python install 3.12
|
||||||
|
|
@ -57,7 +57,7 @@ jobs:
|
||||||
path: dist/
|
path: dist/
|
||||||
|
|
||||||
- name: Publish package distributions to PyPI
|
- name: Publish package distributions to PyPI
|
||||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
|
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||||
with:
|
with:
|
||||||
# Lets a re-run after a partially failed upload publish the remaining
|
# Lets a re-run after a partially failed upload publish the remaining
|
||||||
# files instead of erroring on the ones already on PyPI.
|
# files instead of erroring on the ones already on PyPI.
|
||||||
|
|
|
||||||
85
.github/workflows/require-linked-issue.yml
vendored
Normal file
85
.github/workflows/require-linked-issue.yml
vendored
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
# PR intake gate — see CONTRIBUTING.md ("How pull requests get in") for the
|
||||||
|
# policy and .github/scripts/pr_intake_gate.js for the rules as applied.
|
||||||
|
#
|
||||||
|
# In short: a PR from someone without triage rights stays open only if it links
|
||||||
|
# an open issue here that is assigned to them (or labeled `help wanted`);
|
||||||
|
# otherwise it is labeled `missing-issue-link`, gets one comment, and is closed,
|
||||||
|
# and it reopens automatically once the author is assigned. Drafts are gated
|
||||||
|
# too; bots are skipped. A triage+ user reopening the PR, removing the label,
|
||||||
|
# or adding `bypass-issue-check` overrides.
|
||||||
|
#
|
||||||
|
# Operating it:
|
||||||
|
# - Live by default. To pause it without a revert, set the repository
|
||||||
|
# variable PR_GATE_ENFORCE to "false": runs then only log their verdicts.
|
||||||
|
# - PRs below the number in the job `if:` predate the gate and are ignored
|
||||||
|
# unless evaluated by hand: `gh workflow run require-linked-issue.yml -f pr_number=N`.
|
||||||
|
#
|
||||||
|
# Security: pull_request_target runs with a write token in the base repo's
|
||||||
|
# context, and GitHub takes both this file and the checkout from the default
|
||||||
|
# branch whatever the PR targets (so PRs against v1.x are covered too). The
|
||||||
|
# job checks out only that, for the script, and never fetches, builds, or runs
|
||||||
|
# anything from the pull request.
|
||||||
|
#
|
||||||
|
# Adapted from PrefectHQ/fastmcp's require-issue-link.yml (Apache-2.0), itself
|
||||||
|
# from langchain-ai/langchain (MIT).
|
||||||
|
|
||||||
|
name: Require Linked Issue
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target: # zizmor: ignore[dangerous-triggers] checks out the default branch only and never runs PR code — see header
|
||||||
|
types: [opened, edited, reopened, ready_for_review, labeled, unlabeled]
|
||||||
|
issues:
|
||||||
|
types: [assigned]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
pr_number:
|
||||||
|
description: PR number to evaluate
|
||||||
|
required: true
|
||||||
|
type: number
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
gate:
|
||||||
|
name: Evaluate
|
||||||
|
# Routing only; the rules are in the script. PR events run at or above the
|
||||||
|
# grandfathering floor, or for PRs the gate has already labeled; label
|
||||||
|
# events only for the two labels the gate cares about.
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
(github.event_name == 'issues' && !github.event.issue.pull_request && github.event.issue.state == 'open') ||
|
||||||
|
(
|
||||||
|
github.event_name == 'pull_request_target' &&
|
||||||
|
(
|
||||||
|
github.event.pull_request.number >= 3200 ||
|
||||||
|
contains(github.event.pull_request.labels.*.name, 'missing-issue-link') ||
|
||||||
|
github.event.action == 'unlabeled'
|
||||||
|
) &&
|
||||||
|
(github.event.action != 'unlabeled' || github.event.label.name == 'missing-issue-link') &&
|
||||||
|
(github.event.action != 'labeled' || github.event.label.name == 'bypass-issue-check')
|
||||||
|
)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
concurrency:
|
||||||
|
group: require-linked-issue-${{ github.event.pull_request.number || inputs.pr_number || format('issue-{0}-{1}', github.event.issue.number, github.event.assignee.login) }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
permissions:
|
||||||
|
contents: read # check out the gate script from the default branch
|
||||||
|
issues: write # read linked issues; label and comment on the PR
|
||||||
|
pull-requests: write # close and reopen the PR
|
||||||
|
env:
|
||||||
|
ENFORCE: ${{ vars.PR_GATE_ENFORCE != 'false' && 'true' || 'false' }} # kill switch: set the variable to "false" to go log-only
|
||||||
|
PR_NUMBER_INPUT: ${{ inputs.pr_number }}
|
||||||
|
steps:
|
||||||
|
- name: Check out the gate script (default branch)
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
sparse-checkout: .github/scripts
|
||||||
|
|
||||||
|
- name: Evaluate
|
||||||
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const run = require('./.github/scripts/pr_intake_gate.js');
|
||||||
|
await run({ github, context, core });
|
||||||
71
.github/workflows/shared.yml
vendored
71
.github/workflows/shared.yml
vendored
|
|
@ -13,23 +13,12 @@ jobs:
|
||||||
pre-commit:
|
pre-commit:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
# setup-uv's manifest fetch is a single request with a hard 5s timeout
|
|
||||||
# (astral-sh/setup-uv#869); retry once. Drop when upstream adds a retry.
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
id: setup-uv
|
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||||
continue-on-error: true
|
|
||||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
|
||||||
with:
|
|
||||||
enable-cache: true
|
|
||||||
version: 0.9.5
|
|
||||||
|
|
||||||
- name: Install uv (retry)
|
|
||||||
if: steps.setup-uv.outcome == 'failure'
|
|
||||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
version: 0.9.5
|
version: 0.9.5
|
||||||
|
|
@ -41,6 +30,12 @@ jobs:
|
||||||
with:
|
with:
|
||||||
extra_args: --all-files --verbose
|
extra_args: --all-files --verbose
|
||||||
|
|
||||||
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
|
with:
|
||||||
|
node-version: 24 # match actions/github-script's node24 runtime
|
||||||
|
- name: Workflow script tests
|
||||||
|
run: node --test '.github/scripts/*.test.js'
|
||||||
|
|
||||||
- name: Surface types match vendored schema
|
- name: Surface types match vendored schema
|
||||||
run: |
|
run: |
|
||||||
uv sync --group codegen --frozen
|
uv sync --group codegen --frozen
|
||||||
|
|
@ -51,7 +46,7 @@ jobs:
|
||||||
- name: mcp-types installs and imports standalone
|
- name: mcp-types installs and imports standalone
|
||||||
run: |
|
run: |
|
||||||
uv run --isolated --no-project --with ./src/mcp-types python -c \
|
uv run --isolated --no-project --with ./src/mcp-types python -c \
|
||||||
"import mcp_types, mcp_types.jsonrpc, mcp_types.methods, mcp_types.version, mcp_types.v2025_11_25, mcp_types.v2026_07_28"
|
"import mcp_types, mcp_types.jsonrpc, mcp_types.methods, mcp_types.version, mcp_types._v2025_11_25, mcp_types._v2026_07_28"
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: test (${{ matrix.python-version }}, ${{ matrix.dep-resolution.name }}, ${{ matrix.os }})
|
name: test (${{ matrix.python-version }}, ${{ matrix.dep-resolution.name }}, ${{ matrix.os }})
|
||||||
|
|
@ -69,23 +64,12 @@ jobs:
|
||||||
os: [ubuntu-latest, windows-latest]
|
os: [ubuntu-latest, windows-latest]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
# setup-uv's manifest fetch is a single request with a hard 5s timeout
|
|
||||||
# (astral-sh/setup-uv#869); retry once. Drop when upstream adds a retry.
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
id: setup-uv
|
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||||
continue-on-error: true
|
|
||||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
|
||||||
with:
|
|
||||||
enable-cache: true
|
|
||||||
version: 0.9.5
|
|
||||||
|
|
||||||
- name: Install uv (retry)
|
|
||||||
if: steps.setup-uv.outcome == 'failure'
|
|
||||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
version: 0.9.5
|
version: 0.9.5
|
||||||
|
|
@ -99,6 +83,9 @@ jobs:
|
||||||
# tests/examples/test_stories_smoke.py is gated on this var; it spawns real
|
# tests/examples/test_stories_smoke.py is gated on this var; it spawns real
|
||||||
# stdio + uvicorn subprocesses, so run it on exactly one matrix cell.
|
# stdio + uvicorn subprocesses, so run it on exactly one matrix cell.
|
||||||
MCP_EXAMPLES_SMOKE: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' && matrix.dep-resolution.name == 'locked' && '1' || '' }}
|
MCP_EXAMPLES_SMOKE: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' && matrix.dep-resolution.name == 'locked' && '1' || '' }}
|
||||||
|
# PEP 597: text I/O without encoding= raises EncodingWarning, which pytest's "error"
|
||||||
|
# filter makes fatal on every cell. An env var (not -X) so pytest-xdist workers inherit it.
|
||||||
|
PYTHONWARNDEFAULTENCODING: "1"
|
||||||
run: |
|
run: |
|
||||||
uv run --frozen --no-sync coverage erase
|
uv run --frozen --no-sync coverage erase
|
||||||
uv run --frozen --no-sync coverage run -m pytest -n auto
|
uv run --frozen --no-sync coverage run -m pytest -n auto
|
||||||
|
|
@ -112,23 +99,12 @@ jobs:
|
||||||
readme-snippets:
|
readme-snippets:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
# setup-uv's manifest fetch is a single request with a hard 5s timeout
|
|
||||||
# (astral-sh/setup-uv#869); retry once. Drop when upstream adds a retry.
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
id: setup-uv
|
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||||
continue-on-error: true
|
|
||||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
|
||||||
with:
|
|
||||||
enable-cache: true
|
|
||||||
version: 0.9.5
|
|
||||||
|
|
||||||
- name: Install uv (retry)
|
|
||||||
if: steps.setup-uv.outcome == 'failure'
|
|
||||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
version: 0.9.5
|
version: 0.9.5
|
||||||
|
|
@ -152,23 +128,12 @@ jobs:
|
||||||
docs:
|
docs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
# setup-uv's manifest fetch is a single request with a hard 5s timeout
|
|
||||||
# (astral-sh/setup-uv#869); retry once. Drop when upstream adds a retry.
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
id: setup-uv
|
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||||
continue-on-error: true
|
|
||||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
|
||||||
with:
|
|
||||||
enable-cache: true
|
|
||||||
version: 0.9.5
|
|
||||||
|
|
||||||
- name: Install uv (retry)
|
|
||||||
if: steps.setup-uv.outcome == 'failure'
|
|
||||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
version: 0.9.5
|
version: 0.9.5
|
||||||
|
|
|
||||||
4
.github/workflows/zizmor.yml
vendored
4
.github/workflows/zizmor.yml
vendored
|
|
@ -17,9 +17,9 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Run zizmor 🌈
|
- name: Run zizmor 🌈
|
||||||
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
|
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue