diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 016febe..124027e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,5 +47,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - run: python3 Tests/check_runtime_rejection.py + runtime: + uses: computer-mcp/.github/.github/workflows/plugin-runtime.yml@35766b350427ed900b456aa3c39098e97d9d3aa1 # master brand: uses: computer-mcp/.github/.github/workflows/brand-check.yml@f402911abcdfb87ef0c41a2759e8c51387b18935 # master diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fa9732c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,31 @@ +name: Release +on: + workflow_dispatch: + inputs: + ref: + description: Version tag, or commit for a dry run + type: string + required: true + publish: + description: Publish the verified release + type: boolean + default: false +permissions: {} +jobs: + release: + permissions: + contents: write + actions: read + uses: computer-mcp/.github/.github/workflows/plugin-release.yml@35766b350427ed900b456aa3c39098e97d9d3aa1 # master + with: + ref: ${{ inputs.ref }} + publish: ${{ inputs.publish }} + build: python3 Scripts/build_package.py + ci_artifact: cursor-plugin-${commit}-3.13 + notify-catalog: + needs: release + if: inputs.publish && needs.release.outputs.published == 'true' + permissions: {} + uses: ./.github/workflows/notify-catalog.yml + secrets: + CATALOG_APP_PRIVATE_KEY: ${{ secrets.CATALOG_APP_PRIVATE_KEY }} diff --git a/Documentation/Architecture/VersioningAndRelease.md b/Documentation/Architecture/VersioningAndRelease.md index 35b3c3b..f62a274 100644 --- a/Documentation/Architecture/VersioningAndRelease.md +++ b/Documentation/Architecture/VersioningAndRelease.md @@ -1,26 +1,24 @@ # Versioning and Release -Release status: partly manual. CI builds and checks candidate archives on every -pull request and `master` push; tagging, acceptance and publication are manual -steps described in [Release](../Reference/Release.md). +CI builds and checks candidate archives on every pull request and `master` +push. `release.yml` verifies and publishes accepted archives after manual +acceptance, as described in [Release](../Reference/Release.md). -This document owns the current version and release rules for plugin-cursor. +This document owns plugin-cursor's version sources and acceptance checks. The +organization's [VERSIONING.md](https://github.com/computer-mcp/.github/blob/master/VERSIONING.md) +owns shared versioning, plugin compatibility, tags, release contents and +recovery rules. ## Components and Version Authority | Shipped component | Version meaning | Authoritative source | Derived fields | Update and read-only check | | --- | --- | --- | --- | --- | -| `cursor.zip` plugin package | SemVer 2.0.0 package version | `version` in `computer-mcp-plugin.toml` | adapter `--version`, builder receipt, tag `vX.Y.Z`, `release-receipt.json`, catalog entry | Edit the manifest; `Tests/test_package.py` checks the packaged adapter's `--version` | - -While the version is `0.x`, compatible fixes advance the patch number, and new -features or incompatible changes advance the minor number. Compatibility is -established by review and tests, not by comparing version numbers. +| `cursor.zip` plugin package | Package version under the shared versioning rules | `version` in `computer-mcp-plugin.toml` | adapter `--version`, builder receipt, release tag, `release-receipt.json`, catalog entry | Edit the manifest; `Tests/test_package.py` checks the packaged adapter's `--version` | The archive contains the manifest, `cli-tree.json`, `bin/`, `skills/`, the README, -contribution guide, license, notices, `Documentation/` and `Examples/`. Any change -to those files ships only under a new version; a published version is never -rebuilt from different bytes. Changes to tests, scripts or workflows alone do not -change the archive and do not require a release. +contribution guide, license, notices, `Documentation/` and `Examples/`. Changes +to these packaged files change the release bytes. Tests, scripts and workflows +are outside the archive. ## Dependencies and Verified Combinations @@ -28,9 +26,14 @@ The package uses the Python standard library only and requires Python 3.13 or newer on the host launch PATH. CI runs the tests on Python 3.13 and 3.14 and checks that 3.11 and 3.12 are rejected before any vendor process starts. -`[compatibility]` in the manifest declares the minimum Computer MCP host and the -supported architectures. Raise `minimum_host` only when the package needs a newer -host contract, after validating against that host. +`bin/plugin_runtime.py` is copied from the organization's canonical runtime. +The `runtime` job in `ci.yml` checks it against the pinned shared workflow's +digest. Adapter and probe callers extract the native assertion from the CLI +tree with `expected_version` before calling `verify_version`. + +`computer-mcp-plugin.toml` declares the plugin API and host and architecture +compatibility. Validate host-specific requirements against the selected host +before changing the host bounds. `cli-tree.json` pins the exact Cursor Agent version through its `executable_checks`, which the host applies before every CLI and adapter call. A @@ -41,22 +44,26 @@ different vendor version requires a reviewed update of the tree, verified with `Scripts/build_package.py` verifies the plugin ID, parses the CLI tree, and produces the same archive bytes from the same source. Its receipt prints the ID, -version and SHA-256. Agreement between the manifest version and the release tag -is checked manually when tagging. +version and SHA-256. `release.yml` calls the pinned organization release workflow +with `python3 Scripts/build_package.py` and selects the exact +`cursor-plugin-${commit}-3.13` CI artifact. The shared workflow checks the source +and manifest, rebuilds the archive and compares its bytes with that artifact. ## Candidate, Acceptance and Publication -The candidate is the CI artifact for a reviewed `master` commit. Acceptance -requires a byte-identical local rebuild, the native version and help check, the -ACP initialization probe with `Scripts/probe_acp.py`, and the isolated host check -with `Scripts/validate_host.py` against the selected Computer MCP release. +The candidate is the Python 3.13 CI artifact for a reviewed `master` commit. +Run `release.yml` with `publish: false` first. Manual acceptance requires the +native version and help check with `Scripts/validate_native.py`, the ACP +initialization probe with `Scripts/probe_acp.py`, and the isolated host check +with `Scripts/validate_host.py` against the selected Computer MCP release and +the exact archive verified by the dry run. Authenticated model execution and production installation are separate checks and are never implied by these results. -A signed annotated `vX.Y.Z` tag binds the accepted commit, and the GitHub -Release publishes that exact archive with `SHA256SUMS` and -`release-receipt.json`. Published tags and archives are immutable; a defect is -fixed in a new version. Publishing triggers the catalog notification. +Record the manual results and their limits in the draft Release notes. Dispatch +`release.yml` with the accepted version tag and `publish: true` to publish. +Only a reported publication enables its dependent catalog notification job; +only that job receives the catalog secret. ## Evidence Reuse and Invalidation @@ -71,10 +78,10 @@ packaged file produces a new archive that needs the affected checks again. | --- | --- | --- | | Version update and check | Edit `computer-mcp-plugin.toml`; `python3 -m unittest discover -s Tests -p 'test_*.py'` | Local checkout | | Candidate validation and build | `ci.yml`; locally `python3 Scripts/build_package.py ` | CI or local checkout | -| Status and interrupted-run recovery | Rerun the failed CI job or check; outputs go to new directories | Repository Actions | -| Acceptance and publication | Manual steps in [Release](../Reference/Release.md) | Signing key and release write access | +| Status and interrupted-run recovery | Inspect CI or `release.yml`; follow [Release](../Reference/Release.md#retry) | Repository Actions | +| Acceptance and publication | Manual checks and `release.yml` in [Release](../Reference/Release.md) | Signing key and release write access | | Cleanup | Delete local output and evidence directories | Local checkout | The builder refuses to overwrite a different archive, and `validate_host.py` -requires a new evidence directory, so earlier results stay intact. Keep local -evidence outside the repository. +requires a new evidence directory. Keep local build and evidence outputs under +ignored `dist/` paths and remove them after use. diff --git a/Documentation/Reference/Release.md b/Documentation/Reference/Release.md index daafbf7..876a81c 100644 --- a/Documentation/Reference/Release.md +++ b/Documentation/Reference/Release.md @@ -1,28 +1,43 @@ # Release -This guide covers publishing an accepted package and notifying the official -plugin catalog. [Versioning and Release](../Architecture/VersioningAndRelease.md) -owns the version, acceptance and immutability rules. +This guide covers `release.yml` and notification of the official plugin catalog. +[Versioning and Release](../Architecture/VersioningAndRelease.md) owns this +repository's version sources and acceptance checks. The organization's +[VERSIONING.md](https://github.com/computer-mcp/.github/blob/master/VERSIONING.md) +owns shared versioning, release contents, immutability and recovery rules. + +The repository owner must enable GitHub release immutability before the first +release. The release workflow refuses publication unless it can verify that +setting; enabling it is a prerequisite to publishing. ## Publish 1. Confirm that `version` in `computer-mcp-plugin.toml` has not been released - and that the reviewed `master` commit has a successful CI run. -2. Download that run's `cursor-plugin--3.13` artifact. Rebuild the same - commit with `python3 Scripts/build_package.py ` and confirm the - two `cursor.zip` files are byte-identical. -3. Run the native, ACP initialization and isolated host checks in + and that the reviewed `master` commit has successful `ci.yml` from a `push`. + Its exact archive artifact name is `cursor-plugin--3.13`. +2. Dispatch `.github/workflows/release.yml` with `ref` set to that full commit + SHA and `publish: false` (the default). The pinned organization workflow + runs `python3 Scripts/build_package.py` with an output-directory argument, + verifies the CI source and artifact digest, and requires the rebuilt + `cursor.zip` to match the CI archive byte for byte. +3. Inspect the `plugin-release--` Actions artifact. Its + `assets/` directory contains `cursor.zip`, the generated + `release-receipt.json` and `SHA256SUMS`. An untagged dry run reports the + signed annotated tag still needed for publication. +4. Run the native version/help, ACP initialization and isolated host checks in [Installation](Installation.md#isolated-host-interoperability) against that - exact archive. -4. Create a signed annotated tag `vX.Y.Z` on the accepted commit and push it. -5. Create the GitHub Release for the tag with `cursor.zip`, `SHA256SUMS` and - `release-receipt.json`, then publish it. + exact archive. Record the manual acceptance results and their limits in the + draft Release notes. Create and push the signed annotated `vX.Y.Z` tag on + the accepted commit, and target the draft Release at that full commit SHA. +5. Dispatch `release.yml` with `ref` set to that version tag and `publish: true`. + The workflow verifies the tag signature and manifest version, repeats the + source and archive checks, completes and verifies the draft assets, and + publishes. Its dependent notifier runs only when `publish` is true and the + shared workflow reports `published: true`. -`release-receipt.json` is written by the publisher; no script generates it. It -records the plugin ID and version, tag, source commit, archive name, size and -SHA-256, the source CI run and artifact digest, the declared compatibility, and -the validation results with their limits. `SHA256SUMS` covers the archive and the -receipt. +The shared workflow generates the deterministic receipt and checksums under the +organization's [release contents contract](https://github.com/computer-mcp/.github/blob/master/VERSIONING.md#plugin-release-contents). +Manual acceptance evidence belongs in the Release notes. ## Catalog notification @@ -44,20 +59,20 @@ that website commit first, then update the pin here when adopting a change to the notification contract. A release published with a repository `GITHUB_TOKEN` does not trigger -release-event workflows. Automation that publishes that way calls the workflow -as a dependent of the job that makes the release public: - -```yaml -notify-catalog: - needs: publish - uses: ./.github/workflows/notify-catalog.yml - secrets: - CATALOG_APP_PRIVATE_KEY: ${{ secrets.CATALOG_APP_PRIVATE_KEY }} -``` +release-event workflows. `release.yml` calls `notify-catalog.yml` as a dependent +job after confirmed publication. Only the notifier receives +`CATALOG_APP_PRIVATE_KEY`; the reusable release workflow receives no catalog +secret. Dry runs do not notify the catalog. ## Retry -For an operator-driven publication or a missed or failed notification, dispatch: +For a failed publication, use **Re-run failed jobs**. Recovery preserves the +receipt's frozen CI provenance and retains matching draft assets. Follow the +organization's [release recovery guide](https://github.com/computer-mcp/.github/blob/master/Documentation/Reference/PluginRelease.md#run-and-recover) +for conflicts or unavailable source artifacts. Published releases are verified +without changing their assets. + +For a missed or failed notification, rerun only the notifier or dispatch: ```sh gh workflow run notify-catalog.yml --repo computer-mcp/plugin-cursor --ref master diff --git a/Scripts/probe_acp.py b/Scripts/probe_acp.py index e20de8c..50d4ee0 100644 --- a/Scripts/probe_acp.py +++ b/Scripts/probe_acp.py @@ -9,7 +9,7 @@ ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT / 'bin')) -from plugin_runtime import Job, Process, decoded, load_package, resolve_executable, verify_version +from plugin_runtime import Job, Process, decoded, expected_version, load_package, resolve_executable, verify_version def main(): @@ -19,7 +19,7 @@ def main(): args = parser.parse_args() _, tree = load_package(ROOT / 'bin/cursor-mcp-adapter') executable = resolve_executable(args.executable, 'CURSOR_AGENT_EXECUTABLE', 'agent') - verify_version(executable, tree, Job(), os.getcwd()) + verify_version(executable, expected_version(tree), Job(), os.getcwd()) process = Process([executable, 'acp'], os.getcwd()) deadline = time.monotonic()+30 response = None diff --git a/Tests/test_lifecycle.py b/Tests/test_lifecycle.py index 9fb1f6b..b86f808 100644 --- a/Tests/test_lifecycle.py +++ b/Tests/test_lifecycle.py @@ -4,6 +4,7 @@ import os from pathlib import Path import signal +import subprocess import sys import tempfile import time @@ -47,6 +48,17 @@ def test_version_mismatch_prevents_execution(self): response=client.call('cursor.acp.prompt' if VENDOR=='cursor' else 'claude.run',{'prompt':'hello'}) self.assertEqual(Client.value(response)['error']['code'],'incompatible_vendor') finally:client.close() + def test_acp_initialization_probe_uses_native_assertion(self): + with tempfile.TemporaryDirectory() as temp: + report=Path(temp)/'probe.json' + result=subprocess.run([sys.executable,str(ROOT/'Scripts/probe_acp.py'), + '--executable',str(ROOT/'Tests/Fixtures/vendor.py'), + '--output',str(report)],capture_output=True,text=True,timeout=10) + self.assertEqual(result.returncode,0,result.stderr) + evidence=json.loads(report.read_text()) + self.assertEqual(evidence['status'],'passed') + self.assertEqual(evidence['response']['result']['protocolVersion'],1) + self.assertEqual(evidence['cleanup'],'confirmed') def test_event_buffer_reports_exact_retention_and_small_pages(self): events=EventBuffer(max_events=3,max_bytes=1024) for i in range(3):events.append({'index':i}) diff --git a/Tests/test_package.py b/Tests/test_package.py index d78cffb..d81a868 100644 --- a/Tests/test_package.py +++ b/Tests/test_package.py @@ -7,6 +7,7 @@ import subprocess import sys import tempfile +import tomllib import unittest import zipfile @@ -18,9 +19,12 @@ class PackageTests(unittest.TestCase): def test_reproducible_archive_and_executable_mode(self): first,manifest=builder.package_bytes(ROOT) self.assertEqual(first,builder.package_bytes(ROOT)[0]) + self.assertIs(type(manifest['plugin_api']),int) + self.assertEqual(manifest['plugin_api'],1) with zipfile.ZipFile(io.BytesIO(first)) as archive: names=archive.namelist() self.assertIn('computer-mcp-plugin.toml',names) + self.assertEqual(tomllib.loads(archive.read('computer-mcp-plugin.toml').decode()),manifest) self.assertIn('cli-tree.json',names) self.assertIn('bin/plugin_runtime.py',names) for entry in archive.infolist(): diff --git a/Tests/test_review_regressions.py b/Tests/test_review_regressions.py index 57016e2..d2f88e7 100644 --- a/Tests/test_review_regressions.py +++ b/Tests/test_review_regressions.py @@ -60,6 +60,18 @@ def verify(*_):session.close() self.assertEqual(error.exception.code,'cancelled') process.assert_not_called() + def test_missing_version_assertion_prevents_native_launch(self): + for tree in ({}, {'executable_checks': []}, + {'executable_checks': [{'args': ['--help'], 'stdout': 'usage\n'}]}, + {'executable_checks': [{'args': ['--version']}]}): + with self.subTest(tree=tree): + session=adapter.Session('handle','/unused','manual') + with patch.object(adapter,'TREE',tree),patch('plugin_runtime.Process') as probe,patch.object(adapter,'Process') as native: + with self.assertRaises(Failure) as error:session.open({},Job()) + self.assertEqual(error.exception.code,'invalid_configuration') + probe.assert_not_called() + native.assert_not_called() + def test_duplicate_and_invalid_native_request_ids_fail_closed(self): for identifier in (900,True,None,[],{}): with self.subTest(identifier=identifier): diff --git a/bin/cursor-mcp-adapter b/bin/cursor-mcp-adapter index 493b5c2..898a6c5 100755 --- a/bin/cursor-mcp-adapter +++ b/bin/cursor-mcp-adapter @@ -11,7 +11,7 @@ import time import uuid from plugin_runtime import (EventBuffer, Failure, Job, MCPServer, Process, Tool, - decoded, encoded, integer_schema, load_package, resolve_executable, schema, + decoded, encoded, expected_version, integer_schema, load_package, resolve_executable, schema, text_schema, validate, verify_version) MANIFEST, TREE = load_package(__file__) @@ -135,7 +135,7 @@ class Session: self.startup_done.clear() try: deadline = time.monotonic()+args.get('timeout_seconds',45) - verify_version(self.executable,TREE,job,os.getcwd(),self.retain_version_process) + verify_version(self.executable,expected_version(TREE),job,os.getcwd(),self.retain_version_process) with self.lock: self.version_process = None job.check() with self.lock: diff --git a/bin/plugin_runtime.py b/bin/plugin_runtime.py index 33e056e..65975a8 100644 --- a/bin/plugin_runtime.py +++ b/bin/plugin_runtime.py @@ -414,10 +414,14 @@ def load_package(entry): return manifest, tree -def verify_version(executable, tree, job, cwd, retain_process=None): +def expected_version(tree): expected = next((x.get('stdout') for x in tree.get('executable_checks', []) if x.get('args') == ['--version']), None) if expected is None: raise Failure('invalid_configuration', 'Package lacks its native version assertion') + return expected + + +def verify_version(executable, expected, job, cwd, retain_process=None): process = Process([executable, '--version'], cwd) deadline = time.monotonic()+5 output = bytearray() diff --git a/computer-mcp-plugin.toml b/computer-mcp-plugin.toml index e718e8c..badb804 100644 --- a/computer-mcp-plugin.toml +++ b/computer-mcp-plugin.toml @@ -1,6 +1,7 @@ id = "cursor" name = "Cursor" version = "0.1.0" +plugin_api = 1 repository = "https://github.com/computer-mcp/plugin-cursor" description = "Cursor Agent CLI automation plus an ACP-to-MCP adapter."