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/.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 &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 be0a63a..c612ad2 100644 --- a/docs/release.md +++ b/docs/release.md @@ -24,13 +24,17 @@ Configure it at , 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. @@ -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== 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 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()