Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/public-surfaces.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ on:
push:
branches: [main]
schedule:
- cron: "17 15 * * 1"
- cron: "17 15 * * *"
workflow_dispatch:

jobs:
Expand Down
46 changes: 44 additions & 2 deletions .github/workflows/publish-npm.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -33,15 +40,50 @@ jobs:
package-manager-cache: false
- run: npm install --global [email protected]
- 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 </dev/null || true)"
echo "$output"
# 1.0.0 exited 0 with no output through the bin symlink npx uses; the
# only proof the entrypoint runs is that it reaches its own argument check.
echo "$output" | grep -q 'AGENTMAIL_API_KEY is required'
21 changes: 21 additions & 0 deletions .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand All @@ -33,6 +53,7 @@ jobs:

publish:
needs: build
if: needs.build.outputs.published != 'true'
runs-on: ubuntu-latest
permissions:
id-token: write
Expand Down
16 changes: 12 additions & 4 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,17 @@ Configure it at <https://www.npmjs.com/package/agentmail-mcp/access>, 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@<version>` 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@<version>` 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.
Expand All @@ -39,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==<version> agentmail-mcp --help` before announcing support.

## Rollback

Expand Down
6 changes: 5 additions & 1 deletion python/stdio-bridge/scripts/smoke-artifacts.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@ 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
python -m venv "$tmp/venv"
"$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
50 changes: 50 additions & 0 deletions scripts/audit-public-surfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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()
Expand Down
Loading