diff --git a/.github/workflows/announce.yml b/.github/workflows/announce.yml index 8098b30..68ae661 100644 --- a/.github/workflows/announce.yml +++ b/.github/workflows/announce.yml @@ -28,6 +28,13 @@ permissions: jobs: announce: runs-on: ubuntu-latest + # This announces the npm package only (`v*` tags). The Python package + # (`nimblebrain-synapse-v*`) publishes on its own track and is not announced + # here. The load-bearing guard is the `^v[0-9]` filter on PREV below; this + # `if` is mostly defensive, since a `nimblebrain-synapse-v*` release cut by + # publish-python.yml with GITHUB_TOKEN does not fire `release: published` + # anyway (it only covers a hand-cut release or a manual dispatch). + if: ${{ !startsWith(github.event.release.tag_name || github.event.inputs.tag, 'nimblebrain-synapse-') }} steps: - uses: actions/checkout@v6 @@ -43,13 +50,15 @@ jobs: gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \ --json tagName,name,body,url,publishedAt > release.json # Previous release tag for the compare link: the newest non-current - # published release. Assumes single-track sequential releases (true for - # synapse — semver order matches publish order). A backport published - # after a higher version would yield a cosmetically backwards compare - # link; switch the pipe to `sort -V` if synapse ever ships out of order. + # published release on the npm track (`v*` only — the Python + # `nimblebrain-synapse-v*` track is excluded so the compare link never + # crosses tracks). Assumes single-track sequential releases within npm + # (semver order matches publish order). A backport published after a + # higher version would yield a cosmetically backwards compare link; + # switch the pipe to `sort -V` if synapse ever ships out of order. # Empty on the first release -> the script omits the compare link. - PREV=$(gh release list --repo "$GITHUB_REPOSITORY" --exclude-drafts \ - --json tagName --jq '.[].tagName' | grep -vxF "$TAG" | head -n1 || true) + PREV=$(gh release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --limit 100 \ + --json tagName --jq '.[].tagName' | grep -E '^v[0-9]' | grep -vxF "$TAG" | head -n1 || true) echo "PREV_TAG=$PREV" >> "$GITHUB_ENV" - name: Announce to Discord diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a420847..41b4e9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,10 @@ on: branches: [main] pull_request: branches: [main] + workflow_call: # reused as the pre-publish gate by publish-python.yml (tag path) + +permissions: + contents: read jobs: lint: @@ -66,4 +70,51 @@ jobs: test -f dist/synapse-runtime.iife.global.js test -f dist/synapse-ui.iife.global.js - name: Vendored Python IIFE is fresh (fail on drift from the build) - run: diff dist/synapse-ui.iife.global.js python/synapse_ui/_assets/synapse-ui.iife.js + run: diff dist/synapse-ui.iife.global.js python/nimblebrain_synapse/_assets/synapse-ui.iife.js + - name: Client version pin matches the npm package + # The vendored IIFE is forced to the current tree's build (step above), + # whose version is package.json's — so the recorded provenance pin must + # equal it, or it silently lies after the next npm release. + run: | + PIN=$(sed -n 's/^__client_version__ = "\([^"]*\)".*/\1/p' python/nimblebrain_synapse/__init__.py) + NPM=$(node -p "require('./package.json').version") + if [ "$PIN" != "$NPM" ]; then + echo "::error::nimblebrain_synapse.__client_version__ ('$PIN') != @nimblebrain/synapse package.json ('$NPM') — re-vendor the IIFE and bump __client_version__" + exit 1 + fi + + python: + name: Python (nimblebrain-synapse) + runs-on: ubuntu-latest + defaults: + run: + working-directory: python + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8.3.2 + - run: uv sync + - run: uv run ruff check nimblebrain_synapse tests + - run: uv run ruff format --check nimblebrain_synapse tests + - run: uv run ty check nimblebrain_synapse # py.typed is a promise; keep it honest + - run: uv run pytest -q + - name: Tests pass at the declared mcp floor + # Default resolution installs the newest mcp, so the >= floor is never + # exercised. Pin mcp to exactly the declared floor and re-run, so adopting + # a newer mcp API without raising the floor fails here, not for a consumer. + run: | + FLOOR=$(sed -n 's/.*"mcp>=\([0-9.]*\)".*/\1/p' pyproject.toml) + uv run --isolated --no-project --with "mcp==$FLOOR" --with pytest --with pytest-asyncio \ + --with-editable . pytest -q + - name: Tests pass on the minimum supported Python + # requires-python is >=3.11 and the classifiers promise 3.11–3.13, but the + # default resolution runs one interpreter — exercise the floor too. + run: uv run --python 3.11 --isolated --no-project --with-editable . --with pytest --with pytest-asyncio pytest -q + - run: uv build # catch packaging breakage before a release tag is cut + - name: Built wheel ships and loads the client asset + # Import from the installed wheel (python -P keeps the source tree off + # sys.path) so a missing _assets/ fails here, not after an irreversible + # publish — pytest runs against source and would not catch it. + run: | + WHEEL=$(ls "$PWD"/dist/*.whl | head -1) + uv run --isolated --no-project --with "$WHEEL" \ + python -P -c "from nimblebrain_synapse import SynapseUI; from nimblebrain_synapse.server import _load_bundled_sdk; assert 'SynapseUI' in _load_bundled_sdk()" diff --git a/.github/workflows/publish-python.yml b/.github/workflows/publish-python.yml new file mode 100644 index 0000000..c68b36e --- /dev/null +++ b/.github/workflows/publish-python.yml @@ -0,0 +1,84 @@ +name: Publish nimblebrain-synapse to PyPI + +on: + push: + tags: + - 'nimblebrain-synapse-v*' + +defaults: + run: + working-directory: python + +jobs: + # The full CI suite is the pre-publish gate — reused, not hand-copied, so the + # tag path can't drift from main's checks (lint, test, the IIFE-freshness diff, + # the __client_version__ pin gate, and the Python job's install-from-wheel check). + verify: + uses: ./.github/workflows/ci.yml + + publish: + needs: verify + runs-on: ubuntu-latest + environment: pypi + permissions: + contents: write # create the GitHub Release + id-token: write # PyPI trusted publishing (OIDC) + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8.3.2 + + - name: Verify tag matches package version + run: | + TAG_VERSION="${GITHUB_REF#refs/tags/nimblebrain-synapse-v}" + PKG_VERSION=$(python3 -c \ + "import tomllib,pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])") + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "::error::Tag version ($TAG_VERSION) != pyproject version ($PKG_VERSION)" + exit 1 + fi + + # Extract + validate notes BEFORE publishing: PyPI never permits reusing a + # version, so a missing CHANGELOG section must fail here, not after upload. + - name: Extract release notes + run: | + VERSION="${GITHUB_REF#refs/tags/nimblebrain-synapse-v}" + NOTES=$(awk -v ver="$VERSION" ' + index($0, "## [" ver "]") == 1 { capture=1; next } + capture && /^## \[/ { exit } + capture { print } + ' CHANGELOG.md) + if [ -z "$(printf '%s' "$NOTES" | tr -d '[:space:]')" ]; then + echo "::error::No python/CHANGELOG.md section found for version $VERSION" + exit 1 + fi + printf '%s\n' "$NOTES" > "$RUNNER_TEMP/notes.md" + + - name: Build sdist + wheel + run: uv build + + # Trusted publishing (OIDC) — no token. Requires a PyPI publisher registered + # for repo NimbleBrainInc/synapse, workflow publish-python.yml, environment pypi. + - name: Publish to PyPI + # SHA-pinned (not the mutable @release/v1 branch): this job holds + # id-token: write and the publish is irreversible on PyPI. + uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 + with: + packages-dir: python/dist + # Idempotent reruns: if a later step (the GitHub Release) fails after a + # successful upload, re-running must not 400 on "File already exists" and + # wedge the release — PyPI never permits reusing a version. + skip-existing: true + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${GITHUB_REF#refs/tags/}" + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "Release $TAG already exists; skipping." + exit 0 + fi + gh release create "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --title "$TAG" \ + --notes-file "$RUNNER_TEMP/notes.md" diff --git a/CLAUDE.md b/CLAUDE.md index ee8c0bb..8f33b2a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,14 +99,16 @@ unchanged. **is** the `CallToolResult` (data at `params.structuredContent`), not wrapped in `params.result`. Keep `synapse/*` NimbleBrain-private fields out of the chatgpt/mcpapps payloads. -- The server half is the Python `synapse-ui` package (`python/`). It registers the - component as **two `ui://` resources** — `text/html+skybridge` (ChatGPT) and +- The server half is the Python `nimblebrain-synapse` package (`python/`). It registers + the component as **two `ui://` resources** — `text/html+skybridge` (ChatGPT) and `text/html;profile=mcp-app` (Claude/MCP Apps) — emits the tool `_meta` (`openai/outputTemplate` + nested `ui.resourceUri`), the `