From f7d0044b5292f74fd63a185ffd8a9f7cb0e24f8e Mon Sep 17 00:00:00 2001 From: showxu <10173746+showxu@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:09:38 +0800 Subject: [PATCH 01/11] feat: share plugin runtime and release automation --- .github/workflows/plugin-release.yml | 170 ++++ .github/workflows/plugin-runtime.yml | 26 + Documentation/Reference/PluginRelease.md | 167 +++ PluginRuntime/README.md | 73 ++ PluginRuntime/plugin_runtime.py | 722 +++++++++++++ actions/notify-catalog/action.yml | 33 + actions/notify-catalog/notify.py | 108 ++ scripts/plugin-runtime.py | 80 ++ scripts/plugin_release.py | 583 +++++++++++ scripts/release-step.py | 46 + tests/test_catalog_notification.py | 234 +++++ tests/test_plugin_release.py | 1180 ++++++++++++++++++++++ tests/test_plugin_runtime.py | 111 ++ tests/test_runtime_delivery.py | 191 ++++ 14 files changed, 3724 insertions(+) create mode 100644 .github/workflows/plugin-release.yml create mode 100644 .github/workflows/plugin-runtime.yml create mode 100644 Documentation/Reference/PluginRelease.md create mode 100644 PluginRuntime/README.md create mode 100644 PluginRuntime/plugin_runtime.py create mode 100644 actions/notify-catalog/action.yml create mode 100644 actions/notify-catalog/notify.py create mode 100644 scripts/plugin-runtime.py create mode 100644 scripts/plugin_release.py create mode 100644 scripts/release-step.py create mode 100644 tests/test_catalog_notification.py create mode 100644 tests/test_plugin_release.py create mode 100644 tests/test_plugin_runtime.py create mode 100644 tests/test_runtime_delivery.py diff --git a/.github/workflows/plugin-release.yml b/.github/workflows/plugin-release.yml new file mode 100644 index 0000000..70fb6d6 --- /dev/null +++ b/.github/workflows/plugin-release.yml @@ -0,0 +1,170 @@ +name: Plugin release +on: + workflow_call: + inputs: + ref: + description: Signed annotated version tag, or a commit for a dry run + type: string + required: true + publish: + description: Publish the verified release (false assembles a dry-run bundle) + type: boolean + default: false + build: + description: Build command; the workflow appends the output directory as its final argument + type: string + required: true + ci_artifact: + description: Exact ci.yml artifact name; ${commit} expands to the resolved commit + type: string + required: true + runner: + description: Runner for the isolated caller build + type: string + default: ubuntu-24.04 + outputs: + published: + description: True only when a complete immutable release is public + value: ${{ jobs.publish.outputs.published || 'false' }} + release_id: + description: Published GitHub Release ID (empty for a dry run) + value: ${{ jobs.publish.outputs.release_id }} + tag: + description: Verified version tag (empty for an untagged dry run) + value: ${{ jobs.verify.outputs.tag }} +permissions: {} +env: + PLUGIN_RELEASE_SHA256: 'a7622635843b200caf28dc61edc905eb1ec53c696b54794dbadf786063d130d9' +jobs: + source: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + actions: read + outputs: + commit: ${{ steps.source.outputs.commit }} + workflow_ref: ${{ steps.source.outputs.workflow_ref }} + workflow_sha: ${{ steps.source.outputs.workflow_sha }} + steps: + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: '3.13' + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .shared + persist-credentials: false + - name: Resolve and verify the source ref + id: source + env: + GH_TOKEN: ${{ github.token }} + GH_REPOSITORY: ${{ github.repository }} + RELEASE_REF: ${{ inputs.ref }} + RELEASE_PUBLISH: ${{ inputs.publish }} + RELEASE_WORKFLOW_REF: ${{ job.workflow_ref }} + RELEASE_WORKFLOW_SHA: ${{ job.workflow_sha }} + shell: bash + run: python3 -I .shared/scripts/release-step.py source + build: + needs: source + runs-on: ${{ inputs.runner }} + timeout-minutes: 30 + permissions: {} + outputs: + artifact_id: ${{ steps.upload.outputs.artifact-id }} + steps: + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: '3.13' + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.source.outputs.commit }} + persist-credentials: false + - name: Build the caller's archives + env: + RELEASE_BUILD: ${{ inputs.build }} + shell: bash + run: | + bash -euo pipefail -c "$RELEASE_BUILD \"\$1\"" plugin-build "$RUNNER_TEMP/plugin-release-build" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + id: upload + with: + name: plugin-build-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/plugin-release-build/ + if-no-files-found: error + retention-days: 14 + verify: + needs: [source, build] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + actions: read + outputs: + tag: ${{ steps.bundle.outputs.tag }} + asset_digests: ${{ steps.bundle.outputs.asset_digests }} + artifact_id: ${{ steps.upload.outputs.artifact-id }} + steps: + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: '3.13' + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .shared + persist-credentials: false + - name: Verify the helper, compare archive bytes and assemble the bundle + id: bundle + env: + GH_TOKEN: ${{ github.token }} + GH_REPOSITORY: ${{ github.repository }} + RELEASE_REF: ${{ inputs.ref }} + RELEASE_PUBLISH: ${{ inputs.publish }} + RELEASE_BUILD_ARTIFACT_ID: ${{ needs.build.outputs.artifact_id }} + RELEASE_COMMIT: ${{ needs.source.outputs.commit }} + GH_RUN_ID: ${{ github.run_id }} + RELEASE_CI_ARTIFACT: ${{ inputs.ci_artifact }} + RELEASE_WORKFLOW_REF: ${{ needs.source.outputs.workflow_ref }} + RELEASE_WORKFLOW_SHA: ${{ needs.source.outputs.workflow_sha }} + shell: bash + run: python3 -I .shared/scripts/release-step.py verify + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + id: upload + with: + name: plugin-release-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/plugin-release/ + if-no-files-found: error + retention-days: 14 + publish: + if: inputs.publish + needs: verify + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + outputs: + published: ${{ steps.release.outputs.published }} + release_id: ${{ steps.release.outputs.release_id }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .shared + persist-credentials: false + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + artifact-ids: ${{ needs.verify.outputs.artifact_id }} + path: ${{ runner.temp }}/plugin-release + merge-multiple: true + - name: Verify the helper and publish only the verified bundle + id: release + env: + GH_TOKEN: ${{ github.token }} + GH_REPOSITORY: ${{ github.repository }} + RELEASE_ASSET_DIGESTS: ${{ needs.verify.outputs.asset_digests }} + shell: bash + run: python3 -I .shared/scripts/release-step.py publish diff --git a/.github/workflows/plugin-runtime.yml b/.github/workflows/plugin-runtime.yml new file mode 100644 index 0000000..be8396f --- /dev/null +++ b/.github/workflows/plugin-runtime.yml @@ -0,0 +1,26 @@ +name: Plugin runtime +on: + workflow_call: +permissions: + contents: read +jobs: + runtime: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .shared + persist-credentials: false + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: member + persist-credentials: false + - name: Verify shared Python runtime + env: + PLUGIN_RUNTIME_SHA256: '5a9f797bfca4b6ab31a9a648017fb1d09400593a9aab0d2250d426efa51f97cf' + run: | + python3 -I .shared/scripts/plugin-runtime.py check-workflow + python3 -I .shared/scripts/plugin-runtime.py check member diff --git a/Documentation/Reference/PluginRelease.md b/Documentation/Reference/PluginRelease.md new file mode 100644 index 0000000..9e1cb9c --- /dev/null +++ b/Documentation/Reference/PluginRelease.md @@ -0,0 +1,167 @@ +# Plugin release workflow + +The reusable [workflow](../../.github/workflows/plugin-release.yml) builds and +publishes reproducible plugin packages. [Shared VERSIONING](https://github.com/computer-mcp/.github/blob/master/VERSIONING.md#plugin-release-contents) +owns the contents, receipt schema, provenance and recovery rules. Codex uses its +documented native artifact promotion exception. + +## Caller + +Add `.github/workflows/release.yml` in the plugin repository, substituting the +full shared-workflows commit SHA and the exact artifact name from its `ci.yml`: + +```yaml +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-workflows/.github/workflows/plugin-release.yml@ + with: + ref: ${{ inputs.ref }} + publish: ${{ inputs.publish }} + build: python3 scripts/build_package.py + ci_artifact: + notify-catalog: + needs: release + if: needs.release.outputs.published == 'true' + permissions: {} + uses: ./.github/workflows/notify-catalog.yml + secrets: + CATALOG_APP_PRIVATE_KEY: ${{ secrets.CATALOG_APP_PRIVATE_KEY }} +``` + +`notify-catalog.yml` is the caller repository's existing reusable notification +workflow. It receives the catalog App private key; the release workflow has no +secret inputs. The caller grants the permission ceiling needed by these jobs: + +| Job | Permissions | Data flow | +| --- | --- | --- | +| `source` | `contents: read`, `actions: read` | Check out the pinned helper and resolve the source commit | +| `build` | `{}` | Check out that commit, execute the caller's build and upload its archives | +| `verify` | `contents: read`, `actions: read` | On a fresh runner, check the helper pin, read the source manifest and both artifacts as data, compare archive bytes and assemble the bundle | +| `publish` | `contents: write` | Download the verified bundle by ID, recompute every digest and publish | + +The build job receives no secrets, does not persist checkout credentials and +has no access to the verification runner's files or job outputs. Only its +archive artifact ID crosses into verification. Verification and publication execute only the helper from their own workflow +revision. Their checkouts contain shared automation; caller source is read as data. + +Plugin repositories must enable GitHub release immutability in their repository +settings before publishing. The helper checks `/repos/{owner}/{repo}/immutable-releases` +through the API before any Release write and fails clearly unless it reports +`enabled: true`. Server enforcement protects immutable release assets even if +another publisher makes the draft public after a draft precheck. Enabling the +setting does not lock previously published releases. The helper also requires the +Release's own `immutable: true` when it finds an existing public release and in +the response after publishing a draft. A public but mutable release fails with +an error requiring the repository owner to handle it; the workflow does not +change it or report it as published. + +| Input | Contract | +| --- | --- | +| `ref` | Required. `vX.Y.Z` (including `-alpha.N`, `-beta.N`, `-rc.N`) for publication; a version tag or commit for a dry run | +| `publish` | Boolean, default `false` | +| `build` | Required shell command run at the source checkout; a quoted absolute output-directory argument is appended. It must create that directory with only the plugin ZIP archives | +| `ci_artifact` | Required exact artifact name from `ci.yml`; `${commit}` expands to the resolved full commit SHA. For matrix jobs, choose one exact matrix member | +| `runner` | Build runner, default `ubuntu-24.04`; source resolution, verification and publication use fresh `ubuntu-24.04` runners. Python 3.13 is supplied for source resolution, building and verification | + +| Output | Contract | +| --- | --- | +| `published` | String `true` after publishing or verifying a complete public release with `immutable: true`; `false` for a dry run | +| `release_id` | Published GitHub Release ID, empty for a dry run | +| `tag` | Verified version tag, empty for an untagged commit dry run | + +The release workflow must be pinned by its full commit SHA. Each trusted job checks out `job.workflow_repository` at `job.workflow_sha`. +The source job checks `job.workflow_ref` is a full SHA pin and records it in +the receipt provenance. An embedded SHA-256 checks the helper both +before verification and before publication; caller code cannot supply an +executable publisher. Verification passes the uploaded bundle's exact artifact +ID and a SHA-256 map for all release files through job outputs. Publication +recomputes and compares every file digest, validates the receipt and checks +`SHA256SUMS` before any Release write. + +## Run and recover + +1. Obtain successful default-branch push CI for the candidate commit. Set + `ci_artifact` to its exact artifact name, including any matrix suffix. +2. Dispatch with that commit and `publish: false`. Inspect the + `plugin-release--` Actions artifact: `assets/` contains the + verified ZIPs, deterministic `release-receipt.json` and `SHA256SUMS`. + An untagged dry run reports the signed annotated tag still required. +3. Perform the plugin repository's manual archive acceptance. Create the signed + annotated version tag on that same commit. Record the manual results and + limits in a draft Release's notes, targeting the full commit SHA. +4. Dispatch with the version tag and `publish: true`. The workflow verifies the + tag signature through GitHub, the manifest version and `plugin_api`, the CI + source and archive set, then rebuilds and assembles the same release files. + It creates a draft if none exists, uploads the receipt first, completes and + verifies all assets, and publishes. The dependent job requests catalog + reconciliation only after `published` is `true`. + +A failed publication can be retried with **Re-run failed jobs**. If verification +already succeeded, the publish job downloads the same bundle artifact ID and +uses the saved digest outputs; it does not derive a new artifact name from the +retry's run attempt. + +When verification runs again, a complete receipt in a draft freezes its +provenance. Verification validates the recorded historical CI run attempt +through the API: `.github/workflows/ci.yml`, event `push`, the repository's +default branch, status `completed`, conclusion `success` and the exact source +commit. It downloads the recorded artifact by ID, checks its digest and creation +within that attempt, compares it against the isolated build and reproduces the +same receipt and checksums. It refuses an invalid or unavailable recorded +attempt. A new CI attempt or a new release workflow pin does not change frozen +provenance. If receipt upload itself was incomplete, no source has been frozen +yet. + +Missing assets are uploaded; matching completed assets are retained. Only +expected `starter` assets in a draft are removed and uploaded again. A completed +asset conflict requires inspecting the unpublished draft and its intended +source; the workflow does not replace it. If the recorded CI artifact expired +or was removed, delete the unpublished draft, obtain fresh successful CI +artifacts and dispatch again. This deliberately starts a new assembly with new +provenance. Published tags and assets remain immutable. + +An already public release must report `immutable: true`. It is checked against +its frozen receipt and exact asset set and reported as published without writes. +Verification still rebuilds the archives; it can compare against the public +archive bytes after CI retention has expired. Notification failure needs only a +rerun of the notification job or a manual dispatch of `notify-catalog.yml`; +publication need not be repeated. + +## Maintaining the shared workflow + +Release logic lives in `scripts/plugin_release.py`, behind a four-operation +GitHub API interface. The offline tests use an in-memory API and inject failures +before and after draft creation, every asset upload, starter deletion and +publication. They also exercise provenance recovery, immutable public releases, +source checks, archive comparison, malicious build isolation, publish-only +retries and the workflow's helper boundary. The fake server fixes each release's +immutability when it is published: older mutable releases stay mutable when the +repository setting is enabled. It rejects mutations of immutable releases, +including visibility changes between draft prechecks and mutation requests. + +When changing the helper, update `PLUGIN_RELEASE_SHA256` in the workflow to its +SHA-256. The repository CI runs the offline suite and verifies that digest: + +```sh +python3 -m unittest discover -s tests +actionlint .github/workflows/*.yml +``` + +Release jobs have no shared concurrency group or caches. Callers must preserve +running and queued publications; coordinate simultaneous dispatches for a tag. diff --git a/PluginRuntime/README.md b/PluginRuntime/README.md new file mode 100644 index 0000000..cb734d8 --- /dev/null +++ b/PluginRuntime/README.md @@ -0,0 +1,73 @@ +# Shared Python plugin runtime + +[plugin_runtime.py](plugin_runtime.py) is the canonical stdlib runtime shared by +the Claude, Cursor and TRAE plugins. It provides stdio MCP, bounded JSON and +schema validation, process ownership and cleanup, cancellation, event buffers +and native-version checks. Each plugin owns its vendor adapter, version +baseline and supported Python versions. + +## Adoption and updates + +From the organization shared-workflows checkout at the revision the plugin will pin: + +```sh +python3 scripts/plugin-runtime.py sync +python3 scripts/plugin-runtime.py check +``` + +`sync` copies the canonical file to `bin/plugin_runtime.py` and checks it; +`check` is read-only and fails if that file is missing or differs byte for byte. +Both commands require an existing checkout directory and keep delivery paths +inside it. + +Adapters whose native assertion lives in `cli-tree.json` import +`expected_version` and pass its result to `verify_version`: + +```python +from plugin_runtime import expected_version, verify_version + +verify_version(executable, expected_version(TREE), job, cwd, retain_process) +``` + +`expected_version(tree)` returns the `stdout` of its `--version` executable +check. A missing assertion raises `Failure('invalid_configuration', 'Package +lacks its native version assertion')` before any process starts. Claude and +Cursor use this form. TRAE keeps its direct version-string call: + +```python +verify_version(executable, NATIVE_VERSION, job, cwd, retain_process) +``` + +`verify_version(executable, expected, job, cwd, retain_process=None)` runs the +owned native `--version` process, compares its exact output with `expected`, +and preserves the runtime's deadlines, output bound, cancellation and cleanup. +The optional callback retains the version process for adapter shutdown. + +Add a job to the plugin's CI, pinning the reusable workflow by the full +40-character commit SHA of this repository revision that supplied the file: + +```yaml +jobs: + runtime: + uses: computer-mcp/github-workflows/.github/workflows/plugin-runtime.yml@ +``` + +The workflow has no inputs or secrets. With read-only `contents` permission, +it checks out the caller without persisting credentials and compares +`bin/plugin_runtime.py` with the SHA-256 embedded in that pinned workflow. +The copied runtime and workflow pin are updated together in the consumer's +pull request, with its own adapter tests passing. + +## Maintaining the canonical runtime + +After changing the canonical file, update `PLUGIN_RUNTIME_SHA256` in +`.github/workflows/plugin-runtime.yml` to its SHA-256. Check the tools and +embedded digest: + +```sh +python3 -m unittest discover -s tests +python3 scripts/plugin-runtime.py check-workflow +``` + +The organization CI runs both commands. Consumer behavior is verified with the +plugins' own suites; those repositories own their test entry points. diff --git a/PluginRuntime/plugin_runtime.py b/PluginRuntime/plugin_runtime.py new file mode 100644 index 0000000..65975a8 --- /dev/null +++ b/PluginRuntime/plugin_runtime.py @@ -0,0 +1,722 @@ +"""Private stdio, process ownership and validation primitives for this plugin.""" +from collections import deque +from dataclasses import dataclass +import hashlib +import json +import math +import os +from pathlib import Path +import queue +import select +import selectors +import shutil +import signal +import subprocess +import sys +import threading +import time +import tomllib +import uuid + +MAX_FRAME = 1_048_576 +MAX_RESULT = 196_608 +SUPPORTED_MCP = ('2024-11-05', '2025-03-26', '2025-06-18') +WORK_URI = 'computer-mcp://runtime/work/v1' +WORK_METADATA = 'io.github.computer-mcp/work' +CONTINUATION_METADATA = 'io.github.computer-mcp/continuation' +WORK_INVOCATION = 'io.github.computer-mcp/work-invocation' + + +class Failure(Exception): + def __init__(self, code, message): + super().__init__(message) + self.code = code + + +def encoded(value): + return json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(',', ':')).encode('utf-8') + + +def decoded(raw): + def unique(pairs): + value = {} + for k, v in pairs: + if k in value: + raise ValueError('Duplicate JSON object key') + value[k] = v + return value + def invalid_number(_): + raise ValueError('Non-finite JSON number') + def finite_float(text): + value = float(text) + if not math.isfinite(value): + raise ValueError('JSON floating-point number is not finite') + return value + value = json.loads(raw.decode('utf-8'), object_pairs_hook=unique, parse_constant=invalid_number, parse_float=finite_float) + pending = [value] + while pending: + item = pending.pop() + if isinstance(item,str): item.encode('utf-8') + elif isinstance(item,dict): + pending.extend(item.keys()) + pending.extend(item.values()) + elif isinstance(item,list): pending.extend(item) + return value + + +def schema(properties, required=()): + return {'type':'object', 'properties':properties, 'required':list(required), 'additionalProperties':False} + + +def text_schema(limit=131072, minimum=1): + return {'type':'string', 'minLength':minimum, 'maxLength':limit} + + +def integer_schema(minimum, maximum, default=None): + result = {'type':'integer', 'minimum':minimum, 'maximum':maximum} + if default is not None: + result['default'] = default + return result + + +def validate(value, spec, location='arguments', depth=0): + if depth > 20: + raise Failure('invalid_arguments', 'Input exceeds the nesting limit') + kind = spec.get('type') + valid = { + 'object':lambda: isinstance(value, dict), + 'array':lambda: isinstance(value, list), + 'string':lambda: isinstance(value, str), + 'integer':lambda: type(value) is int, + 'number':lambda: type(value) is int or (type(value) is float and math.isfinite(value)), + 'boolean':lambda: type(value) is bool, + 'null':lambda: value is None, + } + if kind in valid and not valid[kind](): + raise Failure('invalid_arguments', f'{location} must be {kind}') + if 'enum' in spec and value not in spec['enum']: + raise Failure('invalid_arguments', f'{location} is not an allowed value') + if isinstance(value, str): + if '\0' in value or len(value.encode('utf-8')) > MAX_FRAME: + raise Failure('invalid_arguments', f'{location} contains NUL or exceeds its byte bound') + if not spec.get('minLength', 0) <= len(value) <= spec.get('maxLength', MAX_FRAME): + raise Failure('invalid_arguments', f'{location} exceeds its length bounds') + if type(value) in (int, float): + if (type(value) is float and not math.isfinite(value)) or value < spec.get('minimum', -math.inf) or value > spec.get('maximum', math.inf): + raise Failure('invalid_arguments', f'{location} exceeds its numeric bounds') + if isinstance(value, dict): + props = spec.get('properties', {}) + missing = set(spec.get('required', ())) - value.keys() + if missing: + raise Failure('invalid_arguments', f'{location} is missing required fields: {", ".join(sorted(missing))}') + if spec.get('additionalProperties') is False and set(value) - props.keys(): + raise Failure('invalid_arguments', f'{location} has unknown fields') + for k, v in value.items(): + validate(v, props.get(k, {}), f'{location}.{k}', depth+1) + if isinstance(value, list): + if not spec.get('minItems', 0) <= len(value) <= spec.get('maxItems', 4096): + raise Failure('invalid_arguments', f'{location} exceeds its item bound') + for v in value: + validate(v, spec.get('items', {}), location+'[]', depth+1) + + +class Job: + def __init__(self, work_invocation=None): + self.cancelled = threading.Event() + self.work_invocation = work_invocation + def cancel(self): + self.cancelled.set() + def check(self): + if self.cancelled.is_set(): + raise Failure('cancelled', 'Operation cancelled') + + +class LineReader: + """Bound bytes before parsing, and bound queued frames before admitting more.""" + def __init__(self, stream, limit=MAX_FRAME): + self.stream, self.limit = stream, limit + self.queue = queue.Queue(maxsize=8) + self.stopped = threading.Event() + self.thread = threading.Thread(target=self._read, daemon=True) + self.thread.start() + def _put(self, item): + while not self.stopped.is_set(): + try: + self.queue.put(item, timeout=.05) + return + except queue.Full: + pass + def _read(self): + buf = bytearray() + try: + with selectors.DefaultSelector() as selector: + selector.register(self.stream, selectors.EVENT_READ) + while not self.stopped.is_set(): + if not selector.select(.05): + continue + chunk = os.read(self.stream.fileno(), 16384) + if not chunk: + if buf: + self._put(bytes(buf)) + break + buf.extend(chunk) + while True: + index = buf.find(b'\n') + if index < 0: + if len(buf) > self.limit: + raise Failure('frame_too_large', 'A protocol line exceeds its byte bound') + break + if index+1 > self.limit: + raise Failure('frame_too_large', 'A protocol line exceeds its byte bound') + self._put(bytes(buf[:index+1])) + del buf[:index+1] + except Exception as error: + self._put(error if isinstance(error, Failure) else Failure('stream_failed', 'Protocol stream capture failed')) + finally: + self._put(None) + def next(self, deadline, job=None): + while True: + if job: + job.check() + remaining = deadline-time.monotonic() + if remaining <= 0: + raise Failure('timeout', 'Operation exceeded its deadline') + try: + item = self.queue.get(timeout=min(.05, remaining)) + except queue.Empty: + continue + if isinstance(item, Exception): + raise item + return item + def close(self): + self.stopped.set() + self.thread.join(timeout=.5) + + +def write_bytes(fd, data, deadline, cancelled=None): + view = memoryview(data) + with selectors.DefaultSelector() as selector: + selector.register(fd, selectors.EVENT_WRITE) + while view: + if cancelled is not None and cancelled.is_set(): + raise Failure('cancelled', 'Operation cancelled') + remaining = deadline-time.monotonic() + if remaining <= 0: + raise Failure('timeout', 'Protocol writer exceeded its deadline') + if not selector.select(min(.05, remaining)): + continue + try: + count = os.write(fd, view) + except BlockingIOError: + continue + except (BrokenPipeError, OSError) as error: + raise Failure('connection_closed', 'Protocol peer closed its input') from error + view = view[count:] + + +def vendor_environment(): + # Private host provenance and inherited callback authority do not belong to a vendor. + return {k:v for k,v in os.environ.items() if not k.startswith('COMPUTER_MCP_')} + + +def supervise(read_fd, receipt_fd, command): + """Pin the vendor group until retirement and report cleanup separately from exit.""" + stopped = threading.Event() + for signum in (signal.SIGTERM, signal.SIGINT): + signal.signal(signum, lambda *_: stopped.set()) + child = None + exit_code = 70 + confirmed = False + try: + child = subprocess.Popen(command, close_fds=True, start_new_session=True) + while not stopped.is_set(): + status = os.waitid(os.P_PID, child.pid, os.WEXITED | os.WNOHANG | os.WNOWAIT) + if status is not None: + break + ready, _, _ = select.select([read_fd], [], [], .05) + if ready and not os.read(read_fd, 1): + break + except Exception: + os.write(2, b'Owned vendor process could not start or be supervised.\n') + finally: + try: + if child is not None: + # Do not reap the leader before signalling its group: its PID pins ownership. + for sig, delay in ((signal.SIGTERM, .15), (signal.SIGKILL, 0)): + try: + os.killpg(child.pid, sig) + except ProcessLookupError: + pass + except PermissionError: + report = subprocess.run( + ['/bin/ps', '-g', str(child.pid), '-o', 'pid=,stat='], + capture_output=True, text=True, timeout=1) + rows = [line.split() for line in report.stdout.splitlines() if line.strip()] + if report.stderr.strip() or report.returncode not in (0, 1) or any( + len(row) != 2 or not row[1].startswith('Z') for row in rows + ): + raise Failure('cleanup_unconfirmed', 'Owned group could not be retired') + if delay: + time.sleep(delay) + child.wait(timeout=2) + exit_code = child.returncode if child.returncode >= 0 else 128-child.returncode + confirmed = True + except Exception: + exit_code = 70 + os.write(2, b'Owned vendor process cleanup is unconfirmed.\n') + finally: + try: + os.write(receipt_fd, encoded({'cleanup_confirmed': confirmed})+b'\n') + except OSError: + pass + os.close(receipt_fd) + os.close(read_fd) + return exit_code + + +class Process: + """A supervisor owns vendor descendants and watches an adapter-only lifeline.""" + def __init__(self, command, cwd): + read_fd, self.life = os.pipe() + self.receipt, receipt_write = os.pipe() + try: + self.child = subprocess.Popen( + [sys.executable, str(Path(__file__).resolve()), '--supervise', str(read_fd), str(receipt_write), '--', *command], + cwd=cwd, env=vendor_environment(), close_fds=True, pass_fds=(read_fd, receipt_write), + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + start_new_session=True, + ) + except BaseException: + os.close(self.life) + os.close(self.receipt) + raise + finally: + os.close(read_fd) + os.close(receipt_write) + self.writer_lock = threading.Lock() + self.close_lock = threading.Lock() + self.closed = False + self.cleanup_failure = None + self.stderr = bytearray() + self.stderr_truncated = False + self.stop_stderr = threading.Event() + self.output, self.stderr_thread = None, None + try: + os.set_blocking(self.child.stdin.fileno(), False) + self.output = LineReader(self.child.stdout) + self.stderr_thread = threading.Thread(target=self._stderr, daemon=True) + self.stderr_thread.start() + except BaseException: + self.close() + raise + def _stderr(self): + try: + with selectors.DefaultSelector() as selector: + selector.register(self.child.stderr, selectors.EVENT_READ) + while not self.stop_stderr.is_set(): + if not selector.select(.05): + continue + raw = os.read(self.child.stderr.fileno(), 16384) + if not raw: + return + capacity = 65536-len(self.stderr) + self.stderr.extend(raw[:capacity]) + self.stderr_truncated |= len(raw) > capacity + except (ValueError, OSError): + pass + def send(self, message, deadline, job=None): + data = encoded(message)+b'\n' + if len(data) > MAX_FRAME: + raise Failure('frame_too_large', 'Outbound protocol frame exceeds its byte bound') + with self.writer_lock: + if self.closed: + raise Failure('connection_closed', 'Vendor process is closed') + write_bytes(self.child.stdin.fileno(), data, deadline, job.cancelled if job else None) + def end_input(self): + with self.writer_lock: + if not self.child.stdin.closed: + self.child.stdin.close() + def read(self, deadline, job=None): + return self.output.next(deadline, job) + def finish(self, deadline, job=None): + while True: + if job: + job.check() + remaining = deadline-time.monotonic() + if remaining <= 0: + raise Failure('timeout', 'Vendor did not exit before its deadline') + try: + return self.child.wait(timeout=min(.05,remaining)) + except subprocess.TimeoutExpired: + pass + @staticmethod + def confirm_cleanup(descriptor): + try: + raw = os.read(descriptor, 4096) + value = decoded(raw) + if not isinstance(value, dict) or value.get('cleanup_confirmed') is not True: + raise ValueError('Cleanup not confirmed') + except (ValueError, UnicodeError, OSError, RecursionError) as error: + raise Failure('cleanup_unconfirmed', 'Supervisor did not confirm owned process cleanup') from error + + def close(self): + with self.close_lock: + if self.closed: + if self.cleanup_failure: + raise self.cleanup_failure + return + self.closed = True + try: + try: + os.close(self.life) + try: + self.child.wait(timeout=3) + except subprocess.TimeoutExpired: + self.child.terminate() + try: + self.child.wait(timeout=2) + except subprocess.TimeoutExpired as error: + self.child.kill() + self.child.wait(timeout=2) + raise Failure('cleanup_unconfirmed', 'Vendor supervisor did not confirm shutdown') from error + self.confirm_cleanup(self.receipt) + finally: + os.close(self.receipt) + if self.output is not None: self.output.close() + self.stop_stderr.set() + if self.stderr_thread is not None and self.stderr_thread.ident is not None: + self.stderr_thread.join(timeout=.5) + with self.writer_lock: + for stream in (self.child.stdin, self.child.stdout, self.child.stderr): + stream.close() + except Exception as error: + self.cleanup_failure = error if isinstance(error,Failure) else Failure('cleanup_unconfirmed','Owned process cleanup could not be confirmed') + if self.cleanup_failure is error: raise + raise self.cleanup_failure from error + + +def resolve_executable(explicit, environment_key, default): + candidate = explicit or os.environ.get(environment_key) + if candidate: + if not os.path.isabs(candidate): + raise Failure('invalid_configuration', 'The configured vendor executable must be absolute') + else: + candidate = shutil.which(default) + if not candidate or not os.path.isfile(candidate) or not os.access(candidate, os.X_OK): + raise Failure('dependency_unavailable', f'Install {default} separately or configure --executable') + return candidate + + +def load_package(entry): + root = Path(entry).resolve().parent.parent + manifest = tomllib.loads((root/'computer-mcp-plugin.toml').read_text()) + tree = json.loads((root/'cli-tree.json').read_text()) + return manifest, tree + + +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() + try: + if retain_process is not None: retain_process(process) + process.end_input() + while True: + raw = process.read(deadline, job) + if raw is None: + break + output.extend(raw) + if len(output) > 65536: + raise Failure('incompatible_vendor', 'Native version output exceeded its bound') + if process.finish(deadline, job) != 0 or bytes(output) != expected.encode(): + raise Failure('incompatible_vendor', 'Vendor version differs from the verified plugin baseline') + finally: + process.close() + + +class EventBuffer: + def __init__(self, max_events=256, max_bytes=262144): + self.lock = threading.Lock() + self.items = deque() + self.max_events, self.max_bytes = max_events, max_bytes + self.bytes, self.cursor, self.omitted = 0, 0, 0 + def append(self, value): + data = encoded(value) + with self.lock: + self.cursor += 1 + if len(data) > self.max_bytes//2: + value = {'omitted':True, 'reason':'event_byte_bound', 'bytes':len(data), 'sha256':hashlib.sha256(data).hexdigest()} + data = encoded(value) + self.omitted += 1 + self.items.append((self.cursor, value, len(data))) + self.bytes += len(data) + while len(self.items) > self.max_events or self.bytes > self.max_bytes: + self.bytes -= self.items.popleft()[2] + def page(self, after=0, limit=128, max_bytes=196608): + with self.lock: + if after > self.cursor: + raise Failure('invalid_cursor', 'Cursor is newer than this event stream') + oldest = self.items[0][0] if self.items else self.cursor+1 + rows, total, next_cursor = [], 0, after + for cursor, value, size in self.items: + if cursor <= after: + continue + row = {'cursor':cursor,'event':value} + row_size = len(encoded(row)) + if not rows and row_size > max_bytes: + row = {'cursor':cursor,'event':{'omitted':True,'reason':'page_byte_bound','bytes':size}} + row_size = len(encoded(row)) + if len(rows) >= limit or total+row_size > max_bytes: + break + rows.append(row) + total += row_size + next_cursor = cursor + return {'events':rows,'next_cursor':next_cursor,'oldest_cursor':oldest,'has_more':next_cursor1024 + or any(ord(c)<32 or 127<=ord(c)<=159 for c in kind) + or not isinstance(argument,str) or not argument + or len(('/'+argument.replace('~','~0').replace('/','~1')).encode('utf-8'))>1024 + or any(ord(c)<32 or 127<=ord(c)<=159 for c in argument) + or argument not in self.input_schema.get('required',[]) + or self.input_schema.get('properties',{}).get(argument,{}).get('type') not in {'string','integer'}): + raise ValueError('Continuation requires a resource kind and required scalar handle') + def definition(self): + read_only = self.risk == 'read-only' + metadata = {'io.github.computer-mcp/risk':self.risk} + if self.continuation is not None: + kind, argument = self.continuation + pointer = '/' + argument.replace('~','~0').replace('/','~1') + metadata[CONTINUATION_METADATA] = {'format_version':1,'selectors':[{'kind':kind,'handles':{'id':pointer}}]} + return {'name':self.name,'description':self.description,'inputSchema':self.input_schema, + '_meta':metadata, + 'annotations':{'readOnlyHint':read_only,'destructiveHint':self.risk in {'destructive','full-shell'}, + 'idempotentHint':read_only,'openWorldHint':not read_only}} + + +class MCPServer: + def __init__(self, name, version, tools, shutdown, work=None): + self.name, self.version = name, version + self.tools = {t.name:t for t in tools} + self.shutdown = shutdown + self.stop = threading.Event() + self.jobs, self.threads = {}, set() + self.lock, self.write_lock = threading.Lock(), threading.Lock() + self.initialized = False + self.work = work + self.work_instance = str(uuid.uuid4()) + self.work_revision = 0 + self.work_last = None + def work_resource(self): + resources = self.work() + if not isinstance(resources,list) or len(resources)>1024: + raise Failure('work_unavailable','Complete work observation is unavailable') + keys = set() + rows = [] + def identifier(value): + return isinstance(value,str) and 0=2**63: + raise Failure('work_unavailable','Work observation revision is exhausted') + snapshot = {'format_version':1,'instance_id':self.work_instance,'revision':revision,'resources':rows} + body = encoded(snapshot) + if len(body)>524288: + raise Failure('work_unavailable','Complete work observation exceeds its byte bound') + self.work_revision, self.work_last = revision, rows + return {'contents':[{'uri':WORK_URI,'mimeType':'application/json','text':body.decode('utf-8')}]} + def emit(self, message): + try: + raw = encoded(message)+b'\n' + except (ValueError,UnicodeError,RecursionError): + try: + self.id_key(message.get('id')) + identifier = message['id'] + except ValueError: + identifier = None + raw = encoded({'jsonrpc':'2.0','id':identifier,'error':{'code':-32603,'message':'Response cannot be encoded'}})+b'\n' + if len(raw) > MAX_FRAME: + raw = encoded({'jsonrpc':'2.0','id':message.get('id'),'error':{'code':-32603,'message':'Response exceeds the protocol bound'}})+b'\n' + try: + with self.write_lock: + write_bytes(1, raw, time.monotonic()+5, self.stop) + except (Failure, OSError): + self.stop.set() + def rpc_error(self, request_id, code, message): + self.emit({'jsonrpc':'2.0','id':request_id,'error':{'code':code,'message':message}}) + def call(self, request_id, key, tool, arguments, job): + try: + validate(arguments, tool.input_schema) + job.check() + value = tool.handler(arguments, job) + body = encoded(value) + if len(body) > 524288: + raise Failure('result_too_large', 'Result exceeds its structured byte bound; use event pagination') + is_error = bool(value.get('is_error', False)) if isinstance(value, dict) else False + except Failure as error: + value, is_error = {'error':{'code':error.code,'message':str(error)[:1024]}}, True + except Exception: + value, is_error = {'error':{'code':'internal_error','message':'Adapter operation failed; no automatic replay was attempted'}}, True + summary = json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(',',':')) + if len(summary.encode()) > 8192: + summary = 'Structured result available in structuredContent.result; events are separately paginated.' + self.emit({'jsonrpc':'2.0','id':request_id,'result':{'content':[{'type':'text','text':summary}],'structuredContent':{'result':value},'isError':is_error}}) + with self.lock: + self.jobs.pop(key, None) + self.threads.discard(threading.current_thread()) + @staticmethod + def id_key(value): + if type(value) not in (int,str) or (isinstance(value,str) and (not value or len(value)>256)): + raise ValueError('Invalid request ID') + if isinstance(value,str): value.encode('utf-8') + return type(value).__name__, value + def dispatch(self, message): + if not isinstance(message, dict) or message.get('jsonrpc') != '2.0' or not isinstance(message.get('method'),str): + self.rpc_error(None,-32600,'Invalid JSON-RPC request') + return + method, params = message['method'], message.get('params', {}) + if 'id' not in message: + if method == 'notifications/cancelled' and isinstance(params,dict): + try: key = self.id_key(params.get('requestId')) + except ValueError: return + with self.lock: job = self.jobs.get(key) + if job: job.cancel() + return + request_id = message['id'] + try: key = self.id_key(request_id) + except ValueError: + self.rpc_error(None,-32600,'Invalid request ID'); return + with self.lock: active = key in self.jobs + if active: + self.rpc_error(request_id,-32600,'Request ID is already active'); return + if not isinstance(params,dict): + self.rpc_error(request_id,-32602,'Parameters must be an object'); return + if method == 'initialize': + if self.initialized: + self.rpc_error(request_id,-32600,'Connection is already initialized'); return + requested = params.get('protocolVersion') + client = params.get('clientInfo') + if not isinstance(requested,str) or not isinstance(params.get('capabilities'),dict) or not isinstance(client,dict) or not all(isinstance(client.get(k),str) and client[k] for k in ('name','version')): + self.rpc_error(request_id,-32602,'Initialize requires protocolVersion, capabilities and typed clientInfo'); return + self.initialized = True + capabilities = {'tools':{'listChanged':False}} + if self.work is not None: capabilities['resources'] = {'subscribe':False,'listChanged':False} + self.emit({'jsonrpc':'2.0','id':request_id,'result':{'protocolVersion':requested if requested in SUPPORTED_MCP else SUPPORTED_MCP[-1],'capabilities':capabilities,'serverInfo':{'name':self.name,'version':self.version}}}) + elif method == 'ping': + self.emit({'jsonrpc':'2.0','id':request_id,'result':{}}) + elif not self.initialized: + self.rpc_error(request_id,-32002,'Initialize the MCP connection first') + elif method == 'tools/list': + if params.get('cursor'): + self.rpc_error(request_id,-32602,'This finite catalog has no continuation cursor'); return + definitions = [t.definition() for t in self.tools.values()] + if self.work is not None: + for definition in definitions: + definition['_meta'][WORK_METADATA] = {'format_version':1,'uri':WORK_URI} + self.emit({'jsonrpc':'2.0','id':request_id,'result':{'tools':definitions}}) + elif method == 'resources/list' and self.work is not None: + if params.get('cursor'): + self.rpc_error(request_id,-32602,'This finite resource catalog has no continuation cursor'); return + self.emit({'jsonrpc':'2.0','id':request_id,'result':{'resources':[{'uri':WORK_URI,'name':'Runtime work','mimeType':'application/json','description':'Complete connection-owned live work and cleanup observation.'}]}}) + elif method == 'resources/read' and self.work is not None: + if params.get('uri') != WORK_URI: + self.rpc_error(request_id,-32602,'Unknown resource'); return + try: result = self.work_resource() + except Failure as error: + self.rpc_error(request_id,-32000,str(error)); return + except Exception: + self.rpc_error(request_id,-32000,'Complete work observation is unavailable'); return + self.emit({'jsonrpc':'2.0','id':request_id,'result':result}) + elif method == 'tools/call': + name = params.get('name') + if not isinstance(name,str) or name not in self.tools: + self.rpc_error(request_id,-32602,'Unknown tool'); return + args = params.get('arguments', {}) + origin = None + if self.work is not None: + meta = params.get('_meta',{}) + if not isinstance(meta,dict): + self.rpc_error(request_id,-32602,'Tool metadata must be an object'); return + if WORK_INVOCATION in meta: + try: origin = str(uuid.UUID(meta[WORK_INVOCATION])) + except (ValueError,TypeError,AttributeError): + self.rpc_error(request_id,-32602,'Work invocation reference must be a UUID'); return + with self.lock: + if key in self.jobs: + self.rpc_error(request_id,-32600,'Request ID is already active'); return + if len(self.jobs)>=16: + self.rpc_error(request_id,-32000,'Concurrent request capacity reached'); return + job = Job(work_invocation=origin) + thread = threading.Thread(target=self.call,args=(request_id,key,self.tools[name],args,job)) + self.jobs[key] = job + self.threads.add(thread) + thread.start() + else: + self.rpc_error(request_id,-32601,'Method not found') + def serve(self): + os.set_blocking(1,False) + for sig in (signal.SIGTERM,signal.SIGINT): + signal.signal(sig,lambda *_:self.stop.set()) + reader = LineReader(sys.stdin.buffer) + try: + while not self.stop.is_set(): + try: raw = reader.next(time.monotonic()+.1) + except Failure as error: + if error.code=='timeout': continue + self.rpc_error(None,-32600,str(error)); break + if raw is None: break + try: message = decoded(raw) + except (ValueError,UnicodeError,RecursionError): + self.rpc_error(None,-32700,'Invalid JSON'); continue + self.dispatch(message) + finally: + self.stop.set() + reader.close() + with self.lock: + jobs, threads = list(self.jobs.values()), list(self.threads) + for job in jobs: job.cancel() + self.shutdown() + for thread in threads: thread.join(timeout=5) + + +if __name__ == '__main__': + if len(sys.argv) < 6 or sys.argv[1] != '--supervise' or sys.argv[4] != '--': + raise SystemExit('Private process supervisor requires an inherited lifeline') + raise SystemExit(supervise(int(sys.argv[2]), int(sys.argv[3]), sys.argv[5:])) diff --git a/actions/notify-catalog/action.yml b/actions/notify-catalog/action.yml new file mode 100644 index 0000000..24272ad --- /dev/null +++ b/actions/notify-catalog/action.yml @@ -0,0 +1,33 @@ +name: Notify official plugin catalog +description: Request a complete central catalog reconciliation and return its accepted run. +inputs: + client-id: + description: Client ID of the organization's Computer MCP Release Notifier GitHub App. + required: true + private-key: + description: + Release Notifier App private key supplied from the authorized sender's Actions secret. + required: true +outputs: + run-url: + description: Accepted central workflow run; acceptance is not deployment success. + value: ${{ steps.notify.outputs.run-url }} +runs: + using: composite + steps: + - name: Create receiver-scoped installation token + id: identity + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ inputs.client-id }} + private-key: ${{ inputs.private-key }} + owner: computer-mcp + repositories: computer-mcp.github.io + permission-actions: write + - name: Request central reconciliation + id: notify + shell: bash + env: + CATALOG_DISPATCH_TOKEN: ${{ steps.identity.outputs.token }} + CATALOG_ACTION_PATH: ${{ github.action_path }} + run: python3 "$CATALOG_ACTION_PATH/notify.py" diff --git a/actions/notify-catalog/notify.py b/actions/notify-catalog/notify.py new file mode 100644 index 0000000..15cb029 --- /dev/null +++ b/actions/notify-catalog/notify.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Request the official catalog workflow without supplying catalog metadata.""" + +import json +import http.client +import os +import sys +import time +import urllib.error +import urllib.request + + +REPOSITORY = "computer-mcp/computer-mcp.github.io" +ENDPOINT = f"https://api.github.com/repos/{REPOSITORY}/actions/workflows/pages.yml/dispatches" +MAX_RESPONSE = 16384 + + +class NotificationError(Exception): + pass + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, fp, code, message, headers, new_url): + fp.close() + raise NotificationError("Catalog notification refuses HTTP redirects") + + +def accepted_run(response, deadline, clock): + if response.status != 200: + raise NotificationError("Catalog notification did not return an accepted run") + data = bytearray() + while True: + if clock() >= deadline: + raise NotificationError("Catalog notification response exceeded its time budget") + chunk = response.read1(min(4096, MAX_RESPONSE + 1 - len(data))) + if not chunk: + break + data.extend(chunk) + if len(data) > MAX_RESPONSE: + raise NotificationError("Catalog notification response exceeded its byte budget") + try: + result = json.loads(data) + except (ValueError, UnicodeError): + raise NotificationError("Catalog notification returned invalid JSON") from None + run_id = result.get("workflow_run_id") if isinstance(result, dict) else None + if (type(run_id) is not int or not 0 < run_id <= 9007199254740991 + or result.get("run_url") != f"https://api.github.com/repos/{REPOSITORY}/actions/runs/{run_id}" + or result.get("html_url") != f"/{REPOSITORY}/actions/runs/{run_id}"): + raise NotificationError("Catalog notification returned an invalid run identity") + return result["html_url"] + + +def notify(token, *, opener=None, clock=time.monotonic, sleep=time.sleep): + if not token or len(token) > 8192 or any(not 33 <= ord(c) <= 126 for c in token): + raise NotificationError("CATALOG_DISPATCH_TOKEN is missing or invalid; check the catalog App token step") + if opener is None: + opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) + deadline = clock() + 90 + for attempt in range(3): + remaining = deadline - clock() + if remaining <= 0: + raise NotificationError("Catalog notification exhausted its retry budget") + request = urllib.request.Request(ENDPOINT, data=b'{"ref":"master"}', method="POST", headers={ + "Accept": "application/vnd.github+json", "Content-Type": "application/json", + "Authorization": "Bearer " + token, "X-GitHub-Api-Version": "2026-03-10", + "User-Agent": "computer-mcp-catalog-notification", + }) + delay = 2 ** attempt + try: + with opener.open(request, timeout=min(20, remaining)) as response: + return accepted_run(response, deadline, clock) + except urllib.error.HTTPError as error: + with error: + rate_limited = error.code == 403 and error.headers.get("X-RateLimit-Remaining") == "0" + if error.code not in (429, 500, 502, 503, 504) and not rate_limited: + raise NotificationError(f"Catalog notification rejected (HTTP {error.code})") from None + retry_after = error.headers.get("Retry-After") + if retry_after is None and (error.code == 429 or rate_limited): + raise NotificationError("Catalog notification is rate limited; wait for the server cooldown before retrying") from None + if retry_after is not None: + if not retry_after.isascii() or not retry_after.isdigit() or len(retry_after) > 2: + raise NotificationError("Catalog notification cannot honor the server retry delay") from None + delay = max(delay, int(retry_after)) + except (urllib.error.URLError, OSError, http.client.HTTPException): + # A lost response may have accepted the request. Central reconciliation is idempotent. + pass + if attempt == 2 or delay > 30 or clock() + delay >= deadline: + raise NotificationError("Catalog notification failed within its retry budget; retry the workflow later") + sleep(delay) + raise AssertionError("Unreachable notification state") + + +def main(): + try: + url = notify(os.environ.get("CATALOG_DISPATCH_TOKEN", "")) + if output := os.environ.get("GITHUB_OUTPUT"): + with open(output, "a", encoding="utf-8") as file: + file.write(f"run-url={url}\n") + print(f"Catalog reconciliation accepted: {url}") + print("Acceptance does not prove catalog verification or Pages deployment; inspect the central run.") + except (NotificationError, OSError, ValueError) as error: + message = str(error) if isinstance(error, NotificationError) else type(error).__name__ + print(f"Catalog notification failed: {message}", file=sys.stderr) + raise SystemExit(1) from None + + +if __name__ == "__main__": + main() diff --git a/scripts/plugin-runtime.py b/scripts/plugin-runtime.py new file mode 100644 index 0000000..6d1f0cf --- /dev/null +++ b/scripts/plugin-runtime.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Deliver and verify the shared Computer MCP Python plugin runtime. + + python3 scripts/plugin-runtime.py sync + python3 scripts/plugin-runtime.py check + python3 scripts/plugin-runtime.py check-workflow +""" + +import argparse +import hashlib +from pathlib import Path +import re +import shutil +import sys + +ROOT = Path(__file__).resolve().parent.parent +CANONICAL = ROOT / "PluginRuntime/plugin_runtime.py" +WORKFLOW = ROOT / ".github/workflows/plugin-runtime.yml" +TARGET = "bin/plugin_runtime.py" + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def runtime_path(checkout): + root = Path(checkout).resolve() + if not root.is_dir(): + raise ValueError(f"Checkout is not a directory: {checkout}") + path = (root / TARGET).resolve() + if not path.is_relative_to(root): + raise ValueError(f"Runtime path escapes the checkout: {TARGET}") + return path + + +def check(checkout): + path = runtime_path(checkout) + if not path.is_file(): + raise ValueError(f"Missing plugin runtime: {TARGET}") + if digest(path) != digest(CANONICAL): + raise ValueError(f"Plugin runtime differs from the canonical file: {TARGET}") + print("Plugin runtime matches the canonical file", flush=True) + return 0 + + +def sync(checkout): + path = runtime_path(checkout) + path.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(CANONICAL, path) + return check(checkout) + + +def check_workflow(): + values = re.findall(r"^ +PLUGIN_RUNTIME_SHA256: (.+)$", WORKFLOW.read_text(), re.MULTILINE) + if len(values) != 1 or not re.fullmatch(r"'[0-9a-f]{64}'", values[0]): + raise ValueError("Workflow must embed exactly one quoted PLUGIN_RUNTIME_SHA256 digest") + if values[0][1:-1] != digest(CANONICAL): + raise ValueError("Workflow PLUGIN_RUNTIME_SHA256 differs from the canonical runtime") + print("Workflow digest matches the canonical runtime", flush=True) + return 0 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + commands = parser.add_subparsers(dest="command", required=True) + for command in ("sync", "check"): + commands.add_parser(command).add_argument("checkout") + commands.add_parser("check-workflow") + args = parser.parse_args() + try: + if args.command == "check-workflow": + return check_workflow() + return sync(args.checkout) if args.command == "sync" else check(args.checkout) + except (OSError, ValueError) as error: + print(str(error), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/plugin_release.py b/scripts/plugin_release.py new file mode 100644 index 0000000..e818651 --- /dev/null +++ b/scripts/plugin_release.py @@ -0,0 +1,583 @@ +"""Verify reproducible plugin archives and publish their verified release bundle. + +The GitHub boundary has four operations: get JSON, list paginated JSON, +download bytes, and send a mutation. Tests supply an in-memory implementation. +""" + +import argparse +import base64 +from datetime import datetime +import hashlib +import io +import json +import os +from pathlib import Path, PurePosixPath +import re +import stat +import sys +import tomllib +from urllib.error import HTTPError +from urllib.parse import quote, urlencode, urlsplit +from urllib.request import HTTPRedirectHandler, Request, build_opener +import zipfile + + +RECEIPT = "release-receipt.json" +SUMS = "SHA256SUMS" +WORKFLOW = "computer-mcp/github-workflows/.github/workflows/plugin-release.yml@" +# Frozen receipts retain the workflow identity that originally assembled them. +RECEIPT_WORKFLOWS = (WORKFLOW, "computer-mcp/.github/.github/workflows/plugin-release.yml@") +SHA = r"[0-9a-f]{40}" +HASH = r"[0-9a-f]{64}" +VERSION = r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-(?:alpha|beta|rc)\.[1-9][0-9]*)?" +ARCHIVE = r"[A-Za-z0-9][A-Za-z0-9_.-]*\.zip" + + +class ReleaseError(ValueError): + pass + + +class APIError(ReleaseError): + def __init__(self, status, path): + self.status = status + super().__init__(f"GitHub API returned {status}: {path}") + + +def require(condition, message): + if not condition: + raise ReleaseError(message) + + +def sha256(data): + return hashlib.sha256(data).hexdigest() + + +def canonical(value): + return (json.dumps(value, sort_keys=True, indent=2, ensure_ascii=True) + "\n").encode() + + +def positive(value): + return type(value) is int and value > 0 + + +def digest_value(value): + require(isinstance(value, str) and re.fullmatch("sha256:" + HASH, value), + "Expected a GitHub SHA-256 digest") + return value[7:] + + +class SafeRedirect(HTTPRedirectHandler): + def redirect_request(self, request, fp, code, message, headers, new_url): + require(urlsplit(new_url).scheme == "https", "Refusing a non-HTTPS redirect") + redirected = super().redirect_request(request, fp, code, message, headers, new_url) + if redirected is not None: + redirected.remove_header("Authorization") + return redirected + + +class GitHub: + """REST transport; credentials are sent only to the GitHub API or uploader.""" + + def __init__(self, token): + self.token = token + self.opener = build_opener(SafeRedirect()) + + def _request(self, method, path, data=None, content_type="application/json", binary=False): + host = "https://uploads.github.com" if method == "POST" and "/assets?" in path else "https://api.github.com" + require(path.startswith("/repos/") and not path.startswith("//"), "Invalid GitHub API path") + headers = {"Authorization": f"Bearer {self.token}", "X-GitHub-Api-Version": "2022-11-28", + "Accept": "application/octet-stream" if binary else "application/vnd.github+json", + "Content-Type": content_type} + body = data if isinstance(data, bytes) else json.dumps(data).encode() if data is not None else None + try: + with self.opener.open(Request(host + path, data=body, headers=headers, method=method), timeout=60) as response: + content = response.read() + except HTTPError as error: + raise APIError(error.code, path) from None + return content if binary else json.loads(content) if content else None + + def get(self, path): + return self._request("GET", path) + + def list(self, path, key=None): + result = [] + for page in range(1, 10001): + separator = "&" if "?" in path else "?" + response = self.get(f"{path}{separator}per_page=100&page={page}") + items = response[key] if key else response + require(isinstance(items, list), "Invalid GitHub list response") + result.extend(items) + if len(items) < 100: + return result + raise ReleaseError("GitHub pagination exceeded its limit") + + def download(self, path): + return self._request("GET", path, binary=True) + + def send(self, method, path, data=None, content_type="application/json"): + return self._request(method, path, data, content_type) + + +def repository_path(repository): + require(re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository), "Invalid repository") + return f"/repos/{repository}" + + +def resolve(api, repository, ref, publish): + root = repository_path(repository) + if re.fullmatch("v" + VERSION, ref): + tag = api.get(f"{root}/git/ref/tags/{quote(ref, safe='')}") + require(tag["object"]["type"] == "tag", "Release requires an annotated tag") + annotated = api.get(f"{root}/git/tags/{tag['object']['sha']}") + require(annotated.get("tag") == ref and annotated["object"]["type"] == "commit", + "Annotated tag must point directly to the release commit") + verification = annotated.get("verification", {}) + require(verification.get("verified") is True and verification.get("reason") == "valid", + "Tag signature must pass GitHub verification") + commit = annotated["object"]["sha"] + tag_name = ref + else: + require(not publish, "Publishing requires a signed annotated vX.Y.Z tag") + require(re.fullmatch(r"[0-9a-f]{7,40}", ref), "Dry-run ref must be a version tag or commit") + commit = api.get(f"{root}/commits/{ref}")["sha"] + tag_name = None + require(re.fullmatch(SHA, commit), "Invalid resolved commit") + return commit, tag_name + + +def manifest_info(manifest, tag): + plugin_id = manifest.get("id") + version = manifest.get("version") + require(isinstance(plugin_id, str) and re.fullmatch(r"[a-z0-9][a-z0-9._-]*", plugin_id), + "Manifest requires a valid plugin id") + require(isinstance(version, str) and re.fullmatch(VERSION, version), "Manifest requires a valid version") + require(tag is None or tag == "v" + version, "Manifest version differs from the tag") + require(positive(manifest.get("plugin_api")), "Manifest plugin_api must be a positive integer") + compatibility = manifest.get("compatibility", {}) + require(isinstance(compatibility, dict), "Manifest compatibility must be a table") + declared = compatibility.get("artifacts") + names = None + if declared is not None: + require(isinstance(declared, list) and declared, "Manifest artifacts must be a nonempty array") + names = [item.get("name") if isinstance(item, dict) else None for item in declared] + require(all(isinstance(name, str) and re.fullmatch(ARCHIVE, name) for name in names), + "Manifest artifact names must be ZIP basenames") + require(len(set(names)) == len(names), "Manifest artifact names must be unique") + names = sorted(names) + return {"plugin_id": plugin_id, "version": version, "plugin_api": manifest["plugin_api"], + "compatibility": compatibility}, names + + +def archive_names(names, declared): + require(bool(names), "No plugin archives found") + require(len(names) == len(set(names)) and all(re.fullmatch(ARCHIVE, name) for name in names), + "Archive names must be unique ZIP basenames") + require(sorted(names) == declared if declared is not None else len(names) == 1, + "Archives differ from compatibility.artifacts (or the required single archive)") + + +def zip_entries(data): + result = {} + with zipfile.ZipFile(io.BytesIO(data)) as archive: + for entry in archive.infolist(): + path = PurePosixPath(entry.filename) + require(not path.is_absolute() and ".." not in path.parts and "\\" not in entry.filename + and "\x00" not in entry.filename, "Unsafe ZIP member") + require(entry.filename not in result, "Duplicate ZIP member") + mode = entry.external_attr >> 16 + require(stat.S_IFMT(mode) in (0, stat.S_IFREG, stat.S_IFDIR), "ZIP contains a special file") + if not entry.is_dir(): + result[entry.filename] = archive.read(entry) + return result + + +def ci_archives(data, declared): + contents = zip_entries(data) + archives = {} + for path, content in contents.items(): + if path.endswith(".zip"): + name = PurePosixPath(path).name + require(name not in archives, "Duplicate CI archive basename") + archives[name] = content + archive_names(list(archives), declared) + return archives + + +def check_run(run, repository, branch, commit): + require(run.get("event") == "push" and run.get("head_branch") == branch + and run.get("head_sha") == commit and run.get("path") == ".github/workflows/ci.yml" + and run.get("head_repository", {}).get("full_name") == repository, + "CI must be this repository's default-branch push run of ci.yml for the exact commit") + + +def select_ci(api, repository, commit, artifact_pattern): + root = repository_path(repository) + branch = api.get(root)["default_branch"] + query = urlencode({"event": "push", "branch": branch, "head_sha": commit}) + runs = api.list(f"{root}/actions/workflows/ci.yml/runs?{query}", "workflow_runs") + candidates = [] + for run in runs: + try: + check_run(run, repository, branch, commit) + except ReleaseError: + continue + if run.get("status") == "completed" and run.get("conclusion") == "success": + candidates.append(run) + require(candidates, "No successful authoritative CI run found") + run = api.get(f"{root}/actions/runs/{max(candidates, key=lambda item: item['id'])['id']}") + check_run(run, repository, branch, commit) + require(run.get("status") == "completed" and run.get("conclusion") == "success" + and positive(run.get("run_attempt")), "Latest CI attempt must have succeeded") + attempt = api.get(f"{root}/actions/runs/{run['id']}/attempts/{run['run_attempt']}") + check_run(attempt, repository, branch, commit) + require(attempt.get("id") == run["id"] and attempt.get("run_attempt") == run["run_attempt"] + and attempt.get("status") == "completed" and attempt.get("conclusion") == "success", + "Latest CI attempt must have succeeded") + name = artifact_pattern.replace("${commit}", commit) + require(name and "${" not in name, "ci_artifact must be an exact name, optionally containing ${commit}") + # The artifacts endpoint spans attempts. Creation within the latest attempt + # excludes outputs retained from a previous attempt or a partial rerun. + started = datetime.fromisoformat(attempt["run_started_at"].replace("Z", "+00:00")) + finished = datetime.fromisoformat(attempt["updated_at"].replace("Z", "+00:00")) + artifacts = api.list(f"{root}/actions/runs/{run['id']}/artifacts", "artifacts") + matching = [item for item in artifacts if item.get("name") == name + and started <= datetime.fromisoformat(item["created_at"].replace("Z", "+00:00")) <= finished] + require(len(matching) == 1, "Expected exactly one named CI artifact from the latest attempt") + artifact = matching[0] + require(not artifact.get("expired"), "Selected CI artifact has expired; rerun CI before releasing") + digest_value(artifact.get("digest")) + require(positive(artifact.get("id")), "Invalid CI artifact ID") + return {"run_id": run["id"], "run_attempt": run["run_attempt"], + "artifacts": [{"id": artifact["id"], "digest": artifact["digest"]}]} + + +def get_ci_archives(api, repository, commit, ci, declared, recovering=False): + root = repository_path(repository) + recovery = "Recorded CI artifact is expired or missing; delete the unpublished draft release, then rerun release.yml to select fresh CI provenance" + require(len(ci["artifacts"]) == 1, "Rebuild releases require one source CI artifact") + attempt = None + if recovering: + branch = api.get(root)["default_branch"] + try: + attempt = api.get(f"{root}/actions/runs/{ci['run_id']}/attempts/{ci['run_attempt']}") + except APIError as error: + raise ReleaseError(f"Cannot validate recorded CI attempt: {error}") from None + check_run(attempt, repository, branch, commit) + require(attempt.get("id") == ci["run_id"] and attempt.get("run_attempt") == ci["run_attempt"] + and attempt.get("status") == "completed" and attempt.get("conclusion") == "success", + "Recorded CI attempt must exist and have succeeded") + source = ci["artifacts"][0] + try: + artifact = api.get(f"{root}/actions/artifacts/{source['id']}") + if artifact.get("expired"): + raise APIError(410, "recorded CI artifact") + require(artifact.get("id") == source["id"] and artifact.get("digest") == source["digest"], + "CI artifact digest or ID conflicts with provenance") + require(artifact.get("workflow_run", {}).get("id") == ci["run_id"] + and artifact.get("workflow_run", {}).get("head_sha") == commit, + "CI artifact belongs to another run or commit") + if attempt is not None: + created = datetime.fromisoformat(artifact["created_at"].replace("Z", "+00:00")) + require(datetime.fromisoformat(attempt["run_started_at"].replace("Z", "+00:00")) <= created + <= datetime.fromisoformat(attempt["updated_at"].replace("Z", "+00:00")), + "CI artifact was not created within the recorded CI attempt") + data = api.download(f"{root}/actions/artifacts/{source['id']}/zip") + except APIError as error: + if recovering and error.status in (404, 410): + raise ReleaseError(recovery) from None + raise + require(sha256(data) == digest_value(source["digest"]), "Downloaded CI artifact digest mismatch") + return ci_archives(data, declared) + + +def find_release(api, root, tag): + if tag is None: + return None + matches = [release for release in api.list(f"{root}/releases") if release.get("tag_name") == tag] + require(len(matches) <= 1, "Multiple releases for the tag") + return matches[0] if matches else None + + +def check_immutable_release(release): + require(release.get("immutable") is True, + "Release is public but mutable; the repository owner must handle it (immutable: true required)") + + +def release_assets(api, root, release): + assets = api.list(f"{root}/releases/{release['id']}/assets") + names = [asset["name"] for asset in assets] + require(len(names) == len(set(names)), "Duplicate release asset names") + return {asset["name"]: asset for asset in assets} + + +def read_asset(api, root, asset): + require(asset.get("state") == "uploaded", "Release asset is incomplete") + data = api.download(f"{root}/releases/assets/{asset['id']}") + require(sha256(data) == digest_value(asset.get("digest")) and len(data) == asset.get("size"), + "Release asset download differs from its API digest or size") + return data + + +def validate_receipt(data): + receipt = json.loads(data) + require(isinstance(receipt, dict) and set(receipt) == { + "schema", "plugin_id", "version", "plugin_api", "compatibility", "tag", "commit", + "archives", "ci", "workflow", "reproduction"}, "Invalid release receipt fields") + require(type(receipt["schema"]) is int and receipt["schema"] == 1, "Unsupported receipt schema") + require(receipt["tag"] is None or isinstance(receipt["tag"], str) and re.fullmatch("v" + VERSION, receipt["tag"]), + "Invalid receipt tag") + _, declared = manifest_info({"id": receipt["plugin_id"], "version": receipt["version"], + "plugin_api": receipt["plugin_api"], "compatibility": receipt["compatibility"]}, receipt["tag"]) + require(isinstance(receipt["commit"], str) and re.fullmatch(SHA, receipt["commit"]), "Invalid receipt commit") + require(receipt["reproduction"] == "rebuild", "Shared workflow requires rebuild reproduction") + ci = receipt["ci"] + require(isinstance(ci, dict) and set(ci) == {"run_id", "run_attempt", "artifacts"} + and all(positive(ci[key]) for key in ("run_id", "run_attempt")), "Invalid receipt CI provenance") + require(isinstance(ci["artifacts"], list) and len(ci["artifacts"]) == 1, + "Rebuild receipt requires one CI artifact") + source = ci["artifacts"][0] + require(isinstance(source, dict) and set(source) == {"id", "digest"} and positive(source["id"]), + "Invalid receipt artifact provenance") + digest_value(source["digest"]) + workflow = receipt["workflow"] + require(isinstance(workflow, dict) and set(workflow) == {"ref", "sha"} + and isinstance(workflow["sha"], str) and re.fullmatch(SHA, workflow["sha"]) + and any(workflow["ref"] == prefix + workflow["sha"] for prefix in RECEIPT_WORKFLOWS), "Invalid receipt workflow provenance") + archives = receipt["archives"] + require(isinstance(archives, list) and archives, "Receipt requires archive records") + for item in archives: + require(isinstance(item, dict) and set(item) == {"name", "size", "sha256"} + and isinstance(item["name"], str) and re.fullmatch(ARCHIVE, item["name"]) + and positive(item["size"]) and isinstance(item["sha256"], str) and re.fullmatch(HASH, item["sha256"]), + "Invalid receipt archive record") + names = [item["name"] for item in archives] + archive_names(names, declared) + require(names == sorted(names) and data == canonical(receipt), "Receipt must use canonical schema 1 encoding") + return receipt + + +def checksums(files): + return "".join(f"{sha256(data)} {name}\n" for name, data in sorted(files.items())).encode() + + +def assemble(receipt, archives): + files = dict(archives) + require(set(files) == {item["name"] for item in receipt["archives"]}, "Receipt archive set differs") + for item in receipt["archives"]: + data = files[item["name"]] + require(len(data) == item["size"] and sha256(data) == item["sha256"], "Archive differs from receipt") + files[RECEIPT] = canonical(receipt) + validate_receipt(files[RECEIPT]) + files[SUMS] = checksums(files) + return files + + +def check_assets(assets, files, draft, complete=False): + require(set(assets) <= set(files), "Release contains unexpected assets") + if complete: + require(set(assets) == set(files), "Release asset set is incomplete") + for name, asset in assets.items(): + if asset.get("state") == "starter" and draft and not complete: + continue + require(asset.get("state") == "uploaded" and asset.get("digest") == "sha256:" + sha256(files[name]) + and asset.get("size") == len(files[name]), f"Completed release asset conflicts: {name}") + + +def get_build_archives(api, repository, artifact_id, run_id, declared): + root = repository_path(repository) + require(positive(artifact_id) and positive(run_id), "Invalid build artifact or release run ID") + artifact = api.get(f"{root}/actions/artifacts/{artifact_id}") + require(artifact.get("id") == artifact_id and not artifact.get("expired") + and artifact.get("workflow_run", {}).get("id") == run_id, + "Build artifact must be an available artifact from this release run") + data = api.download(f"{root}/actions/artifacts/{artifact_id}/zip") + require(sha256(data) == digest_value(artifact.get("digest")), "Downloaded build artifact digest mismatch") + archives = zip_entries(data) + archive_names(list(archives), declared) + return archives + + +def verify(api, repository, ref, publish, source_commit, build_artifact_id, run_id, ci_artifact, workflow_ref, workflow_sha, + output): + root = repository_path(repository) + commit, tag = resolve(api, repository, ref, publish) + require(commit == source_commit, "Release ref moved after source resolution") + source = api.get(f"{root}/contents/computer-mcp-plugin.toml?ref={commit}") + require(source.get("type") == "file" and source.get("encoding") == "base64", + "Release source manifest must be a file with base64 contents") + manifest = tomllib.loads(base64.b64decode(source["content"]).decode()) + info, declared = manifest_info(manifest, tag) + release = find_release(api, root, tag) + if release: + if release["draft"]: + require(release["target_commitish"] == commit, "Draft release target differs from the verified commit") + else: + check_immutable_release(release) + assets = release_assets(api, root, release) if release else {} + receipt = None + if RECEIPT in assets and assets[RECEIPT].get("state") == "uploaded": + receipt = validate_receipt(read_asset(api, root, assets[RECEIPT])) + require(all(receipt[key] == value for key, value in info.items()) + and receipt["tag"] == tag and receipt["commit"] == commit, + "Frozen receipt differs from release source") + if release and not release["draft"]: + require(receipt is not None, "Published release lacks a complete receipt; it cannot be modified") + expected = {item["name"] for item in receipt["archives"]} | {RECEIPT, SUMS} + require(set(assets) == expected, "Published release asset set differs") + archives = {item["name"]: read_asset(api, root, assets[item["name"]]) for item in receipt["archives"]} + else: + if receipt is None: + ci = select_ci(api, repository, commit, ci_artifact) + else: + ci = receipt["ci"] + archives = get_ci_archives(api, repository, commit, ci, declared, recovering=receipt is not None) + rebuilt = get_build_archives(api, repository, build_artifact_id, run_id, declared) + require(rebuilt == archives, "Rebuilt archives differ byte for byte from the accepted archives") + for data in archives.values(): + contents = zip_entries(data) + require("computer-mcp-plugin.toml" in contents, "Archive lacks the root plugin manifest") + require(tomllib.loads(contents["computer-mcp-plugin.toml"].decode()) == manifest, + "Archive manifest differs from the release source") + if receipt is None: + receipt = dict(info, schema=1, tag=tag, commit=commit, + archives=[{"name": name, "size": len(data), "sha256": sha256(data)} + for name, data in sorted(archives.items())], ci=ci, + workflow={"ref": workflow_ref, "sha": workflow_sha}, reproduction="rebuild") + files = assemble(receipt, archives) + if release: + check_assets(assets, files, release["draft"], complete=not release["draft"]) + require(not output.exists(), "Release output directory must not exist") + output.mkdir(parents=True) + for name, data in files.items(): + (output / name).write_bytes(data) + return {"commit": commit, "tag": tag or "", "asset_digests": json.dumps( + {name: sha256(data) for name, data in files.items()}, sort_keys=True, separators=(",", ":"))} + + +def load_bundle(directory, expected_digests): + require(directory.is_dir() and not directory.is_symlink(), "Invalid verified bundle file set") + paths = list(directory.iterdir()) + require(all(path.is_file() and not path.is_symlink() for path in paths), "Invalid verified bundle file set") + files = {path.name: path.read_bytes() for path in paths} + require({name: sha256(data) for name, data in files.items()} == json.loads(expected_digests), + "Bundle differs from the verify job's digests") + require(RECEIPT in files, "Invalid verified bundle file set") + receipt = validate_receipt(files[RECEIPT]) + expected = {item["name"] for item in receipt["archives"]} | {RECEIPT, SUMS} + require(set(files) == expected, "Invalid verified bundle file set") + require(files == assemble(receipt, {item["name"]: files[item["name"]] for item in receipt["archives"]}), + "Verified bundle checksums differ") + return receipt, files + + +def publish_bundle(api, repository, directory, expected_digests): + receipt, files = load_bundle(directory, expected_digests) + root = repository_path(repository) + require(receipt["tag"] is not None, "Cannot publish an untagged dry-run bundle") + commit, tag = resolve(api, repository, receipt["tag"], True) + require(commit == receipt["commit"], "Release tag moved after verification") + try: + immutable = api.get(f"{root}/immutable-releases") + except APIError as error: + raise ReleaseError(f"Cannot verify repository release immutability: {error}") from None + require(immutable.get("enabled") is True, + "Enable release immutability in the plugin repository before publishing") + release = find_release(api, root, tag) + if release is None: + release = api.send("POST", f"{root}/releases", { + "tag_name": tag, "target_commitish": commit, "name": tag, "draft": True, + "prerelease": "-" in receipt["version"]}) + release_id = release["id"] + + def current(): + observed = api.get(f"{root}/releases/{release_id}") + require(observed["tag_name"] == tag, "Release tag changed") + if not observed["draft"]: + check_immutable_release(observed) + return observed + + def draft(): + observed = current() + require(observed["draft"], "Release became public; rerun to verify its immutable assets") + require(observed["target_commitish"] == commit, "Draft release target differs from the verified commit") + + release = current() + assets = release_assets(api, root, release) + check_assets(assets, files, release["draft"], complete=not release["draft"]) + if not release["draft"]: + return {"published": "true", "release_id": str(release_id), "tag": tag} + draft() + # Receipt-first upload freezes provenance before any archive is exposed. + for name in [RECEIPT, *sorted(item["name"] for item in receipt["archives"]), SUMS]: + assets = release_assets(api, root, release) + check_assets(assets, files, True) + asset = assets.get(name) + if asset and asset["state"] == "uploaded": + continue + if asset: + draft() + api.send("DELETE", f"{root}/releases/assets/{asset['id']}") + draft() + content_type = "application/zip" if name.endswith(".zip") else "application/json" if name == RECEIPT else "text/plain" + uploaded = api.send("POST", f"{root}/releases/{release_id}/assets?{urlencode({'name': name})}", + files[name], content_type) + check_assets({name: uploaded}, {name: files[name]}, True, complete=True) + draft() + check_assets(release_assets(api, root, release), files, True, complete=True) + require(resolve(api, repository, tag, True) == (commit, tag), "Release tag moved during publication") + draft() + # Publishing changes only visibility; release notes remain operator-owned. + published = api.send("PATCH", f"{root}/releases/{release_id}", {"draft": False}) + require(published.get("draft") is False and published.get("tag_name") == tag, "Release did not become public") + check_immutable_release(published) + check_assets(release_assets(api, root, published), files, False, complete=True) + return {"published": "true", "release_id": str(release_id), "tag": tag} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + for name in ("resolve", "verify", "publish"): + command = commands.add_parser(name) + command.add_argument("--repository", required=True) + if name != "publish": + command.add_argument("--ref", required=True) + command.add_argument("--publish", choices=("true", "false"), required=True) + if name == "verify": + command.add_argument("--output", type=Path, required=True) + for flag in ("build-artifact-id", "run-id"): + command.add_argument("--" + flag, type=int, required=True) + for flag in ("commit", "ci-artifact", "workflow-ref", "workflow-sha"): + command.add_argument("--" + flag, required=True) + if name == "publish": + command.add_argument("--directory", type=Path, required=True) + command.add_argument("--asset-digests", required=True) + args = parser.parse_args() + try: + api = GitHub(os.environ["GH_TOKEN"]) + if args.command == "resolve": + commit, tag = resolve(api, args.repository, args.ref, args.publish == "true") + result = {"commit": commit, "tag": tag or ""} + elif args.command == "verify": + result = verify(api, args.repository, args.ref, args.publish == "true", args.commit, args.build_artifact_id, + args.run_id, args.ci_artifact, args.workflow_ref, args.workflow_sha, + args.output.resolve()) + if args.publish == "false": + print("Dry run: no Release writes. Publication requires a signed annotated v" + + json.loads((args.output / RECEIPT).read_bytes())["version"] + + " tag at the verified commit; record manual acceptance in the Release notes.") + else: + result = publish_bundle(api, args.repository, args.directory, args.asset_digests) + if "GITHUB_OUTPUT" in os.environ: + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + for key, value in result.items(): + output.write(f"{key}={value}\n") + print(json.dumps(result, sort_keys=True)) + return 0 + except (OSError, ValueError, KeyError, TypeError, zipfile.BadZipFile) as error: + print(str(error), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/release-step.py b/scripts/release-step.py new file mode 100644 index 0000000..8b9ecb4 --- /dev/null +++ b/scripts/release-step.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Execute the release helper checked out with this job's workflow revision.""" + +import hashlib +import os +from pathlib import Path +import re +import subprocess +import sys + + +def run(stage): + helper = Path(__file__).resolve().with_name("plugin_release.py") + if helper.is_symlink() or hashlib.sha256(helper.read_bytes()).hexdigest() != os.environ["PLUGIN_RELEASE_SHA256"]: + raise ValueError("Release helper differs from this workflow pin") + root = Path(os.environ["RUNNER_TEMP"]) / "plugin-release" + arguments = [sys.executable, "-I", str(helper), "resolve" if stage == "source" else stage, + "--repository", os.environ["GH_REPOSITORY"]] + if stage == "source": + revision = os.environ["RELEASE_WORKFLOW_SHA"] + reference = os.environ["RELEASE_WORKFLOW_REF"] + prefix = "computer-mcp/github-workflows/.github/workflows/plugin-release.yml@" + if not re.fullmatch("[0-9a-f]{40}", revision) or reference != prefix + revision: + raise ValueError("Pin the reusable release workflow by its full commit SHA") + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + output.write(f"workflow_ref={reference}\nworkflow_sha={revision}\n") + if stage in {"source", "verify"}: + arguments += ["--ref", os.environ["RELEASE_REF"], "--publish", os.environ["RELEASE_PUBLISH"]] + if stage == "verify": + for flag, variable in (("commit", "RELEASE_COMMIT"), ("build-artifact-id", "RELEASE_BUILD_ARTIFACT_ID"), + ("run-id", "GH_RUN_ID"), ("ci-artifact", "RELEASE_CI_ARTIFACT"), + ("workflow-ref", "RELEASE_WORKFLOW_REF"), ("workflow-sha", "RELEASE_WORKFLOW_SHA")): + arguments += ["--" + flag, os.environ[variable]] + arguments += ["--output", str(root / "assets")] + if stage == "publish": + arguments += ["--directory", str(root / "assets"), "--asset-digests", os.environ["RELEASE_ASSET_DIGESTS"]] + subprocess.run(arguments, check=True) + + +if __name__ == "__main__": + try: + if len(sys.argv) != 2 or sys.argv[1] not in {"source", "verify", "publish"}: + raise ValueError("Expected source, verify or publish") + run(sys.argv[1]) + except (KeyError, OSError, ValueError, subprocess.CalledProcessError) as error: + sys.exit(str(error)) diff --git a/tests/test_catalog_notification.py b/tests/test_catalog_notification.py new file mode 100644 index 0000000..b6d5d34 --- /dev/null +++ b/tests/test_catalog_notification.py @@ -0,0 +1,234 @@ +import contextlib +import importlib.util +import http.client +import io +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import threading +import unittest +import urllib.error +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from unittest.mock import Mock, patch + + +SCRIPT = Path(__file__).resolve().parent.parent / "actions/notify-catalog/notify.py" +spec = importlib.util.spec_from_file_location("catalog_notification", SCRIPT) +notification = importlib.util.module_from_spec(spec) +spec.loader.exec_module(notification) +TOKEN = "fixture-token-never-a-real-credential" +RUN = { + "workflow_run_id": 123, + "run_url": "https://api.github.com/repos/computer-mcp/computer-mcp.github.io/actions/runs/123", + "html_url": "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/computer-mcp/computer-mcp.github.io/actions/runs/123", +} + + +class Response(io.BytesIO): + def __init__(self, body=None, status=200): + super().__init__(json.dumps(RUN).encode() if body is None else body) + self.status = status + + +def failure(status, headers=None): + return urllib.error.HTTPError(notification.ENDPOINT, status, TOKEN, headers or {}, io.BytesIO(TOKEN.encode())) + + +class NotificationTests(unittest.TestCase): + def test_dispatch_uses_only_fixed_central_ref_and_returns_validated_run(self): + response = Response() + opener = Mock() + opener.open.return_value = response + self.assertEqual(notification.notify(TOKEN, opener=opener), RUN["html_url"]) + request = opener.open.call_args.args[0] + self.assertEqual(request.full_url, notification.ENDPOINT) + self.assertEqual(request.method, "POST") + self.assertEqual(json.loads(request.data), {"ref": "master"}) + self.assertEqual(request.get_header("Authorization"), "Bearer " + TOKEN) + self.assertEqual(request.get_header("X-github-api-version"), "2026-03-10") + self.assertLessEqual(opener.open.call_args.kwargs["timeout"], 20) + self.assertTrue(response.closed) + + def test_invalid_credentials_do_not_start_requests(self): + for token in ("", " ", "with\nnewline", "with\rreturn", "é", "a" * 8193): + with self.subTest(token_length=len(token)): + opener = Mock() + with self.assertRaises(notification.NotificationError): + notification.notify(token, opener=opener) + opener.open.assert_not_called() + + def test_untrusted_run_identity_is_not_reported_as_success(self): + for changes in ({"workflow_run_id": True}, {"workflow_run_id": 0}, + {"workflow_run_id": 9007199254740992}, {"workflow_run_id": "123"}, + {"run_url": "https://example.invalid/123"}, + {"html_url": "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/another/repo/actions/runs/123"}): + with self.subTest(changes=changes): + response = Response(json.dumps({**RUN, **changes}).encode()) + opener = Mock() + opener.open.return_value = response + with self.assertRaisesRegex(notification.NotificationError, "identity"): + notification.notify(TOKEN, opener=opener) + self.assertTrue(response.closed) + + def test_malformed_and_oversized_responses_fail_without_body_disclosure(self): + for body in (TOKEN.encode(), b"[]", b"\xff", b"x" * (notification.MAX_RESPONSE + 1)): + response = Response(body) + opener = Mock() + opener.open.return_value = response + with self.assertRaises(notification.NotificationError) as error: + notification.notify(TOKEN, opener=opener) + self.assertNotIn(TOKEN, str(error.exception)) + self.assertTrue(response.closed) + + def test_response_without_run_receipt_is_not_deployment_evidence(self): + opener = Mock() + opener.open.return_value = Response(b"", status=204) + with self.assertRaisesRegex(notification.NotificationError, "accepted run"): + notification.notify(TOKEN, opener=opener) + + def test_transient_failure_retries_idempotent_reconciliation(self): + opener, sleep = Mock(), Mock() + failed = failure(503) + opener.open.side_effect = [failed, Response()] + self.assertEqual(notification.notify(TOKEN, opener=opener, sleep=sleep), RUN["html_url"]) + self.assertTrue(failed.closed) + sleep.assert_called_once_with(1) + self.assertEqual(opener.open.call_count, 2) + + def test_server_retry_delay_is_respected(self): + for status, headers in ((429, {"Retry-After": "5"}), + (403, {"X-RateLimit-Remaining": "0", "Retry-After": "5"})): + opener, sleep = Mock(), Mock() + opener.open.side_effect = [failure(status, headers), Response()] + self.assertEqual(notification.notify(TOKEN, opener=opener, sleep=sleep), RUN["html_url"]) + sleep.assert_called_once_with(5) + + def test_long_or_invalid_retry_delay_is_not_ignored(self): + for value in ("31", "1000", "tomorrow", "-1", "١"): + opener, sleep = Mock(), Mock() + opener.open.side_effect = failure(429, {"Retry-After": value}) + with self.assertRaises(notification.NotificationError): + notification.notify(TOKEN, opener=opener, sleep=sleep) + self.assertEqual(opener.open.call_count, 1) + sleep.assert_not_called() + + def test_rate_limit_without_explicit_delay_never_retries_early(self): + for status in (403, 429): + with self.subTest(status=status): + opener, sleep = Mock(), Mock() + opener.open.side_effect = [failure(status, {"X-RateLimit-Remaining": "0", + "X-RateLimit-Reset": "9999999999"}), Response()] + with self.assertRaises(notification.NotificationError): + notification.notify(TOKEN, opener=opener, sleep=sleep) + self.assertEqual(opener.open.call_count, 1) + sleep.assert_not_called() + + def test_authentication_and_configuration_failures_are_not_retried(self): + for status in (400, 401, 403, 404, 422): + opener, sleep = Mock(), Mock() + opener.open.side_effect = failure(status) + with self.assertRaisesRegex(notification.NotificationError, f"HTTP {status}"): + notification.notify(TOKEN, opener=opener, sleep=sleep) + self.assertEqual(opener.open.call_count, 1) + sleep.assert_not_called() + + def test_network_retry_limit(self): + for failure in (urllib.error.URLError(TOKEN), http.client.IncompleteRead(TOKEN.encode()), + http.client.RemoteDisconnected(TOKEN)): + opener, sleep = Mock(), Mock() + opener.open.side_effect = failure + with self.assertRaises(notification.NotificationError) as error: + notification.notify(TOKEN, opener=opener, sleep=sleep) + self.assertNotIn(TOKEN, str(error.exception)) + self.assertEqual(opener.open.call_count, 3) + self.assertEqual([c.args[0] for c in sleep.call_args_list], [1, 2]) + + def test_elapsed_budget_prevents_retry(self): + elapsed = [0] + opener, sleep = Mock(), Mock() + + def slow_request(*args, **kwargs): + elapsed[0] = 89.5 + raise urllib.error.URLError(TOKEN) + + opener.open.side_effect = slow_request + with self.assertRaises(notification.NotificationError): + notification.notify(TOKEN, opener=opener, clock=lambda: elapsed[0], sleep=sleep) + self.assertEqual(opener.open.call_count, 1) + sleep.assert_not_called() + + def test_body_read_checks_elapsed_budget(self): + response = Response() + with self.assertRaisesRegex(notification.NotificationError, "time budget"): + notification.accepted_run(response, 90, lambda: 91) + + def test_actual_http_redirects_never_receive_forwarded_credentials(self): + observed = [] + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + observed.append(self.path) + self.send_response(int(self.path[1:])) + self.send_header("Location", "/credential-sink") + self.end_headers() + + def do_GET(self): + observed.append(self.path) + self.send_response(500) + self.end_headers() + + def log_message(self, *args): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + worker = threading.Thread(target=server.serve_forever, daemon=True) + worker.start() + try: + for status in (301, 302, 303, 307, 308): + with patch.object(notification, "ENDPOINT", f"http://127.0.0.1:{server.server_port}/{status}"): + with self.assertRaises(notification.NotificationError): + notification.notify(TOKEN) + self.assertEqual(observed, [f"/{status}" for status in (301, 302, 303, 307, 308)]) + finally: + server.shutdown() + server.server_close() + worker.join(5) + self.assertFalse(worker.is_alive()) + + def test_action_outputs_only_accepted_run_and_distinguishes_deployment(self): + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "outputs" + stdout = io.StringIO() + with patch.dict(os.environ, {"CATALOG_DISPATCH_TOKEN": TOKEN, "GITHUB_OUTPUT": str(output)}): + with patch.object(notification, "notify", return_value=RUN["html_url"]), contextlib.redirect_stdout(stdout): + notification.main() + self.assertEqual(output.read_text(), "run-url=" + RUN["html_url"] + "\n") + self.assertIn("does not prove", stdout.getvalue()) + self.assertNotIn(TOKEN, stdout.getvalue()) + + def test_actual_command_missing_credential_fails_without_traceback(self): + result = subprocess.run([sys.executable, str(SCRIPT)], capture_output=True, text=True, + timeout=5, env={**os.environ, "CATALOG_DISPATCH_TOKEN": ""}) + self.assertEqual(result.returncode, 1) + self.assertIn("CATALOG_DISPATCH_TOKEN", result.stderr) + self.assertNotIn("Traceback", result.stderr) + self.assertEqual(result.stdout, "") + + def test_command_does_not_disclose_transport_error(self): + opener = Mock() + opener.open.side_effect = urllib.error.URLError(TOKEN) + stderr = io.StringIO() + with patch.dict(os.environ, {"CATALOG_DISPATCH_TOKEN": TOKEN}): + with patch.object(notification.urllib.request, "build_opener", return_value=opener): + with patch.object(notification.time, "sleep"), contextlib.redirect_stderr(stderr): + with self.assertRaises(SystemExit) as error: + notification.main() + self.assertEqual(error.exception.code, 1) + self.assertNotIn(TOKEN, stderr.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_plugin_release.py b/tests/test_plugin_release.py new file mode 100644 index 0000000..04d0c9c --- /dev/null +++ b/tests/test_plugin_release.py @@ -0,0 +1,1180 @@ +"""Offline release verification, publication, interruption and recovery tests.""" + +import base64 +from contextlib import redirect_stderr, redirect_stdout +import copy +import importlib.util +import io +import json +import os +from pathlib import Path +import re +import shlex +import stat +import subprocess +import sys +import tempfile +import textwrap +import unittest +from unittest.mock import patch +from urllib.parse import parse_qs, urlsplit +from urllib.request import Request +import zipfile + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts/plugin_release.py" +WORKFLOW = ROOT / ".github/workflows/plugin-release.yml" +spec = importlib.util.spec_from_file_location("plugin_release", SCRIPT) +release = importlib.util.module_from_spec(spec) +spec.loader.exec_module(release) +REPOSITORY = "example/plugin-widget" +API_ROOT = "/repos/" + REPOSITORY +COMMIT = "a" * 40 +WORKFLOW_SHA = "b" * 40 +WORKFLOW_REF = release.WORKFLOW + WORKFLOW_SHA +TAG = "v0.2.0" +MANIFEST = b'id = "widget"\nversion = "0.2.0"\nplugin_api = 1\n[compatibility]\nminimum_host = "0.1.0"\n' + + +def workflow_job(name): + return re.search(r"(?ms)^ " + name + r":\n(.*?)(?=^ [a-z_]+:\n|\Z)", WORKFLOW.read_text())[1] + + + + +def zip_bytes(files): + output = io.BytesIO() + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_STORED) as archive: + for name, data in sorted(files.items()): + entry = zipfile.ZipInfo(name, (1980, 1, 1, 0, 0, 0)) + entry.create_system = 3 + entry.external_attr = (stat.S_IFREG | 0o644) << 16 + archive.writestr(entry, data) + return output.getvalue() + + +class FakeGitHub: + """The same four-operation API boundary as the real REST transport.""" + + def __init__(self, archives): + self.calls = [] + self.writes = [] + self.manifest = MANIFEST + self.runs = {10: self.run()} + self.attempts = {(10, 1): self.run()} + self.artifacts = {} + self.artifact_bytes = {} + self.releases = {} + self.assets = {} + self.asset_bytes = {} + self.next_id = 100 + self.fail = None + self.tag_type = "tag" + self.tag_commit = COMMIT + self.verified = True + self.reason = "valid" + self.immutability = {"enabled": True, "enforced_by_owner": False} + self.install_artifact(20, archives) + self.install_build_artifact(archives) + + def run(self, **changes): + run = {"id": 10, "run_attempt": 1, "event": "push", "head_branch": "main", + "head_sha": COMMIT, "head_repository": {"full_name": REPOSITORY}, + "path": ".github/workflows/ci.yml", "status": "completed", "conclusion": "success", + "run_started_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:10:00Z"} + run.update(changes) + return run + + def install_artifact(self, artifact_id, archives, **changes): + data = zip_bytes(dict({"dist/" + name: content for name, content in archives.items()}, + **{"package-receipt.json": b'{"builder": "metadata"}\n'})) + artifact = {"id": artifact_id, "name": "widget-" + COMMIT + "-3.13", "expired": False, + "digest": "sha256:" + release.sha256(data), "created_at": "2026-01-01T00:05:00Z", + "workflow_run": {"id": 10, "head_sha": COMMIT}} + artifact.update(changes) + self.artifacts[artifact_id] = artifact + self.artifact_bytes[artifact_id] = data + + def rerun_ci(self, archives): + self.runs[10] = self.run(run_attempt=2, run_started_at="2026-01-02T00:00:00Z", + updated_at="2026-01-02T00:10:00Z") + self.attempts[10, 2] = copy.deepcopy(self.runs[10]) + self.install_artifact(21, archives, created_at="2026-01-02T00:05:00Z") + + def install_build_artifact(self, archives): + data = zip_bytes(archives) + self.artifacts[30] = {"id": 30, "name": "plugin-build-12-1", "expired": False, + "digest": "sha256:" + release.sha256(data), "workflow_run": {"id": 12}} + self.artifact_bytes[30] = data + + def _record(self, method, path): + self.calls.append((method, path)) + assert path.startswith(API_ROOT), path + return path[len(API_ROOT):] + + def get(self, path): + route = self._record("GET", path) + if route == "": + result = {"default_branch": "main"} + elif route == "/immutable-releases": + result = self.immutability + elif route.startswith("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/contents/computer-mcp-plugin.toml?"): + assert parse_qs(urlsplit(route).query) == {"ref": [self.tag_commit]} + result = {"type": "file", "encoding": "base64", "content": base64.b64encode(self.manifest).decode()} + elif route.startswith("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/git/ref/tags/"): + result = {"object": {"type": self.tag_type, "sha": "c" * 40}} + elif route.startswith("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/git/tags/"): + result = {"tag": TAG, "object": {"type": "commit", "sha": self.tag_commit}, + "verification": {"verified": self.verified, "reason": self.reason}} + elif route.startswith("/commits/"): + result = {"sha": COMMIT} + elif route.startswith("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/actions/runs/"): + parts = route.split("/") + run_id = int(parts[3]) + if "/attempts/" in route: + key = run_id, int(parts[5]) + if key not in self.attempts: + raise release.APIError(404, path) + result = self.attempts[key] + else: + result = self.runs[run_id] + elif route.startswith("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/actions/artifacts/"): + artifact_id = int(route.split("/")[3]) + if artifact_id not in self.artifacts: + raise release.APIError(404, path) + result = self.artifacts[artifact_id] + elif route.startswith("/releases/"): + result = self.releases[int(route.split("/")[2])] + else: + raise AssertionError(("GET", route)) + return copy.deepcopy(result) + + def list(self, path, key=None): + route = self._record("LIST", path) + if route.startswith("/actions/workflows/ci.yml/runs?"): + assert key == "workflow_runs" + result = list(self.runs.values()) + elif route.endswith("/artifacts"): + assert key == "artifacts" + run_id = int(route.split("/")[3]) + result = [item for item in self.artifacts.values() if item["workflow_run"]["id"] == run_id] + elif route == "/releases": + result = list(self.releases.values()) + elif route.endswith("/assets"): + release_id = int(route.split("/")[2]) + result = [item for item in self.assets.values() if item["release_id"] == release_id] + else: + raise AssertionError(("LIST", route)) + return copy.deepcopy(result) + + def download(self, path): + route = self._record("DOWNLOAD", path) + if route.startswith("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/actions/artifacts/"): + return self.artifact_bytes[int(route.split("/")[3])] + if route.startswith("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/releases/assets/"): + return self.asset_bytes[int(route.split("/")[3])] + raise AssertionError(("DOWNLOAD", route)) + + def _failure(self, step, when): + if self.fail == (step, when): + self.fail = None + raise release.APIError(502, "injected " + step + " " + when) + + def add_draft(self): + self.releases[50] = {"id": 50, "tag_name": TAG, "target_commitish": COMMIT, "name": TAG, + "draft": True, "immutable": False, "prerelease": False, + "body": "Operator acceptance notes"} + return self.releases[50] + + def publish_release(self, release_id=50): + item = self.releases[release_id] + if item["draft"]: + item.update(draft=False, immutable=self.immutability.get("enabled") is True) + return item + + def add_asset(self, name, data=b"", state="uploaded", release_id=50): + self.next_id += 1 + asset = {"id": self.next_id, "release_id": release_id, "name": name, "state": state, + "digest": "sha256:" + release.sha256(data), "size": len(data)} + self.assets[asset["id"]] = asset + self.asset_bytes[asset["id"]] = data + return asset + + def send(self, method, path, data=None, content_type="application/json"): + route = self._record(method, path) + if method == "POST" and route == "/releases": + step = "draft" + elif method == "POST" and "/assets?" in route: + step = parse_qs(urlsplit(route).query)["name"][0] + elif method == "PATCH": + step = "publish" + elif method == "DELETE": + step = "delete" + else: + raise AssertionError((method, route)) + self._failure(step, "before") + if step != "draft": + release_id = (self.assets[int(route.split("/")[3])]["release_id"] if step == "delete" + else int(route.split("/")[2])) + if self.releases[release_id].get("immutable") is True: + raise release.APIError(422, path) + self.writes.append((step, copy.deepcopy(data))) + if step == "draft": + result = self.add_draft() + result.update(data) + elif step == "publish": + result = self.releases[int(route.split("/")[2])] + if data.get("draft") is False: + self.publish_release(result["id"]) + result.update(data) + elif step == "delete": + asset_id = int(route.split("/")[3]) + del self.assets[asset_id] + del self.asset_bytes[asset_id] + result = None + else: + release_id = int(route.split("/")[2]) + assert not any(item["name"] == step for item in self.assets.values()) + result = self.add_asset(step, data, release_id=release_id) + self._failure(step, "after") + return copy.deepcopy(result) + + +class ReleaseTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="plugin release ") + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.checkout = self.root / "checkout" + self.checkout.mkdir() + self.output = self.root / "assets" + self.set_archives(MANIFEST, {"widget.zip": zip_bytes({"computer-mcp-plugin.toml": MANIFEST, "bin/tool": b"owned"})}) + self.api = FakeGitHub(self.archives) + + def set_archives(self, manifest, archives): + self.manifest = manifest + self.archives = archives + if hasattr(self, "api"): + self.api.manifest = manifest + self.api.install_build_artifact(archives) + (self.checkout / "computer-mcp-plugin.toml").write_bytes(manifest) + (self.checkout / "build.py").write_text( + "import base64, os, sys\nfrom pathlib import Path\n" + "assert 'GH_TOKEN' not in os.environ and 'GITHUB_TOKEN' not in os.environ\n" + "output = Path(sys.argv[1]); output.mkdir()\n" + + "\n".join(f"(output / {name!r}).write_bytes(base64.b64decode({base64.b64encode(data)!r}))" + for name, data in archives.items())) + + def verify(self, **overrides): + arguments = dict(api=self.api, repository=REPOSITORY, ref=TAG, publish=True, + source_commit=COMMIT, build_artifact_id=30, run_id=12, + ci_artifact="widget-${commit}-3.13", workflow_ref=WORKFLOW_REF, + workflow_sha=WORKFLOW_SHA, output=self.output) + arguments.update(overrides) + self.api.manifest = (self.checkout / "computer-mcp-plugin.toml").read_bytes() + result = release.verify(**arguments) + self.asset_digests = result["asset_digests"] + return result + + def publish(self): + return release.publish_bundle(self.api, REPOSITORY, self.output, self.asset_digests) + + def files(self): + return {path.name: path.read_bytes() for path in self.output.iterdir()} + + def frozen_draft(self): + self.verify() + self.api.add_draft() + self.api.add_asset(release.RECEIPT, (self.output / release.RECEIPT).read_bytes()) + + def verify_again(self, **changes): + return self.verify(output=self.root / "recovered", **changes) + + def assert_no_writes(self, function, pattern): + before = copy.deepcopy(self.api.writes) + with self.assertRaisesRegex(release.ReleaseError, pattern): + function() + self.assertEqual(self.api.writes, before) + + def run_build(self, command=None): + runner = self.root / "build-runner" + runner.mkdir() + script = textwrap.dedent(re.search(r" run: \|\n((?: .*\n)+)", workflow_job("build"))[1]) + environment = {"PATH": os.environ["PATH"], "RUNNER_TEMP": str(runner), + "GITHUB_OUTPUT": str(runner / "output"), "GITHUB_ENV": str(runner / "env"), + "GITHUB_PATH": str(runner / "path"), + "RELEASE_BUILD": command or shlex.quote(sys.executable) + " build.py"} + subprocess.run(["bash", "-euo", "pipefail", "-c", script], cwd=self.checkout, env=environment, check=True) + output = runner / "plugin-release-build" + self.api.install_build_artifact({path.name: path.read_bytes() for path in output.iterdir()}) + return runner + + def run_helper_step(self, job, runner, environment): + import runpy + shared = runner / ".shared/scripts" + shared.mkdir(parents=True, exist_ok=True) + helper = (shared / "plugin_release.py").resolve() + helper.write_bytes(SCRIPT.read_bytes()) + step = shared / "release-step.py" + step.write_bytes((ROOT / "scripts/release-step.py").read_bytes()) + + def run_pinned(arguments, check): + self.assertTrue(check) + self.assertEqual(arguments[:3], [sys.executable, "-I", str(helper)]) + with patch.object(sys, "argv", arguments[2:]), patch.object(release, "GitHub", return_value=self.api): + status = release.main() + if status: + raise subprocess.CalledProcessError(status, arguments) + + output = runner / "output" + with patch.dict(os.environ, {"RUNNER_TEMP": str(runner), "GH_TOKEN": "fake", + "GH_REPOSITORY": REPOSITORY, "GITHUB_OUTPUT": str(output), + "PLUGIN_RELEASE_SHA256": release.sha256(SCRIPT.read_bytes()), **environment}), \ + patch.object(subprocess, "run", side_effect=run_pinned), \ + redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): + runpy.run_path(str(step))["run"](job) + return output.read_text() + + def test_frozen_receipt_from_original_workflow_recovers_without_rewriting_provenance(self): + self.verify(workflow_ref=release.RECEIPT_WORKFLOWS[1] + WORKFLOW_SHA) + self.api.add_draft() + receipt = (self.output / release.RECEIPT).read_bytes() + self.api.add_asset(release.RECEIPT, receipt) + self.verify_again() + self.assertEqual((self.root / "recovered" / release.RECEIPT).read_bytes(), receipt) + + def test_first_publication_order_contents_checksums_and_notes(self): + outputs = self.verify() + self.assertEqual(self.api.writes, []) + result = self.publish() + self.assertEqual(result, {"published": "true", "release_id": "50", "tag": TAG}) + self.assertIs(self.api.releases[50]["immutable"], True) + self.assertEqual([step for step, _ in self.api.writes], + ["draft", release.RECEIPT, "widget.zip", release.SUMS, "publish"]) + self.assertEqual(self.api.releases[50]["body"], "Operator acceptance notes") + files = self.files() + receipt = json.loads(files[release.RECEIPT]) + self.assertEqual(set(receipt), {"schema", "plugin_id", "version", "plugin_api", "compatibility", + "tag", "commit", "archives", "ci", "workflow", "reproduction"}) + self.assertEqual(receipt["schema"], 1) + self.assertEqual(receipt["ci"], {"run_id": 10, "run_attempt": 1, + "artifacts": [{"id": 20, "digest": self.api.artifacts[20]["digest"]}]}) + self.assertEqual(receipt["workflow"], {"ref": WORKFLOW_REF, "sha": WORKFLOW_SHA}) + self.assertEqual(receipt["reproduction"], "rebuild") + self.assertEqual(json.loads(outputs["asset_digests"]), {name: release.sha256(data) for name, data in files.items()}) + self.assertEqual(files[release.SUMS], release.checksums({name: data for name, data in files.items() + if name != release.SUMS})) + self.assertEqual({item["name"]: self.api.asset_bytes[item["id"]] for item in self.api.assets.values()}, files) + + def test_failures_before_and_after_every_publication_step_are_recoverable(self): + self.verify() + expected = self.files() + for step in ("draft", release.RECEIPT, "widget.zip", release.SUMS, "publish"): + for when in ("before", "after"): + with self.subTest(step=step, when=when): + self.api = FakeGitHub(self.archives) + self.api.fail = (step, when) + with self.assertRaises(release.APIError): + self.publish() + if self.api.releases and self.api.releases[50]["draft"]: + self.assertNotEqual(self.api.writes[-1][0], "publish") + result = self.publish() + self.assertEqual(result["published"], "true") + self.assertEqual({item["name"]: self.api.asset_bytes[item["id"]] + for item in self.api.assets.values()}, expected) + + def test_publish_only_retry_downloads_the_original_artifact_id_on_a_fresh_runner(self): + self.verify() + expected = self.files() + artifact_id = "901" + self.api.artifact_bytes[int(artifact_id)] = zip_bytes({"assets/" + name: data for name, data in expected.items()}) + job = workflow_job("publish") + download_id = re.search(r"artifact-ids: (.+)", job)[1] + digests = re.search(r"RELEASE_ASSET_DIGESTS: (.+)", job)[1] + self.assertIn("artifact_id: ${{ steps.upload.outputs.artifact-id }}", workflow_job("verify")) + + def attempt(number): + context = {"github.run_id": "12", "github.run_attempt": str(number), + "needs.verify.outputs.artifact_id": artifact_id, + "needs.verify.outputs.asset_digests": self.asset_digests} + + def evaluate(value): + return re.sub(r"\$\{\{ (.*?) \}\}", lambda match: context[match[1]], value) + + directory = self.root / f"publish-attempt-{number}" + root = directory / "plugin-release" + root.mkdir(parents=True) + data = self.api.download(API_ROOT + "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/actions/artifacts/" + evaluate(download_id) + "/zip") + for name, content in release.zip_entries(data).items(): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + + return self.run_helper_step("publish", directory, {"RELEASE_ASSET_DIGESTS": evaluate(digests)}) + + self.api.fail = (release.SUMS, "before") + with self.assertRaises(subprocess.CalledProcessError): + attempt(1) + self.assertIn("published=true", attempt(2)) + self.assertEqual({item["name"]: self.api.asset_bytes[item["id"]] + for item in self.api.assets.values()}, expected) + self.assertEqual(self.api.calls.count(("DOWNLOAD", API_ROOT + "/actions/artifacts/901/zip")), 2) + + def test_partial_upload_recovery_freezes_ci_attempt_artifact_and_workflow(self): + self.verify() + original = self.files() + self.api.fail = (release.SUMS, "before") + with self.assertRaises(release.APIError): + self.publish() + changed = {"widget.zip": self.archives["widget.zip"] + b"new CI bytes"} + self.api.rerun_ci(changed) + self.api.calls.clear() + self.verify_again(workflow_ref=release.WORKFLOW + "d" * 40, workflow_sha="d" * 40) + recovered = {path.name: path.read_bytes() for path in (self.root / "recovered").iterdir()} + self.assertEqual(recovered, original) + self.assertIn(("GET", API_ROOT + "/actions/runs/10/attempts/1"), self.api.calls) + self.assertFalse(any("/workflows/" in path or path.endswith("/actions/runs/10") + or "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/attempts/2" in path for _, path in self.api.calls)) + self.assertIn(("DOWNLOAD", API_ROOT + "/actions/artifacts/20/zip"), self.api.calls) + self.output = self.root / "recovered" + self.publish() + self.assertEqual([name for name, _ in self.api.writes].count(release.RECEIPT), 1) + + def test_recovery_accepts_recorded_success_when_latest_attempt_and_newer_run_fail(self): + self.frozen_draft() + expected = self.files() + self.api.rerun_ci({"widget.zip": b"newer bytes"}) + self.api.runs[10]["conclusion"] = "failure" + self.api.runs[11] = self.api.run(id=11, event="workflow_dispatch", conclusion="failure") + self.api.calls.clear() + self.verify_again() + self.assertEqual({path.name: path.read_bytes() for path in (self.root / "recovered").iterdir()}, expected) + self.assertIn(("GET", API_ROOT + "/actions/runs/10/attempts/1"), self.api.calls) + self.assertFalse(any("/workflows/" in path or "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/attempts/2" in path or "/actions/runs/11" in path + for _, path in self.api.calls)) + + def test_recovery_refuses_invalid_or_unavailable_recorded_attempt(self): + self.frozen_draft() + for field, value in (("event", "workflow_dispatch"), ("head_branch", "other"), + ("head_sha", "d" * 40), ("path", ".github/workflows/other.yml"), + ("head_repository", {"full_name": "fork/plugin-widget"}), + ("conclusion", "failure"), ("conclusion", "cancelled"), + ("status", "in_progress"), ("id", 99), ("run_attempt", 99)): + with self.subTest(field=field, value=value): + with patch.dict(self.api.attempts[10, 1], {field: value}): + self.assert_no_writes(self.verify_again, "CI must|Recorded CI attempt") + del self.api.attempts[10, 1] + self.assert_no_writes(self.verify_again, "Cannot validate recorded CI attempt.*404") + + def test_recovery_refuses_an_artifact_outside_the_recorded_attempt(self): + self.frozen_draft() + self.api.artifacts[20]["created_at"] = "2026-01-02T00:05:00Z" + self.assert_no_writes(self.verify_again, "not created within the recorded CI attempt") + + def test_recovery_refuses_a_nonexistent_attempt_recorded_in_the_receipt(self): + self.frozen_draft() + asset = next(item for item in self.api.assets.values() if item["name"] == release.RECEIPT) + receipt = json.loads(self.api.asset_bytes[asset["id"]]) + receipt["ci"]["run_attempt"] = 99 + data = release.canonical(receipt) + self.api.asset_bytes[asset["id"]] = data + asset.update(size=len(data), digest="sha256:" + release.sha256(data)) + self.assert_no_writes(self.verify_again, "Cannot validate recorded CI attempt.*404") + self.assertIn(("GET", API_ROOT + "/actions/runs/10/attempts/99"), self.api.calls) + + def test_starter_assets_at_each_upload_position_are_deleted_and_replaced(self): + self.verify() + files = self.files() + for name in (release.RECEIPT, "widget.zip", release.SUMS): + with self.subTest(name=name): + self.api = FakeGitHub(self.archives) + self.api.add_draft() + if name != release.RECEIPT: + self.api.add_asset(release.RECEIPT, files[release.RECEIPT]) + self.api.add_asset(name, b"partial", state="starter") + self.publish() + self.assertIn("delete", [step for step, _ in self.api.writes]) + self.assertEqual({item["name"]: self.api.asset_bytes[item["id"]] + for item in self.api.assets.values()}, files) + + def test_starter_deletion_failure_is_recoverable(self): + self.frozen_draft() + self.api.add_asset("widget.zip", state="starter") + for when in ("before", "after"): + with self.subTest(when=when): + self.api.fail = ("delete", when) + with self.assertRaises(release.APIError): + self.publish() + if not any(item["name"] == "widget.zip" for item in self.api.assets.values()): + self.api.add_asset("widget.zip", state="starter") + self.publish() + + def test_completed_digest_or_size_conflicts_are_never_overwritten(self): + self.verify() + for field, value in (("digest", "sha256:" + "0" * 64), ("size", 999), ("state", "unknown")): + with self.subTest(field=field): + self.api = FakeGitHub(self.archives) + self.api.add_draft() + asset = self.api.add_asset("widget.zip", self.archives["widget.zip"]) + asset[field] = value + self.assert_no_writes(self.publish, "conflicts") + + def test_conflicting_frozen_receipt_is_refused_before_any_upload(self): + self.verify() + self.api.add_draft() + self.api.add_asset(release.RECEIPT, b"another receipt") + self.assert_no_writes(self.publish, "conflicts") + + def test_unexpected_and_duplicate_assets_are_refused(self): + self.verify() + for duplicate in (False, True): + with self.subTest(duplicate=duplicate): + self.api = FakeGitHub(self.archives) + self.api.add_draft() + name = "widget.zip" if duplicate else "unowned.txt" + self.api.add_asset(name) + if duplicate: + self.api.add_asset(name) + self.assert_no_writes(self.publish, "Duplicate|unexpected") + + def test_recorded_artifact_expired_missing_or_gone_has_explicit_recovery(self): + self.frozen_draft() + for mode in ("expired", "missing", "gone"): + with self.subTest(mode=mode): + saved = copy.deepcopy(self.api.artifacts) + if mode == "expired": + self.api.artifacts[20]["expired"] = True + elif mode == "missing": + del self.api.artifacts[20] + original = self.api.download + + def download(path): + if mode == "gone" and "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/actions/artifacts/" in path: + raise release.APIError(410, path) + return original(path) + + with patch.object(self.api, "download", side_effect=download): + self.assert_no_writes(self.verify_again, "delete the unpublished draft release.*rerun release.yml") + self.api.artifacts = saved + + def test_recovery_rejects_replaced_artifact_metadata_or_bytes(self): + self.frozen_draft() + for change in ("digest", "bytes", "run"): + with self.subTest(change=change): + with patch.dict(self.api.artifacts[20], {"digest": "sha256:" + "0" * 64} if change == "digest" + else {"workflow_run": {"id": 99}} if change == "run" else {}): + original = self.api.artifact_bytes[20] + if change == "bytes": + self.api.artifact_bytes[20] += b"changed" + self.assert_no_writes(self.verify_again, "digest|another run") + self.api.artifact_bytes[20] = original + + def test_public_release_repeat_is_read_only_even_after_ci_expiry(self): + self.verify() + self.publish() + writes = copy.deepcopy(self.api.writes) + del self.api.artifacts[20] + self.api.calls.clear() + self.verify_again() + self.assertFalse(any("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/actions/runs/" in path or "/actions/artifacts/20" in path for _, path in self.api.calls)) + self.output = self.root / "recovered" + self.assertEqual(self.publish()["published"], "true") + self.assertEqual(self.api.writes, writes) + + def test_release_published_before_immutability_was_enabled_requires_owner_handling(self): + self.verify() + self.api.add_draft() + for name, data in self.files().items(): + self.api.add_asset(name, data) + self.api.immutability["enabled"] = False + self.api.publish_release() + self.api.immutability["enabled"] = True + self.assertIs(self.api.get(API_ROOT + "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/releases/50")["immutable"], False) + for operation in (self.verify_again, self.publish): + with self.subTest(operation=operation.__name__): + self.assert_no_writes(operation, "public but mutable.*owner") + + def test_public_release_requires_explicit_immutable_true(self): + self.verify() + self.publish() + item = self.api.releases[50] + for immutable in (None, "true", 1, "missing"): + with self.subTest(immutable=immutable): + if immutable == "missing": + item.pop("immutable") + else: + item["immutable"] = immutable + for operation in (self.verify_again, self.publish): + self.assert_no_writes(operation, "public but mutable.*owner") + + def test_publication_requires_the_published_release_to_report_immutable_true(self): + self.verify() + for immutable in (False, None, "true", 1, "missing"): + with self.subTest(immutable=immutable): + self.api = FakeGitHub(self.archives) + original = self.api.send + + def send(method, path, *arguments): + if method == "PATCH": + self.api.immutability["enabled"] = False + result = original(method, path, *arguments) + if method == "PATCH": + if immutable == "missing": + result.pop("immutable") + else: + result["immutable"] = immutable + return result + + with patch.object(self.api, "send", side_effect=send): + with self.assertRaisesRegex(release.ReleaseError, "public but mutable.*owner"): + self.publish() + self.assertIs(self.api.releases[50]["draft"], False) + self.assertIs(self.api.releases[50]["immutable"], False) + self.assertEqual(self.api.writes[-1], ("publish", {"draft": False})) + + def test_public_release_incomplete_extra_starter_or_conflicting_assets_are_immutable(self): + self.verify() + self.publish() + assets = copy.deepcopy(self.api.assets) + for mode in ("missing", "extra", "starter", "digest"): + with self.subTest(mode=mode): + self.api.assets = copy.deepcopy(assets) + asset_id = next(item["id"] for item in self.api.assets.values() if item["name"] == "widget.zip") + if mode == "missing": + del self.api.assets[asset_id] + elif mode == "extra": + self.api.add_asset("extra.txt") + elif mode == "starter": + self.api.assets[asset_id]["state"] = "starter" + else: + self.api.assets[asset_id]["digest"] = "sha256:" + "0" * 64 + self.assert_no_writes(self.publish, "incomplete|unexpected|conflicts") + + def test_release_becoming_public_before_mutation_cannot_be_changed(self): + self.frozen_draft() + self.api.add_asset("widget.zip", state="starter") + original = self.api.get + count = 0 + + def get(path): + nonlocal count + if path.endswith("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/releases/50"): + count += 1 + if count == 2: + self.api.publish_release() + return original(path) + + with patch.object(self.api, "get", side_effect=get): + self.assert_no_writes(self.publish, "became public") + + def test_publication_requires_server_enforced_release_immutability_before_any_write(self): + self.verify() + for enabled in (False, None, "true", 1): + with self.subTest(enabled=enabled): + self.api.immutability["enabled"] = enabled + self.assert_no_writes(self.publish, "Enable release immutability.*before publishing") + self.assertEqual(self.api.releases, {}) + + def test_immutability_api_failure_is_closed_and_explained(self): + self.verify() + original = self.api.get + + def get(path): + if path.endswith("/immutable-releases"): + raise release.APIError(403, path) + return original(path) + + with patch.object(self.api, "get", side_effect=get): + self.assert_no_writes(self.publish, "Cannot verify repository release immutability.*403") + + def test_server_refuses_mutation_after_the_draft_precheck_and_cli_stops_writing(self): + self.verify() + for mutation in ("DELETE", "POST", "PATCH"): + with self.subTest(mutation=mutation): + self.api = FakeGitHub(self.archives) + self.api.add_draft() + self.api.add_asset(release.RECEIPT, self.files()[release.RECEIPT]) + if mutation == "DELETE": + self.api.add_asset("widget.zip", state="starter") + elif mutation == "PATCH": + for name in ("widget.zip", release.SUMS): + self.api.add_asset(name, self.files()[name]) + assets = copy.deepcopy(self.api.assets) + original = self.api.send + + def send(method, path, *arguments): + self.assertEqual(method, mutation) + self.api.publish_release() + return original(method, path, *arguments) + + stderr = io.StringIO() + arguments = [str(SCRIPT), "publish", "--repository", REPOSITORY, "--directory", str(self.output), + "--asset-digests", self.asset_digests] + with patch.object(self.api, "send", side_effect=send), \ + patch.object(release, "GitHub", return_value=self.api), patch.object(sys, "argv", arguments), \ + patch.dict(os.environ, {"GH_TOKEN": "fake"}), redirect_stderr(stderr): + self.assertEqual(release.main(), 1) + self.assertIn("GitHub API returned 422", stderr.getvalue()) + self.assertEqual(self.api.writes, []) + self.assertEqual(self.api.assets, assets) + self.assertEqual(self.api.calls[-1][0], mutation) + + def test_upload_api_digest_mismatch_leaves_draft_unpublished(self): + self.verify() + original = self.api.send + + def send(*args): + result = original(*args) + if "/assets?" in args[1]: + result["digest"] = "sha256:" + "0" * 64 + return result + + with patch.object(self.api, "send", side_effect=send): + with self.assertRaisesRegex(release.ReleaseError, "conflicts"): + self.publish() + self.assertTrue(self.api.releases[50]["draft"]) + self.assertEqual([step for step, _ in self.api.writes], ["draft", release.RECEIPT]) + + def test_draft_target_and_tag_movement_are_refused(self): + self.verify() + self.api.add_draft()["target_commitish"] = "d" * 40 + self.assert_no_writes(self.publish, "target differs") + self.api.tag_commit = "d" * 40 + self.assert_no_writes(self.publish, "tag moved") + + def test_tag_movement_during_upload_prevents_publication(self): + self.verify() + original = self.api.send + + def send(*args): + result = original(*args) + if args[1].endswith("?name=SHA256SUMS"): + self.api.tag_commit = "d" * 40 + return result + + with patch.object(self.api, "send", side_effect=send): + with self.assertRaisesRegex(release.ReleaseError, "tag moved during"): + self.publish() + self.assertTrue(self.api.releases[50]["draft"]) + self.assertNotIn("publish", [step for step, _ in self.api.writes]) + + def test_asset_change_before_final_check_prevents_publication(self): + self.verify() + original = self.api.send + + def send(*args): + result = original(*args) + if args[1].endswith("?name=SHA256SUMS"): + asset = next(item for item in self.api.assets.values() if item["name"] == "widget.zip") + asset["digest"] = "sha256:" + "0" * 64 + return result + + with patch.object(self.api, "send", side_effect=send): + with self.assertRaisesRegex(release.ReleaseError, "conflicts"): + self.publish() + self.assertTrue(self.api.releases[50]["draft"]) + + def test_cli_dry_run_reports_publication_conditions_and_outputs(self): + output = self.root / "github-output" + arguments = [str(SCRIPT), "verify", "--repository", REPOSITORY, "--ref", COMMIT, + "--publish", "false", "--commit", COMMIT, "--build-artifact-id", "30", "--run-id", "12", + "--ci-artifact", "widget-${commit}-3.13", + "--workflow-ref", WORKFLOW_REF, "--workflow-sha", WORKFLOW_SHA, + "--output", str(self.output)] + stdout = io.StringIO() + with patch.object(release, "GitHub", return_value=self.api), patch.object(sys, "argv", arguments), \ + patch.dict(os.environ, {"GH_TOKEN": "fake", "GITHUB_OUTPUT": str(output)}), redirect_stdout(stdout): + self.assertEqual(release.main(), 0) + self.assertIn("signed annotated v0.2.0", stdout.getvalue()) + self.assertIn("manual acceptance", stdout.getvalue()) + self.assertIn("commit=" + COMMIT + "\ntag=\nasset_digests=", output.read_text()) + self.assertEqual(self.api.writes, []) + + def test_dry_run_tag_uses_identical_assembly_without_writes(self): + self.verify() + expected = self.files() + self.verify_again(publish=False) + self.assertEqual({path.name: path.read_bytes() for path in (self.root / "recovered").iterdir()}, expected) + self.assertEqual(self.api.writes, []) + + def test_commit_dry_run_checks_ci_and_rebuild_without_tag_access(self): + self.verify(ref=COMMIT, publish=False) + receipt = json.loads((self.output / release.RECEIPT).read_bytes()) + self.assertIsNone(receipt["tag"]) + self.assertFalse(any("/git/" in path for _, path in self.api.calls)) + self.assert_no_writes(self.publish, "untagged") + self.api.runs[10]["conclusion"] = "failure" + self.assert_no_writes(lambda: self.verify_again(ref=COMMIT, publish=False), "successful authoritative CI") + + def test_tag_format_annotation_signature_and_manifest_version_checks(self): + for change, pattern in (({"tag_type": "commit"}, "annotated"), + ({"verified": False}, "signature"), + ({"reason": "unknown_key"}, "signature")): + with self.subTest(change=change), patch.multiple(self.api, **change): + self.assert_no_writes(self.verify, pattern) + for invalid in (COMMIT, "main", "v00.2.0", "v0.2.0-preview.1", "v0.2.0-rc.0"): + with self.subTest(ref=invalid): + self.assert_no_writes(lambda: self.verify(ref=invalid), "signed annotated") + self.set_archives(MANIFEST.replace(b'0.2.0', b'0.2.1'), self.archives) + self.assert_no_writes(self.verify, "version differs") + + def test_prerelease_tag_preserves_prerelease_status(self): + manifest = MANIFEST.replace(b'0.2.0', b'0.2.0-rc.1') + self.set_archives(manifest, {"widget.zip": zip_bytes({"computer-mcp-plugin.toml": manifest})}) + self.api = FakeGitHub(self.archives) + original = self.api.get + + def get(path): + result = original(path) + if "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/git/tags/" in path: + result["tag"] = "v0.2.0-rc.1" + return result + + with patch.object(self.api, "get", side_effect=get): + self.verify(ref="v0.2.0-rc.1") + self.publish() + self.assertTrue(self.api.releases[50]["prerelease"]) + + def test_invalid_plugin_api_is_refused(self): + for replacement in (b"", b"plugin_api = 0\n", b"plugin_api = -1\n", b"plugin_api = true\n", + b'plugin_api = "1"\n', b"plugin_api = 1.0\n"): + with self.subTest(replacement=replacement): + (self.checkout / "computer-mcp-plugin.toml").write_bytes(MANIFEST.replace(b"plugin_api = 1\n", replacement)) + self.assert_no_writes(self.verify, "plugin_api") + + def test_verification_reads_source_at_the_resolved_commit_without_running_caller_code(self): + with patch.object(subprocess, "run", side_effect=AssertionError("Verification executed a command")): + self.verify() + self.assertIn(("GET", API_ROOT + "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/contents/computer-mcp-plugin.toml?ref=" + COMMIT), self.api.calls) + + def test_verification_refuses_a_ref_that_moved_after_source_resolution(self): + self.api.tag_commit = "d" * 40 + self.assert_no_writes(self.verify, "ref moved after source resolution") + + def test_malicious_build_cannot_forge_the_verification_bundle_or_its_output_digests(self): + forged_archive = b"forged archive, not a ZIP" + info, _ = release.manifest_info(release.tomllib.loads(MANIFEST.decode()), TAG) + receipt = dict(info, schema=1, tag=TAG, commit=COMMIT, reproduction="rebuild", + archives=[{"name": "widget.zip", "size": len(forged_archive), + "sha256": release.sha256(forged_archive)}], + ci={"run_id": 10, "run_attempt": 1, + "artifacts": [{"id": 20, "digest": self.api.artifacts[20]["digest"]}]}, + workflow={"ref": WORKFLOW_REF, "sha": WORKFLOW_SHA}) + forged = release.assemble(receipt, {"widget.zip": forged_archive}) + forged_digests = json.dumps({name: release.sha256(data) for name, data in forged.items()}, sort_keys=True) + with (self.checkout / "build.py").open("a") as script: + script.write("\nroot = Path(os.environ['RUNNER_TEMP']) / 'plugin-release'\n" + "(root / 'assets').mkdir(parents=True)\n") + for name, data in forged.items(): + script.write(f"(root / 'assets' / {name!r}).write_bytes(base64.b64decode({base64.b64encode(data)!r}))\n") + script.write("shared = Path.cwd() / '.shared/scripts'\nshared.mkdir(parents=True)\n" + "(shared / 'plugin_release.py').write_text('raise SystemExit(99)')\n" + + f"Path(os.environ['GITHUB_OUTPUT']).write_text({'asset_digests=' + forged_digests + chr(10)!r})\n" + + f"Path(os.environ['GITHUB_ENV']).write_text({'RELEASE_ASSET_DIGESTS=' + forged_digests + chr(10)!r})\n") + build_runner = self.run_build() + self.assertEqual((build_runner / "plugin-release/assets/widget.zip").read_bytes(), forged_archive) + self.assertIn(forged_digests, (build_runner / "output").read_text()) + verify_runner = self.root / "verify-runner" + root = verify_runner / "plugin-release" + root.mkdir(parents=True) + self.output = root / "assets" + outputs = self.run_helper_step("verify", verify_runner, { + "RELEASE_REF": TAG, "RELEASE_PUBLISH": "true", "RELEASE_COMMIT": COMMIT, + "RELEASE_BUILD_ARTIFACT_ID": "30", "GH_RUN_ID": "12", "RELEASE_CI_ARTIFACT": "widget-${commit}-3.13", + "RELEASE_WORKFLOW_REF": WORKFLOW_REF, "RELEASE_WORKFLOW_SHA": WORKFLOW_SHA}) + self.asset_digests = dict(line.split("=", 1) for line in outputs.splitlines())["asset_digests"] + self.assertNotEqual(json.loads(self.asset_digests), json.loads(forged_digests)) + expected = self.files() + self.assertEqual(expected["widget.zip"], self.archives["widget.zip"]) + self.publish() + self.assertEqual({item["name"]: self.api.asset_bytes[item["id"]] + for item in self.api.assets.values()}, expected) + + def test_build_failure_cannot_reach_verification_or_publication(self): + with self.assertRaises(subprocess.CalledProcessError): + self.run_build("false") + self.assertEqual(self.api.writes, []) + self.assertFalse(self.output.exists()) + + def test_build_artifact_from_another_run_or_expired_is_refused(self): + for changes in ({"workflow_run": {"id": 99}}, {"expired": True}, {"id": 99}): + with self.subTest(changes=changes), patch.dict(self.api.artifacts[30], changes): + self.assert_no_writes(self.verify, "available artifact from this release run") + + def test_build_artifact_is_only_archive_data(self): + for extra in ("release-receipt.json", "SHA256SUMS", "plugin_release.py", "nested/widget.zip"): + with self.subTest(extra=extra): + self.api.install_build_artifact({**self.archives, extra: b"caller data"}) + self.assert_no_writes(self.verify, "ZIP basenames") + self.api.install_build_artifact({"widget.zip": b"forged archive, not a ZIP"}) + self.assert_no_writes(self.verify, "byte for byte") + + def test_authoritative_ci_ignores_other_event_branch_commit_workflow_or_repository(self): + for field, value in (("event", "pull_request"), ("event", "workflow_dispatch"), ("head_branch", "other"), + ("head_sha", "d" * 40), ("path", ".github/workflows/other.yml"), + ("head_repository", {"full_name": "fork/plugin-widget"})): + with self.subTest(field=field, value=value): + self.api.runs = {10: self.api.run(**{field: value})} + self.assert_no_writes(self.verify, "No successful authoritative CI") + + def test_ci_requires_successful_latest_attempt(self): + for state in ("failure", "cancelled", None): + with self.subTest(state=state): + self.api.runs = {10: self.api.run(run_attempt=2, conclusion=state)} + self.assert_no_writes(self.verify, "successful authoritative CI") + self.api.runs = {10: self.api.run(status="in_progress")} + self.assert_no_writes(self.verify, "successful authoritative CI") + + def test_latest_successful_authoritative_run_is_selected(self): + self.api.runs[9] = self.api.run(id=9) + self.api.runs[11] = self.api.run(id=11, conclusion="failure") + self.verify() + receipt = json.loads((self.output / release.RECEIPT).read_bytes()) + self.assertEqual(receipt["ci"]["run_id"], 10) + + def test_ci_rerun_after_listing_cannot_reuse_previous_attempt_success(self): + original = self.api.get + + def get(path): + result = original(path) + if path.endswith("/actions/runs/10"): + result.update(run_attempt=2, conclusion="failure") + return result + + with patch.object(self.api, "get", side_effect=get): + self.assert_no_writes(self.verify, "Latest CI attempt") + + def test_latest_attempt_excludes_artifacts_left_by_earlier_attempts(self): + self.api.rerun_ci(self.archives) + result = self.verify() + self.assertTrue(result["asset_digests"]) + receipt = json.loads((self.output / release.RECEIPT).read_bytes()) + self.assertEqual(receipt["ci"]["run_attempt"], 2) + self.assertEqual(receipt["ci"]["artifacts"][0]["id"], 21) + + def test_attempt_specific_metadata_defines_artifact_creation_window(self): + self.api.rerun_ci(self.archives) + attempt = copy.deepcopy(self.api.runs[10]) + self.api.runs[10]["run_started_at"] = "2026-01-01T00:00:00Z" + original = self.api.get + + def get(path): + return copy.deepcopy(attempt) if path.endswith("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/attempts/2") else original(path) + + with patch.object(self.api, "get", side_effect=get): + self.verify() + receipt = json.loads((self.output / release.RECEIPT).read_bytes()) + self.assertEqual(receipt["ci"]["artifacts"][0]["id"], 21) + + def test_ci_artifact_requires_one_exact_live_artifact_with_digest(self): + for mode, pattern in (("missing", "exactly one"), ("duplicate", "exactly one"), + ("expired", "expired"), ("digest", "SHA-256"), ("old", "exactly one")): + with self.subTest(mode=mode): + self.api = FakeGitHub(self.archives) + if mode == "missing": + self.api.artifacts[20]["name"] += "-other" + elif mode == "duplicate": + self.api.install_artifact(21, self.archives) + elif mode == "old": + self.api.artifacts[20]["created_at"] = "2025-01-01T00:00:00Z" + elif mode == "digest": + self.api.artifacts[20]["digest"] = None + else: + self.api.artifacts[20]["expired"] = True + self.assert_no_writes(self.verify, pattern) + + def test_downloaded_ci_and_build_artifact_digest_mismatches_are_read_only(self): + self.api.artifact_bytes[20] += b"corrupt" + self.assert_no_writes(self.verify, "digest mismatch") + self.api = FakeGitHub(self.archives) + self.api.artifact_bytes[30] += b"corrupt" + self.assert_no_writes(self.verify, "build artifact digest mismatch") + self.assertEqual(self.api.writes, []) + self.assertFalse(self.output.exists()) + + def test_rebuild_mismatch_is_read_only_for_publication_and_dry_run(self): + self.api.install_artifact(20, {"widget.zip": self.archives["widget.zip"] + b"different"}) + for publish in (True, False): + with self.subTest(publish=publish): + self.assert_no_writes(lambda: self.verify(publish=publish), "byte for byte") + + def test_multiple_declared_archives_are_sorted_rebuilt_and_published(self): + manifest = MANIFEST + b'[[compatibility.artifacts]]\nname = "z-arm64.zip"\n[[compatibility.artifacts]]\nname = "a-x86.zip"\n' + self.set_archives(manifest, {name: zip_bytes({"computer-mcp-plugin.toml": manifest, "platform": name.encode()}) + for name in ("z-arm64.zip", "a-x86.zip")}) + self.api = FakeGitHub(self.archives) + self.verify() + self.publish() + self.assertEqual([step for step, _ in self.api.writes], + ["draft", release.RECEIPT, "a-x86.zip", "z-arm64.zip", release.SUMS, "publish"]) + + def test_invalid_artifact_declarations_are_refused(self): + for declaration in (b'artifacts = []\n', b'artifacts = ["widget.zip"]\n', + b'artifacts = [{name = "../widget.zip"}]\n', + b'artifacts = [{name = "widget.zip"}, {name = "widget.zip"}]\n'): + with self.subTest(declaration=declaration): + (self.checkout / "computer-mcp-plugin.toml").write_bytes(MANIFEST + declaration) + self.assert_no_writes(self.verify, "artifacts|artifact names") + + def test_archive_set_must_match_manifest_or_be_single(self): + for manifest in (MANIFEST, MANIFEST + b'[[compatibility.artifacts]]\nname = "missing.zip"\n'): + with self.subTest(manifest=manifest): + self.set_archives(manifest, self.archives) + self.api = FakeGitHub(dict(self.archives, **{"extra.zip": b"extra"})) + self.assert_no_writes(self.verify, "Archives differ") + + def test_archive_manifest_must_equal_source(self): + self.set_archives(MANIFEST, {"widget.zip": zip_bytes({"computer-mcp-plugin.toml": MANIFEST + b'platforms = ["macos"]\n'})}) + self.api = FakeGitHub(self.archives) + self.assert_no_writes(self.verify, "manifest differs") + + def test_bundle_tampering_is_refused_before_release_writes(self): + self.verify() + expected = self.files() + for name in (release.RECEIPT, "widget.zip", release.SUMS, "extra.txt"): + with self.subTest(name=name): + (self.output / name).write_bytes(expected.get(name, b"") + b"tampered") + self.assert_no_writes(self.publish, "differ|file set") + if name in expected: + (self.output / name).write_bytes(expected[name]) + else: + (self.output / name).unlink() + + def test_a_self_consistent_forged_bundle_cannot_replace_verify_job_digests(self): + self.verify() + receipt = json.loads(self.files()[release.RECEIPT]) + archive = b"forged archive, not a ZIP" + receipt["archives"][0].update(size=len(archive), sha256=release.sha256(archive)) + forged = release.assemble(receipt, {"widget.zip": archive}) + for name, data in forged.items(): + (self.output / name).write_bytes(data) + self.assert_no_writes(self.publish, "verify job's digests") + + def test_symlink_in_bundle_is_refused_before_reading_its_target(self): + self.verify() + (self.output / release.RECEIPT).unlink() + (self.output / release.RECEIPT).symlink_to(self.root / "missing-target") + self.assert_no_writes(self.publish, "file set") + + def test_invalid_frozen_receipt_or_source_is_rejected(self): + self.frozen_draft() + receipt_asset = next(item for item in self.api.assets.values() if item["name"] == release.RECEIPT) + original = (self.output / release.RECEIPT).read_bytes() + for mode in ("schema", "incomplete", "noncanonical", "commit", "plugin_api", "ci", "digest"): + with self.subTest(mode=mode): + receipt = json.loads(original) + if mode == "schema": + receipt["schema"] = 2 + elif mode == "incomplete": + del receipt["ci"] + elif mode == "commit": + receipt["commit"] = "d" * 40 + elif mode == "plugin_api": + receipt["plugin_api"] = 2 + elif mode == "ci": + receipt["ci"]["run_attempt"] = 0 + data = json.dumps(receipt).encode() if mode == "noncanonical" else release.canonical(receipt) + self.api.asset_bytes[receipt_asset["id"]] = data + receipt_asset["size"] = len(data) + receipt_asset["digest"] = "sha256:" + ("0" * 64 if mode == "digest" else release.sha256(data)) + self.assert_no_writes(self.verify_again, "schema|fields|canonical|source|provenance|download differs") + + +class FormatAndTransportTests(unittest.TestCase): + def test_unsafe_zip_paths_duplicate_names_and_symlinks_are_refused(self): + for name in ("../widget.zip", "/widget.zip", "dir\\widget.zip"): + with self.subTest(name=name), self.assertRaisesRegex(release.ReleaseError, "Unsafe"): + release.zip_entries(zip_bytes({name: b"data"})) + output = io.BytesIO() + with zipfile.ZipFile(output, "w") as archive: + entry = zipfile.ZipInfo("widget.zip") + entry.external_attr = (stat.S_IFLNK | 0o777) << 16 + archive.writestr(entry, b"target") + with self.assertRaisesRegex(release.ReleaseError, "special file"): + release.zip_entries(output.getvalue()) + with self.assertRaisesRegex(release.ReleaseError, "Duplicate CI"): + release.ci_archives(zip_bytes({"a/widget.zip": b"one", "b/widget.zip": b"two"}), None) + + def test_pagination_consumes_all_pages(self): + api = release.GitHub("fake") + with patch.object(api, "get", side_effect=[{"items": list(range(100))}, {"items": [100]}]) as get: + self.assertEqual(api.list(API_ROOT + "/example?filter=one", "items"), list(range(101))) + self.assertIn("&per_page=100&page=2", get.call_args[0][0]) + + def test_download_redirect_strips_token_and_requires_https(self): + request = Request("https://api.github.com/repos/example/plugin/actions/artifacts/20/zip", + headers={"Authorization": "Bearer fake"}) + redirect = release.SafeRedirect() + moved = redirect.redirect_request(request, None, 302, "Found", {}, "https://blob.example/archive.zip") + self.assertFalse(moved.has_header("Authorization")) + with self.assertRaisesRegex(release.ReleaseError, "non-HTTPS"): + redirect.redirect_request(request, None, 302, "Found", {}, "http://blob.example/archive.zip") + + +class WorkflowBoundaryTests(unittest.TestCase): + def test_workflow_helper_digest_matches_source_and_actions_are_pinned(self): + workflow = WORKFLOW.read_text() + self.assertEqual(re.findall(r"PLUGIN_RELEASE_SHA256: '([0-9a-f]{64})'", workflow), + [release.sha256(SCRIPT.read_bytes())]) + uses = re.findall(r"uses: (\S+)", workflow) + self.assertTrue(uses) + self.assertTrue(all(re.fullmatch(r"[^@]+@[0-9a-f]{40}", use) for use in uses)) + source, build, verify, publish = (workflow_job(name) for name in ("source", "build", "verify", "publish")) + self.assertIn(" contents: read\n actions: read", source) + self.assertIn(" permissions: {}", build) + self.assertNotIn("GH_TOKEN", build) + self.assertNotIn("github.token", build) + self.assertNotIn("plugin-release.py", build) + self.assertIn("persist-credentials: false", build) + self.assertIn("needs: source", build) + self.assertIn("ref: ${{ needs.source.outputs.commit }}", build) + self.assertIn("RELEASE_BUILD: ${{ inputs.build }}", build) + self.assertEqual(workflow.count("inputs.build"), 1) + self.assertIn(" contents: read\n actions: read", verify) + self.assertIn("needs: [source, build]", verify) + self.assertIn("runs-on: ubuntu-24.04", verify) + self.assertIn("ref: ${{ job.workflow_sha }}", verify) + self.assertNotIn("--build'", verify) + self.assertIn(" contents: write", publish) + self.assertIn("needs: verify", publish) + self.assertNotIn("actions: read", publish) + self.assertIn("ref: ${{ job.workflow_sha }}", publish) + self.assertNotIn("secrets", workflow) + self.assertNotIn("RELEASE_BUILD", publish) + + def test_steps_reject_modified_helper_without_executing_it(self): + import runpy + for stage in ("source", "verify", "publish"): + with self.subTest(stage=stage), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + script = root / "release-step.py" + script.write_bytes((ROOT / "scripts/release-step.py").read_bytes()) + marker = root / "executed" + (root / "plugin_release.py").write_text(f"from pathlib import Path\nPath({str(marker)!r}).touch()\n") + with patch.dict(os.environ, {"PLUGIN_RELEASE_SHA256": release.sha256(SCRIPT.read_bytes())}): + with self.assertRaisesRegex(ValueError, "differs from this workflow pin"): + runpy.run_path(str(script))["run"](stage) + self.assertFalse(marker.exists()) + + def test_source_requires_full_workflow_pin_and_records_job_revision(self): + import runpy + run = runpy.run_path(str(ROOT / "scripts/release-step.py"))["run"] + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "output" + env = {"RUNNER_TEMP": directory, "PLUGIN_RELEASE_SHA256": release.sha256(SCRIPT.read_bytes()), + "GH_REPOSITORY": REPOSITORY, "GITHUB_OUTPUT": str(output), "RELEASE_REF": COMMIT, + "RELEASE_PUBLISH": "false", "RELEASE_WORKFLOW_REF": WORKFLOW_REF, + "RELEASE_WORKFLOW_SHA": WORKFLOW_SHA} + with patch.dict(os.environ, env), patch.object(subprocess, "run") as execute: + run("source") + self.assertEqual(output.read_text(), f"workflow_ref={WORKFLOW_REF}\nworkflow_sha={WORKFLOW_SHA}\n") + self.assertIn("resolve", execute.call_args.args[0]) + for invalid in ("master", "0" * 40): + with patch.dict(os.environ, {"RELEASE_WORKFLOW_REF": release.WORKFLOW + invalid}): + with self.assertRaisesRegex(ValueError, "full commit SHA"): + run("source") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_plugin_runtime.py b/tests/test_plugin_runtime.py new file mode 100644 index 0000000..abc5c6d --- /dev/null +++ b/tests/test_plugin_runtime.py @@ -0,0 +1,111 @@ +"""Version assertions are resolved before the owned native probe starts.""" + +from pathlib import Path +import sys +import unittest +from unittest.mock import Mock, patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "PluginRuntime")) +import plugin_runtime as runtime + + +class VersionTests(unittest.TestCase): + def test_tree_assertion_preserves_exact_stdout(self): + tree = {"executable_checks": [ + {"args": ["--help"], "stdout": "help\n"}, + {"args": ["--version"], "stdout": "native 1.0\n"}, + {"args": ["--version"], "stdout": "later\n"}, + ]} + self.assertEqual(runtime.expected_version(tree), "native 1.0\n") + + def test_empty_stdout_is_an_assertion(self): + self.assertEqual(runtime.expected_version({ + "executable_checks": [{"args": ["--version"], "stdout": ""}], + }), "") + + def test_missing_assertion_fails_before_any_process_starts(self): + for tree in ( + {}, + {"executable_checks": []}, + {"executable_checks": [{"args": ["--help"], "stdout": "help\n"}]}, + {"executable_checks": [{"args": ["--version", "extra"], "stdout": "1\n"}]}, + {"executable_checks": [{"args": ["--version"]}]}, + {"executable_checks": [{"args": ["--version"], "stdout": None}]}, + ): + with self.subTest(tree=tree), patch.object(runtime, "Process") as process: + with self.assertRaises(runtime.Failure) as raised: + runtime.verify_version("/native", runtime.expected_version(tree), runtime.Job(), "/work") + self.assertEqual(raised.exception.code, "invalid_configuration") + self.assertEqual(str(raised.exception), "Package lacks its native version assertion") + process.assert_not_called() + + def test_tree_and_direct_version_strings_use_the_same_probe(self): + expected = "native α\n" + tree = {"executable_checks": [{"args": ["--version"], "stdout": expected}]} + for value in (runtime.expected_version(tree), expected): + with self.subTest(value=value): + process = Mock() + process.read.side_effect = [b"native ", "α\n".encode(), None] + process.finish.return_value = 0 + retained = Mock() + job = runtime.Job() + with patch.object(runtime, "Process", return_value=process) as factory: + runtime.verify_version("/native", value, job, "/work", retained) + factory.assert_called_once_with(["/native", "--version"], "/work") + retained.assert_called_once_with(process) + process.end_input.assert_called_once_with() + deadline = process.read.call_args_list[0].args[0] + for call in process.read.call_args_list: + self.assertEqual(call.args, (deadline, job)) + process.finish.assert_called_once_with(deadline, job) + process.close.assert_called_once_with() + + def test_mismatch_and_nonzero_exit_keep_the_existing_failure(self): + for output, status in ((b"other\n", 0), (b"native", 0), (b"native\n", 1)): + with self.subTest(output=output, status=status): + process = Mock() + process.read.side_effect = [output, None] + process.finish.return_value = status + with patch.object(runtime, "Process", return_value=process): + with self.assertRaises(runtime.Failure) as raised: + runtime.verify_version("/native", "native\n", runtime.Job(), "/work") + self.assertEqual(raised.exception.code, "incompatible_vendor") + self.assertEqual(str(raised.exception), "Vendor version differs from the verified plugin baseline") + process.close.assert_called_once_with() + + def test_output_bound_closes_the_probe(self): + process = Mock() + process.read.side_effect = [b"x" * 65536, b"x"] + with patch.object(runtime, "Process", return_value=process): + with self.assertRaises(runtime.Failure) as raised: + runtime.verify_version("/native", "native\n", runtime.Job(), "/work") + self.assertEqual(raised.exception.code, "incompatible_vendor") + self.assertEqual(str(raised.exception), "Native version output exceeded its bound") + process.finish.assert_not_called() + process.close.assert_called_once_with() + + def test_cancellation_and_timeout_close_the_probe(self): + for code in ("cancelled", "timeout"): + with self.subTest(code=code): + failure = runtime.Failure(code, "probe stopped") + process = Mock() + process.read.side_effect = failure + with patch.object(runtime, "Process", return_value=process): + with self.assertRaises(runtime.Failure) as raised: + runtime.verify_version("/native", "native\n", runtime.Job(), "/work") + self.assertIs(raised.exception, failure) + process.close.assert_called_once_with() + + def test_retention_failure_still_closes_the_probe(self): + process = Mock() + failure = RuntimeError("adapter shutdown") + with patch.object(runtime, "Process", return_value=process): + with self.assertRaises(RuntimeError) as raised: + runtime.verify_version("/native", "native\n", runtime.Job(), "/work", Mock(side_effect=failure)) + self.assertIs(raised.exception, failure) + process.end_input.assert_not_called() + process.close.assert_called_once_with() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_runtime_delivery.py b/tests/test_runtime_delivery.py new file mode 100644 index 0000000..a6f7c3b --- /dev/null +++ b/tests/test_runtime_delivery.py @@ -0,0 +1,191 @@ +"""Offline delivery and execution of the reusable workflow's actual verifier.""" + +import hashlib +import os +from pathlib import Path +import re +import shlex +import shutil +import subprocess +import sys +import tempfile +import textwrap +import unittest + +ROOT = Path(__file__).resolve().parents[1] +CANONICAL = ROOT / "PluginRuntime/plugin_runtime.py" +WORKFLOW = ROOT / ".github/workflows/plugin-runtime.yml" + + +class DeliveryTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.authority = self.root / "standards" + self.script = self.authority / "scripts/plugin-runtime.py" + self.canonical = self.authority / "PluginRuntime/plugin_runtime.py" + self.workflow = self.authority / ".github/workflows/plugin-runtime.yml" + for source, target in ( + (ROOT / "scripts/plugin-runtime.py", self.script), + (CANONICAL, self.canonical), + (WORKFLOW, self.workflow), + ): + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, target) + self.checkout = self.root / "plugin checkout" + self.checkout.mkdir() + self.target = self.checkout / "bin/plugin_runtime.py" + + def run_command(self, *args, expected=0): + result = subprocess.run( + [sys.executable, "-I", str(self.script), *map(str, args)], + cwd=self.checkout, capture_output=True, text=True, timeout=10, + ) + self.assertEqual(result.returncode, expected, result.stdout + result.stderr) + self.assertNotIn("Traceback", result.stderr) + return result + + def test_sync_and_check_from_another_directory_with_spaces(self): + self.run_command("sync", self.checkout) + self.assertEqual(self.target.read_bytes(), self.canonical.read_bytes()) + before = self.target.stat().st_mtime_ns + self.run_command("check", self.checkout) + self.assertEqual(self.target.stat().st_mtime_ns, before) + + def test_sync_replaces_drift_and_can_be_repeated(self): + self.target.parent.mkdir() + self.target.write_bytes(b"local copy\n") + self.run_command("sync", self.checkout) + self.run_command("sync", self.checkout) + self.assertEqual(self.target.read_bytes(), self.canonical.read_bytes()) + + def test_check_rejects_missing_file_without_writing(self): + result = self.run_command("check", self.checkout, expected=1) + self.assertIn("Missing plugin runtime", result.stderr) + self.assertFalse(self.target.parent.exists()) + + def test_check_rejects_byte_drift_without_writing(self): + self.run_command("sync", self.checkout) + for content in (self.canonical.read_bytes() + b"\n", b"\xff\x00"): + with self.subTest(content=content[-8:]): + self.target.write_bytes(content) + result = self.run_command("check", self.checkout, expected=1) + self.assertIn("differs from the canonical", result.stderr) + self.assertEqual(self.target.read_bytes(), content) + + def test_commands_require_an_existing_checkout_directory(self): + invalid = self.root / "missing" + for command in ("sync", "check"): + with self.subTest(command=command): + self.run_command(command, invalid, expected=1) + self.assertFalse(invalid.exists()) + self.run_command(command, self.script, expected=1) + + def test_delivery_cannot_follow_symlinks_outside_checkout(self): + outside = self.root / "outside" + outside.mkdir() + protected = outside / "plugin_runtime.py" + protected.write_bytes(b"outside content\n") + for parent_link in (True, False): + with self.subTest(parent_link=parent_link): + if parent_link: + self.target.parent.symlink_to(outside, target_is_directory=True) + else: + self.target.parent.mkdir() + self.target.symlink_to(protected) + for command in ("sync", "check"): + result = self.run_command(command, self.checkout, expected=1) + self.assertIn("escapes the checkout", result.stderr) + self.assertEqual(protected.read_bytes(), b"outside content\n") + if parent_link: + self.target.parent.unlink() + else: + self.target.unlink() + self.target.parent.rmdir() + + def test_check_workflow_matches_canonical(self): + self.run_command("check-workflow") + + def test_check_workflow_rejects_a_stale_digest(self): + self.canonical.write_bytes(self.canonical.read_bytes() + b"\n") + result = self.run_command("check-workflow", expected=1) + self.assertIn("differs from the canonical runtime", result.stderr) + + def test_check_workflow_rejects_missing_malformed_and_duplicate_digest(self): + original = self.workflow.read_text() + line = next(line for line in original.splitlines() if "PLUGIN_RUNTIME_SHA256:" in line) + for replacement in ("", " PLUGIN_RUNTIME_SHA256: 'bad'", line + "\n" + line): + with self.subTest(replacement=replacement): + self.workflow.write_text(original.replace(line, replacement)) + result = self.run_command("check-workflow", expected=1) + self.assertIn("exactly one quoted", result.stderr) + + +class WorkflowVerifierTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.checkout = self.root / "consumer" + self.checkout.mkdir() + self.target = self.checkout / "bin/plugin_runtime.py" + self.target.parent.mkdir() + workflow = WORKFLOW.read_text() + self.command = [sys.executable, "-I", str(ROOT / "scripts/plugin-runtime.py"), "check", str(self.checkout)] + self.digest = re.search(r"PLUGIN_RUNTIME_SHA256: '([0-9a-f]{64})'", workflow).group(1) + + def verify(self, expected): + result = subprocess.run( + self.command, text=True, + cwd=self.checkout, capture_output=True, timeout=10, + env=dict(os.environ, PLUGIN_RUNTIME_SHA256=self.digest), + ) + self.assertEqual(result.returncode, expected, result.stdout + result.stderr) + self.assertNotIn("Traceback", result.stderr) + return result + + def test_exact_runtime_passes(self): + self.target.write_bytes(CANONICAL.read_bytes()) + self.verify(0) + + def test_missing_runtime_fails(self): + result = self.verify(1) + self.assertIn("Missing plugin runtime", result.stderr) + + def test_changed_runtime_fails_without_executing_consumer_code(self): + marker = self.checkout / "executed" + self.target.write_text("from pathlib import Path\nPath('executed').touch()\n") + result = self.verify(1) + self.assertIn("differs from the canonical", result.stderr) + self.assertFalse(marker.exists()) + + def test_changed_runtime_fails_with_consumer_hashlib(self): + self.target.write_bytes(CANONICAL.read_bytes() + b"\n") + (self.checkout / "hashlib.py").write_text(textwrap.dedent("""\ + import os + from pathlib import Path + + Path("hashlib-imported").touch() + + class Digest: + def hexdigest(self): + return os.environ["PLUGIN_RUNTIME_SHA256"] + + def sha256(data): + return Digest() + """)) + result = self.verify(1) + self.assertIn("differs from the canonical", result.stderr) + self.assertFalse((self.checkout / "hashlib-imported").exists()) + + def test_runtime_outside_checkout_fails_even_when_digest_matches(self): + outside = self.root / "outside.py" + outside.write_bytes(CANONICAL.read_bytes()) + self.assertEqual(hashlib.sha256(outside.read_bytes()).hexdigest(), self.digest) + self.target.symlink_to(outside) + self.verify(1) + + +if __name__ == "__main__": + unittest.main() From 672e91654b66939ea44ff380b964644626bc76f1 Mon Sep 17 00:00:00 2001 From: showxu <10173746+showxu@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:16:57 +0800 Subject: [PATCH 02/11] feat: check organization catalog and declared settings --- .github/workflows/organization.yml | 66 ++ fixtures/declaration/MAINTENANCE.md | 91 ++ fixtures/declaration/profile/catalog.json | 90 ++ fixtures/organization.json | 1157 +++++++++++++++++++++ scripts/check-organization.py | 95 ++ scripts/declaration.py | 103 ++ scripts/github_api.py | 60 ++ scripts/settings.py | 142 +++ tests/test_organization.py | 213 ++++ 9 files changed, 2017 insertions(+) create mode 100644 .github/workflows/organization.yml create mode 100644 fixtures/declaration/MAINTENANCE.md create mode 100644 fixtures/declaration/profile/catalog.json create mode 100644 fixtures/organization.json create mode 100644 scripts/check-organization.py create mode 100644 scripts/declaration.py create mode 100644 scripts/github_api.py create mode 100644 scripts/settings.py create mode 100644 tests/test_organization.py diff --git a/.github/workflows/organization.yml b/.github/workflows/organization.yml new file mode 100644 index 0000000..318cb45 --- /dev/null +++ b/.github/workflows/organization.yml @@ -0,0 +1,66 @@ +name: Organization +on: + workflow_call: + inputs: + declaration-revision: + description: Full .github SHA owning both MAINTENANCE.md and profile/catalog.json + type: string + required: true + mode: + description: online reads GitHub; offline checks the bundled fixture + type: string + default: online + secrets: + organization-token: + description: Read access to organization/member settings, Apps and credential names + required: false + outputs: + declaration-revision: + description: Checked declaration revision (fixture label in offline mode) + value: ${{ jobs.organization.outputs.declaration-revision }} +permissions: {} +jobs: + organization: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + outputs: + declaration-revision: ${{ inputs.declaration-revision }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .shared + persist-credentials: false + - name: Validate inputs + env: + REVISION: ${{ inputs.declaration-revision }} + MODE: ${{ inputs.mode }} + shell: bash + run: | + [[ "$REVISION" =~ ^[0-9a-f]{40}$ ]] + [[ "$MODE" == online || "$MODE" == offline ]] + - if: inputs.mode == 'online' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: computer-mcp/.github + ref: ${{ inputs.declaration-revision }} + path: declaration + persist-credentials: false + - name: Check organization + env: + GH_TOKEN: ${{ secrets.organization-token || github.token }} + DECLARATION_REVISION: ${{ inputs.declaration-revision }} + MODE: ${{ inputs.mode }} + shell: bash + run: | + if [[ "$MODE" == offline ]]; then + python3 -I .shared/scripts/check-organization.py --mode offline \ + --declaration-root .shared/fixtures/declaration --declaration-revision "$DECLARATION_REVISION" \ + --fixture .shared/fixtures/organization.json + else + python3 -I .shared/scripts/check-organization.py --mode online \ + --declaration-root declaration --declaration-revision "$DECLARATION_REVISION" + fi diff --git a/fixtures/declaration/MAINTENANCE.md b/fixtures/declaration/MAINTENANCE.md new file mode 100644 index 0000000..944caae --- /dev/null +++ b/fixtures/declaration/MAINTENANCE.md @@ -0,0 +1,91 @@ +# Maintenance fixture + +## Branches and changes + +- The default branch is `master`. A fork keeps its upstream branch names; the + MCP Swift SDK fork's default branch is its maintained release branch. +- Changes land through pull requests with passing checks and are squash merged. + Merged branches are deleted automatically. +- Maintainer commits use the owner's name `showxu` and noreply email + `10173746+showxu@users.noreply.github.com`, and are SSH-signed as described + under [Signing](#signing). Commits that GitHub makes, such as squash merges + and the Computer MCP Updater App's proposals, are signed by GitHub. + Dependabot may author changes. +- Pull request titles and commit subjects follow Conventional Commits: + `type(scope)!: subject`, with optional scope and `!`. Types are `build`, + `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style` + and `test`. +- Commits and pull requests carry no tool or agent attribution, such as + generated-by lines or co-author trailers for tools; real human co-authors may + be credited. +- Committed content contains no paths from a maintainer's machine, such as + `/Users//`, and no references into local `.agent/` task state. +- The [source policy](https://github.com/computer-mcp/github-workflows#source-policy) + checks pull request titles, added content and added or modified paths, and + requires default-branch commits to be verified and committed by GitHub or the + owner. +- Each public repository enforces these rules with a `default branch` ruleset + that has no bypass: it blocks deletion and force pushes, requires signed + commits, and accepts only squash-merged pull requests whose required checks + pass. The host repository, `computer-mcp`, whose pull requests often build on + one another, merges through a merge queue instead of requiring branches to be + up to date: the queue squash merges each pull request only after its own + required checks pass on its queued result, and its CI also runs on + `merge_group` events. Automation proposes its changes as pull requests that merge + automatically once those checks pass. It acts through the Computer MCP + Updater GitHub App, installed only where automation proposes changes: the + App's installation token has GitHub create each proposal commit through the + API, which GitHub signs, and opens the pull request, whose checks then run on + their own. A commit made with `git commit` in a workflow is unsigned, and a + pull request opened with the workflow token waits for manual approval before + its checks run. + +## Signing + +Configure signing in each repository checkout with the owner's registered SSH +signing key; do not change global Git configuration: + +```sh +git config user.name showxu +git config user.email 10173746+showxu@users.noreply.github.com +git config gpg.format ssh +git config user.signingkey ~/.ssh/signing_key.pub +git config commit.gpgsign true +git config tag.gpgsign true +git config gpg.ssh.allowedSignersFile ~/.ssh/allowed_signers +``` + +Replace the key and allowed-signers paths with the local signing +configuration. The allowed-signers file holds the signer's email followed by +the SSH public key, which is registered as a signing key on GitHub. Verify +commits with `git verify-commit HEAD`. + +## GitHub settings + +The [organization check](https://github.com/computer-mcp/github-workflows#organization-check) +compares the organization's live settings with this section and the rulesets +above, and lists every difference; it also compares `profile/catalog.json` +with the public repositories and their About sentences. Run it with an +organization owner's `gh` login after changing a repository setting, an App or +an Actions credential, and update this section in the same change. + +- Every repository allows only squash merges and deletes merged branches. Its + workflows, like the organization default, get a read-only token that cannot + approve pull requests. +- Each public repository's `default branch` ruleset requires at least one + check; each repository's CI owns which. The `computer-mcp` default branch + ruleset is the only ruleset with a merge queue, and its required checks do + not also require an up-to-date branch. +- Only these GitHub Apps are installed. Each has exactly the listed + permissions, is installed only on the listed repositories and subscribes to + no events: + +| App | Permissions | Installed on | Credentials | Stored in | +| --- | --- | --- | --- | --- | +| `computer-mcp-release-notifier` | `actions: write` | `computer-mcp.github.io` | `CATALOG_APP` | `plugin-claude`, `plugin-codex`, `plugin-computer-use`, `plugin-cursor`, `plugin-swift-format` | +| `computer-mcp-updater` | `contents: write`, `pull_requests: write` | `computer-mcp.github.io`, `homebrew-tap` | `AUTOMATION_APP` | `computer-mcp.github.io`, `homebrew-tap` | + +- A repository that uses an App keeps the App's client ID in the + `_CLIENT_ID` variable and its private key in the + `_PRIVATE_KEY` secret. No repository and not the organization + holds any other Actions secret or variable. diff --git a/fixtures/declaration/profile/catalog.json b/fixtures/declaration/profile/catalog.json new file mode 100644 index 0000000..cd846c7 --- /dev/null +++ b/fixtures/declaration/profile/catalog.json @@ -0,0 +1,90 @@ +{ + "organization": { + "login": "computer-mcp", + "name": "Computer MCP", + "about": "Tools for computers" + }, + "promise": "Use computer tools", + "secondary": "Reusable automation", + "mark": "assets/mark.svg", + "navigation": {}, + "groups": [ + { + "title": "Repositories", + "repositories": [ + { + "name": "computer-mcp", + "kind": "tool", + "title": "computer-mcp", + "about": "computer-mcp fixture About", + "outcome": "Run computer-mcp", + "icon": "assets/mark.svg" + }, + { + "name": "computer-mcp.github.io", + "kind": "tool", + "title": "computer-mcp.github.io", + "about": "computer-mcp.github.io fixture About", + "outcome": "Run computer-mcp.github.io", + "icon": "assets/mark.svg" + }, + { + "name": "github-workflows", + "kind": "tool", + "title": "github-workflows", + "about": "github-workflows fixture About", + "outcome": "Run github-workflows", + "icon": "assets/mark.svg" + }, + { + "name": "homebrew-tap", + "kind": "tool", + "title": "homebrew-tap", + "about": "homebrew-tap fixture About", + "outcome": "Run homebrew-tap", + "icon": "assets/mark.svg" + }, + { + "name": "plugin-claude", + "kind": "tool", + "title": "plugin-claude", + "about": "plugin-claude fixture About", + "outcome": "Run plugin-claude", + "icon": "assets/mark.svg" + }, + { + "name": "plugin-codex", + "kind": "tool", + "title": "plugin-codex", + "about": "plugin-codex fixture About", + "outcome": "Run plugin-codex", + "icon": "assets/mark.svg" + }, + { + "name": "plugin-computer-use", + "kind": "tool", + "title": "plugin-computer-use", + "about": "plugin-computer-use fixture About", + "outcome": "Run plugin-computer-use", + "icon": "assets/mark.svg" + }, + { + "name": "plugin-cursor", + "kind": "tool", + "title": "plugin-cursor", + "about": "plugin-cursor fixture About", + "outcome": "Run plugin-cursor", + "icon": "assets/mark.svg" + }, + { + "name": "plugin-swift-format", + "kind": "tool", + "title": "plugin-swift-format", + "about": "plugin-swift-format fixture About", + "outcome": "Run plugin-swift-format", + "icon": "assets/mark.svg" + } + ] + } + ] +} diff --git a/fixtures/organization.json b/fixtures/organization.json new file mode 100644 index 0000000..ba0d6ca --- /dev/null +++ b/fixtures/organization.json @@ -0,0 +1,1157 @@ +{ + "orgs/computer-mcp/repos?type=all&per_page=100&page=1": [ + { + "name": ".github", + "private": false, + "archived": false, + "fork": false, + "description": ".github fixture About" + }, + { + "name": "computer-mcp", + "private": false, + "archived": false, + "fork": false, + "description": "computer-mcp fixture About" + }, + { + "name": "computer-mcp.github.io", + "private": false, + "archived": false, + "fork": false, + "description": "computer-mcp.github.io fixture About" + }, + { + "name": "github-workflows", + "private": false, + "archived": false, + "fork": false, + "description": "github-workflows fixture About" + }, + { + "name": "homebrew-tap", + "private": false, + "archived": false, + "fork": false, + "description": "homebrew-tap fixture About" + }, + { + "name": "plugin-claude", + "private": false, + "archived": false, + "fork": false, + "description": "plugin-claude fixture About" + }, + { + "name": "plugin-codex", + "private": false, + "archived": false, + "fork": false, + "description": "plugin-codex fixture About" + }, + { + "name": "plugin-computer-use", + "private": false, + "archived": false, + "fork": false, + "description": "plugin-computer-use fixture About" + }, + { + "name": "plugin-cursor", + "private": false, + "archived": false, + "fork": false, + "description": "plugin-cursor fixture About" + }, + { + "name": "plugin-swift-format", + "private": false, + "archived": false, + "fork": false, + "description": "plugin-swift-format fixture About" + }, + { + "name": "upstream-fork", + "private": false, + "archived": false, + "fork": true, + "description": "Upstream fixture" + }, + { + "name": "retired", + "private": false, + "archived": true, + "fork": false, + "description": "Retired fixture" + }, + { + "name": "private-repo", + "private": true, + "archived": false, + "fork": false, + "description": "Private fixture" + } + ], + "orgs/computer-mcp/actions/permissions/workflow": { + "default_workflow_permissions": "read", + "can_approve_pull_request_reviews": false + }, + "orgs/computer-mcp/actions/secrets?per_page=100&page=1": { + "secrets": [] + }, + "orgs/computer-mcp/actions/variables?per_page=100&page=1": { + "variables": [] + }, + "repos/computer-mcp/.github": { + "name": ".github", + "private": false, + "archived": false, + "fork": false, + "description": ".github fixture About", + "allow_squash_merge": true, + "allow_merge_commit": false, + "allow_rebase_merge": false, + "delete_branch_on_merge": true, + "default_branch": "master" + }, + "repos/computer-mcp/.github/actions/permissions/workflow": { + "default_workflow_permissions": "read", + "can_approve_pull_request_reviews": false + }, + "repos/computer-mcp/.github/actions/secrets?per_page=100&page=1": { + "secrets": [] + }, + "repos/computer-mcp/.github/actions/variables?per_page=100&page=1": { + "variables": [] + }, + "repos/computer-mcp/.github/rulesets?includes_parents=true&per_page=100&page=1": [ + { + "id": 1, + "name": "default branch", + "source_type": "Repository", + "source": "computer-mcp/.github" + } + ], + "repos/computer-mcp/.github/rulesets/1": { + "id": 1, + "name": "default branch", + "source_type": "Repository", + "source": "computer-mcp/.github", + "target": "branch", + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "ref_name": { + "include": [ + "~DEFAULT_BRANCH" + ], + "exclude": [] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_signatures" + }, + { + "type": "pull_request", + "parameters": { + "allowed_merge_methods": [ + "squash" + ] + } + }, + { + "type": "required_status_checks", + "parameters": { + "required_status_checks": [ + { + "context": "validate" + } + ], + "strict_required_status_checks_policy": false + } + } + ] + }, + "repos/computer-mcp/computer-mcp": { + "name": "computer-mcp", + "private": false, + "archived": false, + "fork": false, + "description": "computer-mcp fixture About", + "allow_squash_merge": true, + "allow_merge_commit": false, + "allow_rebase_merge": false, + "delete_branch_on_merge": true, + "default_branch": "master" + }, + "repos/computer-mcp/computer-mcp/actions/permissions/workflow": { + "default_workflow_permissions": "read", + "can_approve_pull_request_reviews": false + }, + "repos/computer-mcp/computer-mcp/actions/secrets?per_page=100&page=1": { + "secrets": [] + }, + "repos/computer-mcp/computer-mcp/actions/variables?per_page=100&page=1": { + "variables": [] + }, + "repos/computer-mcp/computer-mcp/rulesets?includes_parents=true&per_page=100&page=1": [ + { + "id": 1, + "name": "default branch", + "source_type": "Repository", + "source": "computer-mcp/computer-mcp" + } + ], + "repos/computer-mcp/computer-mcp/rulesets/1": { + "id": 1, + "name": "default branch", + "source_type": "Repository", + "source": "computer-mcp/computer-mcp", + "target": "branch", + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "ref_name": { + "include": [ + "~DEFAULT_BRANCH" + ], + "exclude": [] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_signatures" + }, + { + "type": "pull_request", + "parameters": { + "allowed_merge_methods": [ + "squash" + ] + } + }, + { + "type": "required_status_checks", + "parameters": { + "required_status_checks": [ + { + "context": "validate" + } + ], + "strict_required_status_checks_policy": false + } + }, + { + "type": "merge_queue", + "parameters": { + "merge_method": "SQUASH", + "grouping_strategy": "ALLGREEN" + } + } + ] + }, + "repos/computer-mcp/computer-mcp.github.io": { + "name": "computer-mcp.github.io", + "private": false, + "archived": false, + "fork": false, + "description": "computer-mcp.github.io fixture About", + "allow_squash_merge": true, + "allow_merge_commit": false, + "allow_rebase_merge": false, + "delete_branch_on_merge": true, + "default_branch": "master" + }, + "repos/computer-mcp/computer-mcp.github.io/actions/permissions/workflow": { + "default_workflow_permissions": "read", + "can_approve_pull_request_reviews": false + }, + "repos/computer-mcp/computer-mcp.github.io/actions/secrets?per_page=100&page=1": { + "secrets": [ + { + "name": "AUTOMATION_APP_PRIVATE_KEY" + } + ] + }, + "repos/computer-mcp/computer-mcp.github.io/actions/variables?per_page=100&page=1": { + "variables": [ + { + "name": "AUTOMATION_APP_CLIENT_ID" + } + ] + }, + "repos/computer-mcp/computer-mcp.github.io/rulesets?includes_parents=true&per_page=100&page=1": [ + { + "id": 1, + "name": "default branch", + "source_type": "Repository", + "source": "computer-mcp/computer-mcp.github.io" + } + ], + "repos/computer-mcp/computer-mcp.github.io/rulesets/1": { + "id": 1, + "name": "default branch", + "source_type": "Repository", + "source": "computer-mcp/computer-mcp.github.io", + "target": "branch", + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "ref_name": { + "include": [ + "~DEFAULT_BRANCH" + ], + "exclude": [] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_signatures" + }, + { + "type": "pull_request", + "parameters": { + "allowed_merge_methods": [ + "squash" + ] + } + }, + { + "type": "required_status_checks", + "parameters": { + "required_status_checks": [ + { + "context": "validate" + } + ], + "strict_required_status_checks_policy": false + } + } + ] + }, + "repos/computer-mcp/github-workflows": { + "name": "github-workflows", + "private": false, + "archived": false, + "fork": false, + "description": "github-workflows fixture About", + "allow_squash_merge": true, + "allow_merge_commit": false, + "allow_rebase_merge": false, + "delete_branch_on_merge": true, + "default_branch": "master" + }, + "repos/computer-mcp/github-workflows/actions/permissions/workflow": { + "default_workflow_permissions": "read", + "can_approve_pull_request_reviews": false + }, + "repos/computer-mcp/github-workflows/actions/secrets?per_page=100&page=1": { + "secrets": [] + }, + "repos/computer-mcp/github-workflows/actions/variables?per_page=100&page=1": { + "variables": [] + }, + "repos/computer-mcp/github-workflows/rulesets?includes_parents=true&per_page=100&page=1": [ + { + "id": 1, + "name": "default branch", + "source_type": "Repository", + "source": "computer-mcp/github-workflows" + } + ], + "repos/computer-mcp/github-workflows/rulesets/1": { + "id": 1, + "name": "default branch", + "source_type": "Repository", + "source": "computer-mcp/github-workflows", + "target": "branch", + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "ref_name": { + "include": [ + "~DEFAULT_BRANCH" + ], + "exclude": [] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_signatures" + }, + { + "type": "pull_request", + "parameters": { + "allowed_merge_methods": [ + "squash" + ] + } + }, + { + "type": "required_status_checks", + "parameters": { + "required_status_checks": [ + { + "context": "validate" + } + ], + "strict_required_status_checks_policy": false + } + } + ] + }, + "repos/computer-mcp/homebrew-tap": { + "name": "homebrew-tap", + "private": false, + "archived": false, + "fork": false, + "description": "homebrew-tap fixture About", + "allow_squash_merge": true, + "allow_merge_commit": false, + "allow_rebase_merge": false, + "delete_branch_on_merge": true, + "default_branch": "master" + }, + "repos/computer-mcp/homebrew-tap/actions/permissions/workflow": { + "default_workflow_permissions": "read", + "can_approve_pull_request_reviews": false + }, + "repos/computer-mcp/homebrew-tap/actions/secrets?per_page=100&page=1": { + "secrets": [ + { + "name": "AUTOMATION_APP_PRIVATE_KEY" + } + ] + }, + "repos/computer-mcp/homebrew-tap/actions/variables?per_page=100&page=1": { + "variables": [ + { + "name": "AUTOMATION_APP_CLIENT_ID" + } + ] + }, + "repos/computer-mcp/homebrew-tap/rulesets?includes_parents=true&per_page=100&page=1": [ + { + "id": 1, + "name": "default branch", + "source_type": "Repository", + "source": "computer-mcp/homebrew-tap" + } + ], + "repos/computer-mcp/homebrew-tap/rulesets/1": { + "id": 1, + "name": "default branch", + "source_type": "Repository", + "source": "computer-mcp/homebrew-tap", + "target": "branch", + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "ref_name": { + "include": [ + "~DEFAULT_BRANCH" + ], + "exclude": [] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_signatures" + }, + { + "type": "pull_request", + "parameters": { + "allowed_merge_methods": [ + "squash" + ] + } + }, + { + "type": "required_status_checks", + "parameters": { + "required_status_checks": [ + { + "context": "validate" + } + ], + "strict_required_status_checks_policy": false + } + } + ] + }, + "repos/computer-mcp/plugin-claude": { + "name": "plugin-claude", + "private": false, + "archived": false, + "fork": false, + "description": "plugin-claude fixture About", + "allow_squash_merge": true, + "allow_merge_commit": false, + "allow_rebase_merge": false, + "delete_branch_on_merge": true, + "default_branch": "master" + }, + "repos/computer-mcp/plugin-claude/actions/permissions/workflow": { + "default_workflow_permissions": "read", + "can_approve_pull_request_reviews": false + }, + "repos/computer-mcp/plugin-claude/actions/secrets?per_page=100&page=1": { + "secrets": [ + { + "name": "CATALOG_APP_PRIVATE_KEY" + } + ] + }, + "repos/computer-mcp/plugin-claude/actions/variables?per_page=100&page=1": { + "variables": [ + { + "name": "CATALOG_APP_CLIENT_ID" + } + ] + }, + "repos/computer-mcp/plugin-claude/rulesets?includes_parents=true&per_page=100&page=1": [ + { + "id": 1, + "name": "default branch", + "source_type": "Repository", + "source": "computer-mcp/plugin-claude" + } + ], + "repos/computer-mcp/plugin-claude/rulesets/1": { + "id": 1, + "name": "default branch", + "source_type": "Repository", + "source": "computer-mcp/plugin-claude", + "target": "branch", + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "ref_name": { + "include": [ + "~DEFAULT_BRANCH" + ], + "exclude": [] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_signatures" + }, + { + "type": "pull_request", + "parameters": { + "allowed_merge_methods": [ + "squash" + ] + } + }, + { + "type": "required_status_checks", + "parameters": { + "required_status_checks": [ + { + "context": "validate" + } + ], + "strict_required_status_checks_policy": false + } + } + ] + }, + "repos/computer-mcp/plugin-codex": { + "name": "plugin-codex", + "private": false, + "archived": false, + "fork": false, + "description": "plugin-codex fixture About", + "allow_squash_merge": true, + "allow_merge_commit": false, + "allow_rebase_merge": false, + "delete_branch_on_merge": true, + "default_branch": "master" + }, + "repos/computer-mcp/plugin-codex/actions/permissions/workflow": { + "default_workflow_permissions": "read", + "can_approve_pull_request_reviews": false + }, + "repos/computer-mcp/plugin-codex/actions/secrets?per_page=100&page=1": { + "secrets": [ + { + "name": "CATALOG_APP_PRIVATE_KEY" + } + ] + }, + "repos/computer-mcp/plugin-codex/actions/variables?per_page=100&page=1": { + "variables": [ + { + "name": "CATALOG_APP_CLIENT_ID" + } + ] + }, + "repos/computer-mcp/plugin-codex/rulesets?includes_parents=true&per_page=100&page=1": [ + { + "id": 1, + "name": "default branch", + "source_type": "Repository", + "source": "computer-mcp/plugin-codex" + } + ], + "repos/computer-mcp/plugin-codex/rulesets/1": { + "id": 1, + "name": "default branch", + "source_type": "Repository", + "source": "computer-mcp/plugin-codex", + "target": "branch", + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "ref_name": { + "include": [ + "~DEFAULT_BRANCH" + ], + "exclude": [] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_signatures" + }, + { + "type": "pull_request", + "parameters": { + "allowed_merge_methods": [ + "squash" + ] + } + }, + { + "type": "required_status_checks", + "parameters": { + "required_status_checks": [ + { + "context": "validate" + } + ], + "strict_required_status_checks_policy": false + } + } + ] + }, + "repos/computer-mcp/plugin-computer-use": { + "name": "plugin-computer-use", + "private": false, + "archived": false, + "fork": false, + "description": "plugin-computer-use fixture About", + "allow_squash_merge": true, + "allow_merge_commit": false, + "allow_rebase_merge": false, + "delete_branch_on_merge": true, + "default_branch": "master" + }, + "repos/computer-mcp/plugin-computer-use/actions/permissions/workflow": { + "default_workflow_permissions": "read", + "can_approve_pull_request_reviews": false + }, + "repos/computer-mcp/plugin-computer-use/actions/secrets?per_page=100&page=1": { + "secrets": [ + { + "name": "CATALOG_APP_PRIVATE_KEY" + } + ] + }, + "repos/computer-mcp/plugin-computer-use/actions/variables?per_page=100&page=1": { + "variables": [ + { + "name": "CATALOG_APP_CLIENT_ID" + } + ] + }, + "repos/computer-mcp/plugin-computer-use/rulesets?includes_parents=true&per_page=100&page=1": [ + { + "id": 1, + "name": "default branch", + "source_type": "Repository", + "source": "computer-mcp/plugin-computer-use" + } + ], + "repos/computer-mcp/plugin-computer-use/rulesets/1": { + "id": 1, + "name": "default branch", + "source_type": "Repository", + "source": "computer-mcp/plugin-computer-use", + "target": "branch", + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "ref_name": { + "include": [ + "~DEFAULT_BRANCH" + ], + "exclude": [] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_signatures" + }, + { + "type": "pull_request", + "parameters": { + "allowed_merge_methods": [ + "squash" + ] + } + }, + { + "type": "required_status_checks", + "parameters": { + "required_status_checks": [ + { + "context": "validate" + } + ], + "strict_required_status_checks_policy": false + } + } + ] + }, + "repos/computer-mcp/plugin-cursor": { + "name": "plugin-cursor", + "private": false, + "archived": false, + "fork": false, + "description": "plugin-cursor fixture About", + "allow_squash_merge": true, + "allow_merge_commit": false, + "allow_rebase_merge": false, + "delete_branch_on_merge": true, + "default_branch": "master" + }, + "repos/computer-mcp/plugin-cursor/actions/permissions/workflow": { + "default_workflow_permissions": "read", + "can_approve_pull_request_reviews": false + }, + "repos/computer-mcp/plugin-cursor/actions/secrets?per_page=100&page=1": { + "secrets": [ + { + "name": "CATALOG_APP_PRIVATE_KEY" + } + ] + }, + "repos/computer-mcp/plugin-cursor/actions/variables?per_page=100&page=1": { + "variables": [ + { + "name": "CATALOG_APP_CLIENT_ID" + } + ] + }, + "repos/computer-mcp/plugin-cursor/rulesets?includes_parents=true&per_page=100&page=1": [ + { + "id": 1, + "name": "default branch", + "source_type": "Repository", + "source": "computer-mcp/plugin-cursor" + } + ], + "repos/computer-mcp/plugin-cursor/rulesets/1": { + "id": 1, + "name": "default branch", + "source_type": "Repository", + "source": "computer-mcp/plugin-cursor", + "target": "branch", + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "ref_name": { + "include": [ + "~DEFAULT_BRANCH" + ], + "exclude": [] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_signatures" + }, + { + "type": "pull_request", + "parameters": { + "allowed_merge_methods": [ + "squash" + ] + } + }, + { + "type": "required_status_checks", + "parameters": { + "required_status_checks": [ + { + "context": "validate" + } + ], + "strict_required_status_checks_policy": false + } + } + ] + }, + "repos/computer-mcp/plugin-swift-format": { + "name": "plugin-swift-format", + "private": false, + "archived": false, + "fork": false, + "description": "plugin-swift-format fixture About", + "allow_squash_merge": true, + "allow_merge_commit": false, + "allow_rebase_merge": false, + "delete_branch_on_merge": true, + "default_branch": "master" + }, + "repos/computer-mcp/plugin-swift-format/actions/permissions/workflow": { + "default_workflow_permissions": "read", + "can_approve_pull_request_reviews": false + }, + "repos/computer-mcp/plugin-swift-format/actions/secrets?per_page=100&page=1": { + "secrets": [ + { + "name": "CATALOG_APP_PRIVATE_KEY" + } + ] + }, + "repos/computer-mcp/plugin-swift-format/actions/variables?per_page=100&page=1": { + "variables": [ + { + "name": "CATALOG_APP_CLIENT_ID" + } + ] + }, + "repos/computer-mcp/plugin-swift-format/rulesets?includes_parents=true&per_page=100&page=1": [ + { + "id": 1, + "name": "default branch", + "source_type": "Repository", + "source": "computer-mcp/plugin-swift-format" + } + ], + "repos/computer-mcp/plugin-swift-format/rulesets/1": { + "id": 1, + "name": "default branch", + "source_type": "Repository", + "source": "computer-mcp/plugin-swift-format", + "target": "branch", + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "ref_name": { + "include": [ + "~DEFAULT_BRANCH" + ], + "exclude": [] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_signatures" + }, + { + "type": "pull_request", + "parameters": { + "allowed_merge_methods": [ + "squash" + ] + } + }, + { + "type": "required_status_checks", + "parameters": { + "required_status_checks": [ + { + "context": "validate" + } + ], + "strict_required_status_checks_policy": false + } + } + ] + }, + "repos/computer-mcp/upstream-fork": { + "name": "upstream-fork", + "private": false, + "archived": false, + "fork": true, + "description": "Upstream fixture", + "allow_squash_merge": true, + "allow_merge_commit": false, + "allow_rebase_merge": false, + "delete_branch_on_merge": true, + "default_branch": "upstream" + }, + "repos/computer-mcp/upstream-fork/actions/permissions/workflow": { + "default_workflow_permissions": "read", + "can_approve_pull_request_reviews": false + }, + "repos/computer-mcp/upstream-fork/actions/secrets?per_page=100&page=1": { + "secrets": [] + }, + "repos/computer-mcp/upstream-fork/actions/variables?per_page=100&page=1": { + "variables": [] + }, + "repos/computer-mcp/upstream-fork/rulesets?includes_parents=true&per_page=100&page=1": [ + { + "id": 1, + "name": "default branch", + "source_type": "Repository", + "source": "computer-mcp/upstream-fork" + } + ], + "repos/computer-mcp/upstream-fork/rulesets/1": { + "id": 1, + "name": "default branch", + "source_type": "Repository", + "source": "computer-mcp/upstream-fork", + "target": "branch", + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "ref_name": { + "include": [ + "~DEFAULT_BRANCH" + ], + "exclude": [] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_signatures" + }, + { + "type": "pull_request", + "parameters": { + "allowed_merge_methods": [ + "squash" + ] + } + }, + { + "type": "required_status_checks", + "parameters": { + "required_status_checks": [ + { + "context": "validate" + } + ], + "strict_required_status_checks_policy": false + } + } + ] + }, + "repos/computer-mcp/retired": { + "name": "retired", + "private": false, + "archived": true, + "fork": false, + "description": "Retired fixture", + "allow_squash_merge": true, + "allow_merge_commit": false, + "allow_rebase_merge": false, + "delete_branch_on_merge": true, + "default_branch": "master" + }, + "repos/computer-mcp/retired/actions/permissions/workflow": { + "default_workflow_permissions": "read", + "can_approve_pull_request_reviews": false + }, + "repos/computer-mcp/retired/actions/secrets?per_page=100&page=1": { + "secrets": [] + }, + "repos/computer-mcp/retired/actions/variables?per_page=100&page=1": { + "variables": [] + }, + "repos/computer-mcp/retired/rulesets?includes_parents=true&per_page=100&page=1": [ + { + "id": 1, + "name": "default branch", + "source_type": "Repository", + "source": "computer-mcp/retired" + } + ], + "repos/computer-mcp/retired/rulesets/1": { + "id": 1, + "name": "default branch", + "source_type": "Repository", + "source": "computer-mcp/retired", + "target": "branch", + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "ref_name": { + "include": [ + "~DEFAULT_BRANCH" + ], + "exclude": [] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_signatures" + }, + { + "type": "pull_request", + "parameters": { + "allowed_merge_methods": [ + "squash" + ] + } + }, + { + "type": "required_status_checks", + "parameters": { + "required_status_checks": [ + { + "context": "validate" + } + ], + "strict_required_status_checks_policy": false + } + } + ] + }, + "repos/computer-mcp/private-repo": { + "name": "private-repo", + "private": true, + "archived": false, + "fork": false, + "description": "Private fixture", + "allow_squash_merge": true, + "allow_merge_commit": false, + "allow_rebase_merge": false, + "delete_branch_on_merge": true, + "default_branch": "master" + }, + "repos/computer-mcp/private-repo/actions/permissions/workflow": { + "default_workflow_permissions": "read", + "can_approve_pull_request_reviews": false + }, + "repos/computer-mcp/private-repo/actions/secrets?per_page=100&page=1": { + "secrets": [] + }, + "repos/computer-mcp/private-repo/actions/variables?per_page=100&page=1": { + "variables": [] + }, + "repos/computer-mcp/private-repo/rulesets?includes_parents=true&per_page=100&page=1": [], + "user/installations/1/repositories?per_page=100&page=1": { + "repositories": [ + { + "name": "computer-mcp.github.io" + } + ] + }, + "user/installations/2/repositories?per_page=100&page=1": { + "repositories": [ + { + "name": "computer-mcp.github.io" + }, + { + "name": "homebrew-tap" + } + ] + }, + "orgs/computer-mcp/installations?per_page=100&page=1": { + "installations": [ + { + "id": 1, + "app_slug": "computer-mcp-release-notifier", + "permissions": { + "actions": "write", + "metadata": "read" + }, + "events": [], + "repository_selection": "selected" + }, + { + "id": 2, + "app_slug": "computer-mcp-updater", + "permissions": { + "contents": "write", + "pull_requests": "write", + "metadata": "read" + }, + "events": [], + "repository_selection": "selected" + } + ] + } +} diff --git a/scripts/check-organization.py b/scripts/check-organization.py new file mode 100644 index 0000000..02533ae --- /dev/null +++ b/scripts/check-organization.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Check catalog membership, About text and declared settings using GET only.""" + +import argparse +import json +from pathlib import Path +import re +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import declaration +from github_api import Fixture, GitHub, Unavailable, unique_object +import settings + + +def read_catalog(root, organization, revision=None): + path = Path(root) / "profile/catalog.json" + if revision is None and not path.is_file(): + raise ValueError("profile/catalog.json is missing at the requested .github revision") + catalog = json.loads(declaration.read_file(root, "profile/catalog.json", revision), object_pairs_hook=unique_object) + # The profile renderer owns the complete visual schema in catalog.ts. + # This projection validates every field used for membership and About checks. + try: + if catalog["organization"]["login"] != organization or not isinstance(catalog["groups"], list): + raise ValueError("catalog organization/groups do not match the requested organization") + entries = {} + for group in catalog["groups"]: + if not isinstance(group["repositories"], list) or not group["repositories"]: + raise ValueError("catalog group repositories must be a nonempty array") + for entry in group["repositories"]: + name, about = entry["name"], entry["about"] + if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9_.-]+", name) or name in {".", "..", ".github", ".github-private"}: + raise ValueError("invalid public catalog repository name") + if name.lower() in entries: + raise ValueError("duplicate catalog repository: " + name) + if not isinstance(about, str) or not about.strip(): + raise ValueError(f"{name}: catalog about must be nonempty") + entries[name.lower()] = entry + except (KeyError, TypeError): + raise ValueError("catalog requires organization.login and groups[].repositories[].name/about") from None + return entries + + +def membership(entries, repositories): + if not isinstance(repositories, list) or any(not isinstance(repo, dict) or not isinstance(repo.get("name"), str) + or any(type(repo.get(key)) is not bool for key in ("private", "archived", "fork")) for repo in repositories): + raise ValueError("GitHub returned an invalid repository inventory") + live = {repo["name"].lower(): repo for repo in repositories + if not repo["private"] and not repo["archived"] and not repo["fork"] and repo["name"] != ".github"} + findings = [f"DRIFT catalog: missing {name}" for name in sorted(live.keys() - entries.keys())] + findings += [f"DRIFT catalog: extra {name}" for name in sorted(entries.keys() - live.keys())] + for name in sorted(live): + about = live[name].get("description") + if not isinstance(about, str) or not about.strip(): + findings.append(f"DRIFT {name}: GitHub About is empty or unavailable") + elif name in entries and about != entries[name]["about"]: + findings.append(f"DRIFT {name}: catalog about {entries[name]['about']!r}; GitHub About {about!r}") + return findings + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--declaration-root", type=Path, required=True) + parser.add_argument("--declaration-revision", required=True) + parser.add_argument("--organization", default="computer-mcp") + parser.add_argument("--mode", choices=("offline", "online"), default="online") + parser.add_argument("--fixture", type=Path) + args = parser.parse_args(argv) + try: + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9-]*", args.organization): + raise ValueError("invalid organization") + if (args.mode == "offline") != bool(args.fixture): + raise ValueError("offline mode requires --fixture; online mode does not accept fixtures") + text = declaration.revision(args.declaration_root, args.declaration_revision, offline=args.mode == "offline") + entries = read_catalog(args.declaration_root, args.organization, None if args.mode == "offline" else args.declaration_revision) + expected = declaration.settings_policy(text) + api = Fixture(args.fixture) if args.fixture else GitHub() + repositories = api.collection(f"orgs/{args.organization}/repos?type=all") + findings = membership(entries, repositories) + findings += settings.check(api, args.organization, expected, repositories) + for finding in findings: + print(" ".join(finding.splitlines())) + if not findings: + print("PASS organization membership, About and declared settings") + return 2 if any(f.startswith("UNKNOWN ") for f in findings) else int(bool(findings)) + except Unavailable as error: + print("UNKNOWN organization: " + str(error)) + return 2 + except (OSError, ValueError, KeyError, TypeError) as error: + print("FAIL organization: " + " ".join(str(error).splitlines())) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/declaration.py b/scripts/declaration.py new file mode 100644 index 0000000..9711227 --- /dev/null +++ b/scripts/declaration.py @@ -0,0 +1,103 @@ +"""Read the supported MAINTENANCE prose; absent declarations never become defaults.""" + +from pathlib import Path +import re +import subprocess + + +def section(text, heading): + match = re.search(r"^## " + re.escape(heading) + r"\n(.*?)(?=^## |\Z)", text, re.M | re.S) + if not match: + raise ValueError(f"MAINTENANCE.md: missing {heading} section") + return " ".join(match[1].split()) + + +def require(text, phrases, label): + for phrase in phrases: + if phrase not in text: + raise ValueError(f"MAINTENANCE.md: unsupported or missing {label} declaration: {phrase}") + + +def source_policy(text): + changes = section(text, "Branches and changes") + signing = section(text, "Signing") + require(changes, ["are SSH-signed", "are signed by GitHub", "Dependabot may author changes.", + "Pull request titles and commit subjects follow Conventional Commits:", + "optional scope and `!`", "Commits and pull requests carry no tool or agent attribution", + "real human co-authors may be credited.", + "Committed content contains no paths from a maintainer's machine", + "no references into local `.agent/` task state.", + "checks pull request titles, added content and added or modified paths", + "requires default-branch commits to be verified and committed by GitHub or the owner."], + "source policy") + require(signing, ["gpg.format ssh", "commit.gpgsign true", "registered as a signing key on GitHub"], "signing") + identity = re.search(r"owner's name `([^`]+)` and noreply email `([^`]+)`", changes) + types = re.search(r"Types are (.*?)\.", changes) + branch = re.search(r"The default branch is `([^`]+)`", changes) + if not identity or not types or not branch: + raise ValueError("MAINTENANCE.md: missing identity, commit types or default branch") + names = re.findall(r"`([a-z]+)`", types[1]) + if not names or len(names) != len(set(names)): + raise ValueError("MAINTENANCE.md: invalid Conventional Commit types") + return {"owner": identity[1], "email": identity[2], "types": names, "default_branch": branch[1]} + + +def settings_policy(text): + changes = section(text, "Branches and changes") + settings = section(text, "GitHub settings") + require(changes, ["A fork keeps its upstream branch names", "ruleset that has no bypass", "blocks deletion and force pushes", + "requires signed commits", "only squash-merged pull requests", + "only after its own required checks pass"], "branch rules") + require(settings, ["Every repository allows only squash merges and deletes merged branches.", + "read-only token that cannot approve pull requests.", + "Each public repository's `default branch` ruleset requires at least one check", + "the only ruleset with a merge queue", "do not also require an up-to-date branch.", + "Only these GitHub Apps are installed.", "exactly the listed permissions", + "installed only on the listed repositories", "subscribes to no events", + "No repository and not the organization holds any other Actions secret or variable."], "settings") + branch = re.search(r"The default branch is `([^`]+)`", changes) + queue = re.search(r"The `([^`]+)` default branch ruleset is the only ruleset with a merge queue", settings) + credential_names = re.search(r"client ID in the `([^`]+)` variable and its private key in the " + r"`([^`]+)` secret", settings) + if not branch or not queue: + raise ValueError("MAINTENANCE.md: missing default branch or merge queue owner") + if not credential_names: + raise ValueError("MAINTENANCE.md: missing App credential naming declaration") + raw = text.split("## GitHub settings\n", 1)[1].split("\n## ", 1)[0] + rows = [line.strip().strip("|").split("|") for line in raw.splitlines() if line.startswith("|")] + columns = ["App", "Permissions", "Installed on", "Credentials", "Stored in"] + if len(rows) < 3 or [cell.strip() for cell in rows[0]] != columns: + raise ValueError("MAINTENANCE.md: missing GitHub Apps table") + apps = {} + for row in rows[2:]: + try: + (slug,), permissions, installed, (prefix,), stored = [re.findall(r"`([^`]+)`", cell) for cell in row] + permissions = dict(permission.split(": ") for permission in permissions) + except ValueError: + raise ValueError("MAINTENANCE.md: malformed GitHub Apps row") from None + if slug in apps or not installed or not stored or not permissions: + raise ValueError("MAINTENANCE.md: duplicate or incomplete GitHub App") + apps[slug] = {"permissions": permissions, "installed": sorted(installed), "prefix": prefix, "stored": stored} + return {"default_branch": branch[1], "queue_repository": queue[1], "apps": apps, + "credential_variable": credential_names[1], "credential_secret": credential_names[2]} + + +def revision(root, expected, *, offline=False): + if not re.fullmatch(r"[0-9a-f]{40}", expected): + raise ValueError("declaration-revision must be a full 40-hex commit SHA") + if not offline: + actual = subprocess.check_output(["git", "-C", str(root), "rev-parse", "HEAD"], text=True).strip() + if actual != expected: + raise ValueError("declaration checkout does not match declaration-revision") + print(f"MAINTENANCE revision: {expected}" + (" (offline fixture)" if offline else "")) + return read_file(root, "MAINTENANCE.md", None if offline else expected) + + +def read_file(root, path, revision=None): + if revision is None: + return (Path(root) / path).read_text(encoding="utf-8") + try: + return subprocess.check_output(["git", "-C", str(root), "show", revision + ":" + path], + stderr=subprocess.DEVNULL).decode("utf-8") + except subprocess.CalledProcessError: + raise ValueError(f"{path} is missing at the requested .github revision") from None diff --git a/scripts/github_api.py b/scripts/github_api.py new file mode 100644 index 0000000..40d2a1f --- /dev/null +++ b/scripts/github_api.py @@ -0,0 +1,60 @@ +"""GET-only GitHub observations, with the same interface for offline fixtures.""" + +import json +from pathlib import Path +import re +import subprocess + + +class Unavailable(ValueError): + pass + + +def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate JSON key: {key}") + result[key] = value + return result + + +class GitHub: + def get(self, path): + if not re.match(r"^(repos|orgs|user/installations)/", path) or ".." in path: + raise ValueError("Invalid GitHub API path") + try: + result = subprocess.run(["gh", "api", "--method", "GET", path, + "-H", "Accept: application/vnd.github+json"], + capture_output=True, text=True, timeout=60) + except (OSError, subprocess.SubprocessError): + raise Unavailable(f"GET {path}: request unavailable") from None + if result.returncode: + raise Unavailable(f"GET {path}: unavailable; check read permissions and resource existence") + try: + return json.loads(result.stdout, object_pairs_hook=unique_object) + except ValueError: + raise Unavailable(f"GET {path}: invalid JSON") from None + + def collection(self, path, key=None): + result = [] + for page in range(1, 10001): + separator = "&" if "?" in path else "?" + value = self.get(f"{path}{separator}per_page=100&page={page}") + batch = value.get(key) if key and isinstance(value, dict) else value if key is None else None + if not isinstance(batch, list): + raise Unavailable(f"GET {path}: invalid collection") + result.extend(batch) + if len(batch) < 100: + return result + raise Unavailable(f"GET {path}: pagination limit exceeded") + + +class Fixture(GitHub): + def __init__(self, path): + self.responses = json.loads(Path(path).read_text(), object_pairs_hook=unique_object) + + def get(self, path): + if path not in self.responses: + raise Unavailable(f"GET {path}: absent from offline fixture") + return self.responses[path] diff --git a/scripts/settings.py b/scripts/settings.py new file mode 100644 index 0000000..c475f92 --- /dev/null +++ b/scripts/settings.py @@ -0,0 +1,142 @@ +"""Compare the settings declared in Computer MCP's MAINTENANCE with GET data.""" + +from github_api import Unavailable + + +READ_ONLY = {"default_workflow_permissions": "read", "can_approve_pull_request_reviews": False} +MERGES = {"allow_squash_merge": True, "allow_merge_commit": False, + "allow_rebase_merge": False, "delete_branch_on_merge": True} +RULES = {"deletion", "non_fast_forward", "required_signatures", "pull_request", "required_status_checks"} + + +def compare(label, expected, actual): + result = [] + if not isinstance(actual, dict): + return [f"UNKNOWN {label}: expected an object response"] + for key, value in expected.items(): + if key not in actual: + result.append(f"UNKNOWN {label}.{key}: field unavailable") + elif type(actual[key]) is not type(value) or actual[key] != value: + result.append(f"DRIFT {label}.{key}: expected {value!r}; observed {actual[key]!r}") + return result + + +def ruleset_findings(repo, ruleset, queue): + findings = compare(repo + ".ruleset", {"target": "branch", "enforcement": "active", "bypass_actors": []}, ruleset) + findings += compare(repo + ".ruleset.ref_name", {"include": ["~DEFAULT_BRANCH"], "exclude": []}, + ruleset.get("conditions", {}).get("ref_name")) + entries = ruleset.get("rules") + if not isinstance(entries, list) or any(not isinstance(rule, dict) or "type" not in rule for rule in entries): + return findings + [f"UNKNOWN {repo}.ruleset: rules unavailable"] + rules = {rule["type"]: rule.get("parameters", {}) for rule in entries} + if len(rules) != len(entries): + findings.append(f"DRIFT {repo}.ruleset: duplicate rule types") + for missing in sorted(RULES - rules.keys()): + findings.append(f"DRIFT {repo}.ruleset: missing {missing}") + if "pull_request" in rules: + findings += compare(repo + ".ruleset.pull_request", {"allowed_merge_methods": ["squash"]}, rules["pull_request"]) + if "required_status_checks" in rules: + checks = rules["required_status_checks"].get("required_status_checks") + if not isinstance(checks, list) or not checks or any(not item.get("context") for item in checks): + findings.append(f"DRIFT {repo}.ruleset: requires no valid checks") + if queue: + findings += compare(repo + ".ruleset.required_status_checks", + {"strict_required_status_checks_policy": False}, rules["required_status_checks"]) + if queue: + if "merge_queue" not in rules: + findings.append(f"DRIFT {repo}.ruleset: missing merge queue") + else: + findings += compare(repo + ".ruleset.merge_queue", {"merge_method": "SQUASH", "grouping_strategy": "ALLGREEN"}, + rules["merge_queue"]) + return findings + + +def check(api, organization, declaration, repositories): + findings = [] + + def observe(function): + try: + findings.extend(function()) + except (Unavailable, KeyError, TypeError, AttributeError) as error: + findings.append("UNKNOWN " + (str(error) if isinstance(error, Unavailable) else "incomplete settings response")) + + def names(path, key): + return sorted(item["name"] for item in api.collection(path, key)) + + def credential_findings(root, expected): + result = [] + for kind in ("secrets", "variables"): + result += compare(root, {kind: sorted(expected.get(kind, []))}, {kind: names(root + "/actions/" + kind, kind)}) + return result + + credentials = {} + apps = declaration["apps"] + for app in apps.values(): + for repo in app["stored"]: + expected = credentials.setdefault(repo, {"secrets": set(), "variables": set()}) + expected["secrets"].add(declaration["credential_secret"].replace("", app["prefix"])) + expected["variables"].add(declaration["credential_variable"].replace("", app["prefix"])) + root = "orgs/" + organization + observe(lambda: compare("organization.workflow_token", READ_ONLY, api.get(root + "/actions/permissions/workflow"))) + observe(lambda: credential_findings(root, {})) + repo_names = {repo["name"] for repo in repositories} + declared = set(credentials).union(*(app["installed"] for app in apps.values())) + findings.extend(f"DRIFT {repo}: declared in MAINTENANCE but absent from organization" for repo in sorted(declared - repo_names)) + + def repository_findings(repo): + name = repo["name"] + root = f"repos/{organization}/{name}" + detail = api.get(root) + result = compare(name, MERGES, detail) + if not repo["fork"]: + result += compare(name, {"default_branch": declaration["default_branch"]}, detail) + observe(lambda: compare(name + ".workflow_token", READ_ONLY, api.get(root + "/actions/permissions/workflow"))) + observe(lambda: credential_findings(root, credentials.get(name, {}))) + rulesets = api.collection(root + "/rulesets?includes_parents=true") + owned = [] + for item in rulesets: + source = item.get("source") + if item.get("source_type") == "Repository" and source == f"{organization}/{name}": + endpoint = root + f"/rulesets/{item['id']}" + elif item.get("source_type") == "Organization" and source == organization: + endpoint = f"orgs/{organization}/rulesets/{item['id']}" + else: + raise Unavailable(f"{name}: unknown ruleset source") + ruleset = api.get(endpoint) + if ruleset["name"] == "default branch" and source == f"{organization}/{name}": + owned.append(ruleset) + has_queue = any(rule["type"] == "merge_queue" for rule in ruleset["rules"]) + if has_queue and not (name == declaration["queue_repository"] and ruleset in owned): + result.append(f"DRIFT {name}: undeclared merge queue in {ruleset['name']}") + if not repo["private"]: + if len(owned) != 1: + result.append(f"DRIFT {name}: expected one owned default branch ruleset; observed {len(owned)}") + else: + result += ruleset_findings(name, owned[0], name == declaration["queue_repository"]) + return result + + for repo in repositories: + observe(lambda repo=repo: repository_findings(repo)) + + def app_findings(): + result = [] + installed_list = api.collection(f"orgs/{organization}/installations", "installations") + installed = {item["app_slug"]: item for item in installed_list} + if len(installed) != len(installed_list): + result.append("DRIFT organization: duplicate App installations") + result.extend(f"DRIFT {slug}: installed but undeclared" for slug in sorted(installed.keys() - apps.keys())) + for slug, app in apps.items(): + installation = installed.get(slug) + if installation is None: + result.append(f"DRIFT {slug}: not installed") + continue + permissions = {key: value for key, value in installation["permissions"].items() if key != "metadata"} + result += compare(slug, {"permissions": app["permissions"], "events": [], "repository_selection": "selected"}, + {**installation, "permissions": permissions}) + observe(lambda installation=installation, app=app, slug=slug: + compare(slug, {"repositories": app["installed"]}, + {"repositories": names(f"user/installations/{installation['id']}/repositories", "repositories")})) + return result + + observe(app_findings) + return findings diff --git a/tests/test_organization.py b/tests/test_organization.py new file mode 100644 index 0000000..b471eb0 --- /dev/null +++ b/tests/test_organization.py @@ -0,0 +1,213 @@ +import copy +import importlib.util +import io +import json +import re +from contextlib import redirect_stdout +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +import declaration +from github_api import Fixture, GitHub, Unavailable +import settings + +spec = importlib.util.spec_from_file_location("organization", ROOT / "scripts/check-organization.py") +organization = importlib.util.module_from_spec(spec) +spec.loader.exec_module(organization) +STANDARD = ROOT / "fixtures/declaration" +FIXTURE = ROOT / "fixtures/organization.json" +REVISION = "1" * 40 + + +class OrganizationTests(unittest.TestCase): + def setUp(self): + self.api = Fixture(FIXTURE) + self.policy = declaration.settings_policy((STANDARD / "MAINTENANCE.md").read_text()) + self.repos = self.api.collection("orgs/computer-mcp/repos?type=all") + self.catalog = organization.read_catalog(STANDARD, "computer-mcp") + + def check_settings(self): + return settings.check(self.api, "computer-mcp", self.policy, self.repos) + + def test_complete_fixture_passes(self): + self.assertEqual(organization.membership(self.catalog, self.repos), []) + self.assertEqual(self.check_settings(), []) + + def test_offline_cli_reports_revision_without_network(self): + with patch.object(GitHub, "get", side_effect=AssertionError("network")), redirect_stdout(io.StringIO()) as output: + status = organization.main(["--declaration-root", str(STANDARD), "--declaration-revision", REVISION, + "--mode", "offline", "--fixture", str(FIXTURE)]) + self.assertEqual(status, 0) + self.assertIn(REVISION + " (offline fixture)", output.getvalue()) + + def test_online_missing_catalog_fails_clearly_before_api(self): + with tempfile.TemporaryDirectory() as directory: + Path(directory, "MAINTENANCE.md").write_text((STANDARD / "MAINTENANCE.md").read_text()) + with patch.object(declaration.subprocess, "check_output", side_effect=[REVISION + "\n", + (STANDARD / "MAINTENANCE.md").read_bytes(), subprocess.CalledProcessError(128, ["git"])]), \ + patch.object(GitHub, "get", side_effect=AssertionError("network")), redirect_stdout(io.StringIO()) as output: + status = organization.main(["--declaration-root", directory, "--declaration-revision", REVISION]) + self.assertEqual(status, 1) + self.assertIn("profile/catalog.json is missing", output.getvalue()) + + def test_revision_mismatch_and_non_sha_are_rejected(self): + for revision in ("master", "abc123", REVISION): + with patch.object(declaration.subprocess, "check_output", return_value="2" * 40 + "\n"): + with self.assertRaises(ValueError): + declaration.revision(STANDARD, revision) + + def test_public_membership_excludes_archived_private_forks_and_standards(self): + self.assertEqual(organization.membership(self.catalog, self.repos), []) + self.repos.append({"name": "new-tool", "private": False, "archived": False, "fork": False, "description": "New"}) + self.assertIn("DRIFT catalog: missing new-tool", organization.membership(self.catalog, self.repos)) + del self.catalog["github-workflows"] + self.catalog["stale"] = {"name": "stale", "about": "Stale"} + self.assertIn("DRIFT catalog: extra stale", organization.membership(self.catalog, self.repos)) + + def test_about_is_required_and_exact(self): + for about in (None, "", " ", "different"): + with self.subTest(about=about): + repos = copy.deepcopy(self.repos) + next(repo for repo in repos if repo["name"] == "github-workflows")["description"] = about + self.assertTrue(organization.membership(self.catalog, repos)) + + def test_duplicate_and_malformed_catalog_fields(self): + value = json.loads((STANDARD / "profile/catalog.json").read_text()) + cases = [] + duplicate = copy.deepcopy(value) + duplicate["groups"][0]["repositories"].append({**duplicate["groups"][0]["repositories"][0], "name": "COMPUTER-MCP"}) + cases.append(duplicate) + for field, invalid in (("name", ".github"), ("name", "../escape"), ("about", " ")): + item = copy.deepcopy(value) + item["groups"][0]["repositories"][0][field] = invalid + cases.append(item) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "profile").mkdir() + for value in cases: + (root / "profile/catalog.json").write_text(json.dumps(value)) + with self.assertRaises(ValueError): + organization.read_catalog(root, "computer-mcp") + (root / "profile/catalog.json").write_text('{"groups":[],"groups":[]}') + with self.assertRaisesRegex(ValueError, "duplicate"): + organization.read_catalog(root, "computer-mcp") + + def test_missing_declarations_fail_instead_of_using_builtins(self): + original = (STANDARD / "MAINTENANCE.md").read_text() + for phrase in ("Every repository allows only squash merges", "requires signed commits", "Installed on"): + with self.assertRaises(ValueError): + declaration.settings_policy(re.sub(r"\s+".join(map(re.escape, phrase.split())), "changed declaration", original)) + changed = original.replace("The default branch is `master`", "The default branch is `trunk`") + policy = declaration.settings_policy(changed) + self.assertEqual(policy["default_branch"], "trunk") + + def test_credential_names_come_from_the_declaration(self): + text = (STANDARD / "MAINTENANCE.md").read_text() + self.policy = declaration.settings_policy(text.replace("_CLIENT_ID", "_IDENTIFIER")) + self.assertTrue(any("_IDENTIFIER" in f for f in self.check_settings())) + with self.assertRaisesRegex(ValueError, "credential naming"): + declaration.settings_policy(text.replace("_CLIENT_ID", "missing")) + + def test_online_declaration_reads_committed_bytes(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + subprocess.run(["git", "init", "-q", directory], check=True) + (root / "MAINTENANCE.md").write_text("committed declaration\n") + subprocess.run(["git", "-C", directory, "add", "MAINTENANCE.md"], check=True) + subprocess.run(["git", "-C", directory, "-c", "commit.gpgsign=false", "-c", "user.name=Fixture", + "-c", "user.email=fixture@example.org", "commit", "-qm", "docs: fixture"], check=True) + revision = subprocess.check_output(["git", "-C", directory, "rev-parse", "HEAD"], text=True).strip() + (root / "MAINTENANCE.md").write_text("uncommitted replacement\n") + with redirect_stdout(io.StringIO()): + self.assertEqual(declaration.revision(root, revision), "committed declaration\n") + + def test_each_merge_and_token_setting_is_checked(self): + root = "repos/computer-mcp/github-workflows" + for key in settings.MERGES: + with self.subTest(key=key): + self.api = Fixture(FIXTURE) + self.api.responses[root][key] = not settings.MERGES[key] + self.assertTrue(any(key in f for f in self.check_settings())) + for path in (root, "orgs/computer-mcp"): + self.api = Fixture(FIXTURE) + self.api.responses[path + "/actions/permissions/workflow"]["can_approve_pull_request_reviews"] = True + self.assertTrue(any("can_approve" in f for f in self.check_settings())) + + def test_required_rules_and_targets_are_checked(self): + path = "repos/computer-mcp/github-workflows/rulesets/1" + for kind in settings.RULES: + self.api = Fixture(FIXTURE) + self.api.responses[path]["rules"] = [r for r in self.api.responses[path]["rules"] if r["type"] != kind] + self.assertTrue(any("missing " + kind in f for f in self.check_settings())) + for key, value in (("target", "tag"), ("enforcement", "evaluate"), ("bypass_actors", [{"actor_id": 1}])): + self.api = Fixture(FIXTURE) + self.api.responses[path][key] = value + self.assertTrue(any(key in f for f in self.check_settings())) + self.api = Fixture(FIXTURE) + self.api.responses[path]["conditions"]["ref_name"]["exclude"] = ["refs/heads/master"] + self.assertTrue(any("exclude" in f for f in self.check_settings())) + + def test_queue_requires_squash_allgreen_and_non_strict_checks(self): + path = "repos/computer-mcp/computer-mcp/rulesets/1" + for kind, key, value in (("merge_queue", "merge_method", "MERGE"), ("merge_queue", "grouping_strategy", "HEADGREEN"), + ("required_status_checks", "strict_required_status_checks_policy", True)): + self.api = Fixture(FIXTURE) + next(r for r in self.api.responses[path]["rules"] if r["type"] == kind)["parameters"][key] = value + self.assertTrue(any(key in f for f in self.check_settings())) + + def test_inherited_or_other_repository_queue_fails(self): + root = "repos/computer-mcp/github-workflows" + self.api.responses[root + "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/rulesets/1"]["rules"].append({"type": "merge_queue"}) + self.assertTrue(any("undeclared merge queue" in f for f in self.check_settings())) + self.api = Fixture(FIXTURE) + self.api.responses[root + "/rulesets?includes_parents=true&per_page=100&page=1"].append( + {"id": 2, "name": "shared", "source": "computer-mcp", "source_type": "Organization"}) + self.api.responses["orgs/computer-mcp/rulesets/2"] = {"name": "shared", "rules": [{"type": "merge_queue"}]} + self.assertTrue(any("undeclared merge queue" in f for f in self.check_settings())) + + def test_apps_permissions_events_selection_and_credentials(self): + path = "orgs/computer-mcp/installations?per_page=100&page=1" + for key, value in (("permissions", {"contents": "write"}), ("events", ["push"]), ("repository_selection", "all")): + self.api = Fixture(FIXTURE) + self.api.responses[path]["installations"][0][key] = value + self.assertTrue(any(key in f for f in self.check_settings())) + self.api = Fixture(FIXTURE) + self.api.responses["user/installations/1/repositories?per_page=100&page=1"]["repositories"] = [] + self.assertTrue(any("repositories" in f for f in self.check_settings())) + self.api = Fixture(FIXTURE) + self.api.responses["orgs/computer-mcp/actions/secrets?per_page=100&page=1"]["secrets"] = [{"name": "EXTRA"}] + self.assertTrue(any("EXTRA" in f for f in self.check_settings())) + + def test_unavailable_observation_is_unknown_and_nonzero(self): + del self.api.responses["user/installations/1/repositories?per_page=100&page=1"] + self.assertTrue(any(f.startswith("UNKNOWN ") for f in self.check_settings())) + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "api.json" + path.write_text(json.dumps(self.api.responses)) + with redirect_stdout(io.StringIO()): + self.assertEqual(organization.main(["--declaration-root", str(STANDARD), "--declaration-revision", REVISION, + "--mode", "offline", "--fixture", str(path)]), 2) + + def test_collections_paginate_objects_and_lists(self): + for key in (None, "installations", "repositories", "secrets", "variables"): + with patch.object(GitHub, "get", side_effect=[{key: [1] * 100} if key else [1] * 100, + {key: [2]} if key else [2]]) as get: + self.assertEqual(len(GitHub().collection("orgs/example/items", key)), 101) + self.assertIn("page=2", get.call_args.args[0]) + + def test_transport_is_get_only_and_redacts_failures(self): + with patch.object(subprocess, "run", return_value=subprocess.CompletedProcess([], 1, "", "credential")) as run: + with self.assertRaises(Unavailable) as error: + GitHub().get("orgs/computer-mcp/installations") + self.assertNotIn("credential", str(error.exception)) + self.assertEqual(run.call_args.args[0][2:4], ["--method", "GET"]) + + +if __name__ == "__main__": + unittest.main() From fa71231b25d411cc324e55dd76fbba06d25c4024 Mon Sep 17 00:00:00 2001 From: showxu <10173746+showxu@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:18:46 +0800 Subject: [PATCH 03/11] feat: enforce declared source policy and aggregate checks --- .github/workflows/policy.yml | 52 ++++++++ .github/workflows/result.yml | 32 +++++ scripts/check-policy.py | 184 +++++++++++++++++++++++++++ scripts/check-result.py | 44 +++++++ tests/test_policy.py | 232 +++++++++++++++++++++++++++++++++++ 5 files changed, 544 insertions(+) create mode 100644 .github/workflows/policy.yml create mode 100644 .github/workflows/result.yml create mode 100644 scripts/check-policy.py create mode 100644 scripts/check-result.py create mode 100644 tests/test_policy.py diff --git a/.github/workflows/policy.yml b/.github/workflows/policy.yml new file mode 100644 index 0000000..4bf84fd --- /dev/null +++ b/.github/workflows/policy.yml @@ -0,0 +1,52 @@ +name: Source policy +on: + workflow_call: + inputs: + declaration-revision: + description: Full commit SHA of computer-mcp/.github containing MAINTENANCE.md + type: string + required: true + outputs: + declaration-revision: + description: Enforced MAINTENANCE revision + value: ${{ jobs.policy.outputs.declaration-revision }} +permissions: {} +jobs: + policy: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + outputs: + declaration-revision: ${{ inputs.declaration-revision }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .shared + persist-credentials: false + - name: Validate declaration revision + env: + REVISION: ${{ inputs.declaration-revision }} + run: '[[ "$REVISION" =~ ^[0-9a-f]{40}$ ]]' + shell: bash + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: computer-mcp/.github + ref: ${{ inputs.declaration-revision }} + path: declaration + persist-credentials: false + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + path: member + fetch-depth: 0 + persist-credentials: false + - name: Check source policy + env: + GH_TOKEN: ${{ github.token }} + DECLARATION_REVISION: ${{ inputs.declaration-revision }} + run: >- + python3 -I .shared/scripts/check-policy.py --root member + --declaration-root declaration --declaration-revision "$DECLARATION_REVISION" diff --git a/.github/workflows/result.yml b/.github/workflows/result.yml new file mode 100644 index 0000000..aeac61e --- /dev/null +++ b/.github/workflows/result.yml @@ -0,0 +1,32 @@ +name: Aggregate result +on: + workflow_call: + inputs: + needs-json: + description: toJSON(needs) from an always() caller job + type: string + required: true + optional-jobs: + description: JSON array of dependency names allowed to be skipped + type: string + default: '[]' +permissions: {} +jobs: + validate: + if: always() + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .shared + persist-credentials: false + - name: Require successful dependencies + env: + NEEDS: ${{ inputs.needs-json }} + OPTIONAL_JOBS: ${{ inputs.optional-jobs }} + run: python3 -I .shared/scripts/check-result.py diff --git a/scripts/check-policy.py b/scripts/check-policy.py new file mode 100644 index 0000000..00c850f --- /dev/null +++ b/scripts/check-policy.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Enforce the source rules from the requested Computer MCP declaration.""" + +import argparse +import json +import os +from pathlib import Path +import re +import subprocess +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import declaration +from github_api import Fixture, GitHub, Unavailable + + +ZERO = "0" * 40 +PRIVATE_PATH = re.compile(r"/(?:Users|home)/[\w. -]+(?:/|(?=[\s`\"')]|$))|" + r"[A-Za-z]:[\\/]Users[\\/][\w. -]+(?:[\\/]|(?=[\s`\"')]|$))") +TASK_REFERENCE = re.compile(r"(?]+") +TOOLS = re.compile(r"^(?:claude|codex|chatgpt|(?:github )?copilot|cursor|aider|gemini|AI)" + r"(?: (?:code|bot|assistant|agent))?\s*(?:<|$)|<(?:noreply|bot)@(?:anthropic|openai)[.]com>", re.I) +ATTRIBUTION = re.compile(r"^\s*(?:[#*>/-]+\s*)?(?:[\U0001f300-\U0001faff]\s*)?" + r"(generated[- ](?:by|with):|generated (?:by|with) |" + r"(?:co-authored-by|assisted-by|powered-by|made-with|signed-off-by):)\s*(.*)", re.I | re.M) + + +def attribution(text): + for match in ATTRIBUTION.finditer(text): + kind = match[1].lower() + # Generation statements identify provenance. Co-authors and sign-offs + # identify people too, so only tool/bot identities are rejected there. + if (kind.startswith(("generated", "assisted", "powered", "made")) or TOOLS.search(match[2]) + or (kind.startswith("co-authored") and "[bot]" in match[2].lower())): + return True + return False + + +def content_findings(label, text, *, path=False): + findings = [] + if PRIVATE_PATH.search(text): + findings.append(f"{label}: private machine path") + if TASK_REFERENCE.search(text) or (path and ".agent" in Path(text).parts): + findings.append(f"{label}: reference to local task state") + if attribution(text): + findings.append(f"{label}: tool or agent attribution") + return findings + + +def message_findings(label, message, policy): + subject = message.split("\n", 1)[0] + types = "|".join(re.escape(value) for value in policy["types"]) + pattern = rf"(?:{types})(?:\([^()\r\n]+\))?!?: \S[^\r\n]*" + findings = [] + if not re.fullmatch(pattern, subject) or subject.rstrip() != subject: + findings.append(f"{label}: expected a Conventional Commit subject") + findings += content_findings(label, message) + return findings + + +def commit_findings(sha, record, policy): + details = record.get("commit") or {} + identity = details.get("committer") or {} + account = record.get("committer") or {} + verification = details.get("verification") or {} + findings = [] + if record.get("sha") != sha or verification.get("verified") is not True or verification.get("reason") != "valid": + findings.append(f"{sha}: commit is not verified by GitHub") + owner = (identity.get("name") == policy["owner"] and identity.get("email") == policy["email"] + and account.get("login", "").lower() == policy["owner"].lower()) + github = (identity.get("name") == "GitHub" and identity.get("email") == "noreply@github.com" + and account.get("login") == "web-flow") + if not (owner or github): + findings.append(f"{sha}: committer must be GitHub or the declared owner") + if owner and not (verification.get("signature") or "").startswith("-----BEGIN SSH SIGNATURE-----"): + findings.append(f"{sha}: owner commit must be SSH-signed") + return findings + + +def git(root, *args, binary=False): + result = subprocess.check_output(["git", "-C", str(root), *args], stdin=subprocess.DEVNULL) + return result if binary else result.decode("utf-8", errors="replace") + + +def sha(value): + if not isinstance(value, str) or not re.fullmatch(r"[0-9a-f]{40}", value) or value == ZERO: + raise ValueError("policy requires full, nonzero source commit SHAs") + return value + + +def added_findings(patch, path): + findings, number = [], None + for line in patch.split("\n"): + if match := re.match(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@", line): + number = int(match[1]) + elif number is not None and line.startswith("+"): + findings += content_findings(f"{path}:{number}", line[1:]) + number += 1 + elif number is not None and line.startswith(" "): + number += 1 + return findings + + +def check(root, event, event_name, policy, api): + findings = [] + default_push = False + if event_name == "pull_request": + pr = event["pull_request"] + head, base = sha(pr["head"]["sha"]), sha(pr["base"]["sha"]) + title = pr["title"] + if "\n" in title or "\r" in title: + findings.append("PR title: expected one line") + findings += message_findings("PR title", title, policy) + comparison = git(root, "merge-base", base, head).strip() + elif event_name == "merge_group": + head, base = sha(event["merge_group"]["head_sha"]), sha(event["merge_group"]["base_sha"]) + comparison = base + elif event_name == "push": + if event.get("deleted"): + print("PASS source policy: branch deletion has no added content") + return [] + head = sha(event["after"]) + base = event["before"] + if base == ZERO: + base = None + comparison = git(root, "hash-object", "-t", "tree", "--stdin", binary=False).strip() + else: + comparison = sha(base) + default_push = event["ref"] == "refs/heads/" + event["repository"]["default_branch"] + else: + raise ValueError("policy requires pull_request, merge_group or push") + commits = [head] if base is None else git(root, "rev-list", "--reverse", base + ".." + head).splitlines() + # The queue's temporary merge commits are not accepted default-branch commits. + if event_name != "merge_group": + for commit in commits: + message = git(root, "show", "-s", "--format=%B", commit) + findings += message_findings(commit, message, policy) + if default_push: + repository = event["repository"]["full_name"] + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository): + raise ValueError("invalid repository identity") + findings += commit_findings(commit, api.get(f"repos/{repository}/commits/{commit}"), policy) + paths = git(root, "diff", "--name-only", "--no-renames", "--diff-filter=ACMT", "-z", comparison, head, "--").split("\0") + for path in filter(None, paths): + findings += content_findings(path, path, path=True) + if git(root, "--literal-pathspecs", "ls-tree", head, "--", path).startswith("160000 "): + continue + blob = git(root, "cat-file", "blob", head + ":" + path, binary=True) + if b"\0" in blob[:8000]: + continue + patch = git(root, "--literal-pathspecs", "diff", "--text", "--no-ext-diff", "--no-textconv", "--no-color", + "--no-renames", "--unified=0", comparison, head, "--", path) + findings += added_findings(patch, path) + return findings + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument("--declaration-root", type=Path, required=True) + parser.add_argument("--declaration-revision", required=True) + parser.add_argument("--event", type=Path, default=os.environ.get("GITHUB_EVENT_PATH")) + parser.add_argument("--event-name", default=os.environ.get("GITHUB_EVENT_NAME")) + parser.add_argument("--fixture", type=Path, help="Use offline GET responses and a fixture declaration") + args = parser.parse_args(argv) + try: + text = declaration.revision(args.declaration_root, args.declaration_revision, offline=bool(args.fixture)) + policy = declaration.source_policy(text) + if not args.event: + raise ValueError("event file is required") + api = Fixture(args.fixture) if args.fixture else GitHub() + findings = check(args.root, json.loads(Path(args.event).read_text()), args.event_name, policy, api) + for finding in findings: + print("FAIL " + " ".join(finding.splitlines())) + if not findings: + print("PASS source policy") + return int(bool(findings)) + except (OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError) as error: + print("FAIL policy could not complete: " + " ".join(str(error).splitlines()), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check-result.py b/scripts/check-result.py new file mode 100644 index 0000000..9816771 --- /dev/null +++ b/scripts/check-result.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Require every dependency to succeed, except explicitly optional skipped jobs.""" + +import argparse +import json +import os +import sys + + +def failures(needs, optional): + if not isinstance(needs, dict) or not needs: + raise ValueError("needs must be a nonempty object") + if not isinstance(optional, list) or any(not isinstance(name, str) for name in optional): + raise ValueError("optional jobs must be a JSON array of names") + if not set(optional) <= needs.keys(): + raise ValueError("optional jobs must exist in needs") + failed = [] + for name, job in needs.items(): + result = job.get("result") if isinstance(job, dict) else None + if result not in {"success", "failure", "cancelled", "skipped"}: + raise ValueError(f"{name}: missing or invalid result") + if result != "success" and not (result == "skipped" and name in optional): + failed.append(name) + return failed + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--needs", default=os.environ.get("NEEDS")) + parser.add_argument("--optional", default=os.environ.get("OPTIONAL_JOBS", "[]")) + args = parser.parse_args() + try: + failed = failures(json.loads(args.needs or "null"), json.loads(args.optional)) + if failed: + raise ValueError("jobs did not succeed: " + ", ".join(failed)) + print("PASS all required jobs succeeded") + return 0 + except ValueError as error: + print("FAIL " + str(error)) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_policy.py b/tests/test_policy.py new file mode 100644 index 0000000..ddda3d9 --- /dev/null +++ b/tests/test_policy.py @@ -0,0 +1,232 @@ +import copy +import importlib.util +import io +import json +import re +from contextlib import redirect_stdout +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import Mock, patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +import declaration + + +def load(name, file): + spec = importlib.util.spec_from_file_location(name, ROOT / "scripts" / file) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +policy = load("policy", "check-policy.py") +result = load("aggregate", "check-result.py") +TEXT = (ROOT / "fixtures/declaration/MAINTENANCE.md").read_text() +RULES = declaration.source_policy(TEXT) +PRIVATE = "/".join(("", "Users", "fixture-owner", "project")) +TASK = ".agent" + "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/plans/task.md" +TOOL_TRAILER = "Co-Authored" + "-By: Claude " +GENERATED = "Generated" + " with an assistant" + + +class PolicyRulesTests(unittest.TestCase): + def test_rules_come_from_the_supplied_declaration(self): + changed = TEXT.replace("name `showxu`", "name `another`").replace("`feat`,", "`feature`,") + parsed = declaration.source_policy(changed) + self.assertEqual(parsed["owner"], "another") + self.assertIn("feature", parsed["types"]) + self.assertNotIn("feat", parsed["types"]) + for phrase in ("are SSH-signed", "Dependabot may author changes.", "real human co-authors may be credited.", + "added or modified paths", "gpg.format ssh"): + with self.subTest(phrase=phrase), self.assertRaises(ValueError): + declaration.source_policy(re.sub(r"\s+".join(map(re.escape, phrase.split())), "changed rule", TEXT)) + + def test_all_declared_conventional_types_and_optional_scope_bang(self): + for kind in RULES["types"]: + for suffix in (": update", "(runtime): update", "!: update", "(runtime)!: update"): + self.assertEqual(policy.message_findings("commit", kind + suffix, RULES), []) + for subject in ("update runtime", "feature: update", "fix:", "fix: ", "fix(): update", "fix: update "): + self.assertTrue(policy.message_findings("commit", subject, RULES)) + + def test_human_coauthors_products_and_dependabot_author_signoff_are_allowed(self): + for text in ("feat: support Claude and Codex", "fix: update\n\nCo-Authored-By: Jane Doe ", + "fix: update\n\nCo-Authored-By: Claude Martin ", + "build: dependencies\n\nSigned-off-by: dependabot[bot] "): + self.assertEqual(policy.message_findings("commit", text, RULES), []) + + def test_tool_attribution_in_messages_and_added_lines_is_rejected(self): + for line in (TOOL_TRAILER, GENERATED, "Generated-by: assistant", "# " + GENERATED, + "Co-Authored-By: helper[bot] "): + self.assertTrue(policy.message_findings("commit", "fix: update\n\n" + line, RULES)) + self.assertTrue(policy.added_findings("@@ -0,0 +1 @@\n+" + line + "\n", "file")) + + def test_private_paths_and_task_references(self): + for text in (PRIVATE, PRIVATE.replace("fixture-owner", "Jane Doe"), PRIVATE.replace("fixture-owner", "用户"), + PRIVATE.rsplit("/", 1)[0], TASK, "file://" + PRIVATE, "[local](" + TASK + ")"): + self.assertTrue(policy.content_findings("file", text)) + self.assertTrue(policy.content_findings("path", TASK, path=True)) + # Policy descriptions and product names are not local execution paths. + for text in ("/Users//", "local task state", "docs/Claude.md", "PLAN.md", "file.plan.md"): + self.assertEqual(policy.content_findings("file", text, path=True), []) + + def test_added_lines_exclude_context_and_deletions_and_include_plus_prefix(self): + patch_text = "@@ -1,2 +1,2 @@\n-" + PRIVATE + "\n context\n+++ " + PRIVATE + "\n" + findings = policy.added_findings(patch_text, "file") + self.assertEqual(len(findings), 1) + self.assertIn(":2:", findings[0]) + self.assertEqual(policy.added_findings("@@ -1 +1 @@\n-" + PRIVATE + "\n+safe\n", "file"), []) + + def test_no_undeclared_whitespace_conflict_or_product_term_rules(self): + self.assertEqual(policy.added_findings("@@ -0,0 +1,4 @@\n+Claude\n+trailing \n+<<<<<<< marker\n+\n", "file"), []) + + def record(self, owner=True): + return {"sha": "a" * 40, "author": {"login": "dependabot[bot]", "type": "Bot"}, + "committer": {"login": RULES["owner"] if owner else "web-flow"}, + "commit": {"committer": {"name": RULES["owner"] if owner else "GitHub", + "email": RULES["email"] if owner else "noreply@github.com"}, + "verification": {"verified": True, "reason": "valid", + "signature": "-----BEGIN SSH SIGNATURE-----" if owner else "PGP"}}} + + def test_verified_owner_ssh_and_github_commits_accept_external_authors(self): + for owner in (True, False): + self.assertEqual(policy.commit_findings("a" * 40, self.record(owner), RULES), []) + + def test_unverified_wrong_identity_sha_and_owner_signature_fail(self): + for field, value in (("verified", False), ("reason", "unknown_key"), ("signature", "PGP")): + record = self.record() + record["commit"]["verification"][field] = value + self.assertTrue(policy.commit_findings("a" * 40, record, RULES)) + for field in ("name", "email"): + record = self.record() + record["commit"]["committer"][field] = "someone" + self.assertTrue(policy.commit_findings("a" * 40, record, RULES)) + record = self.record() + record["committer"]["login"] = "spoofed" + self.assertTrue(policy.commit_findings("a" * 40, record, RULES)) + self.assertTrue(policy.commit_findings("b" * 40, self.record(), RULES)) + + +class GitRangeTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.git("init", "-q", "-b", "master") + self.git("config", "user.name", "Fixture") + self.git("config", "user.email", "fixture@example.org") + self.git("config", "commit.gpgsign", "false") + self.write("sample.txt", "base\n") + self.base = self.commit("feat: initial") + self.api = Mock() + + def git(self, *args): + return policy.git(self.root, *args).strip() + + def write(self, name, text): + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + + def commit(self, message): + self.git("add", "--all") + self.git("commit", "-q", "-m", message) + return self.git("rev-parse", "HEAD") + + def event(self, head, title="feat: change"): + return {"repository": {"full_name": "computer-mcp/widget", "default_branch": "master"}, + "pull_request": {"head": {"sha": head}, "base": {"sha": self.base}, "title": title}} + + def test_pr_checks_title_messages_content_and_paths_without_signature_api(self): + self.write("sample.txt", "base\n" + PRIVATE + "\n") + self.write(TASK, "private task\n") + head = self.commit("invalid subject") + findings = policy.check(self.root, self.event(head, "invalid title"), "pull_request", RULES, self.api) + self.assertTrue(any("PR title" in f for f in findings)) + self.assertTrue(any(head in f for f in findings)) + self.assertTrue(any("machine path" in f for f in findings)) + self.assertTrue(any("local task state" in f for f in findings)) + self.api.get.assert_not_called() + + def test_attributes_cannot_hide_added_text_or_execute_diff_helpers(self): + self.write(".gitattributes", "sample.txt -diff\n") + self.write("sample.txt", PRIVATE + "\n") + head = self.commit("fix: update") + self.assertTrue(policy.check(self.root, self.event(head), "pull_request", RULES, self.api)) + + def test_deletions_and_unmodified_historical_content_are_preserved(self): + self.write("sample.txt", PRIVATE + "\n") + self.base = self.commit("feat: historical input") + self.write("sample.txt", "safe\n") + head = self.commit("fix: remove private input") + self.assertEqual(policy.check(self.root, self.event(head), "pull_request", RULES, self.api), []) + + def test_renames_binary_paths_and_symlinks_are_checked(self): + self.write("safe.txt", PRIVATE + "\n") + self.base = self.commit("feat: historical input") + self.git("mv", "safe.txt", "renamed.txt") + (self.root / "link").symlink_to(PRIVATE) + binary = self.root / TASK + binary.parent.mkdir(parents=True) + binary.write_bytes(b"\0\x01") + findings = policy.check(self.root, self.event(self.commit("fix: move files")), "pull_request", RULES, self.api) + self.assertTrue(any("renamed.txt" in f for f in findings)) + self.assertTrue(any("link" in f for f in findings)) + self.assertTrue(any(TASK in f for f in findings)) + + def test_merge_base_excludes_unrelated_default_branch_changes(self): + self.git("checkout", "-q", "-b", "feature") + self.write("feature.txt", "feature\n") + head = self.commit("feat: feature") + self.git("checkout", "-q", "master") + self.write("unrelated.txt", PRIVATE + "\n") + self.base = self.commit("feat: unrelated") + self.assertEqual(policy.check(self.root, self.event(head), "pull_request", RULES, self.api), []) + + def test_default_push_checks_all_new_commits_and_initial_push(self): + self.write("sample.txt", "changed\n") + first = self.commit("fix: first") + self.write("sample.txt", "changed again\n") + head = self.commit("fix: second") + def record(path): + value = PolicyRulesTests().record(False) + value["sha"] = path.rsplit("/", 1)[1] + return value + self.api.get.side_effect = record + event = {**self.event(head), "ref": "refs/heads/master", "before": self.base, "after": head} + self.assertEqual(policy.check(self.root, event, "push", RULES, self.api), []) + self.assertEqual([call.args[0].rsplit("/", 1)[1] for call in self.api.get.call_args_list], [first, head]) + event["before"] = policy.ZERO + self.assertEqual(policy.check(self.root, event, "push", RULES, self.api), []) + + def test_queue_checks_queued_content_without_authenticating_synthetic_commit(self): + self.write("sample.txt", PRIVATE) + head = self.commit("Synthetic queued merge") + event = {"merge_group": {"base_sha": self.base, "head_sha": head}} + findings = policy.check(self.root, event, "merge_group", RULES, self.api) + self.assertEqual(len(findings), 1) + self.assertIn("machine path", findings[0]) + self.api.get.assert_not_called() + + +class ResultTests(unittest.TestCase): + def test_all_success_and_optional_skip_pass(self): + self.assertEqual(result.failures({"test": {"result": "success"}}, []), []) + self.assertEqual(result.failures({"test": {"result": "success"}, "docs": {"result": "skipped"}}, ["docs"]), []) + + def test_failed_cancelled_required_skip_and_missing_results_fail(self): + for state in ("failure", "cancelled", "skipped"): + self.assertEqual(result.failures({"test": {"result": state}}, []), ["test"]) + for state in ("failure", "cancelled"): + self.assertEqual(result.failures({"test": {"result": state}}, ["test"]), ["test"]) + for needs, optional in (({}, []), ({"test": {}}, []), ({"test": {"result": "success"}}, ["missing"]), + ({"test": {"result": "success"}}, "test")): + with self.assertRaises(ValueError): + result.failures(needs, optional) + + +if __name__ == "__main__": + unittest.main() From 55f9d460e722a08fda6885f6bb9835c77ca89eb0 Mon Sep 17 00:00:00 2001 From: showxu <10173746+showxu@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:27:15 +0800 Subject: [PATCH 04/11] ci: validate shared workflows and document adoption --- .github/workflows/ci.yml | 40 +++++ .github/workflows/source-policy.yml | 33 ++++ .gitignore | 1 + Documentation/Reference/Adoption.md | 88 ++++++++++ Documentation/Reference/PluginRelease.md | 24 ++- PluginRuntime/README.md | 4 +- README.md | 215 ++++++++++++++++++++++- fixtures/README.md | 17 ++ requirements-test.txt | 1 + scripts/check-workflows.py | 106 +++++++++++ tests/test_workflows.py | 47 +++++ 11 files changed, 562 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/source-policy.yml create mode 100644 Documentation/Reference/Adoption.md create mode 100644 fixtures/README.md create mode 100644 requirements-test.txt create mode 100644 scripts/check-workflows.py create mode 100644 tests/test_workflows.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..639f8ac --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI +on: + push: + branches: [master] + pull_request: + types: [opened, synchronize, reopened] + merge_group: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + tests: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: '3.13' + - name: Install YAML validator + run: | + python3 -m venv .build/venv + .build/venv/bin/python -m pip install --disable-pip-version-check --no-cache-dir -r requirements-test.txt + - name: Test scripts and workflow contracts + run: | + .build/venv/bin/python -m unittest discover -s tests + .build/venv/bin/python scripts/check-workflows.py + .build/venv/bin/python scripts/plugin-runtime.py check-workflow + git diff --exit-code + validate: + if: always() + needs: tests + uses: ./.github/workflows/result.yml + with: + needs-json: ${{ toJSON(needs) }} diff --git a/.github/workflows/source-policy.yml b/.github/workflows/source-policy.yml new file mode 100644 index 0000000..3bd2469 --- /dev/null +++ b/.github/workflows/source-policy.yml @@ -0,0 +1,33 @@ +name: Source policy checks +on: + push: + branches: [master] + pull_request: + types: [opened, synchronize, reopened, edited] + merge_group: +permissions: + contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + declaration: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + revision: ${{ steps.standard.outputs.revision }} + steps: + - name: Resolve current organization rules once + id: standard + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + revision=$(gh api --method GET repos/computer-mcp/.github/commits/HEAD --jq .sha) + [[ "$revision" =~ ^[0-9a-f]{40}$ ]] + echo "revision=$revision" >> "$GITHUB_OUTPUT" + policy: + needs: declaration + uses: ./.github/workflows/policy.yml + with: + declaration-revision: ${{ needs.declaration.outputs.revision }} diff --git a/.gitignore b/.gitignore index a97f7dc..4f0b716 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .claude/ __pycache__/ .DS_Store +.build/ diff --git a/Documentation/Reference/Adoption.md b/Documentation/Reference/Adoption.md new file mode 100644 index 0000000..395dd8e --- /dev/null +++ b/Documentation/Reference/Adoption.md @@ -0,0 +1,88 @@ +# Adopting shared automation + +Use one reviewed full `github-workflows` commit SHA for the workflows and action +below. Use a separate full `.github` SHA for the rule/catalog declaration. Each +consumer owns its pull request; required checks are selected only after their +actual contexts have been observed. Shared changes are independently versioned. + +## Consumer files + +The following inventory describes the callers present during this migration. +Replace only the specified shared reference and retain repository-specific inputs, +repository guards, secrets and output mappings. All paths are repository-relative. + +| Repository | File | Change | +| --- | --- | --- | +| `plugin-claude` | `.github/workflows/ci.yml` | Pin `plugin-runtime.yml` here; remove brand job and its dependency references; aggregate remaining validation jobs | +| `plugin-claude` | `.github/workflows/release.yml` | Pin `plugin-release.yml` here; keep `ref`, `publish`, `build`, `ci_artifact` and notification dependency | +| `plugin-claude` | `.github/workflows/notify-catalog.yml` | Pin `computer-mcp/github-workflows/actions/notify-catalog` | +| `plugin-cursor` | `.github/workflows/ci.yml` | Pin runtime here; remove brand job/dependencies; aggregate validation | +| `plugin-cursor` | `.github/workflows/release.yml` | Pin release here with existing inputs | +| `plugin-cursor` | `.github/workflows/notify-catalog.yml` | Pin notification action here | +| `plugin-trae` | `.github/workflows/ci.yml` | Pin runtime here; remove brand job/dependencies; aggregate validation | +| `plugin-trae` | `.github/workflows/release.yml` | Pin release here with existing inputs | +| `plugin-trae` | `.github/workflows/notify-catalog.yml` | Pin notification action here after resolving credential scope below | +| `plugin-computer-use` | `.github/workflows/ci.yml` | Remove brand job/dependencies; aggregate validation | +| `plugin-computer-use` | `.github/workflows/release.yml` | Pin release here with existing inputs | +| `plugin-computer-use` | `.github/workflows/notify-catalog.yml` | Pin notification action here | +| `plugin-swift-format` | `.github/workflows/ci.yml` | Remove brand job/dependencies; aggregate validation | +| `plugin-swift-format` | `.github/workflows/release.yml` | Pin release here with existing inputs | +| `plugin-swift-format` | `.github/workflows/notify-catalog.yml` | Pin notification action here | +| `plugin-codex` | `.github/workflows/ci.yml` | Remove brand job/dependencies; aggregate validation | +| `plugin-codex` | `.github/workflows/notify-catalog.yml` | Pin notification action here | +| `computer-mcp` | `.github/workflows/ci.yml` | Remove brand job/dependencies; aggregate validation; retain `merge_group` coverage | +| `apple-cli` | `.github/workflows/ci.yml` | Remove brand job/dependencies; aggregate validation | +| `computer-mcp.github.io` | `.github/workflows/ci.yml` | Remove brand job/dependencies; aggregate validation | +| `.github` | `.github/workflows/ci.yml` | Call `organization.yml` with the reviewed declaration revision; replace migrated implementation tests with thin shared calls | + +Each of these repositories also adds `.github/workflows/source-policy.yml`, using +the separate PR-title-aware caller in the root README. Its validation caller runs +on opened/synchronize/reopened PRs, default-branch pushes and its declared manual +triggers. Policy includes PR title edits. The host also runs both on merge queues. +Use the actual maintained default branch for a fork. + +`homebrew-tap` and the maintained SDK fork have no old reusable references in the +inspected checkout inventory; their owners adopt policy and validation callers +under their own existing CI and upstream compatibility requirements. This is not +evidence of their live workflow state. + +The Codex plugin retains its native artifact-promotion release implementation as +owned by shared VERSIONING. Its release caller is not converted to reproducible +archive rebuilding. + +## Shared owners + +The organization `.github` owner retires the migrated implementations only after +consumers have moved: the plugin runtime/release workflows, `PluginRuntime/`, +`Scripts/plugin-runtime.py`, `Scripts/plugin_release.py`, `Scripts/check-settings.py` +and their tests and operating guide. Its README and MAINTENANCE operational +command link to this repository. It retains rules, catalog data, documentation +and thin workflow callers. Brand-check callers are removed under the accepted +presentation standard; no brand-check workflow is provided here. + +The website owner retires `.github/actions/notify-catalog/` and the migrated +notification transport tests after callers move. Its `pages.yml` receiver, +catalog validation and deployment implementation keep their existing contracts. +Update any local tests/documentation that refer to the retired action location. + +## Adoption prerequisites and owner decisions + +- The `.github` declaration used online must contain both the accepted source + rules and `profile/catalog.json`. Fixtures are never a live fallback. +- The current MAINTENANCE App table lists catalog credential storage in five + plugins, while `plugin-trae` also has a notification caller. Its owner must + either declare and provision that scope or retire that notification caller. + This repository does neither. +- Full online settings checks require administrative read access and access to + App installation repository lists. MAINTENANCE currently allows only the + listed App credentials, not an additional organization-read secret. Decide + the approved execution/credential arrangement before unattended online CI; + local runs can use an organization owner's existing `gh` login. Inaccessible + observations exit nonzero as unknown. +- Run one representative consumer at the candidate pin before broad rollout. + Local fixture/archived-source runs do not replace a GitHub Actions run at that + pin. Retain running and queued releases during the rollout. + +No additional source-policy rule is required for this implementation. The checker +does not infer new allowed identities, contributor restrictions or content bans +from the reference implementation. Changes to rules belong in MAINTENANCE first. diff --git a/Documentation/Reference/PluginRelease.md b/Documentation/Reference/PluginRelease.md index 9e1cb9c..5404b5b 100644 --- a/Documentation/Reference/PluginRelease.md +++ b/Documentation/Reference/PluginRelease.md @@ -33,7 +33,7 @@ jobs: with: ref: ${{ inputs.ref }} publish: ${{ inputs.publish }} - build: python3 scripts/build_package.py + build: python3 scripts/build-package.py ci_artifact: notify-catalog: needs: release @@ -57,8 +57,8 @@ secret inputs. The caller grants the permission ceiling needed by these jobs: The build job receives no secrets, does not persist checkout credentials and has no access to the verification runner's files or job outputs. Only its -archive artifact ID crosses into verification. Verification and publication execute only the helper from their own workflow -revision. Their checkouts contain shared automation; caller source is read as data. +archive artifact ID crosses into verification. Verification and publication +execute only the helper from their own workflow revision. Their checkouts contain shared automation; caller source is read as data. Plugin repositories must enable GitHub release immutability in their repository settings before publishing. The helper checks `/repos/{owner}/{repo}/immutable-releases` @@ -85,10 +85,11 @@ change it or report it as published. | `release_id` | Published GitHub Release ID, empty for a dry run | | `tag` | Verified version tag, empty for an untagged commit dry run | -The release workflow must be pinned by its full commit SHA. Each trusted job checks out `job.workflow_repository` at `job.workflow_sha`. +The release workflow must be pinned by its full commit SHA. Each trusted job +checks out `job.workflow_repository` at `job.workflow_sha`. The source job checks `job.workflow_ref` is a full SHA pin and records it in -the receipt provenance. An embedded SHA-256 checks the helper both -before verification and before publication; caller code cannot supply an +the receipt provenance. An embedded SHA-256 checks the helper before source +resolution, verification and publication; caller code cannot supply an executable publisher. Verification passes the uploaded bundle's exact artifact ID and a SHA-256 map for all release files through job outputs. Publication recomputes and compares every file digest, validates the receipt and checks @@ -156,12 +157,17 @@ repository setting is enabled. It rejects mutations of immutable releases, including visibility changes between draft prechecks and mutation requests. When changing the helper, update `PLUGIN_RELEASE_SHA256` in the workflow to its -SHA-256. The repository CI runs the offline suite and verifies that digest: +SHA-256. After the root README's environment setup, the repository CI commands +run the offline suite and verify that digest: ```sh -python3 -m unittest discover -s tests -actionlint .github/workflows/*.yml +.build/venv/bin/python -m unittest discover -s tests +.build/venv/bin/python scripts/check-workflows.py ``` Release jobs have no shared concurrency group or caches. Callers must preserve running and queued publications; coordinate simultaneous dispatches for a tag. + +Frozen receipts from the original organization workflow remain valid provenance. +Recovery preserves their original workflow identity and bytes; newly assembled +receipts record this repository. diff --git a/PluginRuntime/README.md b/PluginRuntime/README.md index cb734d8..0275f57 100644 --- a/PluginRuntime/README.md +++ b/PluginRuntime/README.md @@ -62,10 +62,10 @@ pull request, with its own adapter tests passing. After changing the canonical file, update `PLUGIN_RUNTIME_SHA256` in `.github/workflows/plugin-runtime.yml` to its SHA-256. Check the tools and -embedded digest: +embedded digest after the root README's environment setup: ```sh -python3 -m unittest discover -s tests +.build/venv/bin/python -m unittest discover -s tests python3 scripts/plugin-runtime.py check-workflow ``` diff --git a/README.md b/README.md index c54bce3..7ce12bb 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,214 @@ # Computer MCP shared workflows -Reusable workflows and checks for Computer MCP repositories. Members pin this -repository by full commit SHA; the organization's rules live in -[computer-mcp/.github](https://github.com/computer-mcp/.github). +Reusable GitHub Actions, plugin automation and organization checks for Computer MCP. +The organization's [MAINTENANCE](https://github.com/computer-mcp/.github/blob/master/MAINTENANCE.md) +owns the rules; this repository implements them. Organization and source checks +read the caller's full `.github` commit SHA and report that revision. Missing or +unsupported declarations fail explicitly. + +## Workflows and actions + +| Entry point | Inputs | Outputs and permissions | +| --- | --- | --- | +| [plugin-runtime.yml](.github/workflows/plugin-runtime.yml) | None | Pass/fail for `bin/plugin_runtime.py`; `contents: read` | +| [plugin-release.yml](.github/workflows/plugin-release.yml) | Required `ref`, `build`, `ci_artifact`; `publish=false`, `runner=ubuntu-24.04` | `published`, `release_id`, `tag`; caller grants `contents: write`, `actions: read`; no secret inputs | +| [actions/notify-catalog](actions/notify-catalog/action.yml) | Required `client-id`, `private-key` | `run-url`; receiver-scoped App token with `actions: write` | +| [organization.yml](.github/workflows/organization.yml) | Required `declaration-revision`; `mode=online` or `offline`; optional secret `organization-token` | `declaration-revision`; GET-only observations; `contents: read` | +| [policy.yml](.github/workflows/policy.yml) | Required `declaration-revision` | `declaration-revision`; pass/fail; `contents: read` | +| [result.yml](.github/workflows/result.yml) | Required `needs-json`; `optional-jobs='[]'` | Pass/fail aggregate; `contents: read` | + +[Runtime adoption](PluginRuntime/README.md) describes the unchanged digest +contract. The [release guide](Documentation/Reference/PluginRelease.md) covers +reproducible archives, isolated builds, immutable publication and recovery. +Notification requests the website's existing `pages.yml` on `master`, with no +catalog metadata. `run-url` proves request acceptance; inspect that run for +catalog validation and deployment. + +Executable automation lives in lowercase `scripts/`; imported Python modules use +snake_case. `tests/` and `fixtures/` hold offline evidence. Operating and adoption +guides live in `Documentation/Reference/`. + +## Pins and versions + +This repository versions independently under the organization's +[VERSIONING](https://github.com/computer-mcp/.github/blob/master/VERSIONING.md). +Consumers pin a reviewed full 40-hex commit SHA. Released pins carry a `# vX.Y.Z` +comment; in-progress pins carry no version comment. A tag is not needed to test +an in-progress commit. Update interfaces and copied runtime bytes through +consumer pull requests after a representative consumer run. + +Every reusable job executing shared scripts checks out its own repository at +`job.workflow_sha`. These [GitHub workflow identity fields](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#job-context) +refer to the reusable workflow rather than the caller. The release build job +executes the caller's build on its own runner, without shared publisher code. + +Replace `` and `` below with full 40-hex SHAs: + +```yaml +jobs: + runtime: + uses: computer-mcp/github-workflows/.github/workflows/plugin-runtime.yml@ +``` + +A release caller grants the permission ceiling for the isolated jobs: + +```yaml +jobs: + release: + permissions: + contents: write + actions: read + uses: computer-mcp/github-workflows/.github/workflows/plugin-release.yml@ + with: + ref: ${{ inputs.ref }} + publish: ${{ inputs.publish }} + build: python3 scripts/build-package.py + ci_artifact: widget-${commit} +``` + +The release guide supplies the complete dispatch trigger. Release callers must +preserve running and queued publications and use no caches. Notification remains +a dependent job or step after successful publication: + +```yaml +- name: Request catalog reconciliation + id: catalog + uses: computer-mcp/github-workflows/actions/notify-catalog@ + with: + client-id: ${{ vars.CATALOG_APP_CLIENT_ID }} + private-key: ${{ secrets.CATALOG_APP_PRIVATE_KEY }} +``` + +## Organization check + +The catalog uses `organization.login` and `groups[].repositories[]`. This checker +validates the membership and `name`/`about` fields it consumes; the profile +renderer owns the complete visual schema. Membership must equal the public, +non-archived, non-fork inventory except `.github`. Every member needs a nonempty +GitHub About exactly matching its catalog `about`. + +Settings come from MAINTENANCE's default branch, branch requirements, GitHub +settings prose and App table. Checks cover squash-only merging, branch cleanup, +workflow token permissions, default-branch protection, required checks, merge +queues, installed Apps, permission/event/repository scopes and allowed +secret/variable **names**. Fork branch names remain upstream-owned. All API +operations are GET requests; secret values are never read. + +```yaml +jobs: + organization: + uses: computer-mcp/github-workflows/.github/workflows/organization.yml@ + with: + declaration-revision: +``` + +Online mode reads both documents at that revision and reports a missing +`profile/catalog.json` before calling GitHub. Exit `0` means all observations +match, `1` means drift/invalid input, and `2` means observations are unavailable. +Administrative read access is needed for settings and App installation repository +lists. A normal workflow token may produce unavailable observations. The optional +`organization-token` secret accepts an already authorized credential; it does +not provision credentials or authorize an additional stored secret. + +Offline mode uses only the bundled declaration and GET-response fixture, labels +its revision as fixture evidence and makes no live conformance claim. + +## Source policy + +The implementation maps to [MAINTENANCE: Branches and changes](https://github.com/computer-mcp/.github/blob/master/MAINTENANCE.md#branches-and-changes) +and [Signing](https://github.com/computer-mcp/.github/blob/master/MAINTENANCE.md#signing): + +| Declared rule | Enforcement | +| --- | --- | +| Owner identity and SSH signing; GitHub-created commits signed by GitHub | On default-branch pushes, every added commit must be GitHub-verified with reason `valid`, and use the declared owner identity plus an SSH signature or GitHub's `web-flow` committer identity | +| Dependabot may author changes | Original authors are unrestricted; accepted committer/signature checks still apply | +| Conventional Commit types, optional scope and breaking marker | PR titles and added commit subjects use the types parsed from the declaration | +| No tool/agent attribution; human co-authors allowed | Attribution statements and identifiable tool/bot co-author trailers are rejected in titles, commit messages and added text; human co-authors and ordinary product references are retained | +| No maintainer-machine paths or local task-state references | Added/modified paths, added text and messages are checked for concrete home-directory paths and local task-file references | +| PR titles, added content and added/modified paths; verified default-branch commits | PR diffs use the merge base; pushes check their supplied range; removed lines are outside the added-content check | + +`merge_group` checks queued content and paths. Temporary queue commits are not +accepted default-branch commits; final push checks verify the accepted commits. +The checker reads Git blobs and forces text diffs for text files so consumer +attributes cannot suppress scanning. Binary payloads are not decoded; their +paths are checked. Attribution recognition is textual, not proof of the identity +of every named co-author. + +Keep policy separate from build/test CI. Title edits rerun policy without +replacing an existing validation result: + +```yaml +name: Source policy checks +on: + pull_request: + types: [opened, synchronize, reopened, edited] + push: + branches: [master] + merge_group: +permissions: + contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + policy: + uses: computer-mcp/github-workflows/.github/workflows/policy.yml@ + with: + declaration-revision: +``` + +This repository's [policy caller](.github/workflows/source-policy.yml) resolves +organization default-branch rules to one SHA per run and calls the same policy. +Until those rules reach the organization's default branch, this check correctly +fails for missing declarations. No temporary organization revision is embedded. + +## Aggregation + +Use `always()` on the caller and reusable aggregate. Required dependencies must +succeed. Jobs named in `optional-jobs` may be skipped; failure and cancellation +always fail. Missing and malformed results fail too. + +```yaml +jobs: + validate: + if: always() + needs: [package, runtime] + uses: computer-mcp/github-workflows/.github/workflows/result.yml@ + with: + needs-json: ${{ toJSON(needs) }} +``` + +Observe actual caller/callee check names before selecting required checks. This +repository does not configure rulesets. + +## Local verification + +Python 3.13+ runs runtime and release fixtures. Checkers use the standard library; +YAML validation uses pinned PyYAML. + +```sh +python3 -m venv .build/venv +.build/venv/bin/python -m pip install --no-cache-dir -r requirements-test.txt +.build/venv/bin/python -m unittest discover -s tests +.build/venv/bin/python scripts/check-workflows.py +python3 scripts/plugin-runtime.py check-workflow +python3 scripts/check-organization.py --mode offline \ + --declaration-root fixtures/declaration \ + --declaration-revision 1111111111111111111111111111111111111111 \ + --fixture fixtures/organization.json +``` + +The offline revision is a fixture label. For online checks, check out the selected +`.github` commit, pass its directory and actual SHA, select `--mode online` and +omit `--fixture`. Online reads use Git objects at that SHA, not uncommitted edits. + +The workflow validator always checks YAML, duplicate keys, action pins and +workflow boundaries. If actionlint is available it also runs that tool, filtering +only the older-linter error for documented `job.workflow_*` fields. All other +errors remain fatal. Tests cover runtime delivery, release assembly/recovery, +publication fault injection, notifier transport, organization drift, source +ranges and aggregation offline. Temporary environments belong in ignored +`.build/` and may be removed after checking. + +The [adoption checklist](Documentation/Reference/Adoption.md) names consumer files +and rollout prerequisites. diff --git a/fixtures/README.md b/fixtures/README.md new file mode 100644 index 0000000..7940e52 --- /dev/null +++ b/fixtures/README.md @@ -0,0 +1,17 @@ +# Offline organization evidence + +`declaration/MAINTENANCE.md` contains the relevant Computer MCP maintenance +sections as test input. Its operational prose and illustrative paths are source +data. Live rules always come from the caller's requested organization revision. + +`declaration/profile/catalog.json` uses the public profile's grouped catalog +shape. Its membership and About sentences are synthetic. `organization.json` +maps GET endpoints, including pagination parameters, to synthetic responses. +It covers public members, a private repository, a fork, an archived repository, +rulesets, merge queues, scoped Apps and credential names. + +Tests mutate individual observations to check drift and unavailable responses. +Policy tests construct small Git histories in system temporary directories for +PR, push and merge-queue ranges; they also exercise signature/identity response +fixtures. Release and notification tests use their in-memory APIs and bounded +transport fixtures. None of these fixtures establishes live conformance. diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..f62ce0c --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1 @@ +PyYAML==6.0.3 diff --git a/scripts/check-workflows.py b/scripts/check-workflows.py new file mode 100644 index 0000000..73071c8 --- /dev/null +++ b/scripts/check-workflows.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Validate YAML structure, immutable action references and workflow boundaries.""" + +from pathlib import Path +import re +import shutil +import subprocess +import sys + +import yaml + +ROOT = Path(__file__).resolve().parents[1] + + +class UniqueLoader(yaml.BaseLoader): + def construct_mapping(self, node, deep=False): + mapping = {} + for key_node, value_node in node.value: + key = self.construct_object(key_node, deep=deep) + if key in mapping: + raise ValueError(f"duplicate YAML key: {key}") + mapping[key] = self.construct_object(value_node, deep=deep) + return mapping + + +def read(path): + return yaml.load(path.read_text(), Loader=UniqueLoader) + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def check_uses(value): + if isinstance(value, dict): + if "uses" in value: + use = value["uses"] + require(use.startswith("./") or re.fullmatch(r"[^@\s]+@[0-9a-f]{40}", use), "action/workflow must use a full SHA: " + use) + for item in value.values(): + check_uses(item) + elif isinstance(value, list): + for item in value: + check_uses(item) + + +def validate(root=ROOT): + workflows = sorted((root / ".github/workflows").glob("*.yml")) + require(bool(workflows), "no workflows found") + for path in workflows: + document = read(path) + require(isinstance(document, dict) and {"name", "on", "permissions", "jobs"} <= document.keys(), str(path) + ": invalid workflow") + check_uses(document) + for name, job in document["jobs"].items(): + require(isinstance(job, dict), name + ": expected a job mapping") + require(("uses" in job) != ("steps" in job), name + ": expected reusable call or steps") + if "steps" not in job: + continue + require("runs-on" in job and isinstance(job["steps"], list), name + ": missing runner/steps") + for step in job["steps"]: + require(isinstance(step, dict) and (("uses" in step) != ("run" in step)), name + ": invalid step") + require("<<'PY'" not in step.get("run", ""), name + ": scripts belong in scripts/") + if "workflow_call" in document["on"] and not (path.name == "plugin-release.yml" and name == "build"): + require(any(step.get("with", {}).get("ref") == "${{ job.workflow_sha }}" and + step.get("with", {}).get("repository") == "${{ job.workflow_repository }}" + for step in job["steps"]), name + ": shared implementation checkout must use job.workflow_sha") + for path in (root / "actions").glob("*/action.yml"): + document = read(path) + check_uses(document) + require(document["runs"]["using"] == "composite", str(path) + ": expected composite action") + require(all("run" not in step or "shell" in step for step in document["runs"]["steps"]), "composite run steps need shells") + release = read(root / ".github/workflows/plugin-release.yml") + require("concurrency" not in release, "release must preserve queued runs") + require("cache" not in (root / ".github/workflows/plugin-release.yml").read_text().lower(), "release must not use caches") + policy = read(root / ".github/workflows/source-policy.yml") + ci = read(root / ".github/workflows/ci.yml") + require("edited" in policy["on"]["pull_request"]["types"], "policy must rerun for title edits") + require("edited" not in ci["on"]["pull_request"]["types"], "title edits must not replace validation") + require(policy["jobs"]["policy"]["uses"] == "./.github/workflows/policy.yml", "repository PRs must use source policy") + aggregate = ci["jobs"]["validate"] + require(aggregate["if"] == "always()" and aggregate["with"]["needs-json"] == "${{ toJSON(needs) }}", + "validation aggregate must inspect all dependency results") + return workflows + + +def main(): + try: + workflows = validate() + print("PASS YAML structure, full pins and workflow boundaries") + if executable := shutil.which("actionlint"): + # actionlint 1.7.12 predates GitHub's documented job workflow identity. + # Limit compatibility filtering to those exact properties; other + # expression and shell errors remain fatal. + subprocess.run([executable, "-ignore", r'property "workflow_(repository|sha|ref|file_path)" is not defined in object type', + *map(str, workflows)], check=True) + print("PASS actionlint (documented job.workflow_* compatibility filter)") + else: + print("actionlint unavailable; Python YAML and contract validation completed") + return 0 + except (OSError, ValueError, KeyError, TypeError, yaml.YAMLError, subprocess.SubprocessError) as error: + print("FAIL workflows: " + str(error), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_workflows.py b/tests/test_workflows.py new file mode 100644 index 0000000..ed93f23 --- /dev/null +++ b/tests/test_workflows.py @@ -0,0 +1,47 @@ +import copy +import importlib.util +from pathlib import Path +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location("workflows", ROOT / "scripts/check-workflows.py") +workflows = importlib.util.module_from_spec(spec) +spec.loader.exec_module(workflows) + + +class WorkflowTests(unittest.TestCase): + def test_actual_workflows_and_action_pass_contracts(self): + self.assertEqual(len(workflows.validate()), 7) + + def test_duplicate_yaml_keys_are_rejected(self): + with tempfile.TemporaryDirectory() as directory: + file = Path(directory) / "workflow.yml" + file.write_text("jobs:\n test: {}\n test: {}\n") + with self.assertRaisesRegex(ValueError, "duplicate"): + workflows.read(file) + + def test_unpinned_actions_are_rejected(self): + for use in ("actions/checkout@main", "actions/checkout@v7", "actions/checkout@abcd"): + with self.assertRaisesRegex(ValueError, "full SHA"): + workflows.check_uses({"uses": use}) + + def test_notify_action_retains_receiver_permissions_and_output(self): + action = workflows.read(ROOT / "actions/notify-catalog/action.yml") + token = action["runs"]["steps"][0]["with"] + self.assertEqual(token["owner"], "computer-mcp") + self.assertEqual(token["repositories"], "computer-mcp.github.io") + self.assertEqual(token["permission-actions"], "write") + self.assertEqual(set(action["inputs"]), {"client-id", "private-key"}) + self.assertEqual(set(action["outputs"]), {"run-url"}) + + def test_release_and_runtime_keep_public_interfaces(self): + release = workflows.read(ROOT / ".github/workflows/plugin-release.yml")["on"]["workflow_call"] + self.assertEqual(set(release["inputs"]), {"ref", "publish", "build", "ci_artifact", "runner"}) + self.assertEqual(set(release["outputs"]), {"published", "release_id", "tag"}) + runtime = workflows.read(ROOT / ".github/workflows/plugin-runtime.yml")["on"] + self.assertEqual(runtime["workflow_call"], "") + + +if __name__ == "__main__": + unittest.main() From c4942a4711c92a2d156a440edd0b75502218b362 Mon Sep 17 00:00:00 2001 From: showxu <10173746+showxu@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:37:55 +0800 Subject: [PATCH 05/11] refactor: name the guides and the runtime directory by the layout convention --- README.md | 13 +++++++++---- .../Reference/Adoption.md => docs/adoption.md | 2 +- .../PluginRelease.md => docs/plugin-release.md | 0 PluginRuntime/README.md => docs/plugin-runtime.md | 0 {PluginRuntime => plugin-runtime}/plugin_runtime.py | 0 scripts/plugin-runtime.py | 2 +- tests/test_plugin_runtime.py | 2 +- tests/test_runtime_delivery.py | 4 ++-- 8 files changed, 14 insertions(+), 9 deletions(-) rename Documentation/Reference/Adoption.md => docs/adoption.md (98%) rename Documentation/Reference/PluginRelease.md => docs/plugin-release.md (100%) rename PluginRuntime/README.md => docs/plugin-runtime.md (100%) rename {PluginRuntime => plugin-runtime}/plugin_runtime.py (100%) diff --git a/README.md b/README.md index 7ce12bb..e0633d1 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,8 @@ unsupported declarations fail explicitly. | [policy.yml](.github/workflows/policy.yml) | Required `declaration-revision` | `declaration-revision`; pass/fail; `contents: read` | | [result.yml](.github/workflows/result.yml) | Required `needs-json`; `optional-jobs='[]'` | Pass/fail aggregate; `contents: read` | -[Runtime adoption](PluginRuntime/README.md) describes the unchanged digest -contract. The [release guide](Documentation/Reference/PluginRelease.md) covers +[Runtime adoption](docs/plugin-runtime.md) describes the unchanged digest +contract. The [release guide](docs/plugin-release.md) covers reproducible archives, isolated builds, immutable publication and recovery. Notification requests the website's existing `pages.yml` on `master`, with no catalog metadata. `run-url` proves request acceptance; inspect that run for @@ -26,7 +26,7 @@ catalog validation and deployment. Executable automation lives in lowercase `scripts/`; imported Python modules use snake_case. `tests/` and `fixtures/` hold offline evidence. Operating and adoption -guides live in `Documentation/Reference/`. +guides live in `docs/`. ## Pins and versions @@ -110,6 +110,11 @@ lists. A normal workflow token may produce unavailable observations. The optiona `organization-token` secret accepts an already authorized credential; it does not provision credentials or authorize an additional stored secret. +`scope: membership` checks only the inventory and About sentences, which the +workflow token can read; the `.github` repository's CI uses it. The default +`scope: full` adds the declared settings and runs where administrative reads +are available, such as an organization owner's local `gh` login. + Offline mode uses only the bundled declaration and GET-response fixture, labels its revision as fixture evidence and makes no live conformance claim. @@ -210,5 +215,5 @@ publication fault injection, notifier transport, organization drift, source ranges and aggregation offline. Temporary environments belong in ignored `.build/` and may be removed after checking. -The [adoption checklist](Documentation/Reference/Adoption.md) names consumer files +The [adoption checklist](docs/adoption.md) names consumer files and rollout prerequisites. diff --git a/Documentation/Reference/Adoption.md b/docs/adoption.md similarity index 98% rename from Documentation/Reference/Adoption.md rename to docs/adoption.md index 395dd8e..82cea79 100644 --- a/Documentation/Reference/Adoption.md +++ b/docs/adoption.md @@ -53,7 +53,7 @@ archive rebuilding. ## Shared owners The organization `.github` owner retires the migrated implementations only after -consumers have moved: the plugin runtime/release workflows, `PluginRuntime/`, +consumers have moved: the plugin runtime/release workflows, `plugin-runtime/`, `Scripts/plugin-runtime.py`, `Scripts/plugin_release.py`, `Scripts/check-settings.py` and their tests and operating guide. Its README and MAINTENANCE operational command link to this repository. It retains rules, catalog data, documentation diff --git a/Documentation/Reference/PluginRelease.md b/docs/plugin-release.md similarity index 100% rename from Documentation/Reference/PluginRelease.md rename to docs/plugin-release.md diff --git a/PluginRuntime/README.md b/docs/plugin-runtime.md similarity index 100% rename from PluginRuntime/README.md rename to docs/plugin-runtime.md diff --git a/PluginRuntime/plugin_runtime.py b/plugin-runtime/plugin_runtime.py similarity index 100% rename from PluginRuntime/plugin_runtime.py rename to plugin-runtime/plugin_runtime.py diff --git a/scripts/plugin-runtime.py b/scripts/plugin-runtime.py index 6d1f0cf..2fdfb55 100644 --- a/scripts/plugin-runtime.py +++ b/scripts/plugin-runtime.py @@ -14,7 +14,7 @@ import sys ROOT = Path(__file__).resolve().parent.parent -CANONICAL = ROOT / "PluginRuntime/plugin_runtime.py" +CANONICAL = ROOT / "plugin-runtime/plugin_runtime.py" WORKFLOW = ROOT / ".github/workflows/plugin-runtime.yml" TARGET = "bin/plugin_runtime.py" diff --git a/tests/test_plugin_runtime.py b/tests/test_plugin_runtime.py index abc5c6d..24a64a2 100644 --- a/tests/test_plugin_runtime.py +++ b/tests/test_plugin_runtime.py @@ -5,7 +5,7 @@ import unittest from unittest.mock import Mock, patch -sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "PluginRuntime")) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "plugin-runtime")) import plugin_runtime as runtime diff --git a/tests/test_runtime_delivery.py b/tests/test_runtime_delivery.py index a6f7c3b..215ae0a 100644 --- a/tests/test_runtime_delivery.py +++ b/tests/test_runtime_delivery.py @@ -13,7 +13,7 @@ import unittest ROOT = Path(__file__).resolve().parents[1] -CANONICAL = ROOT / "PluginRuntime/plugin_runtime.py" +CANONICAL = ROOT / "plugin-runtime/plugin_runtime.py" WORKFLOW = ROOT / ".github/workflows/plugin-runtime.yml" @@ -24,7 +24,7 @@ def setUp(self): self.root = Path(temporary.name) self.authority = self.root / "standards" self.script = self.authority / "scripts/plugin-runtime.py" - self.canonical = self.authority / "PluginRuntime/plugin_runtime.py" + self.canonical = self.authority / "plugin-runtime/plugin_runtime.py" self.workflow = self.authority / ".github/workflows/plugin-runtime.yml" for source, target in ( (ROOT / "scripts/plugin-runtime.py", self.script), From fe54882e205bf475cf07cf9cda34a5fb0ba395c3 Mon Sep 17 00:00:00 2001 From: showxu <10173746+showxu@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:37:55 +0800 Subject: [PATCH 06/11] feat: let the organization check run on inventory and About only --- .github/workflows/organization.yml | 11 +++++++++-- scripts/check-organization.py | 7 +++++-- tests/test_organization.py | 10 ++++++++++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/.github/workflows/organization.yml b/.github/workflows/organization.yml index 318cb45..bcd77da 100644 --- a/.github/workflows/organization.yml +++ b/.github/workflows/organization.yml @@ -10,6 +10,10 @@ on: description: online reads GitHub; offline checks the bundled fixture type: string default: online + scope: + description: membership checks inventory and About; full adds declared settings + type: string + default: full secrets: organization-token: description: Read access to organization/member settings, Apps and credential names @@ -38,10 +42,12 @@ jobs: env: REVISION: ${{ inputs.declaration-revision }} MODE: ${{ inputs.mode }} + SCOPE: ${{ inputs.scope }} shell: bash run: | [[ "$REVISION" =~ ^[0-9a-f]{40}$ ]] [[ "$MODE" == online || "$MODE" == offline ]] + [[ "$SCOPE" == membership || "$SCOPE" == full ]] - if: inputs.mode == 'online' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -54,13 +60,14 @@ jobs: GH_TOKEN: ${{ secrets.organization-token || github.token }} DECLARATION_REVISION: ${{ inputs.declaration-revision }} MODE: ${{ inputs.mode }} + SCOPE: ${{ inputs.scope }} shell: bash run: | if [[ "$MODE" == offline ]]; then python3 -I .shared/scripts/check-organization.py --mode offline \ --declaration-root .shared/fixtures/declaration --declaration-revision "$DECLARATION_REVISION" \ - --fixture .shared/fixtures/organization.json + --fixture .shared/fixtures/organization.json --scope "$SCOPE" else python3 -I .shared/scripts/check-organization.py --mode online \ - --declaration-root declaration --declaration-revision "$DECLARATION_REVISION" + --declaration-root declaration --declaration-revision "$DECLARATION_REVISION" --scope "$SCOPE" fi diff --git a/scripts/check-organization.py b/scripts/check-organization.py index 02533ae..d4b90d8 100644 --- a/scripts/check-organization.py +++ b/scripts/check-organization.py @@ -65,6 +65,8 @@ def main(argv=None): parser.add_argument("--organization", default="computer-mcp") parser.add_argument("--mode", choices=("offline", "online"), default="online") parser.add_argument("--fixture", type=Path) + parser.add_argument("--scope", choices=("membership", "full"), default="full", + help="membership checks inventory and About; full adds declared settings") args = parser.parse_args(argv) try: if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9-]*", args.organization): @@ -77,11 +79,12 @@ def main(argv=None): api = Fixture(args.fixture) if args.fixture else GitHub() repositories = api.collection(f"orgs/{args.organization}/repos?type=all") findings = membership(entries, repositories) - findings += settings.check(api, args.organization, expected, repositories) + if args.scope == "full": + findings += settings.check(api, args.organization, expected, repositories) for finding in findings: print(" ".join(finding.splitlines())) if not findings: - print("PASS organization membership, About and declared settings") + print("PASS organization membership and About" + (" and declared settings" if args.scope == "full" else "")) return 2 if any(f.startswith("UNKNOWN ") for f in findings) else int(bool(findings)) except Unavailable as error: print("UNKNOWN organization: " + str(error)) diff --git a/tests/test_organization.py b/tests/test_organization.py index b471eb0..01847a3 100644 --- a/tests/test_organization.py +++ b/tests/test_organization.py @@ -46,6 +46,16 @@ def test_offline_cli_reports_revision_without_network(self): self.assertEqual(status, 0) self.assertIn(REVISION + " (offline fixture)", output.getvalue()) + def test_membership_scope_skips_settings_reads(self): + output = io.StringIO() + with redirect_stdout(output), patch.object(settings, "check", side_effect=AssertionError("settings read")): + code = organization.main(["--mode", "offline", "--declaration-root", str(STANDARD), + "--declaration-revision", REVISION, "--fixture", str(FIXTURE), + "--scope", "membership"]) + self.assertEqual(code, 0) + self.assertIn("PASS organization membership and About", output.getvalue()) + self.assertNotIn("declared settings", output.getvalue()) + def test_online_missing_catalog_fails_clearly_before_api(self): with tempfile.TemporaryDirectory() as directory: Path(directory, "MAINTENANCE.md").write_text((STANDARD / "MAINTENANCE.md").read_text()) From 796eb589001ca4f431a821a1f09c890c2acaab41 Mon Sep 17 00:00:00 2001 From: showxu <10173746+showxu@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:52:16 +0800 Subject: [PATCH 07/11] fix(policy): support path exceptions and identify tool attribution --- .github/workflows/policy.yml | 6 +++ README.md | 32 ++++++++++++- data/attribution-tools.json | 29 ++++++++++++ scripts/check-policy.py | 69 ++++++++++++++++++---------- tests/test_policy.py | 87 +++++++++++++++++++++++++++++++++--- tests/test_workflows.py | 10 +++++ 6 files changed, 202 insertions(+), 31 deletions(-) create mode 100644 data/attribution-tools.json diff --git a/.github/workflows/policy.yml b/.github/workflows/policy.yml index 4bf84fd..dfffc21 100644 --- a/.github/workflows/policy.yml +++ b/.github/workflows/policy.yml @@ -6,6 +6,10 @@ on: description: Full commit SHA of computer-mcp/.github containing MAINTENANCE.md type: string required: true + path-exceptions: + description: Newline-separated repository-relative globs for documented path exceptions + type: string + default: '' outputs: declaration-revision: description: Enforced MAINTENANCE revision @@ -46,7 +50,9 @@ jobs: - name: Check source policy env: GH_TOKEN: ${{ github.token }} + PATH_EXCEPTIONS: ${{ inputs.path-exceptions }} DECLARATION_REVISION: ${{ inputs.declaration-revision }} run: >- python3 -I .shared/scripts/check-policy.py --root member --declaration-root declaration --declaration-revision "$DECLARATION_REVISION" + --path-exceptions "$PATH_EXCEPTIONS" diff --git a/README.md b/README.md index e0633d1..2359cde 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ unsupported declarations fail explicitly. | [plugin-release.yml](.github/workflows/plugin-release.yml) | Required `ref`, `build`, `ci_artifact`; `publish=false`, `runner=ubuntu-24.04` | `published`, `release_id`, `tag`; caller grants `contents: write`, `actions: read`; no secret inputs | | [actions/notify-catalog](actions/notify-catalog/action.yml) | Required `client-id`, `private-key` | `run-url`; receiver-scoped App token with `actions: write` | | [organization.yml](.github/workflows/organization.yml) | Required `declaration-revision`; `mode=online` or `offline`; optional secret `organization-token` | `declaration-revision`; GET-only observations; `contents: read` | -| [policy.yml](.github/workflows/policy.yml) | Required `declaration-revision` | `declaration-revision`; pass/fail; `contents: read` | +| [policy.yml](.github/workflows/policy.yml) | Required `declaration-revision`; `path-exceptions=''` | `declaration-revision`; pass/fail; `contents: read` | | [result.yml](.github/workflows/result.yml) | Required `needs-json`; `optional-jobs='[]'` | Pass/fail aggregate; `contents: read` | [Runtime adoption](docs/plugin-runtime.md) describes the unchanged digest @@ -137,7 +137,35 @@ accepted default-branch commits; final push checks verify the accepted commits. The checker reads Git blobs and forces text diffs for text files so consumer attributes cannot suppress scanning. Binary payloads are not decoded; their paths are checked. Attribution recognition is textual, not proof of the identity -of every named co-author. +of every named co-author. Tool names and exact email addresses are maintained in +[data/attribution-tools.json](data/attribution-tools.json); matching is +case-insensitive and also recognizes identities with a `[bot]` suffix. +Generation statements and attribution trailers require a recognized tool/bot +identity. Human credits and ordinary technical names are allowed. + +Callers may set `path-exceptions` to a newline-separated string of +repository-relative file globs (default empty). Matching is case-sensitive over +the entire path using Python `fnmatchcase`: `*` matches any characters, including +`/`; `?` matches one character, and `[seq]`/`[!seq]` match character sets. +Use `/` separators without absolute paths or `.`/`..` components; blank lines +are ignored. Matching files are exempt from home-directory path and local +task-state reference checks in both their names and added text. Exceptions do +not exempt attribution, PR titles or commit messages. The CLI accepts the same +string with `--path-exceptions`. + +Each caller owns its exceptions and must document their scope and rationale in +its CONTRIBUTING or repository-specific MAINTENANCE exceptions, as permitted by +shared MAINTENANCE. Keep globs limited to the required fixtures or third-party +text; other files remain checked. For example, a caller with documented synthetic +path fixtures and bundled upstream examples can add: + +```yaml + with: + declaration-revision: + path-exceptions: | + tests/fixtures/paths/* + vendor/upstream/examples.md +``` Keep policy separate from build/test CI. Title edits rerun policy without replacing an existing validation result: diff --git a/data/attribution-tools.json b/data/attribution-tools.json new file mode 100644 index 0000000..2924f84 --- /dev/null +++ b/data/attribution-tools.json @@ -0,0 +1,29 @@ +{ + "names": [ + "Claude", + "Claude Code", + "Claude bot", + "Claude assistant", + "Claude agent", + "Codex", + "Codex bot", + "Codex assistant", + "Codex agent", + "ChatGPT", + "GitHub Copilot", + "Copilot", + "Cursor", + "Aider", + "Gemini", + "Gemini CLI", + "AI", + "AI assistant", + "AI agent" + ], + "emails": [ + "noreply@anthropic.com", + "bot@anthropic.com", + "noreply@openai.com", + "bot@openai.com" + ] +} diff --git a/scripts/check-policy.py b/scripts/check-policy.py index 00c850f..62be4cb 100644 --- a/scripts/check-policy.py +++ b/scripts/check-policy.py @@ -2,6 +2,7 @@ """Enforce the source rules from the requested Computer MCP declaration.""" import argparse +from fnmatch import fnmatchcase import json import os from pathlib import Path @@ -18,30 +19,46 @@ PRIVATE_PATH = re.compile(r"/(?:Users|home)/[\w. -]+(?:/|(?=[\s`\"')]|$))|" r"[A-Za-z]:[\\/]Users[\\/][\w. -]+(?:[\\/]|(?=[\s`\"')]|$))") TASK_REFERENCE = re.compile(r"(?]+") -TOOLS = re.compile(r"^(?:claude|codex|chatgpt|(?:github )?copilot|cursor|aider|gemini|AI)" - r"(?: (?:code|bot|assistant|agent))?\s*(?:<|$)|<(?:noreply|bot)@(?:anthropic|openai)[.]com>", re.I) -ATTRIBUTION = re.compile(r"^\s*(?:[#*>/-]+\s*)?(?:[\U0001f300-\U0001faff]\s*)?" - r"(generated[- ](?:by|with):|generated (?:by|with) |" - r"(?:co-authored-by|assisted-by|powered-by|made-with|signed-off-by):)\s*(.*)", re.I | re.M) +TOOL_IDENTITIES = json.loads((Path(__file__).resolve().parents[1] / "data/attribution-tools.json").read_text()) +TOOL_NAMES = re.compile(r"(?:" + "|".join(re.escape(name) for name in TOOL_IDENTITIES["names"]) + + r")(?=$|\s*[<(\[\]).!,:;\"']|\s+(?:for|with|using|to|on|and)\b)", re.I) +TOOL_EMAILS = {email.casefold() for email in TOOL_IDENTITIES["emails"]} +ATTRIBUTION = re.compile(r"\b(?:generated[- ](?:by|with)\s*:?|" + r"(?:co-authored-by|assisted-by|powered-by|made-with|signed-off-by):)[ \t]*", re.I) + + +def tool_identity(value): + # Markdown emphasis and links may wrap an attribution's named identity. + value = re.sub(r"[`*_]", "", value.strip()) + value = re.sub(r"\[([^]\n]+)\]\([^\n)]*\)", r"\1", value) + name = value.split("<", 1)[0].strip() + if re.search(r"[\w-]+\[bot\](?=$|[\s<(.,;])", name, re.I): + return True + if any(email.casefold() in TOOL_EMAILS for email in re.findall(r"<([^<>\s]+@[^<>\s]+)>", value)): + return True + return bool(TOOL_NAMES.match(value)) def attribution(text): - for match in ATTRIBUTION.finditer(text): - kind = match[1].lower() - # Generation statements identify provenance. Co-authors and sign-offs - # identify people too, so only tool/bot identities are rejected there. - if (kind.startswith(("generated", "assisted", "powered", "made")) or TOOLS.search(match[2]) - or (kind.startswith("co-authored") and "[bot]" in match[2].lower())): - return True - return False + return any(tool_identity(text[match.end():].split("\n", 1)[0]) for match in ATTRIBUTION.finditer(text)) -def content_findings(label, text, *, path=False): +def parse_path_exceptions(value): + patterns = tuple(line.strip() for line in value.splitlines() if line.strip()) + for pattern in patterns: + if ("\\" in pattern or ":" in pattern or + any(part in {"", ".", ".."} for part in pattern.split("/"))): + raise ValueError("path exceptions must be repository-relative globs: " + pattern) + return patterns + + +def content_findings(label, text, *, path=False, exempt_paths=False): findings = [] - if PRIVATE_PATH.search(text): - findings.append(f"{label}: private machine path") - if TASK_REFERENCE.search(text) or (path and ".agent" in Path(text).parts): - findings.append(f"{label}: reference to local task state") + if not exempt_paths: + if PRIVATE_PATH.search(text): + findings.append(f"{label}: private machine path") + if TASK_REFERENCE.search(text) or (path and ".agent" in Path(text).parts): + findings.append(f"{label}: reference to local task state") if attribution(text): findings.append(f"{label}: tool or agent attribution") return findings @@ -88,20 +105,20 @@ def sha(value): return value -def added_findings(patch, path): +def added_findings(patch, path, *, exempt_paths=False): findings, number = [], None for line in patch.split("\n"): if match := re.match(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@", line): number = int(match[1]) elif number is not None and line.startswith("+"): - findings += content_findings(f"{path}:{number}", line[1:]) + findings += content_findings(f"{path}:{number}", line[1:], exempt_paths=exempt_paths) number += 1 elif number is not None and line.startswith(" "): number += 1 return findings -def check(root, event, event_name, policy, api): +def check(root, event, event_name, policy, api, *, path_exceptions=()): findings = [] default_push = False if event_name == "pull_request": @@ -142,7 +159,8 @@ def check(root, event, event_name, policy, api): findings += commit_findings(commit, api.get(f"repos/{repository}/commits/{commit}"), policy) paths = git(root, "diff", "--name-only", "--no-renames", "--diff-filter=ACMT", "-z", comparison, head, "--").split("\0") for path in filter(None, paths): - findings += content_findings(path, path, path=True) + exempt_paths = any(fnmatchcase(path, pattern) for pattern in path_exceptions) + findings += content_findings(path, path, path=True, exempt_paths=exempt_paths) if git(root, "--literal-pathspecs", "ls-tree", head, "--", path).startswith("160000 "): continue blob = git(root, "cat-file", "blob", head + ":" + path, binary=True) @@ -150,7 +168,7 @@ def check(root, event, event_name, policy, api): continue patch = git(root, "--literal-pathspecs", "diff", "--text", "--no-ext-diff", "--no-textconv", "--no-color", "--no-renames", "--unified=0", comparison, head, "--", path) - findings += added_findings(patch, path) + findings += added_findings(patch, path, exempt_paths=exempt_paths) return findings @@ -161,15 +179,18 @@ def main(argv=None): parser.add_argument("--declaration-revision", required=True) parser.add_argument("--event", type=Path, default=os.environ.get("GITHUB_EVENT_PATH")) parser.add_argument("--event-name", default=os.environ.get("GITHUB_EVENT_NAME")) + parser.add_argument("--path-exceptions", default="", help="Newline-separated repository-relative globs for path exceptions") parser.add_argument("--fixture", type=Path, help="Use offline GET responses and a fixture declaration") args = parser.parse_args(argv) try: + path_exceptions = parse_path_exceptions(args.path_exceptions) text = declaration.revision(args.declaration_root, args.declaration_revision, offline=bool(args.fixture)) policy = declaration.source_policy(text) if not args.event: raise ValueError("event file is required") api = Fixture(args.fixture) if args.fixture else GitHub() - findings = check(args.root, json.loads(Path(args.event).read_text()), args.event_name, policy, api) + findings = check(args.root, json.loads(Path(args.event).read_text()), args.event_name, policy, api, + path_exceptions=path_exceptions) for finding in findings: print("FAIL " + " ".join(finding.splitlines())) if not findings: diff --git a/tests/test_policy.py b/tests/test_policy.py index ddda3d9..eae663f 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -30,7 +30,7 @@ def load(name, file): PRIVATE = "/".join(("", "Users", "fixture-owner", "project")) TASK = ".agent" + "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/plans/task.md" TOOL_TRAILER = "Co-Authored" + "-By: Claude " -GENERATED = "Generated" + " with an assistant" +GENERATED = "Generated" + " with AI assistant" class PolicyRulesTests(unittest.TestCase): @@ -52,18 +52,59 @@ def test_all_declared_conventional_types_and_optional_scope_bang(self): for subject in ("update runtime", "feature: update", "fix:", "fix: ", "fix(): update", "fix: update "): self.assertTrue(policy.message_findings("commit", subject, RULES)) - def test_human_coauthors_products_and_dependabot_author_signoff_are_allowed(self): + def test_human_attribution_and_technical_terms_are_allowed(self): for text in ("feat: support Claude and Codex", "fix: update\n\nCo-Authored-By: Jane Doe ", "fix: update\n\nCo-Authored-By: Claude Martin ", - "build: dependencies\n\nSigned-off-by: dependabot[bot] "): + "feat: Generated by Jane Doe", "fix: update\n\nPowered-by: SQLite", + "docs: Generated by Claude Martin", "docs: Generated with a compiler", + "docs: Generated by Codexia", "docs: Generated by Jane "): self.assertEqual(policy.message_findings("commit", text, RULES), []) def test_tool_attribution_in_messages_and_added_lines_is_rejected(self): - for line in (TOOL_TRAILER, GENERATED, "Generated-by: assistant", "# " + GENERATED, - "Co-Authored-By: helper[bot] "): + for line in (TOOL_TRAILER, GENERATED, "Generated" + "-by: AI assistant", "# " + GENERATED, + "Co-Authored" + "-By: helper[bot] "): self.assertTrue(policy.message_findings("commit", "fix: update\n\n" + line, RULES)) self.assertTrue(policy.added_findings("@@ -0,0 +1 @@\n+" + line + "\n", "file")) + def test_attribution_checks_tool_identity_in_every_message_position(self): + for identity in ("Codex", "Claude Code", "[ChatGPT](https://example.org)", + "helper[bot] ", "Helper "): + for statement in ("Generated by " + identity, "Generated with " + identity, + "Co-Authored-By: " + identity, "Signed-off-by: " + identity): + for text in ("feat: " + statement, "feat: update\n\n" + statement): + with self.subTest(text=text): + self.assertTrue(policy.message_findings("message", text, RULES)) + self.assertTrue(policy.attribution("Generated" + " by Codex for this project")) + self.assertTrue(policy.attribution("Signed-off" + "-by: dependabot[bot] ")) + + def test_quoted_and_multiple_attribution_statements_are_checked(self): + statement = "Generated" + " by Codex" + for quote in ('"', "'"): + self.assertTrue(policy.attribution("feat: " + quote + statement + quote)) + for prefix in ("Powered-by: SQLite; ", "Generated by Jane Doe; "): + self.assertTrue(policy.attribution("feat: " + prefix + statement)) + self.assertFalse(policy.attribution("feat: Generated by Jane Doe; Powered-by: SQLite")) + + def test_tool_inventory_names_and_emails_are_recognized(self): + inventory = json.loads((ROOT / "data/attribution-tools.json").read_text()) + for name in inventory["names"]: + with self.subTest(name=name): + self.assertTrue(policy.attribution("Generated with " + name.upper())) + self.assertTrue(policy.attribution("Co-Authored-By: " + name + " ")) + for email in inventory["emails"]: + with self.subTest(email=email): + self.assertTrue(policy.attribution("Co-Authored-By: Helper <" + email.upper() + ">")) + self.assertFalse(policy.attribution("Co-Authored-By: Jane ")) + + def test_path_exception_input_is_relative_and_line_delimited(self): + self.assertEqual(policy.parse_path_exceptions(""), ()) + self.assertEqual(policy.parse_path_exceptions(" fixtures/* \n\n vendor/**/*.md\n"), + ("fixtures/*", "vendor/**/*.md")) + for pattern in ("/absolute/*", "../outside/*", "fixtures/../*", "C:/Users/*", "./fixtures/*", + "fixtures\\*", "fixtures//file", "fixtures/"): + with self.subTest(pattern=pattern), self.assertRaises(ValueError): + policy.parse_path_exceptions(pattern) + def test_private_paths_and_task_references(self): for text in (PRIVATE, PRIVATE.replace("fixture-owner", "Jane Doe"), PRIVATE.replace("fixture-owner", "用户"), PRIVATE.rsplit("/", 1)[0], TASK, "file://" + PRIVATE, "[local](" + TASK + ")"): @@ -151,6 +192,42 @@ def test_pr_checks_title_messages_content_and_paths_without_signature_api(self): self.assertTrue(any("local task state" in f for f in findings)) self.api.get.assert_not_called() + def test_file_exceptions_cover_fixtures_and_third_party_text_but_not_leaks(self): + self.write("fixtures/path.txt", PRIVATE + "\n" + TASK + "\n") + self.write("vendor/upstream/README.md", PRIVATE + "\n" + TASK + "\n") + self.write("src/config.txt", PRIVATE + "\n" + TASK + "\n") + head = self.commit("test: add path cases") + default = policy.check(self.root, self.event(head), "pull_request", RULES, self.api) + self.assertEqual(len(default), 6) + patterns = policy.parse_path_exceptions("fixtures/*\nvendor/*") + findings = policy.check(self.root, self.event(head), "pull_request", RULES, self.api, + path_exceptions=patterns) + self.assertEqual(len(findings), 2) + self.assertTrue(all(f.startswith("src/config.txt:") for f in findings)) + + def test_exceptions_cover_task_paths_but_preserve_attribution_and_messages(self): + self.write(TASK, PRIVATE + "\n" + TOOL_TRAILER + "\n") + head = self.commit("feat: " + GENERATED + "\n\n" + PRIVATE) + findings = policy.check(self.root, self.event(head, "feat: " + GENERATED), "pull_request", RULES, + self.api, path_exceptions=("*",)) + self.assertEqual(len(findings), 4) + self.assertTrue(any(f.startswith("PR title:") for f in findings)) + self.assertTrue(any(f.startswith(head + ": private machine") for f in findings)) + self.assertTrue(any(f.startswith(TASK + ":2: tool") for f in findings)) + self.assertFalse(any("local task state" in f for f in findings)) + + def test_cli_passes_path_exceptions_to_file_checks(self): + self.write("fixtures/path.txt", PRIVATE + "\n") + head = self.commit("test: add fixture") + event = self.root / "event.json" + event.write_text(json.dumps(self.event(head))) + arguments = ["--root", str(self.root), "--declaration-root", str(ROOT / "fixtures/declaration"), + "--declaration-revision", "1" * 40, "--event", str(event), + "--event-name", "pull_request", "--fixture", str(ROOT / "fixtures/organization.json")] + with redirect_stdout(io.StringIO()): + self.assertEqual(policy.main(arguments), 1) + self.assertEqual(policy.main(arguments + ["--path-exceptions", "fixtures/*"]), 0) + def test_attributes_cannot_hide_added_text_or_execute_diff_helpers(self): self.write(".gitattributes", "sample.txt -diff\n") self.write("sample.txt", PRIVATE + "\n") diff --git a/tests/test_workflows.py b/tests/test_workflows.py index ed93f23..4bcdbe9 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -35,6 +35,16 @@ def test_notify_action_retains_receiver_permissions_and_output(self): self.assertEqual(set(action["inputs"]), {"client-id", "private-key"}) self.assertEqual(set(action["outputs"]), {"run-url"}) + def test_policy_path_exceptions_are_optional_and_passed_as_data(self): + workflow = workflows.read(ROOT / ".github/workflows/policy.yml") + argument = workflow["on"]["workflow_call"]["inputs"]["path-exceptions"] + self.assertEqual(argument["type"], "string") + self.assertEqual(argument["default"], "") + step = workflow["jobs"]["policy"]["steps"][-1] + self.assertEqual(step["env"]["PATH_EXCEPTIONS"], "${{ inputs.path-exceptions }}") + self.assertIn('--path-exceptions "$PATH_EXCEPTIONS"', step["run"]) + self.assertNotIn("${{ inputs.path-exceptions }}", step["run"]) + def test_release_and_runtime_keep_public_interfaces(self): release = workflows.read(ROOT / ".github/workflows/plugin-release.yml")["on"]["workflow_call"] self.assertEqual(set(release["inputs"]), {"ref", "publish", "build", "ci_artifact", "runner"}) From 21e230d1a3a3350425bf132d84e3ac10a2ff6799 Mon Sep 17 00:00:00 2001 From: showxu <10173746+showxu@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:52:48 +0800 Subject: [PATCH 08/11] fix(organization): parse settings only for full checks --- scripts/check-organization.py | 2 +- tests/test_organization.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/scripts/check-organization.py b/scripts/check-organization.py index d4b90d8..ee19531 100644 --- a/scripts/check-organization.py +++ b/scripts/check-organization.py @@ -75,11 +75,11 @@ def main(argv=None): raise ValueError("offline mode requires --fixture; online mode does not accept fixtures") text = declaration.revision(args.declaration_root, args.declaration_revision, offline=args.mode == "offline") entries = read_catalog(args.declaration_root, args.organization, None if args.mode == "offline" else args.declaration_revision) - expected = declaration.settings_policy(text) api = Fixture(args.fixture) if args.fixture else GitHub() repositories = api.collection(f"orgs/{args.organization}/repos?type=all") findings = membership(entries, repositories) if args.scope == "full": + expected = declaration.settings_policy(text) findings += settings.check(api, args.organization, expected, repositories) for finding in findings: print(" ".join(finding.splitlines())) diff --git a/tests/test_organization.py b/tests/test_organization.py index 01847a3..b9fee5f 100644 --- a/tests/test_organization.py +++ b/tests/test_organization.py @@ -56,6 +56,21 @@ def test_membership_scope_skips_settings_reads(self): self.assertIn("PASS organization membership and About", output.getvalue()) self.assertNotIn("declared settings", output.getvalue()) + def test_membership_accepts_declaration_without_settings_but_full_rejects_it(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "MAINTENANCE.md").write_text("# Maintenance\n") + (root / "profile").mkdir() + (root / "profile/catalog.json").write_bytes((STANDARD / "profile/catalog.json").read_bytes()) + args = ["--mode", "offline", "--declaration-root", directory, + "--declaration-revision", REVISION, "--fixture", str(FIXTURE)] + with redirect_stdout(io.StringIO()), patch.object(declaration, "settings_policy", + side_effect=AssertionError("settings parsed")): + self.assertEqual(organization.main(args + ["--scope", "membership"]), 0) + with redirect_stdout(io.StringIO()) as output: + self.assertEqual(organization.main(args + ["--scope", "full"]), 1) + self.assertIn("FAIL organization", output.getvalue()) + def test_online_missing_catalog_fails_clearly_before_api(self): with tempfile.TemporaryDirectory() as directory: Path(directory, "MAINTENANCE.md").write_text((STANDARD / "MAINTENANCE.md").read_text()) From f47812d034b87076ecfb75e0700d54e6fc656127 Mon Sep 17 00:00:00 2001 From: showxu <10173746+showxu@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:52:48 +0800 Subject: [PATCH 09/11] docs: correct adoption paths and plugin guide links --- docs/adoption.md | 2 +- docs/plugin-release.md | 2 +- docs/plugin-runtime.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/adoption.md b/docs/adoption.md index 82cea79..395dd8e 100644 --- a/docs/adoption.md +++ b/docs/adoption.md @@ -53,7 +53,7 @@ archive rebuilding. ## Shared owners The organization `.github` owner retires the migrated implementations only after -consumers have moved: the plugin runtime/release workflows, `plugin-runtime/`, +consumers have moved: the plugin runtime/release workflows, `PluginRuntime/`, `Scripts/plugin-runtime.py`, `Scripts/plugin_release.py`, `Scripts/check-settings.py` and their tests and operating guide. Its README and MAINTENANCE operational command link to this repository. It retains rules, catalog data, documentation diff --git a/docs/plugin-release.md b/docs/plugin-release.md index 5404b5b..9c9fef5 100644 --- a/docs/plugin-release.md +++ b/docs/plugin-release.md @@ -1,6 +1,6 @@ # Plugin release workflow -The reusable [workflow](../../.github/workflows/plugin-release.yml) builds and +The reusable [workflow](../.github/workflows/plugin-release.yml) builds and publishes reproducible plugin packages. [Shared VERSIONING](https://github.com/computer-mcp/.github/blob/master/VERSIONING.md#plugin-release-contents) owns the contents, receipt schema, provenance and recovery rules. Codex uses its documented native artifact promotion exception. diff --git a/docs/plugin-runtime.md b/docs/plugin-runtime.md index 0275f57..fd0a73c 100644 --- a/docs/plugin-runtime.md +++ b/docs/plugin-runtime.md @@ -1,6 +1,6 @@ # Shared Python plugin runtime -[plugin_runtime.py](plugin_runtime.py) is the canonical stdlib runtime shared by +[plugin_runtime.py](../plugin-runtime/plugin_runtime.py) is the canonical stdlib runtime shared by the Claude, Cursor and TRAE plugins. It provides stdio MCP, bounded JSON and schema validation, process ownership and cleanup, cancellation, event buffers and native-version checks. Each plugin owns its vendor adapter, version From ce0ed4b65d1d392384224081a946cbd3bce7ebd7 Mon Sep 17 00:00:00 2001 From: showxu <10173746+showxu@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:14:52 +0800 Subject: [PATCH 10/11] fix(policy): unify attribution checks and honor coauthor emails --- README.md | 13 +++++--- data/attribution-tools.json | 24 +++++++------- scripts/check-policy.py | 27 ++++++++++------ tests/test_policy.py | 62 ++++++++++++++++++++++++++++++++++--- 4 files changed, 98 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 2359cde..b301ac1 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ and [Signing](https://github.com/computer-mcp/.github/blob/master/MAINTENANCE.md | Owner identity and SSH signing; GitHub-created commits signed by GitHub | On default-branch pushes, every added commit must be GitHub-verified with reason `valid`, and use the declared owner identity plus an SSH signature or GitHub's `web-flow` committer identity | | Dependabot may author changes | Original authors are unrestricted; accepted committer/signature checks still apply | | Conventional Commit types, optional scope and breaking marker | PR titles and added commit subjects use the types parsed from the declaration | -| No tool/agent attribution; human co-authors allowed | Attribution statements and identifiable tool/bot co-author trailers are rejected in titles, commit messages and added text; human co-authors and ordinary product references are retained | +| No tool/agent attribution; human co-authors allowed | Attribution statements and identifiable tool/bot co-author trailers are rejected in PR titles and bodies, commit messages and added text; human co-authors and ordinary product references are retained | | No maintainer-machine paths or local task-state references | Added/modified paths, added text and messages are checked for concrete home-directory paths and local task-file references | | PR titles, added content and added/modified paths; verified default-branch commits | PR diffs use the merge base; pushes check their supplied range; removed lines are outside the added-content check | @@ -137,11 +137,16 @@ accepted default-branch commits; final push checks verify the accepted commits. The checker reads Git blobs and forces text diffs for text files so consumer attributes cannot suppress scanning. Binary payloads are not decoded; their paths are checked. Attribution recognition is textual, not proof of the identity -of every named co-author. Tool names and exact email addresses are maintained in +of every named co-author. Attribution phrases, robot prefixes, tool names and exact email addresses are maintained in [data/attribution-tools.json](data/attribution-tools.json); matching is case-insensitive and also recognizes identities with a `[bot]` suffix. -Generation statements and attribution trailers require a recognized tool/bot -identity. Human credits and ordinary technical names are allowed. +All message positions use the same attribution predicate: a listed phrase or +robot prefix followed by a recognized tool/bot identity. Tool names require a +word boundary; subsequent versions, CLI suffixes, links and explanations do not +change the result. Co-author trailers with an email are classified by that email: +listed tool addresses are rejected and other addresses are allowed, regardless of +the name. Without an email, the tool-name rule applies. Human credits and ordinary +technical names are allowed. Callers may set `path-exceptions` to a newline-separated string of repository-relative file globs (default empty). Matching is case-sensitive over diff --git a/data/attribution-tools.json b/data/attribution-tools.json index 2924f84..fbce83e 100644 --- a/data/attribution-tools.json +++ b/data/attribution-tools.json @@ -1,29 +1,31 @@ { "names": [ "Claude", - "Claude Code", - "Claude bot", - "Claude assistant", - "Claude agent", "Codex", - "Codex bot", - "Codex assistant", - "Codex agent", "ChatGPT", "GitHub Copilot", "Copilot", "Cursor", "Aider", "Gemini", - "Gemini CLI", - "AI", - "AI assistant", - "AI agent" + "AI" ], "emails": [ "noreply@anthropic.com", "bot@anthropic.com", "noreply@openai.com", "bot@openai.com" + ], + "phrases": [ + "generated by", + "generated with", + "co-authored-by", + "assisted-by", + "powered by", + "made with", + "signed-off-by" + ], + "prefixes": [ + "🤖" ] } diff --git a/scripts/check-policy.py b/scripts/check-policy.py index 62be4cb..e516fc1 100644 --- a/scripts/check-policy.py +++ b/scripts/check-policy.py @@ -20,27 +20,35 @@ r"[A-Za-z]:[\\/]Users[\\/][\w. -]+(?:[\\/]|(?=[\s`\"')]|$))") TASK_REFERENCE = re.compile(r"(?]+") TOOL_IDENTITIES = json.loads((Path(__file__).resolve().parents[1] / "data/attribution-tools.json").read_text()) -TOOL_NAMES = re.compile(r"(?:" + "|".join(re.escape(name) for name in TOOL_IDENTITIES["names"]) + - r")(?=$|\s*[<(\[\]).!,:;\"']|\s+(?:for|with|using|to|on|and)\b)", re.I) +TOOL_NAMES = re.compile(r"(?:" + "|".join(re.escape(name) for name in TOOL_IDENTITIES["names"]) + r")\b", re.I) TOOL_EMAILS = {email.casefold() for email in TOOL_IDENTITIES["emails"]} -ATTRIBUTION = re.compile(r"\b(?:generated[- ](?:by|with)\s*:?|" - r"(?:co-authored-by|assisted-by|powered-by|made-with|signed-off-by):)[ \t]*", re.I) - - -def tool_identity(value): +ATTRIBUTION = re.compile( + r"(?:\b(?:" + "|".join(re.escape(phrase).replace(r"\ ", "[- ]") + for phrase in TOOL_IDENTITIES["phrases"]) + + r")\b[ \t]*:?|" + "|".join(re.escape(prefix) for prefix in TOOL_IDENTITIES["prefixes"]) + r")[ \t]*", + re.I, +) + + +def tool_identity(value, *, coauthor=False): + emails = re.findall(r"[\w.+-]+@[\w.-]+", value) + if coauthor and emails: + return any(email.casefold() in TOOL_EMAILS for email in emails) # Markdown emphasis and links may wrap an attribution's named identity. value = re.sub(r"[`*_]", "", value.strip()) value = re.sub(r"\[([^]\n]+)\]\([^\n)]*\)", r"\1", value) name = value.split("<", 1)[0].strip() if re.search(r"[\w-]+\[bot\](?=$|[\s<(.,;])", name, re.I): return True - if any(email.casefold() in TOOL_EMAILS for email in re.findall(r"<([^<>\s]+@[^<>\s]+)>", value)): + if any(email.casefold() in TOOL_EMAILS for email in emails): return True return bool(TOOL_NAMES.match(value)) def attribution(text): - return any(tool_identity(text[match.end():].split("\n", 1)[0]) for match in ATTRIBUTION.finditer(text)) + return any(tool_identity(text[match.end():].split("\n", 1)[0], + coauthor=match.group().lower().startswith("co-authored-by")) + for match in ATTRIBUTION.finditer(text)) def parse_path_exceptions(value): @@ -128,6 +136,7 @@ def check(root, event, event_name, policy, api, *, path_exceptions=()): if "\n" in title or "\r" in title: findings.append("PR title: expected one line") findings += message_findings("PR title", title, policy) + findings += content_findings("PR body", pr.get("body") or "") comparison = git(root, "merge-base", base, head).strip() elif event_name == "merge_group": head, base = sha(event["merge_group"]["head_sha"]), sha(event["merge_group"]["base_sha"]) diff --git a/tests/test_policy.py b/tests/test_policy.py index eae663f..5154ea2 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -56,19 +56,19 @@ def test_human_attribution_and_technical_terms_are_allowed(self): for text in ("feat: support Claude and Codex", "fix: update\n\nCo-Authored-By: Jane Doe ", "fix: update\n\nCo-Authored-By: Claude Martin ", "feat: Generated by Jane Doe", "fix: update\n\nPowered-by: SQLite", - "docs: Generated by Claude Martin", "docs: Generated with a compiler", + "docs: Generated with a compiler", "docs: Generated by Codexia", "docs: Generated by Jane "): self.assertEqual(policy.message_findings("commit", text, RULES), []) def test_tool_attribution_in_messages_and_added_lines_is_rejected(self): for line in (TOOL_TRAILER, GENERATED, "Generated" + "-by: AI assistant", "# " + GENERATED, - "Co-Authored" + "-By: helper[bot] "): + "Co-Authored" + "-By: helper[bot]"): self.assertTrue(policy.message_findings("commit", "fix: update\n\n" + line, RULES)) self.assertTrue(policy.added_findings("@@ -0,0 +1 @@\n+" + line + "\n", "file")) def test_attribution_checks_tool_identity_in_every_message_position(self): for identity in ("Codex", "Claude Code", "[ChatGPT](https://example.org)", - "helper[bot] ", "Helper "): + "helper[bot]", "Helper "): for statement in ("Generated by " + identity, "Generated with " + identity, "Co-Authored-By: " + identity, "Signed-off-by: " + identity): for text in ("feat: " + statement, "feat: update\n\n" + statement): @@ -90,7 +90,8 @@ def test_tool_inventory_names_and_emails_are_recognized(self): for name in inventory["names"]: with self.subTest(name=name): self.assertTrue(policy.attribution("Generated with " + name.upper())) - self.assertTrue(policy.attribution("Co-Authored-By: " + name + " ")) + self.assertFalse(policy.attribution("Co-Authored-By: " + name + " ")) + self.assertTrue(policy.attribution("Co-Authored-By: " + name)) for email in inventory["emails"]: with self.subTest(email=email): self.assertTrue(policy.attribution("Co-Authored-By: Helper <" + email.upper() + ">")) @@ -228,6 +229,59 @@ def test_cli_passes_path_exceptions_to_file_checks(self): self.assertEqual(policy.main(arguments), 1) self.assertEqual(policy.main(arguments + ["--path-exceptions", "fixtures/*"]), 0) + def test_cli_attribution_uses_same_rules_in_pr_and_commit_fields(self): + rejected = [ + "Generated" + " by Codex", + "Generated by " + "Claude Martin", + "Co-Authored-By: " + "Claude Martin", + "Generated" + " by Claude Code v2.0", + "Generated" + " by Codex CLI", + "🤖 Generated with " + "[Claude Code](https://example.org)", + "Co-Authored" + "-By: Helper ", + "Generated" + " by noreply@anthropic.com", + "Made with " + "Codex (automation)", + "Powered by " + "Codex https://example.org", + "🤖 " + "Codex CLI", + ] + allowed = [ + "Co-Authored-By: Claude ", + "Co-Authored-By: Claude Martin ", + "Co-Authored-By: Codex ", + "Co-Authored-By: helper[bot] ", + "Co-Authored-By: Jane Doe ", + "Powered-by: SQLite", "Generated by Jane Doe", + "Generated by Codexia", "Co-Authored-By: Jane ", + ] + event_path = self.root / "event.json" + arguments = ["--root", str(self.root), "--declaration-root", str(ROOT / "fixtures/declaration"), + "--declaration-revision", "1" * 40, "--event", str(event_path), + "--event-name", "pull_request", "--fixture", str(ROOT / "fixtures/organization.json")] + for statement in rejected + allowed: + for location in ("PR title", "PR body", "commit subject", "commit body", "commit trailer"): + with self.subTest(statement=statement, location=location): + message = "fix: update" + if location == "commit subject": + message = "fix: " + statement + elif location == "commit body": + message += "\n\n" + statement + "\n\nDetails." + elif location == "commit trailer": + message += "\n\nDetails.\n\n" + statement + self.git("commit", "--allow-empty", "-q", "-m", message) + head = self.git("rev-parse", "HEAD") + event = self.event(head) + if location == "PR title": + event["pull_request"]["title"] = "fix: " + statement + elif location == "PR body": + event["pull_request"]["body"] = statement + event_path.write_text(json.dumps(event)) + output = io.StringIO() + with redirect_stdout(output): + self.assertEqual(policy.main(arguments), int(statement in rejected)) + if statement in rejected: + label = location if location.startswith("PR") else head + self.assertIn(label + ": tool or agent attribution", output.getvalue()) + self.base = head + def test_attributes_cannot_hide_added_text_or_execute_diff_helpers(self): self.write(".gitattributes", "sample.txt -diff\n") self.write("sample.txt", PRIVATE + "\n") From d0a0ab588e27a0e1db05c9d26640f52a44bce618 Mon Sep 17 00:00:00 2001 From: showxu <10173746+showxu@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:21:12 +0800 Subject: [PATCH 11/11] fix(policy): match horizontal whitespace in attribution --- scripts/check-policy.py | 10 ++++++++-- tests/test_policy.py | 6 ++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/scripts/check-policy.py b/scripts/check-policy.py index e516fc1..a0659f2 100644 --- a/scripts/check-policy.py +++ b/scripts/check-policy.py @@ -20,10 +20,16 @@ r"[A-Za-z]:[\\/]Users[\\/][\w. -]+(?:[\\/]|(?=[\s`\"')]|$))") TASK_REFERENCE = re.compile(r"(?]+") TOOL_IDENTITIES = json.loads((Path(__file__).resolve().parents[1] / "data/attribution-tools.json").read_text()) -TOOL_NAMES = re.compile(r"(?:" + "|".join(re.escape(name) for name in TOOL_IDENTITIES["names"]) + r")\b", re.I) + + +def inventory_pattern(value, separator=r"[ \t]+"): + return separator.join(re.escape(part) for part in re.split(r"[ \t]+", value)) + + +TOOL_NAMES = re.compile(r"(?:" + "|".join(inventory_pattern(name) for name in TOOL_IDENTITIES["names"]) + r")\b", re.I) TOOL_EMAILS = {email.casefold() for email in TOOL_IDENTITIES["emails"]} ATTRIBUTION = re.compile( - r"(?:\b(?:" + "|".join(re.escape(phrase).replace(r"\ ", "[- ]") + r"(?:\b(?:" + "|".join(inventory_pattern(phrase, r"(?:[ \t]+|-)") for phrase in TOOL_IDENTITIES["phrases"]) + r")\b[ \t]*:?|" + "|".join(re.escape(prefix) for prefix in TOOL_IDENTITIES["prefixes"]) + r")[ \t]*", re.I, diff --git a/tests/test_policy.py b/tests/test_policy.py index 5154ea2..34c83c0 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -243,6 +243,12 @@ def test_cli_attribution_uses_same_rules_in_pr_and_commit_fields(self): "Powered by " + "Codex https://example.org", "🤖 " + "Codex CLI", ] + for phrase in ("Generated by", "Generated with", "Powered by", "Made with"): + for name in ("Codex", "GitHub Copilot"): + for phrase_space in (" ", " ", "\t", " \t "): + for name_space in (" ", " ", "\t", " \t "): + statement = phrase.replace(" ", phrase_space) + " " + name.replace(" ", name_space) + rejected.extend((statement, statement.swapcase())) allowed = [ "Co-Authored-By: Claude ", "Co-Authored-By: Claude Martin ",