From 85c6546cfc36c4f0fefb81a49105a1d309e23fd6 Mon Sep 17 00:00:00 2001 From: Sanjith Sambath Date: Tue, 28 Jul 2026 14:18:03 -0700 Subject: [PATCH 1/3] ci: flag bridge versions that were merged but never published MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agentmail-mcp 1.0.1 fixed a silent-startup regression on 2026-07-21 and then sat on main unpublished for a week while npm kept serving the broken 1.0.0. Both publish workflows are workflow_dispatch, so nothing turned "merged but unreleased" into a signal — the customer found it before we did. - Compare packages/npm-stdio-bridge and python/stdio-bridge versions against npm's and PyPI's latest in the existing public-surface audit, naming the workflow to run when they disagree. - Run the live surface audit daily instead of weekly; a weekly window is the same blind spot that hid this for seven days. - Self-test covers the local version reads, so a moved path or renamed key fails fast instead of silently reporting agreement. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01544GeWAAQvSawpyS7BbnqV --- .github/workflows/public-surfaces.yml | 2 +- scripts/audit-public-surfaces.py | 50 +++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/.github/workflows/public-surfaces.yml b/.github/workflows/public-surfaces.yml index e401c59..1242314 100644 --- a/.github/workflows/public-surfaces.yml +++ b/.github/workflows/public-surfaces.yml @@ -5,7 +5,7 @@ on: push: branches: [main] schedule: - - cron: "17 15 * * 1" + - cron: "17 15 * * *" workflow_dispatch: jobs: diff --git a/scripts/audit-public-surfaces.py b/scripts/audit-public-surfaces.py index d9465de..ae52dbb 100755 --- a/scripts/audit-public-surfaces.py +++ b/scripts/audit-public-surfaces.py @@ -5,14 +5,19 @@ import argparse import json +import re import urllib.error import urllib.parse import urllib.request +from pathlib import Path +ROOT = Path(__file__).resolve().parent.parent ENDPOINT = "https://mcp.agentmail.to/mcp" SOURCE = "https://github.com/agentmail-to/agentmail-mcp" REGISTRY = "https://registry.modelcontextprotocol.io/v0.1/servers?search=" +NPM_LATEST = "https://registry.npmjs.org/agentmail-mcp/latest" +PYPI_LATEST = "https://pypi.org/pypi/agentmail-mcp/json" CONTROLLED = { "MCP docs": "https://docs.agentmail.to/integrations/mcp", "agent onboarding": "https://docs.agentmail.to/agent-onboarding", @@ -55,8 +60,50 @@ def registry(name: str) -> dict: return json.loads(body) +def repo_versions() -> dict[str, str]: + # ponytail: reads pyproject's version line with a regex rather than tomllib, so this + # runs on the 3.9 interpreters developers still have locally (CI pins 3.12). Switch to + # tomllib if the version ever moves out of [project] or stops being a literal. + pyproject = (ROOT / "python/stdio-bridge/pyproject.toml").read_text() + match = re.search(r'(?m)^version = "([^"]+)"', pyproject) + if not match: + raise RuntimeError("could not read version from python/stdio-bridge/pyproject.toml") + return { + "npm": json.loads((ROOT / "packages/npm-stdio-bridge/package.json").read_text())["version"], + "PyPI": match.group(1), + } + + +def unreleased() -> list[str]: + """Flag a bridge whose merged version was never published. + + agentmail-mcp 1.0.1 fixed a silent-startup bug on 2026-07-21 and sat on main + unpublished for a week while npm kept serving the broken 1.0.0, because both + publish workflows are workflow_dispatch and nothing watched the gap. + """ + problems: list[str] = [] + repo = repo_versions() + for name, url, workflow in ( + ("npm", NPM_LATEST, "Publish npm bridge"), + ("PyPI", PYPI_LATEST, "Publish PyPI"), + ): + status, body = fetch(url) + if status != 200: + problems.append(f"{name} returned HTTP {status}") + continue + payload = json.loads(body) + latest = payload["version"] if name == "npm" else payload["info"]["version"] + if latest != repo[name]: + problems.append( + f"{name} publishes {latest} but the repo ships {repo[name]}" + f" — run the '{workflow}' workflow" + ) + return problems + + def audit() -> list[str]: problems: list[str] = [] + problems.extend(unreleased()) endpoint_status, _ = fetch(ENDPOINT) if endpoint_status not in {200, 401, 405}: problems.append(f"canonical endpoint returned HTTP {endpoint_status}") @@ -107,6 +154,9 @@ def main() -> int: if args.self_test: assert urllib.parse.quote("to.agentmail/agentmail", safe="") == "to.agentmail%2Fagentmail" assert "11 tools" in STALE + versions = repo_versions() + assert set(versions) == {"npm", "PyPI"}, versions + assert all(re.fullmatch(r"\d+\.\d+\.\d+", value) for value in versions.values()), versions print("public-surface audit self-test passed") return 0 problems = audit() From 380fb95dda63b83ae6e31ca50cc54cc5dc33e9fd Mon Sep 17 00:00:00 2001 From: Sanjith Sambath Date: Tue, 28 Jul 2026 14:24:07 -0700 Subject: [PATCH 2/3] ci: publish the npm bridge on a merged version bump Detection alone still left a human to remember the dispatch. The merged version bump is the release intent and is already reviewed in the PR, so publish on it and keep dispatch for out-of-band releases. - Trigger on pushes to main that touch packages/npm-stdio-bridge/package.json. - Resolve the version from package.json; a dispatch that disagrees with it fails instead of publishing something nobody asked for. - Skip publishing when the version is already on npm, so a dependency or description edit to package.json is a no-op rather than an EPUBLISHCONFLICT. - Smoke-test npx -y agentmail-mcp@ from a clean cache and require it to reach its own argument check. 1.0.0 exited 0 with no output through the bin symlink npx uses, so "the publish succeeded" never meant "it starts". This was a documented manual step; with no human left in the loop it has to run here. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01544GeWAAQvSawpyS7BbnqV --- .github/workflows/publish-npm.yml | 46 +++++++++++++++++++++++++++++-- docs/release.md | 8 ++++-- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index d0c7882..cdf7aed 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -1,6 +1,13 @@ name: Publish npm bridge on: + # A merged version bump is the release intent, and it was already reviewed in + # the PR. 1.0.1 fixed a silent-startup regression and then waited a week for a + # human to remember this workflow; publishing on the bump removes that wait. + push: + branches: [main] + paths: + - packages/npm-stdio-bridge/package.json workflow_dispatch: inputs: version: @@ -33,15 +40,50 @@ jobs: package-manager-cache: false - run: npm install --global npm@11.18.0 - run: pnpm install --frozen-lockfile - - name: Verify release version + - name: Resolve release version + id: release env: + # Never interpolate a dispatch input straight into the shell. RELEASE_VERSION: ${{ inputs.version }} - run: test "$(node -p "require('./packages/npm-stdio-bridge/package.json').version")" = "$RELEASE_VERSION" + run: | + set -euo pipefail + version="$(node -p "require('./packages/npm-stdio-bridge/package.json').version")" + if [ -n "$RELEASE_VERSION" ] && [ "$RELEASE_VERSION" != "$version" ]; then + echo "requested $RELEASE_VERSION but package.json ships $version" >&2 + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + # A push can touch package.json without bumping the version (a + # dependency or description edit). Publishing then fails the run on + # EPUBLISHCONFLICT and reds main for no reason, so skip instead. + if npm view "agentmail-mcp@$version" version >/dev/null 2>&1; then + echo "$version is already on npm; nothing to publish" + echo "published=true" >> "$GITHUB_OUTPUT" + fi - run: pnpm --filter agentmail-mcp test - run: pnpm check:bridges - name: Inspect package artifact + if: steps.release.outputs.published != 'true' working-directory: packages/npm-stdio-bridge run: npm publish --dry-run --access public - name: Publish package + if: steps.release.outputs.published != 'true' working-directory: packages/npm-stdio-bridge run: npm publish --access public + - name: Smoke-test the published bridge + if: steps.release.outputs.published != 'true' + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + # npm's CDN lags the publish by a few seconds. + for _ in $(seq 1 10); do + npm view "agentmail-mcp@$VERSION" version >/dev/null 2>&1 && break + sleep 5 + done + unset AGENTMAIL_API_KEY + output="$(npx -y "agentmail-mcp@$VERSION" 2>&1 , or with np npm trust github agentmail-mcp --file publish-npm.yml --repo agentmail-to/agentmail-mcp --allow-publish -y ``` -After that external setup is confirmed, open the [Publish npm bridge workflow](https://github.com/agentmail-to/agentmail-mcp/actions/workflows/publish-npm.yml), choose **Run workflow** on the default branch, and enter the exact version from `packages/npm-stdio-bridge/package.json`. The workflow verifies that version, runs the bridge and boundary tests, performs an npm publish dry run, and then publishes with an OIDC identity. After it succeeds, smoke-test `npx -y agentmail-mcp@` in a clean environment before advancing the cutover. +After that external setup is confirmed, the workflow publishes automatically when a version bump to `packages/npm-stdio-bridge/package.json` merges to `main`. The merged bump is the release intent, and it was already reviewed in the PR. The workflow runs the bridge and boundary tests, performs an npm publish dry run, publishes with an OIDC identity, and then smoke-tests `npx -y agentmail-mcp@` from a clean cache — asserting the entrypoint reaches its own argument check, because 1.0.0 exited 0 with no output through the bin symlink that `npx` uses. A push that edits `package.json` without changing the version is a no-op rather than a failure. + +To publish out of band, open the [Publish npm bridge workflow](https://github.com/agentmail-to/agentmail-mcp/actions/workflows/publish-npm.yml), choose **Run workflow** on the default branch, and enter the exact version from `packages/npm-stdio-bridge/package.json`; the workflow refuses to run if the two disagree. + +The daily `Public surfaces` audit backstops all of this: it fails when npm's or PyPI's `latest` disagrees with the version in the repo, catching a failed publish or a bridge that was bumped but never released. ## Human-gated cutover 1. Repoint the existing production project to this repository and canary it. 2. Promote only after health, authentication-characterization, and tool-contract checks pass. -3. Publish npm with the manual trusted-publishing workflow above, then publish PyPI, and smoke-test clean installs. +3. Merge the npm bridge version bump to publish it, then publish PyPI with its manual workflow, and smoke-test clean installs. 4. Publish first-party docs and discovery changes. 5. Repoint and authenticate a real call through Smithery. 6. Publish Registry metadata and retire the duplicate identity. From b63c19b63345bae006b30f7dba0c80ab16fa03ac Mon Sep 17 00:00:00 2001 From: Sanjith Sambath Date: Tue, 28 Jul 2026 14:28:58 -0700 Subject: [PATCH 3/3] ci: publish the PyPI bridge on a merged version bump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same treatment as publish-npm.yml so neither bridge depends on someone remembering a dispatch. - Trigger on pushes to main that touch python/stdio-bridge/pyproject.toml. - Skip the publish job when the version is already on PyPI, so an edit that doesn't bump the version is a no-op instead of a rejected re-upload. - Stop pinning 1.0.0 in the artifact smoke test and read the expected version from pyproject instead. Pinned, it would have failed the build job on the first bump — that is, on every release the automation now fires for. The existing pre-publish smoke test already installs each distribution into a clean virtualenv and runs the console script, so there is no post-publish check here as there is for npm. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01544GeWAAQvSawpyS7BbnqV --- .github/workflows/publish-pypi.yml | 21 +++++++++++++++++++ docs/release.md | 8 +++++-- .../stdio-bridge/scripts/smoke-artifacts.sh | 6 +++++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 29755d1..49cf1d1 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -1,6 +1,12 @@ name: Publish PyPI on: + # Same reasoning as publish-npm.yml: the merged version bump is the release + # intent, already reviewed in the PR, so don't make a human remember to dispatch. + push: + branches: [main] + paths: + - python/stdio-bridge/pyproject.toml workflow_dispatch: concurrency: @@ -13,10 +19,24 @@ permissions: jobs: build: runs-on: ubuntu-latest + outputs: + published: ${{ steps.release.outputs.published }} steps: - name: Require main run: test "$GITHUB_REF" = refs/heads/main - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Resolve release version + id: release + run: | + set -euo pipefail + version="$(sed -n 's/^version = "\(.*\)"/\1/p' python/stdio-bridge/pyproject.toml)" + [ -n "$version" ] || { echo "could not read version from pyproject.toml" >&2; exit 1; } + # A push can edit pyproject.toml without bumping the version. PyPI + # rejects a re-upload, so skip rather than fail the run. + if curl -fsS "https://pypi.org/pypi/agentmail-mcp/$version/json" >/dev/null 2>&1; then + echo "$version is already on PyPI; nothing to publish" + echo "published=true" >> "$GITHUB_OUTPUT" + fi - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 with: version: 0.11.28 @@ -33,6 +53,7 @@ jobs: publish: needs: build + if: needs.build.outputs.published != 'true' runs-on: ubuntu-latest permissions: id-token: write diff --git a/docs/release.md b/docs/release.md index 265fb30..c612ad2 100644 --- a/docs/release.md +++ b/docs/release.md @@ -43,13 +43,17 @@ Do not archive duplicate repositories until the production rollback window and S ## PyPI trusted publishing -Before the first run, an AgentMail administrator must confirm project ownership and recovery access, enable 2FA, and add this repository's `publish-pypi.yml` workflow as a [PyPI Trusted Publisher](https://pypi.org/manage/project/agentmail-mcp/settings/publishing/). After npm `1.0.0` is healthy, dispatch the reviewed commit without adding an API token: +Before the first run, an AgentMail administrator must confirm project ownership and recovery access, enable 2FA, and add this repository's `publish-pypi.yml` workflow as a [PyPI Trusted Publisher](https://pypi.org/manage/project/agentmail-mcp/settings/publishing/). No API token is needed. + +After that, the workflow publishes automatically when a version bump to `python/stdio-bridge/pyproject.toml` merges to `main`. It builds both distributions, installs each into a clean virtualenv, runs the `agentmail-mcp` console script, and checks the installed metadata against the version in `pyproject.toml` before anything reaches PyPI. A push that edits `pyproject.toml` without changing the version skips publishing rather than failing on a rejected re-upload. + +To publish out of band, dispatch the reviewed commit: ```sh gh workflow run publish-pypi.yml --ref main ``` -After it succeeds, verify the registry artifact with `uvx --refresh --from agentmail-mcp==1.0.0 agentmail-mcp --help` before announcing support. +After it succeeds, verify the registry artifact with `uvx --refresh --from agentmail-mcp== agentmail-mcp --help` before announcing support. ## Rollback diff --git a/python/stdio-bridge/scripts/smoke-artifacts.sh b/python/stdio-bridge/scripts/smoke-artifacts.sh index 52a41ee..00484e1 100755 --- a/python/stdio-bridge/scripts/smoke-artifacts.sh +++ b/python/stdio-bridge/scripts/smoke-artifacts.sh @@ -7,6 +7,10 @@ trap 'rm -rf "$tmp"' EXIT dist=${1:-"$tmp/dist"} cd "$root" +# Read the expected version from pyproject rather than pinning it, so a release +# bump doesn't fail its own smoke test. +expected=$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml) +[ -n "$expected" ] || { echo "could not read version from pyproject.toml" >&2; exit 1; } mkdir -p "$dist" python -m build --outdir "$dist" for artifact in "$dist"/*; do @@ -14,6 +18,6 @@ for artifact in "$dist"/*; do "$tmp/venv/bin/python" -m pip install -q "$artifact" "$tmp/venv/bin/python" -m pip check "$tmp/venv/bin/agentmail-mcp" --help >/dev/null - "$tmp/venv/bin/python" -c 'import importlib.metadata as m; assert m.version("agentmail-mcp") == "1.0.0"; assert not any(r.lower().startswith(("agentmail ", "agentmail-toolkit")) for r in m.requires("agentmail-mcp"))' + EXPECTED="$expected" "$tmp/venv/bin/python" -c 'import os, importlib.metadata as m; assert m.version("agentmail-mcp") == os.environ["EXPECTED"], m.version("agentmail-mcp"); assert not any(r.lower().startswith(("agentmail ", "agentmail-toolkit")) for r in m.requires("agentmail-mcp"))' rm -rf "$tmp/venv" done