jt-mcp 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,37 @@
1
+ # Dependabot: security updates only, grouped into one pull request.
2
+ #
3
+ # WHY THIS FILE EXISTS (2026-10-25). There was no dependabot.yml in any of the 22
4
+ # servers. Without one Dependabot runs *security* updates only -- those need no
5
+ # config -- and they are ungrouped by default: one advisory x one package x one
6
+ # repository = one pull request. Four upgrades produced **57 open pull requests**
7
+ # across the fleet, and KI7MT's objection was the right one: nobody could say
8
+ # what 57 pull requests did. Measured, they were four dependency upgrades closing
9
+ # 130 alerts, one of them CRITICAL. The volume was not noise, it was signal
10
+ # nobody could read.
11
+ #
12
+ # `groups` with `applies-to: security-updates` collapses that to one pull request
13
+ # per repository.
14
+ #
15
+ # open-pull-requests-limit: 0 disables *version* updates, which we do not want --
16
+ # ci.yml audits the locked dependencies on every pull request and weekly, so a
17
+ # stale lock already fails CI and does not need a bot to notice. Security updates
18
+ # are unaffected by this limit: they have their own fixed limit of 10, which
19
+ # cannot be configured. Checked against the Dependabot options reference rather
20
+ # than assumed, because setting this wrong would silently switch off the thing
21
+ # that found those 130 alerts.
22
+ #
23
+ # `uv` is the ecosystem for a uv.lock project, also checked against that
24
+ # reference and not inferred from the file name.
25
+ version: 2
26
+ updates:
27
+ - package-ecosystem: "uv"
28
+ directory: "/"
29
+ schedule:
30
+ interval: "weekly"
31
+ day: "monday"
32
+ open-pull-requests-limit: 0
33
+ groups:
34
+ security:
35
+ applies-to: security-updates
36
+ patterns:
37
+ - "*"
@@ -0,0 +1,154 @@
1
+ name: CI
2
+
3
+ # A pull request is tested whatever it targets, and deliberately has no branch
4
+ # filter. `main` is the one long-lived branch now, so `push` names it; but a filter
5
+ # here would mean a pull request into any other branch ran no ruff, no mypy and no
6
+ # pytest while still reporting green. That is not hypothetical: narrowing this to
7
+ # [main] is what let the pull request making this very change merge with CodeQL as
8
+ # its only check.
9
+ on:
10
+ pull_request:
11
+ push:
12
+ branches: [main]
13
+ # Weekly, because an advisory is published against a lockfile that has not
14
+ # changed. Nothing in a pull-request-and-push trigger fires when the danger
15
+ # arrives from outside the repository -- see the audit job below.
16
+ schedule:
17
+ - cron: "17 6 * * 1"
18
+
19
+ concurrency:
20
+ group: ci-${{ github.workflow }}-${{ github.ref }}
21
+ cancel-in-progress: true
22
+
23
+ permissions:
24
+ contents: read
25
+
26
+ jobs:
27
+ # Lint and types, once rather than per Python: both read the source, and
28
+ # neither answer changes with the interpreter running them (qso-graph-devel#66).
29
+ lint:
30
+ name: ruff and mypy
31
+ runs-on: ubuntu-latest
32
+ steps:
33
+ - uses: actions/checkout@v5
34
+
35
+ - uses: astral-sh/setup-uv@v7
36
+ with:
37
+ # Exact patch, not the minor line. Asking for "3.12" lets uv satisfy
38
+ # it with whatever interpreter the runner already has -- on
39
+ # ubuntu-latest that is the system /usr/bin/python3.12 at 3.12.3,
40
+ # twelve releases behind and without the eMudhra CA fix. The same
41
+ # run downloaded patched 3.11.18 and 3.13.18 only because no system
42
+ # copy existed to satisfy those. Naming the patch forces the managed,
43
+ # current build. https://discuss.python.org/t/109576
44
+ python-version: "3.11.18"
45
+ enable-cache: true
46
+
47
+ - name: Install (locked)
48
+ run: uv sync --group dev --frozen
49
+
50
+ - name: Lint (ruff)
51
+ run: uv run ruff check .
52
+
53
+ - name: Type-check (mypy)
54
+ run: uv run mypy src
55
+
56
+ test:
57
+ name: pytest (${{ matrix.python-version }})
58
+ runs-on: ubuntu-latest
59
+ strategy:
60
+ fail-fast: false
61
+ matrix:
62
+ python-version: ["3.11.18", "3.12.16", "3.13.18"]
63
+ steps:
64
+ - uses: actions/checkout@v5
65
+
66
+ - uses: astral-sh/setup-uv@v7
67
+ with:
68
+ python-version: ${{ matrix.python-version }}
69
+ enable-cache: true
70
+
71
+ - name: Install (locked)
72
+ run: uv sync --group dev --frozen
73
+
74
+ - name: Unit tests
75
+ run: uv run pytest tests --ignore=tests/test_security.py -v
76
+
77
+ - name: Security tests
78
+ run: uv run pytest tests/test_security.py -v
79
+
80
+ # Audits the LOCKED environment, which is a different question from the one
81
+ # the release gate asks.
82
+ #
83
+ # publish.yml runs `pip-audit --strict --desc on .` -- the project's *declared*
84
+ # dependencies, resolved fresh. That protects the people who `pip install` this
85
+ # package, because the ranges we publish must not resolve to something
86
+ # vulnerable. It cannot see uv.lock at all: `certifi` is declared unpinned, so a
87
+ # fresh resolve always picks the newest and the locked version is never examined.
88
+ #
89
+ # This job syncs `--frozen` and audits what that produced, so a stale lock
90
+ # cannot sit unnoticed. That is not hypothetical. On 2026-10-25 five
91
+ # repositories carried certifi 2026.7.22 against a CRITICAL advisory
92
+ # (GHSA-6q48-6c7w-9gw2, emSign roots with compromised intermediate CA keys) and
93
+ # **Dependabot had never opened a pull request for any of them** -- alerts on,
94
+ # security updates on, no pull request, no explanation available over the API.
95
+ # The release gate could not see it and nothing else was looking.
96
+ #
97
+ # Falsified rather than assumed: with certifi pinned back to 2026.7.22 this
98
+ # reports `certifi 2026.7.22 PYSEC-2026-4320` and exits 1; with the lock
99
+ # current it reports no known vulnerabilities and exits 0.
100
+ audit:
101
+ name: audit (locked dependencies)
102
+ runs-on: ubuntu-latest
103
+ steps:
104
+ - uses: actions/checkout@v5
105
+
106
+ - uses: astral-sh/setup-uv@v7
107
+ with:
108
+ python-version: "3.12.16"
109
+ enable-cache: true
110
+
111
+ - name: Install (locked)
112
+ run: uv sync --group dev --frozen
113
+
114
+ - name: No known vulnerability in the locked dependencies
115
+ # No --strict, deliberately. --strict fails on any distribution it
116
+ # could not audit, and `uv sync` installs the project itself -- so the
117
+ # moment a release bumps the version to one not yet on PyPI, pip-audit
118
+ # cannot look the project up and --strict turns that into a failure.
119
+ # It blocked every version bump in all 23 repositories, which is how it
120
+ # was found: the first real release, dxe-mcp 0.0.3, went red on it with
121
+ # "dxe-mcp: Dependency not found on PyPI and could not be audited"
122
+ # while the lockfile was perfectly clean.
123
+ #
124
+ # Without --strict, pip-audit still exits 1 when it FINDS a
125
+ # vulnerability, which is the whole job. An unauditable local project is
126
+ # reported as skipped, with its reason, and does not fail the run.
127
+ #
128
+ # Verified in both directions against a lock holding certifi 2026.7.22
129
+ # with the version bumped past PyPI: it reports PYSEC-2026-4320 and
130
+ # exits 1; with the lock current it reports no vulnerabilities, names the
131
+ # project as skipped, and exits 0.
132
+ run: uv run --with pip-audit pip-audit
133
+
134
+ ci-all-green:
135
+ name: ci-all-green
136
+ needs: [lint, test, audit]
137
+ runs-on: ubuntu-latest
138
+ if: always()
139
+ steps:
140
+ - name: Verify required jobs all succeeded
141
+ run: |
142
+ if [[ "${{ needs.lint.result }}" != "success" ]]; then
143
+ echo "FAIL: ruff or mypy did not pass (result=${{ needs.lint.result }})"
144
+ exit 1
145
+ fi
146
+ if [[ "${{ needs.test.result }}" != "success" ]]; then
147
+ echo "FAIL: test matrix did not all succeed (result=${{ needs.test.result }})"
148
+ exit 1
149
+ fi
150
+ if [[ "${{ needs.audit.result }}" != "success" ]]; then
151
+ echo "FAIL: the locked dependencies have a known vulnerability (result=${{ needs.audit.result }})"
152
+ exit 1
153
+ fi
154
+ echo "All required CI jobs passed."
@@ -0,0 +1,449 @@
1
+ name: Publish to PyPI
2
+
3
+ # A release happens on purpose, not as a side effect of merging (KI7MT, 2026-10-25).
4
+ #
5
+ # This used to fire on a push to `main`, which made "merge the release PR" the act of
6
+ # publishing — and that is what forced two pull requests per release: one to prepare the
7
+ # version, and a second to merge `develop` into `main` and ship it.
8
+ #
9
+ # Now `main` is the one working branch. Merging into it builds and tests and publishes
10
+ # nothing; a release is Actions -> Publish to PyPI -> Run workflow, with the version
11
+ # typed in. Typed, because it is a decision: it must match what the tree already says,
12
+ # and every gate below refuses anything it cannot confirm.
13
+ on:
14
+ workflow_dispatch:
15
+ inputs:
16
+ version:
17
+ description: "The version to publish, e.g. 0.2.1 — must match pyproject.toml"
18
+ required: true
19
+ type: string
20
+
21
+ concurrency:
22
+ group: publish
23
+ cancel-in-progress: false
24
+
25
+ permissions:
26
+ contents: read
27
+
28
+ jobs:
29
+ security:
30
+ name: Security gates
31
+ runs-on: ubuntu-latest
32
+ permissions:
33
+ contents: read
34
+ checks: read
35
+ outputs:
36
+ version: ${{ steps.version.outputs.version }}
37
+ # "yes" when this version is on PyPI with no tag: an interrupted release
38
+ # being finished rather than a new one being made.
39
+ resume: ${{ steps.version.outputs.resume }}
40
+ steps:
41
+ - uses: actions/checkout@v5
42
+
43
+ # workflow_dispatch offers a branch picker, so unlike the old push trigger this
44
+ # workflow can be started from anywhere. The lock is the pypi Environment's
45
+ # deployment branch rule (main only); this is the same rule stated where a failed
46
+ # run can show it, because an Environment rule rejects the deployment with no
47
+ # explanation of what was wrong.
48
+ - name: This runs from main
49
+ run: |
50
+ if [ "$GITHUB_REF" != "refs/heads/main" ]; then
51
+ echo "FAIL: a release is published from main only (this is $GITHUB_REF)"
52
+ exit 1
53
+ fi
54
+
55
+ # Merging no longer publishes, so nothing else guarantees that what is about to
56
+ # be released is the code CI passed on. Under the old trigger the release PR had
57
+ # to be green before the merge that published it; this restores that property.
58
+ - name: CI passed on the commit being published
59
+ env:
60
+ GH_TOKEN: ${{ github.token }}
61
+ run: |
62
+ PASSED="$(gh api \
63
+ "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/check-runs?check_name=ci-all-green" \
64
+ --jq '[.check_runs[] | select(.conclusion == "success")] | length')"
65
+ if [ "${PASSED:-0}" -lt 1 ]; then
66
+ echo "FAIL: ci-all-green has not passed on ${GITHUB_SHA}"
67
+ exit 1
68
+ fi
69
+ echo "ci-all-green passed on ${GITHUB_SHA}"
70
+
71
+ # A release publishes a version that is not out yet. PyPI publishes
72
+ # pyproject's version and the Registry server.json's, so they must agree too.
73
+ # Computed once; every later job uses this value (needs.security.outputs.version).
74
+ # The gate fails closed: anything it cannot confirm stops the release.
75
+ - name: The version is the one asked for, new, and consistent
76
+ id: version
77
+ env:
78
+ # Through the environment rather than interpolated into the script: a
79
+ # ${{ }} expansion inside run: is substituted before the shell sees it, so
80
+ # the input would be script rather than data. Only a writer can dispatch
81
+ # this, and the rule is still input validation on every supplied string.
82
+ ASKED: ${{ inputs.version }}
83
+ run: |
84
+ VERSION="$(grep -m1 '^version' pyproject.toml | cut -d'"' -f2)"
85
+ if ! printf '%s' "$ASKED" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([a-z0-9.-]*)$'; then
86
+ echo "FAIL: '$ASKED' is not a version"
87
+ exit 1
88
+ fi
89
+ # Typed by the person releasing, so a slip names a version that is not here
90
+ # rather than shipping whatever happens to be on main.
91
+ if [ "$ASKED" != "$VERSION" ]; then
92
+ echo "FAIL: asked to publish ${ASKED}, but this tree is ${VERSION}"
93
+ exit 1
94
+ fi
95
+ PACKAGE="$(jq -r '.packages[0].identifier' server.json)"
96
+ echo "pyproject=${VERSION} server.json=$(jq -r '.version' server.json)/$(jq -r '.packages[0].version' server.json)"
97
+ if [ "$(jq -r '.version' server.json)" != "$VERSION" ] || [ "$(jq -r '.packages[0].version' server.json)" != "$VERSION" ]; then
98
+ echo "FAIL: server.json does not match pyproject.toml version ${VERSION}"
99
+ exit 1
100
+ fi
101
+ # Only a 404 means "not published yet"; unreachable or 5xx is not a yes.
102
+ # PyPI and the tag are read together, because neither alone says
103
+ # whether this version is releasable.
104
+ #
105
+ # This gate used to refuse on PyPI 200 full stop, and that made every
106
+ # interruption after the upload cost a version. The tag is written by
107
+ # the last job, so a transient failure in between -- the MCP Registry
108
+ # 504s on 2026-10-26, nine bytes served for the mcp-publisher
109
+ # download on 2026-10-27 -- left the version on PyPI, which is
110
+ # immutable, with no tag and no registry entry. The only recovery was
111
+ # to bump and release again, abandoning the version. Three releases
112
+ # went that way.
113
+ #
114
+ # So the four states are named, and only two of them refuse:
115
+ #
116
+ # PyPI 404, no tag a new release
117
+ # PyPI 200, no tag a release that was interrupted after the
118
+ # upload. RESUMABLE: the upload is skipped and
119
+ # the remaining jobs finish the release
120
+ # PyPI 200, tagged already released
121
+ # PyPI 404, tagged a tag with nothing behind it; something is
122
+ # wrong and guessing is worse than stopping
123
+ CODE="$(curl -s -o /dev/null -w '%{http_code}' "https://pypi.org/pypi/${PACKAGE}/${VERSION}/json")"
124
+ case "$CODE" in
125
+ 404|200) : ;;
126
+ *) echo "FAIL: PyPI returned ${CODE}; cannot tell whether ${VERSION} is published"; exit 1 ;;
127
+ esac
128
+ # git ls-remote --exit-code: 2 means no such tag; anything else (0, 128) stops.
129
+ set +e
130
+ git ls-remote --exit-code --tags origin "refs/tags/v${VERSION}" > /dev/null
131
+ RC=$?
132
+ set -e
133
+ case "$RC" in
134
+ 0) TAGGED=yes ;;
135
+ 2) TAGGED=no ;;
136
+ *) echo "FAIL: could not check tags (git ls-remote exited ${RC})"; exit 1 ;;
137
+ esac
138
+
139
+ RESUME=false
140
+ if [ "$CODE" = 404 ] && [ "$TAGGED" = no ]; then
141
+ echo "PyPI: ${PACKAGE} ${VERSION} is new, tag v${VERSION} is free"
142
+ elif [ "$CODE" = 200 ] && [ "$TAGGED" = no ]; then
143
+ RESUME=true
144
+ echo "NOTE: ${PACKAGE} ${VERSION} is already on PyPI with no tag v${VERSION}."
145
+ echo " A previous run uploaded it and did not finish. Resuming:"
146
+ echo " the PyPI upload is skipped and the registry, verify and"
147
+ echo " tag jobs will complete this release."
148
+ elif [ "$CODE" = 200 ] && [ "$TAGGED" = yes ]; then
149
+ echo "FAIL: ${PACKAGE} ${VERSION} is on PyPI and tagged v${VERSION} — already released."
150
+ echo " Bump the version to release again."
151
+ exit 1
152
+ else
153
+ echo "FAIL: tag v${VERSION} exists but PyPI has no ${VERSION}."
154
+ echo " That is not a state this workflow created; stopping rather than guessing."
155
+ exit 1
156
+ fi
157
+ echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
158
+ echo "resume=${RESUME}" >> "$GITHUB_OUTPUT"
159
+
160
+
161
+
162
+ - uses: actions/setup-python@v6
163
+ with:
164
+ python-version: "3.12.16"
165
+
166
+ - name: Install test dependencies
167
+ run: |
168
+ # pip loads its OWN vendored certifi in addition to the system
169
+ # trust store, and there is no supported way to disable that, so a
170
+ # current certifi in uv.lock does not cover pip itself. 26.2.2 is
171
+ # the eMudhra fix. https://discuss.python.org/t/109576
172
+ python -m pip install --upgrade 'pip>=26.2.2'
173
+ python -m pip --version # in the log, not assumed
174
+ pip install pytest pip-audit
175
+
176
+ - name: Run security test suite
177
+ run: python -m pytest tests/test_security.py -v
178
+
179
+ - name: Audit dependencies for vulnerabilities
180
+ # Audits this package's declared dependencies, and can fail: a known CVE stops the release.
181
+ # A finding with no fix yet is excepted only explicitly: --ignore-vuln ID, with a reason and date.
182
+ run: pip-audit --strict --desc on .
183
+
184
+ - name: Grep check — no subprocess/shell
185
+ run: |
186
+ if grep -rn 'subprocess\.\|os\.system\|shell=True' src/; then
187
+ echo "FAIL: subprocess/shell found in source"
188
+ exit 1
189
+ fi
190
+
191
+ - name: Grep check — no plaintext http://
192
+ run: |
193
+ # Tests the output rather than the pipeline's exit status, because the
194
+ # output is what this gate is actually asking about: are there any
195
+ # plaintext URLs. An exit status answers a different question and
196
+ # answers it through whichever grep is on PATH.
197
+ #
198
+ # The earlier comment here blamed macOS /usr/bin/grep for exiting 0 on
199
+ # empty input. Measured 2026-10-25, that is false: /usr/bin/grep exits
200
+ # 1 when no line is selected, as POSIX requires and as GNU grep does.
201
+ # The FAIL-on-a-clean-tree that prompted this form came from a
202
+ # non-conforming grep shim in an agent's shell. The form is kept
203
+ # because it is right for its own reason, not for that one.
204
+ hits="$(grep -rn 'http://' src/ | grep -v 'localhost\|127\.0\.0\.1\|::1' || true)"
205
+ if [ -n "$hits" ]; then
206
+ echo "$hits"
207
+ echo "FAIL: non-HTTPS URL found in source"
208
+ exit 1
209
+ fi
210
+
211
+ - name: Grep check — no credential logging
212
+ run: |
213
+ if grep -rn -i 'print.*password\|print.*api_key\|logging.*password\|logging.*api_key' src/; then
214
+ echo "FAIL: credential logging found in source"
215
+ exit 1
216
+ fi
217
+
218
+ publish:
219
+ name: Build and publish to PyPI
220
+ needs: security
221
+ runs-on: ubuntu-latest
222
+ environment: pypi
223
+ permissions:
224
+ id-token: write # Required for trusted publishing (OIDC)
225
+ steps:
226
+ - uses: actions/checkout@v5
227
+
228
+ - uses: actions/setup-python@v6
229
+ with:
230
+ python-version: "3.12.16"
231
+
232
+ - name: Install build dependencies
233
+ if: needs.security.outputs.resume != 'true'
234
+ run: pip install build
235
+
236
+ - name: Build package
237
+ if: needs.security.outputs.resume != 'true'
238
+ run: python -m build
239
+
240
+ - name: Publish to PyPI
241
+ if: needs.security.outputs.resume != 'true'
242
+ uses: pypa/gh-action-pypi-publish@release/v1
243
+
244
+ - name: Already on PyPI — resuming
245
+ if: needs.security.outputs.resume == 'true'
246
+ run: |
247
+ # The upload happened in an earlier run that did not finish. PyPI
248
+ # rejects a second upload of the same version anyway, so skipping it
249
+ # is required rather than merely tidy. The jobs after this one are
250
+ # what the resume is for.
251
+ echo "skipped the upload: this version is already on PyPI"
252
+
253
+ # ---------------------------------------------------------------------------
254
+ # Registry-publish — pushes server.json to the Official MCP Registry after
255
+ # PyPI publish succeeds, so discovery surfaces stay in sync with PyPI.
256
+ # Canonical template: https://github.com/qso-graph/.github/blob/main/TEMPLATES.md
257
+ # ---------------------------------------------------------------------------
258
+ registry-publish:
259
+ name: Publish to MCP Registry
260
+ needs: [security, publish] # waits for PyPI publish to succeed
261
+ runs-on: ubuntu-latest
262
+ permissions:
263
+ id-token: write # GitHub OIDC
264
+ contents: read
265
+
266
+ steps:
267
+ - uses: actions/checkout@v5
268
+
269
+ - name: Install jq (server.json bump)
270
+ run: sudo apt-get update && sudo apt-get install -y jq
271
+
272
+ - name: Install mcp-publisher
273
+ run: |
274
+ # Downloaded to a file, not piped into tar, and with --fail.
275
+ #
276
+ # A bare `curl -L | tar xz` cannot fail usefully: curl writes the
277
+ # error body to stdout, so a 404 becomes nine bytes of "Not Found"
278
+ # fed to tar, which exits 2 with "not in gzip format" and says
279
+ # nothing about what actually went wrong. That is what happened on
280
+ # 2026-10-27 at 14:45:53Z -- the asset existed and the URL was right;
281
+ # GitHub served nine bytes for one request. --retry turns that into a
282
+ # retry instead of a failed release.
283
+ URL="https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz"
284
+ curl --fail --location --retry 5 --retry-all-errors --retry-delay 3 \
285
+ --output mcp-publisher.tar.gz "$URL"
286
+ tar xzf mcp-publisher.tar.gz mcp-publisher
287
+ ./mcp-publisher --help > /dev/null # smoke test
288
+
289
+ - name: Set server.json to the released version
290
+ run: |
291
+ VERSION="${{ needs.security.outputs.version }}"
292
+ echo "Setting server.json to ${VERSION}"
293
+ jq --arg v "$VERSION" \
294
+ '.version = $v | .packages[0].version = $v' \
295
+ server.json > /tmp/server.json
296
+ mv /tmp/server.json server.json
297
+ cat server.json
298
+
299
+ # The Registry checks PyPI itself and refuses a version PyPI isn't
300
+ # serving yet, which can lag the upload by a minute or more.
301
+ - name: Wait until PyPI serves this version
302
+ run: |
303
+ VERSION="${{ needs.security.outputs.version }}"
304
+ PACKAGE="$(jq -r '.packages[0].identifier' server.json)"
305
+ for i in $(seq 1 40); do
306
+ if curl -fsS "https://pypi.org/pypi/${PACKAGE}/${VERSION}/json" > /dev/null; then
307
+ echo "PyPI serves ${PACKAGE} ${VERSION}"
308
+ exit 0
309
+ fi
310
+ echo "attempt ${i}: PyPI doesn't serve ${PACKAGE} ${VERSION} yet"
311
+ sleep 15
312
+ done
313
+ echo "FAIL: PyPI never served ${PACKAGE} ${VERSION}"
314
+ exit 1
315
+
316
+ # One step, because authentication has to happen INSIDE the retry loop.
317
+ #
318
+ # The Registry JWT lives about six minutes. This loop sleeps 30+60+90+120+150
319
+ # seconds and each attempt can take another minute, so it is built to run for
320
+ # roughly twelve. pota-mcp 0.5.0 on 2026-10-26 spent its last two attempts on
321
+ # a dead credential:
322
+ # 17:48:52 login
323
+ # 17:49:53 attempt 1 504 <- the Registry was genuinely down
324
+ # 17:51:28 attempt 2 504
325
+ # 17:53:29 attempt 3 504
326
+ # 17:54:59 attempt 4 401 "token has invalid claims: token is expired"
327
+ # 17:57:00 attempt 5 401
328
+ # PyPI had already accepted the upload, so the run failed after publishing,
329
+ # the tag job never ran, and 0.5.0 is on PyPI untagged and unregistered for
330
+ # good -- PyPI is immutable. The outage was theirs; not surviving it was ours.
331
+ - name: Authenticate and publish to the MCP Registry
332
+ run: |
333
+ VERSION="${{ needs.security.outputs.version }}"
334
+ NAME="$(jq -r '.name' server.json)"
335
+ REGISTRY="https://registry.modelcontextprotocol.io/v0/servers?search=${NAME}&version=latest"
336
+ for i in 1 2 3 4 5; do
337
+ # Fresh token per attempt. A login failure is not fatal on its own:
338
+ # if the Registry is down it will fail here too, and the next attempt
339
+ # after the backoff is the one that matters.
340
+ ./mcp-publisher login github-oidc || echo "login attempt ${i} failed"
341
+ if ./mcp-publisher publish; then
342
+ exit 0
343
+ fi
344
+ echo "attempt ${i} failed; retrying in $((i * 30)) s"
345
+ sleep $((i * 30))
346
+ REG="$(curl -fsS "$REGISTRY" | jq -r --arg n "$NAME" '[.servers[] | select(.server.name == $n) | .server.version][0] // empty' || true)"
347
+ if [ "$REG" = "$VERSION" ]; then
348
+ echo "The Registry already has ${NAME} ${VERSION}; an earlier attempt landed"
349
+ exit 0
350
+ fi
351
+ done
352
+ echo "FAIL: the MCP Registry refused the publish 5 times"
353
+ exit 1
354
+
355
+ # ---------------------------------------------------------------------------
356
+ # Verify — a release is complete only when PyPI AND the MCP Registry both
357
+ # serve the tagged version. If either doesn't within 10 minutes, this run
358
+ # fails: a green run means published everywhere, a red one means not done.
359
+ # ---------------------------------------------------------------------------
360
+ verify:
361
+ name: Verify PyPI and MCP Registry
362
+ needs: [security, registry-publish]
363
+ runs-on: ubuntu-latest
364
+ steps:
365
+ - uses: actions/checkout@v5
366
+
367
+ - name: PyPI and Registry serve the released version
368
+ run: |
369
+ VERSION="${{ needs.security.outputs.version }}"
370
+ PACKAGE="$(jq -r '.packages[0].identifier' server.json)"
371
+ NAME="$(jq -r '.name' server.json)"
372
+ REGISTRY="https://registry.modelcontextprotocol.io/v0/servers?search=${NAME}&version=latest"
373
+ for i in $(seq 1 40); do
374
+ PYPI="$(curl -fsS "https://pypi.org/pypi/${PACKAGE}/json" | jq -r '.info.version' || true)"
375
+ REG="$(curl -fsS "$REGISTRY" | jq -r --arg n "$NAME" '[.servers[] | select(.server.name == $n) | .server.version][0] // empty' || true)"
376
+ echo "attempt ${i}: PyPI=${PYPI:-none} Registry=${REG:-none} want=${VERSION}"
377
+ if [ "$PYPI" = "$VERSION" ] && [ "$REG" = "$VERSION" ]; then
378
+ echo "Published everywhere: ${PACKAGE} ${VERSION} on PyPI and ${NAME} ${VERSION} in the MCP Registry"
379
+ exit 0
380
+ fi
381
+ sleep 15
382
+ done
383
+ echo "FAIL: release incomplete. PyPI=${PYPI:-none} Registry=${REG:-none}, expected ${VERSION}"
384
+ exit 1
385
+
386
+ # ---------------------------------------------------------------------------
387
+ # Tag the release, once it is published everywhere. The tag marks the commit on
388
+ # main that was released; nothing is triggered by it. If this job fails after a
389
+ # green verify, the release IS published: re-running this job is safe.
390
+ # ---------------------------------------------------------------------------
391
+ tag:
392
+ name: Tag the release
393
+ needs: [security, verify]
394
+ runs-on: ubuntu-latest
395
+ permissions:
396
+ contents: write
397
+ steps:
398
+ - uses: actions/checkout@v5
399
+
400
+ - name: Create and push vX.Y.Z
401
+ run: |
402
+ VERSION="${{ needs.security.outputs.version }}"
403
+ git config user.name "github-actions[bot]"
404
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
405
+ git tag -a "v${VERSION}" -m "${GITHUB_REPOSITORY#*/} ${VERSION}" "$GITHUB_SHA"
406
+ git push origin "v${VERSION}"
407
+
408
+ # The tag alone left every Releases page in this org empty. The body is
409
+ # this version's own CHANGELOG section, extracted by heading rather than
410
+ # by line count, because the fleet has three heading styles:
411
+ # ## [0.4.0] — date ## [2.0.0] - date ## 0.2.0 (date)
412
+ # It stops at the next "## " heading, and returns nothing for a version
413
+ # it cannot find -- so a missing section falls back to GitHub's own
414
+ # generated notes rather than publishing the whole changelog.
415
+ #
416
+ # After `verify` by construction: a Release only ever describes a version
417
+ # already confirmed on PyPI and the MCP Registry.
418
+ - name: Create the GitHub Release
419
+ env:
420
+ GH_TOKEN: ${{ github.token }}
421
+ run: |
422
+ VERSION="${{ needs.security.outputs.version }}"
423
+ python3 - "$VERSION" CHANGELOG.md > /tmp/notes.md <<'EXTRACT'
424
+ import re, sys, pathlib
425
+ ver, path = sys.argv[1], sys.argv[2]
426
+ text = pathlib.Path(path).read_text()
427
+ start = re.search(r'(?m)^##\s+\[?%s\]?\b.*$' % re.escape(ver), text)
428
+ if not start:
429
+ raise SystemExit(0)
430
+ rest = text[start.end():]
431
+ nxt = re.search(r'(?m)^##\s', rest)
432
+ print((rest[:nxt.start()] if nxt else rest).strip())
433
+ EXTRACT
434
+
435
+ # A version with a suffix (0.2.1rc1) is a pre-release, not Latest.
436
+ PRE=""
437
+ case "$VERSION" in
438
+ *[!0-9.]*) PRE="--prerelease" ;;
439
+ esac
440
+
441
+ if [ -s /tmp/notes.md ]; then
442
+ echo "release notes: this version's CHANGELOG section ($(wc -l < /tmp/notes.md) lines)"
443
+ gh release create "v${VERSION}" --verify-tag --title "v${VERSION}" \
444
+ --notes-file /tmp/notes.md $PRE
445
+ else
446
+ echo "release notes: no CHANGELOG section for ${VERSION}; using generated notes"
447
+ gh release create "v${VERSION}" --verify-tag --title "v${VERSION}" \
448
+ --generate-notes $PRE
449
+ fi
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ .pytest_cache/
8
+ published/