Skip to content

Merge pull request #151 from Sendmux/codex/harden-sdk-tooling #212

Merge pull request #151 from Sendmux/codex/harden-sdk-tooling

Merge pull request #151 from Sendmux/codex/harden-sdk-tooling #212

name: Release Please
on:
push:
branches:
- main
workflow_dispatch:
inputs:
recover_ts_cli_version:
description: Existing ts-cli release version to publish and attach assets for; leave empty for no-op.
required: false
type: string
recover_mcp_registry_version:
description: Existing sendmux-mcp version to publish to the MCP Registry; leave empty for no-op.
required: false
type: string
permissions: {}
concurrency:
group: release-please-${{ github.ref }}
cancel-in-progress: false
jobs:
release-please:
if: ${{ github.event_name == 'push' }}
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
outputs:
releases_created: ${{ steps.release.outputs.releases_created }}
paths_released: ${{ steps.release.outputs.paths_released }}
rust_release_created: ${{ steps.release.outputs['rust--release_created'] }}
steps:
- name: Run release-please
id: release
uses: googleapis/release-please-action@0dfd8538845b8e92600d271a895a5372865d4062 # v5
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
publish-npm:
needs: release-please
if: >-
${{
needs.release-please.outputs.releases_created == 'true' &&
(
contains(needs.release-please.outputs.paths_released, 'packages/ts/core') ||
contains(needs.release-please.outputs.paths_released, 'packages/ts/sending') ||
contains(needs.release-please.outputs.paths_released, 'packages/ts/mailbox') ||
contains(needs.release-please.outputs.paths_released, 'packages/ts/management') ||
contains(needs.release-please.outputs.paths_released, 'packages/ts/sdk') ||
contains(needs.release-please.outputs.paths_released, 'packages/ts/ai-sdk') ||
contains(needs.release-please.outputs.paths_released, 'packages/ts/cli')
)
}}
runs-on: ubuntu-latest
environment: release
permissions:
contents: write
id-token: write
steps:
- name: Check out SDK
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Check out OpenAPI snapshots
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
repository: Sendmux/docs
path: sendmux-docs
- name: Set up pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
with:
version: 10.22.0
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
registry-url: https://registry.npmjs.org
package-manager-cache: false
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: "1.23.x"
cache-dependency-path: go/go.sum
- name: Set up Ruby
uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0
with:
ruby-version: "3.4.1"
bundler-cache: false
- name: Set up npm for trusted publishing
run: npm install --global npm@11.5.1
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build and verify
run: pnpm build
- name: Publish TypeScript packages to npm
run: pnpm publish:npm:ts
- name: Pack CLI tarballs
if: ${{ contains(needs.release-please.outputs.paths_released, 'packages/ts/cli') }}
run: pnpm --filter @sendmux/cli pack:tarballs
- name: Verify CLI tarballs
if: ${{ contains(needs.release-please.outputs.paths_released, 'packages/ts/cli') }}
run: pnpm check:cli-tarballs
- name: Attach CLI tarballs to GitHub Release
if: ${{ contains(needs.release-please.outputs.paths_released, 'packages/ts/cli') }}
env:
GH_TOKEN: ${{ github.token }}
run: |
tag="ts-cli-v$(node -p "require('./packages/ts/cli/package.json').version")"
mapfile -t assets < <(find packages/ts/cli/dist -type f \( -name "*.tar.gz" -o -name "*.tar.xz" -o -name "*.sha256" \) -print)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No CLI tarballs found under packages/ts/cli/dist" >&2
exit 1
fi
gh release upload "$tag" "${assets[@]}" --clobber
- name: Update Homebrew tap
if: ${{ contains(needs.release-please.outputs.paths_released, 'packages/ts/cli') }}
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
SENDMUX_HOMEBREW_TAP_REPO: Sendmux/homebrew-tap
run: node scripts/update-homebrew-tap.mjs
publish-cratesio:
needs: release-please
if: ${{ needs.release-please.outputs.rust_release_created == 'true' }}
runs-on: ubuntu-latest
environment: release
permissions:
contents: read
id-token: write
defaults:
run:
working-directory: rust
steps:
- name: Check out SDK
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up Rust
run: |
rustup toolchain install stable --profile minimal
rustup default stable
- name: Verify crates.io package
run: cargo publish --dry-run --locked
- name: Authenticate to crates.io
id: auth
uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5
- name: Publish Rust crate to crates.io
run: cargo publish --locked
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
recover-ts-cli-release:
if: ${{ github.event_name == 'workflow_dispatch' && inputs.recover_ts_cli_version != '' }}
runs-on: ubuntu-latest
environment: release
permissions:
contents: write
id-token: write
steps:
- name: Check out SDK
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Check out OpenAPI snapshots
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
repository: Sendmux/docs
path: sendmux-docs
- name: Verify CLI recovery target
env:
GH_TOKEN: ${{ github.token }}
RECOVER_TS_CLI_VERSION: ${{ inputs.recover_ts_cli_version }}
run: |
set -euo pipefail
package_version="$(node -p "require('./packages/ts/cli/package.json').version")"
if [ "$RECOVER_TS_CLI_VERSION" != "$package_version" ]; then
echo "Recovery version $RECOVER_TS_CLI_VERSION does not match @sendmux/cli package version $package_version" >&2
exit 1
fi
gh release view "ts-cli-v${RECOVER_TS_CLI_VERSION}" --json tagName --jq .tagName
- name: Set up pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
with:
version: 10.22.0
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
registry-url: https://registry.npmjs.org
package-manager-cache: false
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: "1.23.x"
cache-dependency-path: go/go.sum
- name: Set up Ruby
uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0
with:
ruby-version: "3.4.1"
bundler-cache: false
- name: Set up npm for trusted publishing
run: npm install --global npm@11.5.1
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build and verify
run: pnpm build
- name: Publish CLI package to npm
run: pnpm publish:npm:ts
env:
SENDMUX_NPM_PUBLISH_PACKAGES: packages/ts/cli
- name: Pack CLI tarballs
run: pnpm --filter @sendmux/cli pack:tarballs
- name: Verify CLI tarballs
run: pnpm check:cli-tarballs
- name: Attach CLI tarballs to GitHub Release
env:
GH_TOKEN: ${{ github.token }}
RECOVER_TS_CLI_VERSION: ${{ inputs.recover_ts_cli_version }}
run: |
set -euo pipefail
tag="ts-cli-v${RECOVER_TS_CLI_VERSION}"
mapfile -t assets < <(find packages/ts/cli/dist -type f \( -name "*.tar.gz" -o -name "*.tar.xz" -o -name "*.sha256" \) -print)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No CLI tarballs found under packages/ts/cli/dist" >&2
exit 1
fi
gh release upload "$tag" "${assets[@]}" --clobber
- name: Update Homebrew tap
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
SENDMUX_HOMEBREW_TAP_REPO: Sendmux/homebrew-tap
run: node scripts/update-homebrew-tap.mjs
recover-mcp-registry-release:
if: ${{ github.event_name == 'workflow_dispatch' && inputs.recover_mcp_registry_version != '' }}
runs-on: ubuntu-latest
environment: release
permissions:
contents: read
id-token: write
steps:
- name: Check out SDK
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6
with:
python-version: "3.12"
- name: Prepare MCP Registry recovery manifest
id: mcp-manifest
env:
RECOVER_MCP_REGISTRY_VERSION: ${{ inputs.recover_mcp_registry_version }}
run: |
set -euo pipefail
python3 - <<'PY'
import json
import os
import tomllib
from pathlib import Path
root = Path.cwd()
pyproject = tomllib.loads((root / "packages/python/mcp/pyproject.toml").read_text())
version = pyproject["project"]["version"]
requested_version = os.environ["RECOVER_MCP_REGISTRY_VERSION"]
if requested_version != version:
raise SystemExit(f"Recovery version {requested_version} does not match sendmux-mcp package version {version}")
server_path = root / "packages/python/mcp/server.json"
server = json.loads(server_path.read_text())
if server["name"] != "io.github.Sendmux/sendmux-mcp":
raise SystemExit(f"Unexpected MCP Registry name: {server['name']}")
marker = (root / "packages/python/mcp/README.md").read_text()
expected_marker = f"mcp-name: {server['name']}"
if expected_marker not in marker:
raise SystemExit(f"Missing README marker: {expected_marker}")
server["version"] = version
for package in server.get("packages", []):
if package.get("registryType") == "pypi" and package.get("identifier") == "sendmux-mcp":
package["version"] = version
break
else:
raise SystemExit("Missing PyPI package entry for sendmux-mcp")
server_path.write_text(json.dumps(server, indent=2) + "\n")
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
output.write(f"name={server['name']}\n")
output.write(f"version={version}\n")
print(f"Prepared {server['name']} {version}")
PY
- name: Verify released package marker
env:
MCP_SERVER_NAME: ${{ steps.mcp-manifest.outputs.name }}
MCP_PACKAGE_VERSION: ${{ steps.mcp-manifest.outputs.version }}
run: |
set -euo pipefail
curl -fsSL "https://pypi.org/pypi/sendmux-mcp/${MCP_PACKAGE_VERSION}/json" -o /tmp/sendmux-mcp.json
python3 - <<'PY'
import json
import os
from pathlib import Path
data = json.loads(Path("/tmp/sendmux-mcp.json").read_text())
description = data["info"].get("description") or ""
expected_marker = f"mcp-name: {os.environ['MCP_SERVER_NAME']}"
if data["info"]["version"] != os.environ["MCP_PACKAGE_VERSION"]:
raise SystemExit("PyPI returned the wrong sendmux-mcp version")
if expected_marker not in description:
raise SystemExit(f"PyPI description missing marker: {expected_marker}")
PY
- name: Install MCP Registry publisher
run: |
set -euo pipefail
curl -L "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" | tar xz mcp-publisher
./mcp-publisher --help
- name: Validate MCP Registry manifest
run: ./mcp-publisher validate packages/python/mcp/server.json
- name: Check existing MCP Registry version
id: mcp-registry-status
env:
MCP_SERVER_NAME: ${{ steps.mcp-manifest.outputs.name }}
MCP_SERVER_VERSION: ${{ steps.mcp-manifest.outputs.version }}
run: |
set -euo pipefail
already_published="$(node scripts/mcp-registry-version.mjs --check)"
echo "already_published=${already_published}" >> "$GITHUB_OUTPUT"
- name: Authenticate to MCP Registry
if: ${{ steps.mcp-registry-status.outputs.already_published != 'true' }}
run: ./mcp-publisher login github-oidc
- name: Publish MCP Registry metadata
if: ${{ steps.mcp-registry-status.outputs.already_published != 'true' }}
run: ./mcp-publisher publish packages/python/mcp/server.json
- name: Verify MCP Registry metadata
env:
MCP_SERVER_NAME: ${{ steps.mcp-manifest.outputs.name }}
MCP_SERVER_VERSION: ${{ steps.mcp-manifest.outputs.version }}
run: node scripts/mcp-registry-version.mjs
publish-pypi:
needs: release-please
if: >-
${{
(
needs.release-please.outputs.releases_created == 'true' &&
(
contains(needs.release-please.outputs.paths_released, 'packages/python/core') ||
contains(needs.release-please.outputs.paths_released, 'packages/python/sending') ||
contains(needs.release-please.outputs.paths_released, 'packages/python/mailbox') ||
contains(needs.release-please.outputs.paths_released, 'packages/python/management') ||
contains(needs.release-please.outputs.paths_released, 'packages/python/sdk') ||
contains(needs.release-please.outputs.paths_released, 'packages/python/mcp') ||
contains(needs.release-please.outputs.paths_released, 'packages/python/langchain')
)
)
}}
runs-on: ubuntu-latest
environment: release
permissions:
contents: read
id-token: write
steps:
- name: Check out SDK
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Check out OpenAPI snapshots
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
repository: Sendmux/docs
path: sendmux-docs
- name: Set up pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
with:
version: 10.22.0
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
package-manager-cache: false
- name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6
with:
python-version: "3.12"
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: "1.23.x"
cache-dependency-path: go/go.sum
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Python distributions
env:
PYTHON_PATHS_RELEASED: ${{ needs.release-please.outputs.paths_released }}
run: pnpm build:python:dists
- name: Select unpublished Python distributions
id: python-publish
run: pnpm prepare:publish:pypi
- name: Publish Python packages to PyPI
if: ${{ steps.python-publish.outputs.has_files == 'true' }}
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
with:
packages-dir: .tmp/python-publish
publish-mcp-registry:
needs:
- release-please
- publish-pypi
if: >-
${{
needs.release-please.outputs.releases_created == 'true' &&
contains(fromJSON(needs.release-please.outputs.paths_released), 'packages/python/mcp') &&
needs.publish-pypi.result == 'success'
}}
runs-on: ubuntu-latest
environment: release
permissions:
contents: read
id-token: write
steps:
- name: Check out SDK
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6
with:
python-version: "3.12"
- name: Prepare MCP Registry manifest
id: mcp-manifest
run: |
set -euo pipefail
python3 - <<'PY'
import json
import os
import tomllib
from pathlib import Path
root = Path.cwd()
pyproject = tomllib.loads((root / "packages/python/mcp/pyproject.toml").read_text())
version = pyproject["project"]["version"]
server_path = root / "packages/python/mcp/server.json"
server = json.loads(server_path.read_text())
if server["name"] != "io.github.Sendmux/sendmux-mcp":
raise SystemExit(f"Unexpected MCP Registry name: {server['name']}")
marker = (root / "packages/python/mcp/README.md").read_text()
expected_marker = f"mcp-name: {server['name']}"
if expected_marker not in marker:
raise SystemExit(f"Missing README marker: {expected_marker}")
server["version"] = version
for package in server.get("packages", []):
if package.get("registryType") == "pypi" and package.get("identifier") == "sendmux-mcp":
package["version"] = version
break
else:
raise SystemExit("Missing PyPI package entry for sendmux-mcp")
server_path.write_text(json.dumps(server, indent=2) + "\n")
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
output.write(f"name={server['name']}\n")
output.write(f"version={version}\n")
print(f"Prepared {server['name']} {version}")
PY
- name: Wait for PyPI MCP package marker
env:
MCP_SERVER_NAME: ${{ steps.mcp-manifest.outputs.name }}
MCP_PACKAGE_VERSION: ${{ steps.mcp-manifest.outputs.version }}
run: |
set -euo pipefail
for attempt in {1..30}; do
if curl -fsSL "https://pypi.org/pypi/sendmux-mcp/${MCP_PACKAGE_VERSION}/json" -o /tmp/sendmux-mcp.json; then
if python3 - <<'PY'
import json
import os
from pathlib import Path
data = json.loads(Path("/tmp/sendmux-mcp.json").read_text())
description = data["info"].get("description") or ""
expected_marker = f"mcp-name: {os.environ['MCP_SERVER_NAME']}"
if data["info"]["version"] != os.environ["MCP_PACKAGE_VERSION"]:
raise SystemExit("PyPI returned the wrong sendmux-mcp version")
if expected_marker not in description:
raise SystemExit(f"PyPI description missing marker: {expected_marker}")
PY
then
exit 0
fi
fi
sleep 10
done
echo "Timed out waiting for PyPI sendmux-mcp ${MCP_PACKAGE_VERSION} marker" >&2
exit 1
- name: Install MCP Registry publisher
run: |
set -euo pipefail
curl -L "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" | tar xz mcp-publisher
./mcp-publisher --help
- name: Validate MCP Registry manifest
run: ./mcp-publisher validate packages/python/mcp/server.json
- name: Check existing MCP Registry version
id: mcp-registry-status
env:
MCP_SERVER_NAME: ${{ steps.mcp-manifest.outputs.name }}
MCP_SERVER_VERSION: ${{ steps.mcp-manifest.outputs.version }}
run: |
set -euo pipefail
already_published="$(node scripts/mcp-registry-version.mjs --check)"
echo "already_published=${already_published}" >> "$GITHUB_OUTPUT"
- name: Authenticate to MCP Registry
if: ${{ steps.mcp-registry-status.outputs.already_published != 'true' }}
run: ./mcp-publisher login github-oidc
- name: Publish MCP Registry metadata
if: ${{ steps.mcp-registry-status.outputs.already_published != 'true' }}
run: |
set -euo pipefail
attempts=12
for ((attempt = 1; attempt <= attempts; attempt++)); do
if ./mcp-publisher publish packages/python/mcp/server.json 2>&1 | tee /tmp/mcp-publish.log; then
exit 0
fi
if ! node scripts/mcp-registry-version.mjs --retryable-publish-log /tmp/mcp-publish.log; then
exit 1
fi
if [ "${attempt}" -lt "${attempts}" ]; then
sleep_seconds=$((attempt * 10))
echo "MCP Registry cannot see PyPI package yet; retrying in ${sleep_seconds}s." >&2
sleep "${sleep_seconds}"
fi
done
echo "MCP Registry still cannot see PyPI package after ${attempts} attempts." >&2
exit 1
- name: Verify MCP Registry metadata
env:
MCP_SERVER_NAME: ${{ steps.mcp-manifest.outputs.name }}
MCP_SERVER_VERSION: ${{ steps.mcp-manifest.outputs.version }}
run: node scripts/mcp-registry-version.mjs
publish-rubygems:
needs: release-please
if: >-
${{
needs.release-please.outputs.releases_created == 'true' &&
(
contains(needs.release-please.outputs.paths_released, 'packages/ruby/core') ||
contains(needs.release-please.outputs.paths_released, 'packages/ruby/sending') ||
contains(needs.release-please.outputs.paths_released, 'packages/ruby/mailbox') ||
contains(needs.release-please.outputs.paths_released, 'packages/ruby/management') ||
contains(needs.release-please.outputs.paths_released, 'packages/ruby/sdk')
)
}}
runs-on: ubuntu-latest
environment: release
permissions:
contents: read
id-token: write
steps:
- name: Check out SDK
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Check out OpenAPI snapshots
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
repository: Sendmux/docs
path: sendmux-docs
- name: Set up pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
with:
version: 10.22.0
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
package-manager-cache: false
- name: Set up Ruby
uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0
with:
ruby-version: "3.4.1"
bundler-cache: false
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Ruby gems
run: pnpm build:ruby
env:
RUBY_PATHS_RELEASED: ${{ needs.release-please.outputs.paths_released }}
- name: Configure RubyGems trusted publishing credentials
uses: rubygems/configure-rubygems-credentials@762a4b77c3300434bb57c7ce80b20e36231927aa # v2.0.0
- name: Publish Ruby packages to RubyGems
run: pnpm publish:rubygems
env:
RUBY_PATHS_RELEASED: ${{ needs.release-please.outputs.paths_released }}