From 98f883cac57fd9244839799f5521874741530016 Mon Sep 17 00:00:00 2001 From: showxu <10173746+showxu@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:13:51 +0800 Subject: [PATCH 1/5] feat: adopt organization plugin release standards --- .github/workflows/ci.yml | 4 +- .github/workflows/release.yml | 189 ++--- .../Architecture/VersioningAndRelease.md | 53 +- Documentation/Reference/Release.md | 100 ++- Documentation/Reference/Workflows.md | 20 + Scripts/check-workflow.py | 54 +- Scripts/plugin_release.py | 598 +++++++++++++++ Tests/test_plugin_release.py | 723 ++++++++++++++++++ computer-mcp-plugin.toml | 1 + 9 files changed, 1611 insertions(+), 131 deletions(-) create mode 100644 Scripts/plugin_release.py create mode 100644 Tests/test_plugin_release.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33967fd..24d1e38 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,9 @@ jobs: fi wait "$test_pid" - name: Validate package ownership and publication - run: python3 -m unittest discover -s Tests -p 'test_*.py' -v + run: | + python3 -m unittest discover -s Tests -p 'test_*.py' -v + python3 Scripts/check-workflow.py --check-release-workflow - name: Build relocatable adapter and receipt run: | python3 Scripts/package.py --output dist --configuration release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9d80a69..f7987cb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,108 +3,117 @@ on: workflow_dispatch: inputs: source_run: - description: Successful CI run ID + description: Accepted successful CI run ID (frozen receipt takes precedence on recovery) + type: string required: true release_tag: - description: Existing draft release tag bound to the verified commit - required: true -permissions: - actions: read - contents: write + description: Signed annotated version tag (omit for a dry run) + type: string + required: false + publish: + description: Publish the verified release + type: boolean + default: false +permissions: {} concurrency: - group: release-upload-${{ inputs.release_tag }} + group: plugin-release-${{ github.repository }}-${{ inputs.release_tag || inputs.source_run }} cancel-in-progress: false jobs: - upload: + verify: if: github.repository == 'computer-mcp/plugin-codex' && github.ref == 'refs/heads/master' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - SOURCE_RUN: ${{ inputs.source_run }} - RELEASE_TAG: ${{ inputs.release_tag }} + permissions: + contents: read + actions: read + outputs: + artifact_id: ${{ steps.upload.outputs.artifact-id }} + asset_digests: ${{ steps.bundle.outputs.asset_digests }} + helper_digest: ${{ steps.bundle.outputs.helper_digest }} + tag: ${{ steps.bundle.outputs.tag }} steps: - - name: Verify accepted source and commit-bound draft - id: source - shell: bash - run: | - set -euo pipefail - [[ "$SOURCE_RUN" =~ ^[0-9]+$ ]] - gh api "repos/$GH_REPO/actions/runs/$SOURCE_RUN" > run.json - jq -e --arg repo "$GH_REPO" ' - .status == "completed" and .conclusion == "success" and - .event == "push" and .head_branch == "master" and - .path == ".github/workflows/ci.yml" and - .head_repository.full_name == $repo' run.json > /dev/null - source_sha=$(jq -r .head_sha run.json) - [[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] - gh api "repos/$GH_REPO/git/ref/tags/$RELEASE_TAG" > tag-ref.json - [[ $(jq -r .object.type tag-ref.json) == tag ]] - tag_object=$(jq -r .object.sha tag-ref.json) - gh api "repos/$GH_REPO/git/tags/$tag_object" > tag.json - jq -e --arg sha "$source_sha" '.object.type == "commit" and .object.sha == $sha' tag.json > /dev/null - gh api --paginate "repos/$GH_REPO/releases?per_page=100" --slurp > releases.json - jq -e --arg tag "$RELEASE_TAG" --arg sha "$source_sha" ' - [ .[][] | select(.tag_name == $tag) ] | - if length == 1 and .[0].draft and .[0].target_commitish == $sha - then .[0] else error("Expected one commit-bound draft release") end - ' releases.json > release.json - release_id=$(jq -r .id release.json) - echo "sha=$source_sha" >> "$GITHUB_OUTPUT" - echo "release_id=$release_id" >> "$GITHUB_OUTPUT" + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: '3.13' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - ref: ${{ steps.source.outputs.sha }} + ref: ${{ github.workflow_sha }} persist-credentials: false - - name: Verify and promote every declared native archive - shell: bash + - name: Verify native CI artifacts and assemble the frozen release bundle + id: bundle env: - SOURCE_SHA: ${{ steps.source.outputs.sha }} - RELEASE_ID: ${{ steps.source.outputs.release_id }} + GH_TOKEN: ${{ github.token }} + GH_REPOSITORY: ${{ github.repository }} + SOURCE_RUN: ${{ inputs.source_run }} + RELEASE_TAG: ${{ inputs.release_tag }} + RELEASE_PUBLISH: ${{ inputs.publish }} + RELEASE_WORKFLOW_REF: ${{ github.workflow_ref }} + RELEASE_WORKFLOW_SHA: ${{ github.workflow_sha }} + shell: bash run: | set -euo pipefail - python3 - <<'PYTHON' - import json, os, re, tomllib + python3 -I Scripts/plugin_release.py verify \ + --repository "$GH_REPOSITORY" --source-run "$SOURCE_RUN" \ + --tag "$RELEASE_TAG" --publish "$RELEASE_PUBLISH" \ + --workflow-ref "$RELEASE_WORKFLOW_REF" --workflow-sha "$RELEASE_WORKFLOW_SHA" \ + --output .build/plugin-release/assets + python3 -I - <<'PY' + import hashlib, os + from pathlib import Path + data = Path('Scripts/plugin_release.py').read_bytes() + Path('.build/plugin-release/plugin_release.py').write_bytes(data) + with open(os.environ['GITHUB_OUTPUT'], 'a') as output: + output.write('helper_digest=' + hashlib.sha256(data).hexdigest() + '\n') + PY + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + id: upload + with: + name: plugin-release-${{ github.run_id }}-${{ github.run_attempt }} + path: .build/plugin-release/ + include-hidden-files: true + 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/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 }} + RELEASE_HELPER_DIGEST: ${{ needs.verify.outputs.helper_digest }} + shell: bash + run: | + python3 -I - <<'PY' + import hashlib, os, subprocess, sys from pathlib import Path - manifest = tomllib.loads(Path("computer-mcp-plugin.toml").read_text()) - if manifest["id"] != "codex" or "v" + manifest["version"] != os.environ["RELEASE_TAG"]: - raise SystemExit("Release tag does not match the accepted plugin manifest") - names = [item["name"] for item in manifest["compatibility"]["artifacts"]] - if not names or len(set(names)) != len(names) or any(not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*\.zip", name) for name in names): - raise SystemExit("Expected unique declared native archives") - Path("archive-names.json").write_text(json.dumps(names)) - PYTHON - while IFS= read -r archive; do - name=${archive%.zip} - gh run download "$SOURCE_RUN" --name "$name-$SOURCE_SHA" --dir "artifacts/$name" - python3 Scripts/check-package.py --archive "artifacts/$name/$archive" --receipt "artifacts/$name/receipt.json" > "artifacts/$name/verification.json" - done < <(jq -r '.[]' archive-names.json) - # Upload only after every declared archive passes its exact source-bound check. - upload_asset() { - local file=$1 name=$2 content_type=$3 expected existing - expected=$(sha256sum "$file" | cut -d' ' -f1) - gh api --paginate "repos/$GH_REPO/releases/$RELEASE_ID/assets?per_page=100" --slurp | jq 'add' > assets.json - existing=$(jq -er --arg name "$name" '[.[] | select(.name == $name)] | if length <= 1 then (.[0].id // "") else error("Duplicate release asset") end' assets.json) - if [[ -n "$existing" ]]; then - jq -e --argjson id "$existing" --arg digest "sha256:$expected" ' - .[] | select(.id == $id) | - .state == "starter" or (.state == "uploaded" and .digest == $digest) - ' assets.json > /dev/null - if jq -e --argjson id "$existing" '.[] | select(.id == $id) | .state == "uploaded"' assets.json > /dev/null; then - return - fi - gh api "repos/$GH_REPO/releases/$RELEASE_ID" | jq -e --arg tag "$RELEASE_TAG" --arg sha "$SOURCE_SHA" '.draft and .tag_name == $tag and .target_commitish == $sha' > /dev/null - gh api --method DELETE "repos/$GH_REPO/releases/assets/$existing" - fi - # Re-read immediately before upload; published releases are immutable. - gh api "repos/$GH_REPO/releases/$RELEASE_ID" | jq -e --arg tag "$RELEASE_TAG" --arg sha "$SOURCE_SHA" '.draft and .tag_name == $tag and .target_commitish == $sha' > /dev/null - gh api --method POST "https://uploads.github.com/repos/$GH_REPO/releases/$RELEASE_ID/assets?name=$name" \ - --input "$file" -H "Content-Type: $content_type" > uploaded.json - jq -e --arg digest "sha256:$expected" '.state == "uploaded" and .digest == $digest' uploaded.json > /dev/null - } - while IFS= read -r archive; do - name=${archive%.zip} - upload_asset "artifacts/$name/$archive" "$archive" application/zip - upload_asset "artifacts/$name/receipt.json" "$name.receipt.json" application/json - done < <(jq -r '.[]' archive-names.json) + root = Path(os.environ['RUNNER_TEMP']) / 'plugin-release' + helper = root / 'plugin_release.py' + if helper.is_symlink() or hashlib.sha256(helper.read_bytes()).hexdigest() != os.environ['RELEASE_HELPER_DIGEST']: + raise SystemExit('Release helper differs from the verify job digest') + subprocess.run([sys.executable, '-I', str(helper), 'publish', + '--repository', os.environ['GH_REPOSITORY'], + '--directory', str(root / 'assets'), + '--asset-digests', os.environ['RELEASE_ASSET_DIGESTS']], check=True) + PY + notify-catalog: + needs: publish + if: inputs.publish && needs.publish.outputs.published == 'true' + permissions: {} + uses: ./.github/workflows/notify-catalog.yml + secrets: + CATALOG_APP_PRIVATE_KEY: ${{ secrets.CATALOG_APP_PRIVATE_KEY }} diff --git a/Documentation/Architecture/VersioningAndRelease.md b/Documentation/Architecture/VersioningAndRelease.md index 3724dfa..65676a1 100644 --- a/Documentation/Architecture/VersioningAndRelease.md +++ b/Documentation/Architecture/VersioningAndRelease.md @@ -1,6 +1,12 @@ # Versioning and Release -The root `computer-mcp-plugin.toml` owns the adapter's version. +The organization's +[VERSIONING.md](https://github.com/computer-mcp/.github/blob/master/VERSIONING.md) +owns shared SemVer, plugin API, dependency requirements, release contents and +recovery rules. This repository owns native acceptance and its documented +CI artifact promotion exception. + +The root `computer-mcp-plugin.toml` declares `plugin_api = 1` and owns the adapter's version. `Scripts/version.py generate` derives `BuildInfo`; `check` is read-only and rejects drift. `update --kind … --reason …` changes the manifest and refreshes the constant. `check --base COMMIT` rejects version regression when @@ -38,19 +44,44 @@ acceptance includes authenticated model execution; fixture-only evidence cannot replace it. [Release](../Reference/Release.md#runtime-acceptance) owns the commands and prerequisites. -Accept the complete host/plugin/SDK combination before delivery. Create a formal -signed tag only for the accepted commit; `release.yml` promotes the -already-built artifact from its verified source run into the matching draft. -The upload verifies every platform archive's inventory and manifest against the -formal tag before uploading any asset. It promotes the declared macOS archive -and its receipt from the successful source run without rebuilding. -An already uploaded asset must have the same digest; conflicting bytes fail. -Candidate retries keep the intended product version and use a new run identity. -A public tag and archive remain immutable. Only changed components are released. +Accept the complete host/plugin/SDK combination before delivery. Codex's native +builds cannot be reproduced byte for byte, so `release.yml` is the organization's +documented exception to its reusable release workflow. It promotes the accepted +CI archives and native build receipts without rebuilding. A dry run checks source, +latest successful CI attempt, artifact digests, manifest, `plugin_api`, archive +names and native inventories, and assembles the same content without tag checks, +Release writes or catalog secrets. Publication additionally requires a signed +annotated tag verified by GitHub at the accepted commit and matching the manifest +version. Repository release immutability must be verified enabled. + +The schema 1 `release-receipt.json` follows the organization contract with +`reproduction: ci-artifact-promotion`, every source CI artifact, and each archive's +`native_receipt` name, size and SHA-256. Native `.receipt.json` +files remain Release assets. Deterministic `SHA256SUMS` covers the archives, +native receipts and unified receipt. The receipt has no timestamps or local paths. + +Verification has read-only contents/actions access and no secrets. Publication +alone has contents write permission and executes no builds. It checks the exact +verified bundle artifact ID, helper digest and asset digests, creates or resumes +a commit-bound draft, uploads the receipt first, then the archives and native +receipts, then checksums. It publishes only after every expected asset's API +size and digest matches and requires an immutable public Release. Only a separate +dependent catalog job receives the catalog secret. + +A complete draft receipt freezes the original CI run, historical attempt, +artifact IDs/digests and release workflow ref/commit. Recovery refetches those +artifacts and reproduces the same receipt and checksums even after CI reruns. +Expected incomplete `starter` assets may be replaced only in a draft; completed +conflicts and unexpected names fail. If frozen artifacts expire, delete the +unpublished draft, obtain fresh successful CI artifacts and dispatch again. +A public Release is verified against its complete frozen asset set without +writes or reliance on CI retention. Notification failures need only the notifier +rerun. Candidate retries keep the intended version and use a new CI identity +only before provenance freezes or after deleting the unpublished draft. `CHANGELOG.md` records each public release. See [Installation](../Reference/Installation.md) for packaging, installation, upgrade and rollback commands, and [Release](../Reference/Release.md) for -publication and catalog notification. +publication commands and recovery. Reusable successful evidence must match source, dependency lock, check definition, toolchain, target configuration and artifact digest. Missing or changed inputs diff --git a/Documentation/Reference/Release.md b/Documentation/Reference/Release.md index 9276d93..b5457a6 100644 --- a/Documentation/Reference/Release.md +++ b/Documentation/Reference/Release.md @@ -2,7 +2,16 @@ This guide covers publishing an accepted release and notifying the official plugin catalog. [Versioning and Release](../Architecture/VersioningAndRelease.md) -owns the version, acceptance and immutability rules. +owns the version, acceptance and immutability rules. The organization's +[VERSIONING.md](https://github.com/computer-mcp/.github/blob/master/VERSIONING.md) +owns shared plugin API, release content and recovery requirements. + +Codex is the documented exception to the reusable organization release workflow: +its native builds are not byte reproducible. Its own `release.yml` promotes the +accepted CI artifacts and uses `reproduction: ci-artifact-promotion` under the +same release content and publication contract. Enable GitHub release immutability +in the repository settings before running publication or its dry run; the +workflow refuses when that setting is disabled or cannot be verified. ## Runtime acceptance @@ -83,27 +92,54 @@ python3 -m unittest discover -s Tests -p 'test_host_acceptance.py' -v ## Publish -1. Confirm that `version` in `computer-mcp-plugin.toml` has not been released - and that the reviewed `master` commit has a successful CI run. -2. Accept that run's `codex-plugin-macos-arm64-` artifact as described in - [Versioning and Release](../Architecture/VersioningAndRelease.md), including - the host checks against the exact archive bytes. -3. Create a signed annotated tag `vX.Y.Z` on the accepted commit and push it. -4. Create a draft GitHub Release for the tag whose target is the full commit SHA. -5. Promote the CI artifacts into the draft: +1. Confirm the manifest's `version` has not been released and the reviewed + `master` commit has successful `push` CI from `.github/workflows/ci.yml`. + Each declared archive selects the exact CI artifact + `-` from that run's latest successful attempt. +2. Assemble and check the candidate without Release writes: ```sh gh workflow run release.yml --repo computer-mcp/plugin-codex --ref master \ - -f source_run=RUN_ID -f release_tag=vX.Y.Z + -f source_run=RUN_ID -F publish=false ``` - The workflow accepts only a successful `master` push run of `ci.yml` for the - tagged commit. It checks every archive the manifest declares against the tag - before uploading each archive and its `.receipt.json`. It never - rebuilds and never publishes the draft. -6. Review the draft's assets and notes, then publish it. + The dry run checks the source manifest, positive integer `plugin_api`, + declared archive set, CI attempt and artifact digests, native inventories + and byte-identical manifests. It omits tag and signature checks and reads + no catalog secret. Inspect the `assets/` directory in the + `plugin-release--` Actions artifact. +3. Accept those exact archive bytes through the runtime checks above and record + the results and limits in the draft Release notes. Create and push a signed + annotated `vX.Y.Z` tag directly on the accepted commit. An operator-created + draft must target its full commit SHA; otherwise publication creates a draft. +4. Publish with: + + ```sh + gh workflow run release.yml --repo computer-mcp/plugin-codex --ref master \ + -f source_run=RUN_ID -f release_tag=vX.Y.Z -F publish=true + ``` -The receipt records the archive digest, file inventory and build architecture. + GitHub must verify the tag signature as valid; the tag's commit and manifest + version must match. The verification job has only `contents: read` and + `actions: read`, executes the release workflow's revision, and treats the + accepted source and CI files as data. Only the publishing job has + `contents: write`; it downloads the verified bundle by its exact artifact ID, + checks the helper and file digests, and runs no builds. + +The Release assets are exactly the manifest's `compatibility.artifacts` ZIPs, +their `.receipt.json` native build receipts, `release-receipt.json` +and `SHA256SUMS`. The native receipts remain inputs to `check-package.py`. +The unified schema 1 receipt contains the plugin ID, version, `plugin_api`, +compatibility, tag and commit; sorted archive names, sizes and SHA-256 digests; +each archive's `native_receipt` name, size and SHA-256; the CI run ID, attempt +and artifact IDs/digests; the release workflow ref and commit; and reproduction +`ci-artifact-promotion`. It uses deterministic sorted-key JSON with no timestamps. +`SHA256SUMS` covers every ZIP and receipt, sorted by filename. + +Publication uploads the unified receipt first to freeze provenance, then the +archives and native receipts, then checksums. Every asset's API size and SHA-256 +must match before the draft becomes public. The publication response must report +`immutable: true`. Release notes remain operator-owned. ## Catalog notification @@ -125,20 +161,30 @@ that website commit first, then update the pin here when adopting a change to the notification contract. A release published with a repository `GITHUB_TOKEN` does not trigger -release-event workflows. Automation that publishes that way calls the workflow -as a dependent of the job that makes the release public: - -```yaml -notify-catalog: - needs: publish - uses: ./.github/workflows/notify-catalog.yml - secrets: - CATALOG_APP_PRIVATE_KEY: ${{ secrets.CATALOG_APP_PRIVATE_KEY }} -``` +release-event workflows. `release.yml` calls `notify-catalog.yml` as a separate +dependent job only after confirmed publication. Only that job receives +`CATALOG_APP_PRIVATE_KEY`; dry runs never notify the catalog. ## Retry -For an operator-driven publication or a missed or failed notification, dispatch: +Use **Re-run failed jobs** after a failed publication. A publish-only retry uses +the original bundle artifact ID and digest outputs, including after a workflow +run attempt changes. If verification reruns, a complete uploaded draft receipt +wins over `source_run`: recovery validates its recorded successful historical CI +attempt, fetches each artifact by its recorded ID and digest, and reproduces the +same receipt and checksums. It never selects a newer CI attempt or changes the +recorded workflow identity. + +Expected `starter` assets can be deleted and uploaded again only while the +Release is a draft. Matching completed assets are retained. Unexpected names, +conflicting digests or sizes, or an invalid historical attempt fail without +replacement. If a recorded artifact expired or disappeared, delete the +unpublished draft, obtain fresh successful CI artifacts, and dispatch publication +again to start a new assembly. Published releases are never modified: a rerun +checks the exact asset set against the frozen receipt, requires `immutable: true`, +and reports it already published even after CI artifact retention expires. + +For a missed or failed catalog notification, rerun only the notifier or dispatch: ```sh gh workflow run notify-catalog.yml --repo computer-mcp/plugin-codex --ref master diff --git a/Documentation/Reference/Workflows.md b/Documentation/Reference/Workflows.md index 0b43e97..e6cc000 100644 --- a/Documentation/Reference/Workflows.md +++ b/Documentation/Reference/Workflows.md @@ -257,3 +257,23 @@ persisted thread index; a competing writer remains an error. Live evidence outranks durable receipts. `codex.app.handoff.diagnose` correlates the runtime, process, connection, thread, turn, approvals, user input and durable receipts without claiming control over an external client. + +## Release delivery + +`release.yml` uses the documented Codex exception to the organization's +[VERSIONING.md](https://github.com/computer-mcp/.github/blob/master/VERSIONING.md): +native archives are promoted from accepted CI artifacts because their builds +are not byte reproducible. Read-only verification assembles the declared ZIPs, +native build receipts, unified schema 1 `release-receipt.json` and `SHA256SUMS`. +Only publication has contents write permission; it uploads the receipt first, +verifies all asset digests and publishes an immutable Release. Only the dependent +catalog notifier receives the catalog secret. `publish: false` checks and +assembles without tag/signature checks, Release writes or catalog credentials. + +A complete draft receipt freezes CI and workflow provenance across retries. +Recovery uses its artifact IDs and digests; only draft `starter` assets may be +replaced. Completed conflicts fail. If frozen artifacts expire, delete the +unpublished draft, obtain fresh successful CI artifacts and publish again. +Published releases are checked without modification, and notification failures +need only a notifier rerun. [Release](Release.md#publish) owns the commands, +content details and [recovery steps](Release.md#retry). diff --git a/Scripts/check-workflow.py b/Scripts/check-workflow.py index 6eb9378..9f0bae5 100644 --- a/Scripts/check-workflow.py +++ b/Scripts/check-workflow.py @@ -12,6 +12,7 @@ import os from pathlib import Path import queue +import re import shutil import subprocess import tempfile @@ -20,6 +21,48 @@ import uuid +def check_release_workflow(root): + """Check the repository's release job graph and privilege boundaries without a vendor.""" + source = (root / ".github/workflows/release.yml").read_text() + jobs = dict(re.findall(r"(?ms)^ ([a-z-]+):\n(.*?)(?=^ [a-z-]+:\n|\Z)", + source.split("\njobs:\n", 1)[1])) + + def require(condition, message): + if not condition: + raise ValueError(message) + + require(set(jobs) == {"verify", "publish", "notify-catalog"}, "Unexpected release job graph") + require("\npermissions: {}\n" in source, "Release permissions must default to none") + verify, publish, notify = (jobs[name] for name in ("verify", "publish", "notify-catalog")) + require(" permissions:\n contents: read\n actions: read\n" in verify, + "Verification requires read-only contents and actions") + require("secrets" not in verify and "contents: write" not in verify, + "Verification cannot receive secrets or write permission") + require("ref: ${{ github.workflow_sha }}" in verify and "persist-credentials: false" in verify, + "Verification must execute the workflow revision without persisted credentials") + require("Scripts/plugin_release.py verify" in verify and "--publish" in verify, + "Verification must assemble both publication and dry-run bundles") + require(" if: inputs.publish\n needs: verify\n" in publish + and " permissions:\n contents: write\n" in publish, + "Only publication may write after verification") + require("actions: read" not in publish and "secrets" not in publish + and "actions/checkout@" not in publish + and not re.search(r"swift\s+(build|test)|Scripts/package.py", publish), + "Publication cannot build or receive catalog secrets") + for field in ("artifact_id", "asset_digests", "helper_digest"): + require("needs.verify.outputs." + field in publish, "Publication must use frozen verification outputs") + require("helper.is_symlink()" in publish and "hashlib.sha256(helper.read_bytes())" in publish, + "Publication must verify the helper before execution") + require(" needs: publish\n" in notify and "needs.publish.outputs.published == 'true'" in notify + and "if: inputs.publish" in notify and " permissions: {}\n" in notify + and "uses: ./.github/workflows/notify-catalog.yml" in notify, + "Notification must follow confirmed publication") + require(source.count("${{ secrets.CATALOG_APP_PRIVATE_KEY }}") == 1 + and "${{ secrets.CATALOG_APP_PRIVATE_KEY }}" in notify, + "Only notification may receive the catalog secret") + return {"jobs": ["verify", "publish", "notify-catalog"], "release_workflow_verified": True} + + class ModelFixture(http.server.ThreadingHTTPServer): daemon_threads = True @@ -464,8 +507,10 @@ def run(adapter, codex, gateway=None, database=None, control_socket=None, regist if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--adapter", type=Path, required=True) - parser.add_argument("--codex", type=Path, required=True) + parser.add_argument("--check-release-workflow", action="store_true", + help="Check release jobs and permissions offline without starting a vendor") + parser.add_argument("--adapter", type=Path) + parser.add_argument("--codex", type=Path) parser.add_argument("--gateway", type=Path, help="Optional existing Computer MCP executable; starts only an isolated stdio Gateway") parser.add_argument("--database", type=Path, help="Fresh isolated host-test database containing an installed Codex plugin") parser.add_argument("--control-socket", type=Path, help="Owner socket of that isolated host-test instance") @@ -473,6 +518,11 @@ def run(adapter, codex, gateway=None, database=None, control_socket=None, regist parser.add_argument("--require-host-work", action="store_true", help="Require the candidate Gateway to accept provider aliases and observe final release") options = parser.parse_args() + if options.check_release_workflow: + print(json.dumps(check_release_workflow(Path(__file__).resolve().parents[1]), indent=2)) + parser.exit() + if not options.adapter or not options.codex: + parser.error("Runtime checks require --adapter and --codex") if options.require_host_work and not options.gateway: parser.error("Host work observation requires --gateway") fixture_options = [options.database, options.control_socket, options.workspace] diff --git a/Scripts/plugin_release.py b/Scripts/plugin_release.py new file mode 100644 index 0000000..3ebd97a --- /dev/null +++ b/Scripts/plugin_release.py @@ -0,0 +1,598 @@ +"""Verify native CI archives and publish their frozen 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 importlib.util +import json +import os +from pathlib import Path, PurePosixPath +import re +import stat +import sys +import tempfile +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 = ".github/workflows/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): + root = repository_path(repository) + require(re.fullmatch("v" + VERSION, ref), "Publishing requires a signed annotated vX.Y.Z tag") + 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"] + require(re.fullmatch(SHA, commit), "Invalid resolved commit") + return commit, ref + + +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) + require(names is not None, "Native promotion requires compatibility.artifacts") + 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 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 check_attempt(api, root, repository, commit, run_id, attempt_number): + branch = api.get(root)["default_branch"] + try: + attempt = api.get(f"{root}/actions/runs/{run_id}/attempts/{attempt_number}") + 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") == run_id and attempt.get("run_attempt") == attempt_number + and attempt.get("status") == "completed" and attempt.get("conclusion") == "success", + "Recorded CI attempt must exist and have succeeded") + return attempt + + +def within_attempt(artifact, attempt): + created = datetime.fromisoformat(artifact["created_at"].replace("Z", "+00:00")) + return (datetime.fromisoformat(attempt["run_started_at"].replace("Z", "+00:00")) <= created + <= datetime.fromisoformat(attempt["updated_at"].replace("Z", "+00:00"))) + + +def select_ci(api, repository, commit, run_id, declared): + require(positive(run_id), "Expected a positive source CI run ID") + root = repository_path(repository) + run = api.get(f"{root}/actions/runs/{run_id}") + check_run(run, repository, api.get(root)["default_branch"], commit) + require(run.get("id") == run_id and run.get("status") == "completed" + and run.get("conclusion") == "success" and positive(run.get("run_attempt")), + "Latest CI attempt must have succeeded") + attempt = check_attempt(api, root, repository, commit, run_id, run["run_attempt"]) + artifacts = api.list(f"{root}/actions/runs/{run_id}/artifacts", "artifacts") + selected = [] + for name in declared: + matching = [item for item in artifacts if item.get("name") == name[:-4] + "-" + commit + and within_attempt(item, attempt)] + require(len(matching) == 1, "Expected exactly one named CI artifact from the latest attempt: " + name) + 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") + selected.append({"id": artifact["id"], "digest": artifact["digest"]}) + require(len({item["id"] for item in selected}) == len(selected), "Duplicate CI artifact IDs") + return {"run_id": run_id, "run_attempt": run["run_attempt"], + "artifacts": sorted(selected, key=lambda item: item["id"])} + + +def get_ci_files(api, repository, commit, ci, declared, recovering=False): + root = repository_path(repository) + recovery = ("Recorded CI artifact is expired or missing; delete the unpublished draft release, " + "obtain fresh successful CI artifacts, then rerun release.yml to select fresh CI provenance") + require(len(ci["artifacts"]) == len(declared), "Expected one CI artifact per declared archive") + attempt = check_attempt(api, root, repository, commit, ci["run_id"], ci["run_attempt"]) + files = {} + for source in ci["artifacts"]: + 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") + require(within_attempt(artifact, attempt), "CI artifact was not created within the recorded CI attempt") + names = [name for name in declared if artifact.get("name") == name[:-4] + "-" + commit] + require(len(names) == 1, "CI artifact name differs from the manifest") + name = names[0] + 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") + contents = zip_entries(data) + require(set(contents) == {name, "receipt.json"}, "CI artifact must contain its native archive and receipt.json") + native_name = name[:-4] + ".receipt.json" + require(name not in files, "Duplicate CI archive") + files[name] = contents[name] + files[native_name] = contents["receipt.json"] + archive_names([name for name in files if name.endswith(".zip")], declared) + return files + + +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"] == "ci-artifact-promotion", "Native release requires CI artifact promotion") + 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"]) == len(declared), + "Receipt requires one CI artifact per archive") + for source in ci["artifacts"]: + require(isinstance(source, dict) and set(source) == {"id", "digest"} and positive(source["id"]), + "Invalid receipt artifact provenance") + digest_value(source["digest"]) + ids = [source["id"] for source in ci["artifacts"]] + require(ids == sorted(set(ids)), "Receipt artifact IDs must be unique and sorted") + workflow = receipt["workflow"] + require(isinstance(workflow, dict) and set(workflow) == {"ref", "sha"} + and isinstance(workflow["sha"], str) and re.fullmatch(SHA, workflow["sha"]) + and isinstance(workflow["ref"], str) + and re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/" + re.escape(WORKFLOW) + r"[^\s@]+", + workflow["ref"]), "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", "native_receipt"} + 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") + native = item["native_receipt"] + require(isinstance(native, dict) and set(native) == {"name", "size", "sha256"} + and native["name"] == item["name"][:-4] + ".receipt.json" + and positive(native["size"]) and isinstance(native["sha256"], str) + and re.fullmatch(HASH, native["sha256"]), "Invalid native receipt 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 file_record(name, data): + return {"name": name, "size": len(data), "sha256": sha256(data)} + + +def payload_names(receipt): + return {record["name"] for item in receipt["archives"] for record in (item, item["native_receipt"])} + + +def assemble(receipt, native_files): + validate_receipt(canonical(receipt)) + files = dict(native_files) + require(set(files) == payload_names(receipt), "Receipt native file set differs") + for item in receipt["archives"]: + for record in (item, item["native_receipt"]): + data = files[record["name"]] + require(len(data) == record["size"] and sha256(data) == record["sha256"], + "Native file 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 check_repository_immutability(api, root): + 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") + + +def verify_native_files(files, manifest, parent): + # The workflow revision owns this checker; the source manifest and CI files are data only. + scripts = Path(__file__).resolve().parent + sys.path.insert(0, str(scripts)) + try: + spec = importlib.util.spec_from_file_location("native_package_check", scripts / "check-package.py") + checker = importlib.util.module_from_spec(spec) + spec.loader.exec_module(checker) + finally: + sys.path.pop(0) + with tempfile.TemporaryDirectory(prefix="native-check-", dir=parent) as directory: + root = Path(directory) + manifest_path = root / "computer-mcp-plugin.toml" + manifest_path.write_bytes(manifest) + for name, data in files.items(): + (root / name).write_bytes(data) + for name in sorted(files): + if name.endswith(".zip"): + checker.verify(root / name, root / (name[:-4] + ".receipt.json"), manifest_path) + + +def verify(api, repository, source_run, tag, publish, workflow_ref, workflow_sha, output): + root = repository_path(repository) + if publish: + commit, tag = resolve(api, repository, tag) + else: + require(positive(source_run), "Dry run requires a CI run ID") + commit = api.get(f"{root}/actions/runs/{source_run}")["head_sha"] + require(re.fullmatch(SHA, commit), "Invalid source commit") + tag = None + 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_bytes = base64.b64decode(source["content"]) + info, declared = manifest_info(tomllib.loads(manifest_bytes.decode()), tag) + check_repository_immutability(api, root) + 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 = payload_names(receipt) | {RECEIPT, SUMS} + require(set(assets) == expected, "Published release asset set differs") + native_files = {name: read_asset(api, root, assets[name]) for name in payload_names(receipt)} + else: + ci = receipt["ci"] if receipt else select_ci(api, repository, commit, source_run, declared) + native_files = get_ci_files(api, repository, commit, ci, declared, recovering=receipt is not None) + require(not output.exists(), "Release output directory must not exist") + output.parent.mkdir(parents=True, exist_ok=True) + verify_native_files(native_files, manifest_bytes, output.parent) + if receipt is None: + receipt = dict(info, schema=1, tag=tag, commit=commit, + archives=[dict(file_record(name, native_files[name]), + native_receipt=file_record(name[:-4] + ".receipt.json", + native_files[name[:-4] + ".receipt.json"])) + for name in declared], ci=ci, + workflow={"ref": workflow_ref, "sha": workflow_sha}, reproduction="ci-artifact-promotion") + require(receipt["workflow"]["ref"].startswith(repository + "/" + WORKFLOW), + "Receipt workflow belongs to another repository") + files = assemble(receipt, native_files) + if release: + check_assets(assets, files, release["draft"], complete=not release["draft"]) + output.mkdir() + 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 = payload_names(receipt) | {RECEIPT, SUMS} + require(set(files) == expected, "Invalid verified bundle file set") + require(files == assemble(receipt, {name: files[name] for name in payload_names(receipt)}), + "Verified bundle checksums differ") + return receipt, files + + +def publish_bundle(api, repository, directory, expected_digests): + receipt, files = load_bundle(directory, expected_digests) + require(receipt["workflow"]["ref"].startswith(repository + "/" + WORKFLOW), + "Receipt workflow belongs to another repository") + root = repository_path(repository) + require(receipt["tag"] is not None, "Cannot publish an untagged dry-run bundle") + commit, tag = resolve(api, repository, receipt["tag"]) + require(commit == receipt["commit"], "Release tag moved after verification") + check_repository_immutability(api, root) + 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. + archives = [item["name"] for item in receipt["archives"]] + natives = [item["native_receipt"]["name"] for item in receipt["archives"]] + for name in [RECEIPT, *archives, *natives, 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.endswith(".json") 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) == (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 ("verify", "publish"): + command = commands.add_parser(name) + command.add_argument("--repository", required=True) + if name == "verify": + command.add_argument("--source-run", type=int, required=True) + command.add_argument("--tag", default="") + command.add_argument("--publish", choices=("true", "false"), required=True) + command.add_argument("--output", type=Path, required=True) + command.add_argument("--workflow-ref", required=True) + command.add_argument("--workflow-sha", required=True) + else: + 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 == "verify": + result = verify(api, args.repository, args.source_run, args.tag, args.publish == "true", + args.workflow_ref, args.workflow_sha, args.output.resolve()) + if args.publish == "false": + version = json.loads((args.output / RECEIPT).read_bytes())["version"] + print("Dry run: no Release writes or catalog notification. Publication additionally requires " + "a signed annotated v" + version + " tag at the verified commit passing GitHub signature " + "verification, manual acceptance in Release notes, and an immutable published Release.") + 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/Tests/test_plugin_release.py b/Tests/test_plugin_release.py new file mode 100644 index 0000000..2cd2ef0 --- /dev/null +++ b/Tests/test_plugin_release.py @@ -0,0 +1,723 @@ +"""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 stat +import subprocess +import sys +import tempfile +import textwrap +import unittest +from unittest.mock import patch +from urllib.parse import parse_qs, urlsplit +import zipfile + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "Scripts/plugin_release.py" +WORKFLOW = ROOT / ".github/workflows/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-codex" +API_ROOT = "/repos/" + REPOSITORY +COMMIT = "a" * 40 +WORKFLOW_SHA = "b" * 40 +WORKFLOW_REF = REPOSITORY + "/" + release.WORKFLOW + "refs/heads/master" +TAG = "v0.2.0" +ARCHIVE = "codex-plugin-macos-arm64.zip" +NATIVE = ARCHIVE[:-4] + ".receipt.json" +MANIFEST = (b'id = "codex"\nversion = "0.2.0"\nplugin_api = 1\n[compatibility]\n' + b'minimum_host = "0.1.0"\nplatforms = ["macos"]\narchitectures = ["arm64"]\n' + b'[[compatibility.artifacts]]\nname = "codex-plugin-macos-arm64.zip"\n' + b'platforms = ["macos"]\narchitectures = ["arm64"]\n') + + +def native_files(manifest=MANIFEST, payload=b"owned", name=ARCHIVE, architecture="arm64"): + contents = {"computer-mcp-plugin.toml": manifest, "bin/tool": payload} + data = zip_bytes(contents) + receipt = {"plugin_id": "codex", "platform": "macos", "architectures": [architecture], + "archive": name, "archive_sha256": release.sha256(data), "archive_bytes": len(data), + "files": {name: release.sha256(data) for name, data in contents.items()}} + return {name: data, name[:-4] + ".receipt.json": release.canonical(receipt)} + + + +def workflow_job(name): + return re.search(r"(?ms)^ " + name + r":\n(.*?)(?=^ [a-z_-]+:\n|\Z)", WORKFLOW.read_text())[1] + + +def python_step(job): + return textwrap.dedent(workflow_job(job).split("<<'PY'\n", 1)[1].split("\n PY", 1)[0]) + + +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) + + 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): + name = next(name for name in archives if name.endswith(".zip")) + data = zip_bytes({name: archives[name], "receipt.json": archives[name[:-4] + ".receipt.json"]}) + artifact = {"id": artifact_id, "name": name[:-4] + "-" + COMMIT, "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 _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("/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("/git/ref/tags/"): + result = {"object": {"type": self.tag_type, "sha": "c" * 40}} + elif route.startswith("/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("/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("/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("/actions/artifacts/"): + return self.artifact_bytes[int(route.split("/")[3])] + if route.startswith("/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="native release ") + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.output = self.root / "assets" + self.native = native_files() + self.api = FakeGitHub(self.native) + + def verify(self, **changes): + arguments = dict(api=self.api, repository=REPOSITORY, source_run=10, tag=TAG, publish=True, + workflow_ref=WORKFLOW_REF, workflow_sha=WORKFLOW_SHA, output=self.output) + arguments.update(changes) + 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, output=None): + return {path.name: path.read_bytes() for path in (output or self.output).iterdir()} + + def verify_again(self, **changes): + return self.verify(output=self.root / "recovered", **changes) + + def frozen_draft(self): + self.verify() + self.api.add_draft() + self.api.add_asset(release.RECEIPT, (self.output / release.RECEIPT).read_bytes()) + + def assert_no_writes(self, function, pattern): + before = copy.deepcopy(self.api.writes) + with self.assertRaisesRegex((release.ReleaseError, ValueError), pattern): + function() + self.assertEqual(self.api.writes, before) + + def test_first_publication_order_fields_checksums_and_notes(self): + outputs = self.verify() + self.assertEqual(self.api.writes, []) + self.assertEqual(self.publish(), {"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, ARCHIVE, NATIVE, 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"], receipt["plugin_id"], receipt["version"], receipt["plugin_api"]), + (1, "codex", "0.2.0", 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"], "ci-artifact-promotion") + self.assertEqual(receipt["archives"], [dict(release.file_record(ARCHIVE, files[ARCHIVE]), + native_receipt=release.file_record(NATIVE, files[NATIVE]))]) + self.assertEqual(files[release.RECEIPT], release.canonical(receipt)) + 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, NATIVE, ARCHIVE, release.SUMS, "publish"): + for when in ("before", "after"): + with self.subTest(step=step, when=when): + self.api = FakeGitHub(self.native) + self.api.fail = (step, when) + with self.assertRaises(release.APIError): + self.publish() + self.assertEqual(self.publish()["published"], "true") + self.assertEqual({item["name"]: self.api.asset_bytes[item["id"]] + for item in self.api.assets.values()}, expected) + + def test_partial_upload_then_ci_rerun_recovers_identical_frozen_bundle(self): + self.verify() + original = self.files() + self.api.fail = (ARCHIVE, "before") + with self.assertRaises(release.APIError): + self.publish() + self.api.rerun_ci(native_files(payload=b"new CI bytes")) + self.api.runs[10]["conclusion"] = "failure" + self.api.calls.clear() + self.verify_again(source_run=999, workflow_ref=WORKFLOW_REF + "-updated", workflow_sha="d" * 40) + self.assertEqual(self.files(self.root / "recovered"), original) + self.assertIn(("GET", API_ROOT + "/actions/runs/10/attempts/1"), self.api.calls) + self.assertIn(("DOWNLOAD", API_ROOT + "/actions/artifacts/20/zip"), self.api.calls) + self.assertFalse(any(path.endswith("/actions/runs/10") or "/attempts/2" in path + or "/actions/runs/999" in path for _, path in 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_refuses_invalid_or_missing_historical_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"}), + ("conclusion", "failure"), ("status", "in_progress"), + ("id", 99), ("run_attempt", 99)): + with self.subTest(field=field), 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_artifact_outside_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_starters_at_every_upload_position_are_replaced_only_in_drafts(self): + self.verify() + files = self.files() + for name in (release.RECEIPT, ARCHIVE, NATIVE, release.SUMS): + with self.subTest(name=name): + self.api = FakeGitHub(self.native) + 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_before_and_after_is_recoverable(self): + self.frozen_draft() + for when in ("before", "after"): + with self.subTest(when=when): + self.api.add_asset(ARCHIVE, state="starter") + self.api.fail = ("delete", when) + with self.assertRaises(release.APIError): + self.publish() + self.publish() + self.api = FakeGitHub(self.native) + self.api.add_draft() + self.api.add_asset(release.RECEIPT, self.files()[release.RECEIPT]) + + def test_completed_conflicting_digest_size_or_state_is_never_replaced(self): + self.verify() + for name in (release.RECEIPT, ARCHIVE, NATIVE, release.SUMS): + for field, value in (("digest", "sha256:" + "0" * 64), ("size", 999), ("state", "unknown")): + with self.subTest(name=name, field=field): + self.api = FakeGitHub(self.native) + self.api.add_draft() + self.api.add_asset(name, self.files()[name])[field] = value + self.assert_no_writes(self.publish, "conflicts") + + def test_expired_or_missing_recorded_artifact_reports_explicit_recovery(self): + self.frozen_draft() + for missing in (False, True): + with self.subTest(missing=missing): + self.api.artifacts[20]["expired"] = True + if missing: + del self.api.artifacts[20] + self.assert_no_writes(self.verify_again, "delete the unpublished draft.*fresh successful CI") + + def test_published_rerun_needs_no_ci_retention_and_performs_no_writes(self): + self.verify() + expected = self.files() + self.publish() + self.api.artifacts.clear() + self.api.attempts.clear() + self.api.calls.clear() + self.api.writes.clear() + self.verify_again(source_run=999, workflow_sha="d" * 40) + self.assertEqual(self.files(self.root / "recovered"), expected) + self.output = self.root / "recovered" + self.assertEqual(self.publish()["published"], "true") + self.assertEqual(self.api.writes, []) + self.assertFalse(any("/actions/" in path for _, path in self.api.calls)) + + def test_published_assets_cannot_be_incomplete_conflicting_or_unexpected(self): + self.verify() + self.publish() + self.api.writes.clear() + original = copy.deepcopy(self.api.assets) + for change in ("starter", "missing", "unexpected", "digest"): + with self.subTest(change=change): + self.api.assets = copy.deepcopy(original) + asset = next(iter(self.api.assets.values())) + if change == "starter": + asset["state"] = "starter" + elif change == "missing": + del self.api.assets[asset["id"]] + elif change == "unexpected": + self.api.add_asset("unexpected") + else: + asset["digest"] = "sha256:" + "0" * 64 + self.assert_no_writes(self.publish, "incomplete|conflicts|unexpected") + + def test_repository_immutability_must_be_verifiable_and_enabled(self): + self.verify() + for enabled in (False, None): + with self.subTest(enabled=enabled): + self.api.immutability["enabled"] = enabled + self.assert_no_writes(self.publish, "Enable release immutability") + 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") + + def test_public_but_mutable_release_is_refused_even_after_setting_enabled(self): + self.verify() + self.api.immutability["enabled"] = False + self.api.add_draft() + self.api.publish_release() + self.api.immutability["enabled"] = True + self.assert_no_writes(self.publish, "public but mutable") + self.assert_no_writes(self.verify_again, "public but mutable") + + def test_publish_requires_immutable_true_in_publication_response(self): + self.verify() + original = self.api.send + def send(method, path, data=None, content_type="application/json"): + result = original(method, path, data, content_type) + if method == "PATCH": + result["immutable"] = False + return result + with patch.object(self.api, "send", side_effect=send): + with self.assertRaisesRegex(release.ReleaseError, "public but mutable"): + self.publish() + + def test_publication_race_is_blocked_by_immutable_release_server(self): + self.verify() + self.api.add_draft() + original = self.api.send + def send(method, path, data=None, content_type="application/json"): + if method == "POST" and "/assets?" in path: + self.api.publish_release() + return original(method, path, data, content_type) + with patch.object(self.api, "send", side_effect=send), self.assertRaises(release.APIError): + self.publish() + self.assertEqual(self.api.assets, {}) + + def test_tag_signature_annotation_and_exact_target_are_required(self): + for field, value, pattern in (("tag_type", "commit", "annotated"), + ("verified", False, "signature"), ("reason", "unknown_key", "signature"), + ("tag_commit", "d" * 40, "CI must")): + with self.subTest(field=field), patch.object(self.api, field, value): + self.assert_no_writes(self.verify, pattern) + + def test_tag_move_after_verification_is_refused(self): + self.verify() + self.api.tag_commit = "d" * 40 + self.assert_no_writes(self.publish, "tag moved") + + def test_manifest_version_plugin_api_and_archive_declarations_are_validated(self): + for manifest in (MANIFEST.replace(b"0.2.0", b"0.3.0"), + MANIFEST.replace(b"plugin_api = 1", b"plugin_api = 0"), + MANIFEST.replace(b"plugin_api = 1", b"plugin_api = true"), + MANIFEST.replace(b"plugin_api = 1\n", b""), + MANIFEST.replace(ARCHIVE.encode(), b"../unsafe.zip")): + with self.subTest(manifest=manifest), patch.object(self.api, "manifest", manifest): + self.assert_no_writes(self.verify, "Manifest") + + def test_latest_ci_run_identity_attempt_and_artifact_are_checked(self): + for field, value in (("event", "workflow_dispatch"), ("head_branch", "other"), + ("path", ".github/workflows/other.yml"), ("head_sha", "d" * 40), + ("head_repository", {"full_name": "fork/plugin"}), + ("status", "in_progress"), ("conclusion", "failure")): + with self.subTest(field=field), patch.dict(self.api.runs[10], {field: value}): + self.assert_no_writes(self.verify, "CI must|Latest CI attempt") + self.api.rerun_ci(self.native) + self.api.artifacts[21]["created_at"] = "2026-01-01T00:05:00Z" + self.assert_no_writes(self.verify, "exactly one named CI artifact") + + def test_artifact_id_digest_ownership_and_download_bytes_are_checked(self): + for field, value in (("id", 99), ("digest", "bad"), ("expired", True), + ("workflow_run", {"id": 11, "head_sha": COMMIT})): + with self.subTest(field=field), patch.dict(self.api.artifacts[20], {field: value}): + self.assert_no_writes(self.verify, "digest|expired|another|404|exactly one") + self.api.artifact_bytes[20] += b"tampered" + self.assert_no_writes(self.verify, "Downloaded CI artifact digest mismatch") + + def test_native_receipt_inventory_and_manifest_are_checked_as_data(self): + for change in ("inventory", "manifest", "receipt", "name"): + with self.subTest(change=change): + files = native_files(manifest=MANIFEST + b"\n# different bytes\n") if change == "manifest" else native_files() + if change == "inventory": + receipt = json.loads(files[NATIVE]) + receipt["files"]["bin/tool"] = "0" * 64 + files[NATIVE] = release.canonical(receipt) + elif change == "receipt": + files[NATIVE] = b"{}" + self.api = FakeGitHub(files) + if change == "name": + self.api.artifacts[20]["name"] = "another-name" + with self.assertRaises((ValueError, KeyError)): + self.verify() + self.assertEqual(self.api.writes, []) + + def test_multiple_archives_record_every_artifact_and_native_receipt(self): + name = "codex-plugin-macos-x86_64.zip" + manifest = MANIFEST.replace(b'architectures = ["arm64"]\n', b'architectures = ["arm64", "x86_64"]\n', 1) + manifest += (b'[[compatibility.artifacts]]\nname = "codex-plugin-macos-x86_64.zip"\n' + b'platforms = ["macos"]\narchitectures = ["x86_64"]\n') + self.api = FakeGitHub(native_files(manifest=manifest)) + self.api.manifest = manifest + self.api.install_artifact(19, native_files(manifest=manifest, name=name, architecture="x86_64")) + self.verify() + receipt = json.loads(self.files()[release.RECEIPT]) + self.assertEqual([item["id"] for item in receipt["ci"]["artifacts"]], [19, 20]) + self.assertEqual([item["name"] for item in receipt["archives"]], sorted([name, ARCHIVE])) + self.publish() + self.assertEqual(len(self.api.assets), 6) + + def test_duplicate_unexpected_assets_and_draft_target_conflicts_are_refused(self): + self.verify() + self.api.add_draft() + self.api.releases[50]["target_commitish"] = "d" * 40 + self.assert_no_writes(self.publish, "target differs") + self.api.releases[50]["target_commitish"] = COMMIT + self.api.add_asset("unexpected") + self.assert_no_writes(self.publish, "unexpected") + self.api.add_asset("unexpected") + self.assert_no_writes(self.publish, "Duplicate") + + def test_bundle_tampering_extra_file_and_checksum_mismatch_are_refused(self): + self.verify() + original = self.files() + for name in (ARCHIVE, NATIVE, release.RECEIPT, release.SUMS, "extra"): + with self.subTest(name=name): + path = self.output / name + path.write_bytes(b"tampered") + self.assert_no_writes(self.publish, "verify job's digests") + if name in original: + path.write_bytes(original[name]) + else: + path.unlink() + (self.output / release.SUMS).write_bytes(b"bad sums") + self.asset_digests = json.dumps({name: release.sha256(data) for name, data in self.files().items()}) + self.assert_no_writes(self.publish, "checksums differ") + + def test_upload_response_digest_mismatch_prevents_publication(self): + self.verify() + original = self.api.send + def send(method, path, data=None, content_type="application/json"): + result = original(method, path, data, content_type) + if "/assets?" in path: + result["digest"] = "sha256:" + "0" * 64 + return result + with patch.object(self.api, "send", side_effect=send), self.assertRaisesRegex(release.ReleaseError, "conflicts"): + self.publish() + self.assertTrue(self.api.releases[50]["draft"]) + self.assertEqual([name for name, _ in self.api.writes], ["draft", release.RECEIPT]) + + def test_dry_run_checks_native_bytes_without_tags_writes_or_catalog(self): + self.api.tag_type = "commit" + self.api.verified = False + self.verify(publish=False, tag="") + receipt = json.loads(self.files()[release.RECEIPT]) + self.assertIsNone(receipt["tag"]) + self.assertEqual(self.api.writes, []) + self.assertFalse(any("/git/" in path for _, path in self.api.calls)) + self.assert_no_writes(self.publish, "untagged dry-run") + + def test_dry_run_cli_reports_publication_conditions(self): + arguments = [str(SCRIPT), "verify", "--repository", REPOSITORY, "--source-run", "10", "--publish", "false", + "--workflow-ref", WORKFLOW_REF, "--workflow-sha", WORKFLOW_SHA, "--output", str(self.output)] + output = io.StringIO() + with patch.object(sys, "argv", arguments), patch.dict(os.environ, {"GH_TOKEN": "fake"}), \ + patch.object(release, "GitHub", return_value=self.api), redirect_stdout(output): + self.assertEqual(release.main(), 0) + self.assertIn("signed annotated v0.2.0", output.getvalue()) + self.assertIn("immutable published Release", output.getvalue()) + self.assertEqual(self.api.writes, []) + + def test_receipt_validation_rejects_noncanonical_or_invalid_provenance(self): + self.verify() + data = self.files()[release.RECEIPT] + receipt = json.loads(data) + self.assertEqual(release.validate_receipt(data), receipt) + with self.assertRaises(release.ReleaseError): + release.validate_receipt(data.rstrip()) + for key, value in (("schema", True), ("reproduction", "rebuild"), ("plugin_api", True), + ("ci", {"run_id": 10, "run_attempt": 1, "artifacts": []}), + ("workflow", {"ref": "invalid", "sha": WORKFLOW_SHA})): + with self.subTest(key=key), self.assertRaises(release.ReleaseError): + release.validate_receipt(release.canonical(dict(receipt, **{key: value}))) + + +class ReleaseWorkflowTests(unittest.TestCase): + def test_job_graph_permissions_and_frozen_output_checks(self): + spec = importlib.util.spec_from_file_location("workflow_check", ROOT / "Scripts/check-workflow.py") + checker = importlib.util.module_from_spec(spec) + spec.loader.exec_module(checker) + self.assertTrue(checker.check_release_workflow(ROOT)["release_workflow_verified"]) + source = WORKFLOW.read_text() + mutations = [ + (" contents: read", " contents: write"), + (" if: inputs.publish\n", " if: always()\n"), + ("needs.verify.outputs.artifact_id", "github.run_attempt"), + ("needs.verify.outputs.asset_digests", "inputs.asset_digests"), + ("needs.publish.outputs.published == 'true'", "always()"), + ("helper.is_symlink()", "False"), + ] + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + target = root / ".github/workflows/release.yml" + target.parent.mkdir(parents=True) + for before, after in mutations: + with self.subTest(before=before): + target.write_text(source.replace(before, after)) + with self.assertRaises(ValueError): + checker.check_release_workflow(root) + + def test_publish_only_retry_uses_original_artifact_id_and_digests(self): + case = ReleaseTests() + case.setUp() + self.addCleanup(case.doCleanups) + case.verify() + expected = case.files() + artifact_id = "901" + case.api.artifact_bytes[901] = zip_bytes({"plugin_release.py": SCRIPT.read_bytes(), + **{"assets/" + name: data for name, data in expected.items()}}) + job = workflow_job("publish") + self.assertIn("artifact-ids: ${{ needs.verify.outputs.artifact_id }}", job) + self.assertNotIn("github.run_attempt", job) + script = python_step("publish") + for attempt in (1, 2): + runner = case.root / ("publisher-" + str(attempt)) + bundle = runner / "plugin-release" + bundle.mkdir(parents=True) + data = case.api.download(API_ROOT + "/actions/artifacts/" + artifact_id + "/zip") + for name, content in release.zip_entries(data).items(): + path = bundle / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + def run_helper(arguments, check): + self.assertTrue(check) + self.assertEqual(arguments[:3], [sys.executable, "-I", str(bundle / "plugin_release.py")]) + with patch.object(sys, "argv", arguments[2:]), patch.object(release, "GitHub", return_value=case.api): + status = release.main() + if status: + raise subprocess.CalledProcessError(status, arguments) + environment = {"RUNNER_TEMP": str(runner), "GH_TOKEN": "fake", "GH_REPOSITORY": REPOSITORY, + "GITHUB_OUTPUT": str(runner / "output"), "GITHUB_RUN_ATTEMPT": str(attempt), + "RELEASE_HELPER_DIGEST": release.sha256(SCRIPT.read_bytes()), + "RELEASE_ASSET_DIGESTS": case.asset_digests} + with patch.dict(os.environ, environment), patch.object(subprocess, "run", side_effect=run_helper), \ + redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): + if attempt == 1: + case.api.fail = (release.SUMS, "before") + with self.assertRaises(subprocess.CalledProcessError): + exec(compile(script, str(WORKFLOW), "exec"), {}) + else: + exec(compile(script, str(WORKFLOW), "exec"), {}) + self.assertIn("published=true", (runner / "output").read_text()) + self.assertEqual({item["name"]: case.api.asset_bytes[item["id"]] + for item in case.api.assets.values()}, expected) + + def test_publish_rejects_changed_helper_before_execution(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "plugin-release" + root.mkdir() + (root / "plugin_release.py").write_bytes(b"unverified helper") + environment = {"RUNNER_TEMP": directory, "RELEASE_HELPER_DIGEST": release.sha256(SCRIPT.read_bytes())} + with patch.dict(os.environ, environment), patch.object(subprocess, "run") as run: + with self.assertRaisesRegex(SystemExit, "helper differs"): + exec(compile(python_step("publish"), str(WORKFLOW), "exec"), {}) + run.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/computer-mcp-plugin.toml b/computer-mcp-plugin.toml index 80ed9e3..6c6db4b 100644 --- a/computer-mcp-plugin.toml +++ b/computer-mcp-plugin.toml @@ -1,6 +1,7 @@ id = 'codex' name = 'Codex' version = "0.1.0" +plugin_api = 1 description = 'Codex execution and protocol inspection through standard MCP.' [[mcp]] From 5902361a347ba5b7d859a89a6621ee97e8cf6042 Mon Sep 17 00:00:00 2001 From: showxu <10173746+showxu@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:18:52 +0800 Subject: [PATCH 2/5] fix: migrate subprocess lifecycle to 1.0.2 --- Documentation/Architecture/Package.md | 32 +++- .../Architecture/VersioningAndRelease.md | 9 + Package.resolved | 6 +- Package.swift | 7 +- Sources/CodexAdapter/BrokenPipe.swift | 19 ++ .../ManagedLineProcess+Darwin.swift | 26 ++- .../CodexAppServerProcessTransportTests.swift | 6 +- .../ManagedLineProcessLifecycleTests.swift | 181 ++++++++++++++++++ 8 files changed, 262 insertions(+), 24 deletions(-) create mode 100644 Sources/CodexAdapter/BrokenPipe.swift create mode 100644 Tests/CodexAdapterTests/ManagedLineProcessLifecycleTests.swift diff --git a/Documentation/Architecture/Package.md b/Documentation/Architecture/Package.md index 70689ef..4a74e25 100644 --- a/Documentation/Architecture/Package.md +++ b/Documentation/Architecture/Package.md @@ -8,13 +8,31 @@ dependency. ## Dependencies -| Package | Responsibility | -| --- | --- | -| `computer-mcp/swift-sdk` transport fork | Standard northbound MCP transport, tools and results; stdio and complete POSIX frame writes | -| swift-codex | App Server client and Exec client, each with its own lifecycle | -| swift-subprocess 0.4.0 | Child process launch and line I/O | -| swift-argument-parser 1.8.2 | Named options, schema-comparison subcommand, validation and generated CLI help | -| GRDB 7.11.1 | Codex approval, runtime/thread ownership, acceptance run, worktree lease and managed-worktree storage and transactions | +| Package | Baseline and requirement | Responsibility | +| --- | --- | --- | +| `computer-mcp/swift-sdk` transport fork | `exact: "0.13.1-computer-mcp.1"` | Standard northbound MCP transport, tools and results; stdio and complete POSIX frame writes | +| swift-codex | `exact: "0.4.1"` | App Server client and Exec client, each with its own lifecycle | +| swift-subprocess | `.upToNextMinor(from: "1.0.2")` | Child process launch and line I/O | +| swift-argument-parser | `from: "1.8.2"` | Named options, schema-comparison subcommand, validation and generated CLI help | +| GRDB | `from: "7.11.1"` | Codex approval, runtime/thread ownership, acceptance run, worktree lease and managed-worktree storage and transactions | + +These kinds follow the organization's +[VERSIONING.md](https://github.com/computer-mcp/.github/blob/master/VERSIONING.md#swift-dependencies). +Organization forks and dependencies below 1.0 use exact requirements. Process, +transport and security dependencies at 1.0 or later use the next minor bound; +other dependencies at 1.0 or later use the next major bound. Floors are the +resolved baseline; `Package.resolved` is committed and CI rejects drift. +swift-crypto and swift-nio are transitive dependencies, owned by their declaring +packages. + +The managed line process uses swift-subprocess's explicit input writer, +file-descriptor stdout and stderr sequence. Its child-input owner installs a +no-op `SA_RESTART` SIGPIPE handler once only for the default disposition, +preserving existing handlers and ignored signals. A handler resets on exec, +whereas ignoring SIGPIPE would survive into children. Broken pipe errors from +the library propagate unchanged; a returned short write fails with the adapter's +closed-transport error. Shutdown preserves EOF delivery, bounded process-group +cleanup and frame/output limits. `Scripts/verify-swift-codex-release-gate.sh` verifies that the manifest's exact SDK version, resolved revision and public Git tag agree. CI requires this check diff --git a/Documentation/Architecture/VersioningAndRelease.md b/Documentation/Architecture/VersioningAndRelease.md index 65676a1..6a017ac 100644 --- a/Documentation/Architecture/VersioningAndRelease.md +++ b/Documentation/Architecture/VersioningAndRelease.md @@ -31,6 +31,15 @@ of introducing another SDK version declaration. The protocol import provenance records the schema baseline. Integration receipts separately record the real Codex executable version; these versions need not be equal. +Direct dependency requirements follow the organization kinds: forks and versions +below 1.0 use `exact:`, process/transport/security boundaries at 1.0 or later use +`.upToNextMinor(from:)`, and other dependencies at 1.0 or later use `from:`. +The adopted baselines are MCP Swift SDK fork `0.13.1-computer-mcp.1` and +swift-codex `0.4.1` (exact), swift-subprocess `1.0.2` (next minor), and +swift-argument-parser `1.8.2` and GRDB `7.11.1` (next major). The committed lock +records the selected revisions; CI resolves and rejects lockfile drift. +[Package](Package.md#dependencies) owns each dependency's responsibility. + `Scripts/package.py --output …` checks the declared/generated versions and lock, builds the archive, validates its executable and emits a file/digest receipt. The CI workflow (`ci.yml`) runs the same source checks and publishes an diff --git a/Package.resolved b/Package.resolved index 1a64764..421237c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "2710938c984b3e8c81af989ff73d285a9a9a153f5edc7b8ca5c050bdc0459f8a", + "originHash" : "d140872fb02b7e4c197a7bb62bce1caab4f2de6b609de7fb3d887dea098ca31d", "pins" : [ { "identity" : "async-http-client", @@ -312,8 +312,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-subprocess.git", "state" : { - "revision" : "13d087685b95d64d6aac9b94500d347bbe84c39b", - "version" : "0.4.0" + "revision" : "dbe21bd7dfd4ffef8c32588918542a8abfa1b54c", + "version" : "1.0.2" } }, { diff --git a/Package.swift b/Package.swift index b886f71..6e1c32c 100644 --- a/Package.swift +++ b/Package.swift @@ -7,11 +7,12 @@ let package = Package( platforms: [.macOS(.v14)], products: [.executable(name: "codex-mcp-adapter", targets: ["CodexMCPAdapter"])], dependencies: [ - .package(url: "https://github.com/groue/GRDB.swift.git", exact: "7.11.1"), - .package(url: "https://github.com/apple/swift-argument-parser", exact: "1.8.2"), + .package(url: "https://github.com/groue/GRDB.swift.git", from: "7.11.1"), + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.8.2"), .package(url: "https://github.com/computer-mcp/swift-sdk.git", exact: "0.13.1-computer-mcp.1"), .package(url: "https://github.com/swift-library/swift-codex.git", exact: "0.4.1"), - .package(url: "https://github.com/swiftlang/swift-subprocess.git", exact: "0.4.0"), + .package( + url: "https://github.com/swiftlang/swift-subprocess.git", .upToNextMinor(from: "1.0.2")), ], targets: [ .target( diff --git a/Sources/CodexAdapter/BrokenPipe.swift b/Sources/CodexAdapter/BrokenPipe.swift new file mode 100644 index 0000000..9330304 --- /dev/null +++ b/Sources/CodexAdapter/BrokenPipe.swift @@ -0,0 +1,19 @@ +import Darwin + +/// Installs a process-wide no-op SIGPIPE handler once for the default disposition, +/// so broken child-input pipes report write errors. Existing handlers and SIG_IGN +/// are preserved. A caught signal resets to default on exec; SIG_IGN would survive +/// exec and change the signal behavior of child processes. +func installBrokenPipeHandler() { + _ = brokenPipeHandlerInstallation +} + +private let brokenPipeHandlerInstallation: Void = { + var action = sigaction() + guard sigaction(SIGPIPE, nil, &action) == 0 else { return } + guard action.__sigaction_u.__sa_handler == nil else { return } + action.__sigaction_u.__sa_handler = { _ in } + sigemptyset(&action.sa_mask) + action.sa_flags = SA_RESTART + _ = sigaction(SIGPIPE, &action, nil) +}() diff --git a/Sources/CodexAdapter/ManagedLineProcess+Darwin.swift b/Sources/CodexAdapter/ManagedLineProcess+Darwin.swift index 06b19f8..f2874a6 100644 --- a/Sources/CodexAdapter/ManagedLineProcess+Darwin.swift +++ b/Sources/CodexAdapter/ManagedLineProcess+Darwin.swift @@ -17,6 +17,7 @@ final class ManagedLineProcess: @unchecked Sendable { init(configuration: Configuration) throws { try configuration.validate() + installBrokenPipeHandler() self.configuration = configuration let streamAndContinuation = AsyncThrowingStream.makeStream( bufferingPolicy: .bufferingOldest(16)) @@ -153,14 +154,16 @@ final class ManagedLineProcess: @unchecked Sendable { environment: .custom(environment), workingDirectory: FilePath(configuration.workingDirectory.path), platformOptions: platformOptions, - output: FileDescriptorOutput.fileDescriptor( + input: .inputWriter, + output: .fileDescriptor( standardOutput.writeEnd, closeAfterSpawningProcess: true - ) - ) { execution, inputWriter, stderr in + ), + error: .sequence + ) { execution in await state.attach( execution: execution, - inputWriter: inputWriter + inputWriter: execution.standardInputWriter ) guard let processID = await waitForProcessID(at: supervisor.processIDFile) else { @@ -171,7 +174,7 @@ final class ManagedLineProcess: @unchecked Sendable { } await state.attachChild(processID: processID) do { - for try await _ in stderr {} + for try await _ in execution.standardError {} } catch { await state.recordStreamError(error) } @@ -433,12 +436,16 @@ private final class ManagedLineProcessOutputReader: @unchecked Sendable { } } +private typealias ManagedLineExecution = Execution< + CustomWriteInput, FileDescriptorOutput, SequenceOutput +> + private actor ManagedLineProcessState { private let continuation: AsyncThrowingStream.Continuation private let maximumMessageBytes: Int private let ownerProcessID: Int32 private var state: ManagedLineProcessSnapshot.State = .starting - private var execution: Execution? + private var execution: ManagedLineExecution? private var inputWriter: StandardInputWriter? private var processID: Int32? private var supervisorProcessID: Int32? @@ -464,7 +471,7 @@ private actor ManagedLineProcessState { self.ownerProcessID = ownerProcessID } - func attach(execution: Execution, inputWriter: StandardInputWriter) { + func attach(execution: ManagedLineExecution, inputWriter: StandardInputWriter) { self.execution = execution self.inputWriter = inputWriter supervisorProcessID = Int32(execution.processIdentifier.value) @@ -498,7 +505,8 @@ private actor ManagedLineProcessState { let frame = Data((line + "\n").utf8) pendingWrites += 1 defer { pendingWrites -= 1 } - _ = try await inputWriter.write(Array(frame)) + let count = try await inputWriter.write(Array(frame)) + guard count == frame.count else { throw ManagedLineProcessError.closed } } func appendStandardOutput(_ data: Data) throws { @@ -552,7 +560,7 @@ private actor ManagedLineProcessState { try await inputWriter?.finish() } - func runningExecution() -> Execution? { + func runningExecution() -> ManagedLineExecution? { guard !hasFinished() else { return nil } return execution } diff --git a/Tests/CodexAdapterTests/CodexAppServerProcessTransportTests.swift b/Tests/CodexAdapterTests/CodexAppServerProcessTransportTests.swift index 458a05a..6e39081 100644 --- a/Tests/CodexAdapterTests/CodexAppServerProcessTransportTests.swift +++ b/Tests/CodexAdapterTests/CodexAppServerProcessTransportTests.swift @@ -278,7 +278,7 @@ struct CodexAppServerProcessTransportTests { let running = try await waitForRunning(transport) let processID = try #require(running.processID) let blockedWrite = Task { - try? await transport.sendLine(String(repeating: "x", count: 4 * 1_024 * 1_024)) + try await transport.sendLine(String(repeating: "x", count: 4 * 1_024 * 1_024)) } try await Task.sleep(for: .milliseconds(100)) let clock = ContinuousClock() @@ -288,7 +288,9 @@ struct CodexAppServerProcessTransportTests { #expect(started.duration(to: clock.now) < .seconds(3)) #expect(await waitForProcessExit(processID)) - _ = await blockedWrite.value + if case .success = await blockedWrite.result { + Issue.record("A cancelled, incomplete protocol write must fail.") + } } @Test diff --git a/Tests/CodexAdapterTests/ManagedLineProcessLifecycleTests.swift b/Tests/CodexAdapterTests/ManagedLineProcessLifecycleTests.swift new file mode 100644 index 0000000..e3d8fcf --- /dev/null +++ b/Tests/CodexAdapterTests/ManagedLineProcessLifecycleTests.swift @@ -0,0 +1,181 @@ +import Darwin +import Foundation +import Testing + +@testable import CodexAdapter + +@Suite(.serialized, .timeLimit(.minutes(1))) +struct ManagedLineProcessLifecycleTests { + @Test + func inputRemainsOpenAcrossRequestsAndCloseDeliversEOF() async throws { + let process = try ManagedLineProcess( + configuration: .init( + executable: "/bin/sh", + arguments: [ + "-c", #"while IFS= read -r line; do printf '%s\n' "$line"; done; printf 'eof\n'"#, + ], + workingDirectory: FileManager.default.temporaryDirectory, + terminationGraceMilliseconds: 2_000)) + do { + var lines = process.inboundLines.makeAsyncIterator() + for line in ["first", "second"] { + try await process.sendLine(line) + #expect(try await lines.next() == line) + } + await process.close() + #expect(try await lines.next() == "eof") + #expect(try await lines.next() == nil) + let result = await process.snapshot() + #expect(result.hasExited && result.exitCode == 0) + #expect(result.cleanupConfirmed == true) + #expect(!result.terminationEscalated && result.pendingWrites == 0) + } catch { + await process.close() + throw error + } + } + + @Test + func closeCancelsBlockedWriteAndReapsTheEntireGroup() async throws { + let process = try ManagedLineProcess( + configuration: .init( + executable: "/bin/sh", + arguments: [ + "-c", + #"trap '' TERM; /bin/sh -c 'trap "" TERM; /bin/sleep 30' & printf '%s\n' "$!"; wait"#, + ], + workingDirectory: FileManager.default.temporaryDirectory, + terminationGraceMilliseconds: 100, maximumMessageBytes: 2_097_152)) + do { + var lines = process.inboundLines.makeAsyncIterator() + let ready = try #require(try await lines.next()) + let child = try #require(Int32(ready)) + let writing = Task { try await process.sendLine(String(repeating: "x", count: 1_048_576)) } + let deadline = ContinuousClock.now + .seconds(2) + while await process.snapshot().pendingWrites == 0, ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + #expect(await process.snapshot().pendingWrites == 1) + let started = ContinuousClock.now + async let first: Void = process.close() + async let second: Void = process.close() + _ = await (first, second) + #expect(started.duration(to: .now) < .seconds(3)) + if case .success = await writing.result { + Issue.record("A cancelled, incomplete protocol write must fail.") + } + let result = await process.snapshot() + #expect(result.hasExited && result.terminationEscalated) + #expect(result.pendingWrites == 0 && result.cleanupConfirmed == true) + #expect(kill(child, 0) == -1 && errno == ESRCH) + #expect(kill(try #require(result.processID), 0) == -1 && errno == ESRCH) + #expect(kill(try #require(result.supervisorProcessID), 0) == -1 && errno == ESRCH) + } catch { + await process.close() + throw error + } + } + + @Test + func brokenInputPipeReportsTheLibraryError() async throws { + let process = try ManagedLineProcess( + configuration: .init( + executable: "/bin/sh", + arguments: ["-c", "exec 0<&-; printf 'ready\\n'; exec /bin/sleep 30"], + workingDirectory: FileManager.default.temporaryDirectory, + terminationGraceMilliseconds: 100)) + do { + var lines = process.inboundLines.makeAsyncIterator() + #expect(try await lines.next() == "ready") + do { + try await process.sendLine("write to a closed child input") + Issue.record("A broken input pipe must fail the write.") + } catch { + #expect(!(error is ManagedLineProcessError)) + } + #expect(await process.snapshot().pendingWrites == 0) + await process.close() + #expect(await process.snapshot().cleanupConfirmed == true) + } catch { + await process.close() + throw error + } + } + + @Test(arguments: [false, true]) + func outputRejectsOversizedFrames(newline: Bool) async throws { + let process = try ManagedLineProcess( + configuration: .init( + executable: "/bin/sh", + arguments: ["-c", newline ? "printf '%065d\\n' 0" : "printf '%065d' 0"], + workingDirectory: FileManager.default.temporaryDirectory, + terminationGraceMilliseconds: 100, maximumMessageBytes: 64)) + await #expect(throws: (any Error).self) { + for try await _ in process.inboundLines { Issue.record("An oversized frame was delivered.") } + } + await process.close() + #expect(await process.snapshot().hasExited) + #expect(await process.snapshot().lastError?.contains("64 bytes") == true) + } + + @Test + func outputQueueOverflowIsReported() async throws { + let process = try ManagedLineProcess( + configuration: .init( + executable: "/bin/sh", + arguments: ["-c", "i=0; while [ $i -lt 32 ]; do printf 'line\\n'; i=$((i+1)); done"], + workingDirectory: FileManager.default.temporaryDirectory, + terminationGraceMilliseconds: 100)) + let deadline = ContinuousClock.now + .seconds(2) + while await process.snapshot().lastError == nil, ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + var count = 0 + do { + for try await _ in process.inboundLines { count += 1 } + Issue.record("The bounded output queue must report overflow.") + } catch { + if case ManagedLineProcessError.bufferOverflow = error { + #expect(count == 16) + } else { + Issue.record("Expected output queue overflow, received \(error).") + } + } + await process.close() + #expect(await process.snapshot().cleanupConfirmed == true) + } + + @Test + func managedChildStartsWithDefaultSIGPIPE() async throws { + let process = try ManagedLineProcess( + configuration: .init( + executable: "/bin/sh", arguments: ["-c", "kill -PIPE $$; echo survived"], + workingDirectory: FileManager.default.temporaryDirectory, + terminationGraceMilliseconds: 100)) + var lines = process.inboundLines.makeAsyncIterator() + #expect(try await lines.next() == nil) + await process.close() + let result = await process.snapshot() + // The supervisor reports a signaled child's shell wait status. + #expect(result.exitCode == 128 + SIGPIPE) + #expect(result.cleanupConfirmed == true) + } + + @Test + func foundationChildStartsWithDefaultSIGPIPE() async throws { + let managed = try ManagedLineProcess( + configuration: .init( + executable: "/usr/bin/true", workingDirectory: FileManager.default.temporaryDirectory)) + await managed.close() + let child = Process() + child.executableURL = URL(fileURLWithPath: "/bin/sh") + child.arguments = ["-c", "kill -PIPE $$; echo survived"] + let output = Pipe() + child.standardOutput = output + try child.run() + child.waitUntilExit() + #expect(child.terminationReason == .uncaughtSignal) + #expect(child.terminationStatus == SIGPIPE) + #expect(try (output.fileHandleForReading.readToEnd() ?? Data()).isEmpty) + } +} From 7c3aa5f85ea5fe55bad01fb696bd266bf73a2581 Mon Sep 17 00:00:00 2001 From: showxu <10173746+showxu@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:42:28 +0800 Subject: [PATCH 3/5] fix: serialize managed process frame writes --- Documentation/Architecture/Package.md | 4 +- .../ManagedLineProcess+Darwin.swift | 19 ++- .../ManagedLineProcessLifecycleTests.swift | 113 ++++++++++++++++++ 3 files changed, 132 insertions(+), 4 deletions(-) diff --git a/Documentation/Architecture/Package.md b/Documentation/Architecture/Package.md index 4a74e25..28060fe 100644 --- a/Documentation/Architecture/Package.md +++ b/Documentation/Architecture/Package.md @@ -31,7 +31,9 @@ no-op `SA_RESTART` SIGPIPE handler once only for the default disposition, preserving existing handlers and ignored signals. A handler resets on exec, whereas ignoring SIGPIPE would survive into children. Broken pipe errors from the library propagate unchanged; a returned short write fails with the adapter's -closed-transport error. Shutdown preserves EOF delivery, bounded process-group +closed-transport error. Complete outbound frames are written one at a time in +FIFO order, with queued writers rechecking transport state before writing. +Shutdown preserves EOF delivery, bounded process-group cleanup and frame/output limits. `Scripts/verify-swift-codex-release-gate.sh` verifies that the manifest's exact diff --git a/Sources/CodexAdapter/ManagedLineProcess+Darwin.swift b/Sources/CodexAdapter/ManagedLineProcess+Darwin.swift index f2874a6..7a40c8f 100644 --- a/Sources/CodexAdapter/ManagedLineProcess+Darwin.swift +++ b/Sources/CodexAdapter/ManagedLineProcess+Darwin.swift @@ -455,6 +455,7 @@ private actor ManagedLineProcessState { private var signal: Int32? private var terminationEscalated = false private var pendingWrites = 0 + private var writeWaiters: [CheckedContinuation] = [] private var lastError: String? private var outputBuffer = Data() private var shutdownRequested = false @@ -495,16 +496,28 @@ private actor ManagedLineProcessState { func send(line: String) async throws { try await waitUntilReady() - guard state == .running, let inputWriter else { + guard state == .running else { throw ManagedLineProcessError.closed } guard line.utf8.count <= maximumMessageBytes else { throw ManagedLineProcessError.oversizedMessage(maximumMessageBytes) } guard !line.contains("\n") else { throw ManagedLineProcessError.invalidConfiguration } - let frame = Data((line + "\n").utf8) pendingWrites += 1 - defer { pendingWrites -= 1 } + defer { + pendingWrites -= 1 + if !writeWaiters.isEmpty { + writeWaiters.removeFirst().resume() + } + } + // Hold the FIFO turn across the write's suspension, leaving shutdown free to interrupt I/O. + if pendingWrites > 1 { + await withCheckedContinuation { writeWaiters.append($0) } + } + guard state == .running, let inputWriter else { + throw ManagedLineProcessError.closed + } + let frame = Data((line + "\n").utf8) let count = try await inputWriter.write(Array(frame)) guard count == frame.count else { throw ManagedLineProcessError.closed } } diff --git a/Tests/CodexAdapterTests/ManagedLineProcessLifecycleTests.swift b/Tests/CodexAdapterTests/ManagedLineProcessLifecycleTests.swift index e3d8fcf..b3849d7 100644 --- a/Tests/CodexAdapterTests/ManagedLineProcessLifecycleTests.swift +++ b/Tests/CodexAdapterTests/ManagedLineProcessLifecycleTests.swift @@ -6,6 +6,64 @@ import Testing @Suite(.serialized, .timeLimit(.minutes(1))) struct ManagedLineProcessLifecycleTests { + @Test + func overlappingLargeFramesArriveIntactInFIFOOrder() async throws { + try await withGatedInputProcess { process, gate in + var lines = process.inboundLines.makeAsyncIterator() + let capacityLine = try #require(try await lines.next()) + let pipeCapacity = try #require(Int(capacityLine)) + let firstLine = "{\"id\":1,\"payload\":\"" + String(repeating: "a", count: 1_048_576) + "\"}" + let secondLine = "{\"id\":2,\"payload\":\"" + String(repeating: "b", count: 1_048_576) + "\"}" + try #require(firstLine.utf8.count > pipeCapacity && secondLine.utf8.count > pipeCapacity) + + let first = Task { try await process.sendLine(firstLine) } + try #require(try await lines.next() == "blocked") + try await waitForPendingWrites(1, in: process) + let second = Task { try await process.sendLine(secondLine) } + try await waitForPendingWrites(2, in: process) + + try gate.write(contentsOf: Data([1])) + try await first.value + try await second.value + #expect(try await lines.next() == firstLine) + #expect(try await lines.next() == secondLine) + let running = await process.snapshot() + #expect(running.state == .running && !running.hasExited) + #expect(running.pendingWrites == 0) + #expect(kill(try #require(running.processID), 0) == 0) + } + } + + @Test + func queuedWriteFailsClosedWhenProcessClosesDuringBlockedWrite() async throws { + try await withGatedInputProcess { process, _ in + var lines = process.inboundLines.makeAsyncIterator() + _ = try #require(try await lines.next()) + let first = Task { try await process.sendLine(String(repeating: "x", count: 1_048_576)) } + try #require(try await lines.next() == "blocked") + try await waitForPendingWrites(1, in: process) + let queued = Task { try await process.sendLine("queued frame") } + try await waitForPendingWrites(2, in: process) + + let started = ContinuousClock.now + await process.close() + #expect(started.duration(to: .now) < .seconds(3)) + if case .success = await first.result { + Issue.record("A cancelled, incomplete protocol write must fail.") + } + do { + try await queued.value + Issue.record("A queued frame must fail when the transport closes.") + } catch ManagedLineProcessError.closed { + } catch { + Issue.record("Expected the closed transport error, received \(error).") + } + let stopped = await process.snapshot() + #expect(stopped.hasExited && stopped.cleanupConfirmed == true) + #expect(stopped.pendingWrites == 0) + } + } + @Test func inputRemainsOpenAcrossRequestsAndCloseDeliversEOF() async throws { let process = try ManagedLineProcess( @@ -178,4 +236,59 @@ struct ManagedLineProcessLifecycleTests { #expect(child.terminationStatus == SIGPIPE) #expect(try (output.fileHandleForReading.readToEnd() ?? Data()).isEmpty) } + + private func waitForPendingWrites(_ count: Int, in process: ManagedLineProcess) async throws { + let deadline = ContinuousClock.now + .seconds(10) + while await process.snapshot().pendingWrites != count { + try #require(ContinuousClock.now < deadline, "Expected \(count) pending writes.") + try await Task.sleep(for: .milliseconds(10)) + } + } + + private func withGatedInputProcess( + _ body: (ManagedLineProcess, FileHandle) async throws -> Void + ) async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let path = directory.appendingPathComponent("read-gate").path + try #require(mkfifo(path, 0o600) == 0) + let descriptor = open(path, O_RDWR | O_NONBLOCK) + try #require(descriptor >= 0) + let gate = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) + defer { try? gate.close() } + let process = try ManagedLineProcess( + configuration: .init( + executable: "/usr/bin/python3", + arguments: [ + "-c", + """ + import array, fcntl, os, select, sys, termios + with open(sys.argv[1], "rb", buffering=0) as gate: + print(os.fstat(0).st_blksize, flush=True) + select.select([0], [], []) + while True: + available = array.array("i", [0]) + fcntl.ioctl(0, termios.FIONREAD, available, True) + if available[0] >= os.fstat(0).st_blksize: + break + print("blocked", flush=True) + gate.read(1) + for line in sys.stdin.buffer: + sys.stdout.buffer.write(line) + sys.stdout.buffer.flush() + """, + path, + ], + workingDirectory: directory, + terminationGraceMilliseconds: 100, maximumMessageBytes: 2_097_152)) + do { + try await body(process, gate) + await process.close() + #expect(await process.snapshot().cleanupConfirmed == true) + } catch { + await process.close() + throw error + } + } } From 4221ce9628bab283a5577fc6e15d433674e1570a Mon Sep 17 00:00:00 2001 From: showxu <10173746+showxu@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:25:59 +0800 Subject: [PATCH 4/5] fix: honor child input write ownership --- Documentation/Architecture/Package.md | 30 +- .../ManagedLineProcess+Darwin.swift | 196 +++++++---- .../CodexAppServerProcessTransportTests.swift | 46 ++- .../ManagedLineProcessLifecycleTests.swift | 324 ++++++++++++++---- 4 files changed, 447 insertions(+), 149 deletions(-) diff --git a/Documentation/Architecture/Package.md b/Documentation/Architecture/Package.md index 28060fe..47c58da 100644 --- a/Documentation/Architecture/Package.md +++ b/Documentation/Architecture/Package.md @@ -31,10 +31,27 @@ no-op `SA_RESTART` SIGPIPE handler once only for the default disposition, preserving existing handlers and ignored signals. A handler resets on exec, whereas ignoring SIGPIPE would survive into children. Broken pipe errors from the library propagate unchanged; a returned short write fails with the adapter's -closed-transport error. Complete outbound frames are written one at a time in -FIFO order, with queued writers rechecking transport state before writing. -Shutdown preserves EOF delivery, bounded process-group -cleanup and frame/output limits. +closed-transport error. The child-input owner follows these ordering and +lifetime rules: + +1. Whole frames enter a FIFO queue, with at most one library write outstanding. + A caller-requested EOF follows earlier writes in that same order. +2. Cancellation before admission removes the caller promptly without writing, + including cancellation racing with admission. +3. An admitted write runs in an owner task. Its caller awaits completion even + if cancelled; only process I/O termination can interrupt that write. +4. A positive short count retires input before another caller can be admitted. + Queued and later writes fail closed. A zero count fails that write without + retiring input; thrown library errors propagate unchanged. +5. Shutdown rejects queued writes and proceeds with bounded process-group + termination independently of the active write. Both explicit input finish + and return from the subprocess body wait for that write to settle. Supervisor + exit cancels process I/O even if a descendant retains the pipe. Close allows + up to five seconds for startup, two termination-grace intervals, and one + kill-grace interval before reporting a termination timeout; it never closes + input under an outstanding write. + +Shutdown preserves EOF delivery and frame/output limits. `Scripts/verify-swift-codex-release-gate.sh` verifies that the manifest's exact SDK version, resolved revision and public Git tag agree. CI requires this check @@ -268,8 +285,9 @@ transfer ownership of earlier work. Shutdown joins notification/request consumers, performs bounded unsubscription, marks pending approval records interrupted, and closes the transport. Teardown -closes stdin, waits for graceful exit, sends TERM to the exact owned process -group if necessary, then KILL after another bounded interval and records reaping. +rejects queued input and delivers EOF once any admitted write settles. It waits +for graceful exit, sends TERM to the exact owned process group if necessary, +then KILL after another bounded interval and records reaping. A private supervisor watches the owner's death and applies the same cleanup. No process is selected by executable name or a machine-wide search. diff --git a/Sources/CodexAdapter/ManagedLineProcess+Darwin.swift b/Sources/CodexAdapter/ManagedLineProcess+Darwin.swift index 7a40c8f..4aa5dcc 100644 --- a/Sources/CodexAdapter/ManagedLineProcess+Darwin.swift +++ b/Sources/CodexAdapter/ManagedLineProcess+Darwin.swift @@ -4,9 +4,16 @@ import Subprocess import System /// Owns one supervisor generation. The lock protects closeTask; all process state is actor-owned. -/// Foundation's callback reader is the only other lock boundary; no host process is adopted. +/// Admission cancellation and Foundation's callback reader use separate locks; no host process is adopted. final class ManagedLineProcess: @unchecked Sendable { + enum InputEvent: Equatable, Sendable { + case writeStarted + case writeSettled(bytesWritten: Int?) + case finishStarted + case terminationSignalled(Int32) + } + let inboundLines: AsyncThrowingStream private let configuration: Configuration @@ -15,7 +22,10 @@ final class ManagedLineProcess: @unchecked Sendable { private let closeLock = NSLock() private var closeTask: Task? - init(configuration: Configuration) throws { + init( + configuration: Configuration, + inputObserver: (@Sendable (InputEvent) -> Void)? = nil + ) throws { try configuration.validate() installBrokenPipeHandler() self.configuration = configuration @@ -25,7 +35,8 @@ final class ManagedLineProcess: @unchecked Sendable { let state = ManagedLineProcessState( continuation: streamAndContinuation.continuation, maximumMessageBytes: configuration.maximumMessageBytes, - ownerProcessID: configuration.ownerProcessID + ownerProcessID: configuration.ownerProcessID, + inputObserver: inputObserver ) self.state = state self.launchTask = Task.detached(priority: .userInitiated) { @@ -72,8 +83,8 @@ final class ManagedLineProcess: @unchecked Sendable { try? await state.finishInput() } - // Start EOF delivery without waiting for a back-pressured pipe writer. The - // bounded process wait and signal escalation below remain authoritative. + // EOF waits for the admitted write; signal escalation remains independent + // so process exit can cancel back-pressured I/O before the descriptor closes. if await waitForExit(milliseconds: configuration.terminationGraceMilliseconds) { await launchTask.value await finishInput.value @@ -168,6 +179,7 @@ final class ManagedLineProcess: @unchecked Sendable { guard let processID = await waitForProcessID(at: supervisor.processIDFile) else { try? execution.send(signal: .kill, toProcessGroup: true) + try? await state.finishInput() throw ManagedLineProcessError.launchFailed( "The process supervisor did not report the child PID." ) @@ -178,6 +190,8 @@ final class ManagedLineProcess: @unchecked Sendable { } catch { await state.recordStreamError(error) } + // run automatically finishes stdin when this body returns. + try await state.finishInput() } await outputReader.stop(drainRemainingOutput: true) await state.finish(status: outcome.terminationStatus) @@ -440,10 +454,40 @@ private typealias ManagedLineExecution = Execution< CustomWriteInput, FileDescriptorOutput, SequenceOutput > +/// The lock orders synchronous caller cancellation against actor-owned write admission. +private final class ManagedLineInputAdmission: @unchecked Sendable { + private enum State { case waiting, admitted, cancelled } + private let lock = NSLock() + private var state = State.waiting + + var isCancelled: Bool { lock.withLock { state == .cancelled } } + + func admit() -> Bool { + lock.withLock { + guard state == .waiting else { return false } + state = .admitted + return true + } + } + + func cancel() { + lock.withLock { + if state == .waiting { state = .cancelled } + } + } +} + private actor ManagedLineProcessState { + private struct InputWrite { + let admission: ManagedLineInputAdmission + let frame: [UInt8] + let continuation: CheckedContinuation + } + private let continuation: AsyncThrowingStream.Continuation private let maximumMessageBytes: Int private let ownerProcessID: Int32 + private let inputObserver: (@Sendable (ManagedLineProcess.InputEvent) -> Void)? private var state: ManagedLineProcessSnapshot.State = .starting private var execution: ManagedLineExecution? private var inputWriter: StandardInputWriter? @@ -454,22 +498,25 @@ private actor ManagedLineProcessState { private var exitCode: Int32? private var signal: Int32? private var terminationEscalated = false - private var pendingWrites = 0 - private var writeWaiters: [CheckedContinuation] = [] + private var writeQueue: [InputWrite] = [] + private var activeWrite: Task? + private var inputRetired = false + private var inputFinished = false private var lastError: String? private var outputBuffer = Data() private var shutdownRequested = false private var launchFinished = false - private var readyWaiters: [CheckedContinuation] = [] init( continuation: AsyncThrowingStream.Continuation, maximumMessageBytes: Int, - ownerProcessID: Int32 + ownerProcessID: Int32, + inputObserver: (@Sendable (ManagedLineProcess.InputEvent) -> Void)? ) { self.continuation = continuation self.maximumMessageBytes = maximumMessageBytes self.ownerProcessID = ownerProcessID + self.inputObserver = inputObserver } func attach(execution: ManagedLineExecution, inputWriter: StandardInputWriter) { @@ -479,7 +526,6 @@ private actor ManagedLineProcessState { startedAt = Date() if shutdownRequested { state = .stopping - failReadyWaiters(ManagedLineProcessError.closed) } } @@ -487,39 +533,81 @@ private actor ManagedLineProcessState { self.processID = processID guard !shutdownRequested else { return } state = .running - let waiters = readyWaiters - readyWaiters.removeAll() - for waiter in waiters { - waiter.resume() - } + admitNextWrite() } func send(line: String) async throws { - try await waitUntilReady() - guard state == .running else { - throw ManagedLineProcessError.closed + try Task.checkCancellation() + switch state { + case .starting, .running: break + case .stopping, .stopped: throw ManagedLineProcessError.closed + case .failed: + throw ManagedLineProcessError.launchFailed(lastError ?? "Unknown launch failure.") } + guard !inputRetired else { throw ManagedLineProcessError.closed } guard line.utf8.count <= maximumMessageBytes else { throw ManagedLineProcessError.oversizedMessage(maximumMessageBytes) } guard !line.contains("\n") else { throw ManagedLineProcessError.invalidConfiguration } - pendingWrites += 1 - defer { - pendingWrites -= 1 - if !writeWaiters.isEmpty { - writeWaiters.removeFirst().resume() + let admission = ManagedLineInputAdmission() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + writeQueue.append( + InputWrite( + admission: admission, frame: Array((line + "\n").utf8), continuation: continuation)) + admitNextWrite() } + } onCancel: { + admission.cancel() + Task { await self.cancelQueuedWrite(admission) } } - // Hold the FIFO turn across the write's suspension, leaving shutdown free to interrupt I/O. - if pendingWrites > 1 { - await withCheckedContinuation { writeWaiters.append($0) } + } + + private func admitNextWrite() { + guard activeWrite == nil, state == .running, !inputRetired, let inputWriter else { return } + while !writeQueue.isEmpty { + let write = writeQueue.removeFirst() + guard write.admission.admit() else { + write.continuation.resume(throwing: CancellationError()) + continue + } + // This unstructured task belongs to the input owner. Caller cancellation + // cannot truncate an admitted frame; process-exit I/O cancellation still can. + activeWrite = Task { + inputObserver?(.writeStarted) + let result: Result + do { + let count = try await inputWriter.write(write.frame) + inputObserver?(.writeSettled(bytesWritten: count)) + if count > 0 && count < write.frame.count { + retireInput() + } + result = + count == write.frame.count ? .success(()) : .failure(ManagedLineProcessError.closed) + } catch { + inputObserver?(.writeSettled(bytesWritten: nil)) + result = .failure(error) + } + activeWrite = nil + write.continuation.resume(with: result) + admitNextWrite() + } + return } - guard state == .running, let inputWriter else { - throw ManagedLineProcessError.closed + } + + private func cancelQueuedWrite(_ admission: ManagedLineInputAdmission) { + guard let index = writeQueue.firstIndex(where: { $0.admission === admission }) else { return } + writeQueue.remove(at: index).continuation.resume(throwing: CancellationError()) + } + + private func retireInput(error: Error = ManagedLineProcessError.closed) { + inputRetired = true + let queued = writeQueue + writeQueue.removeAll() + for write in queued { + write.continuation.resume(throwing: write.admission.isCancelled ? CancellationError() : error) } - let frame = Data((line + "\n").utf8) - let count = try await inputWriter.write(Array(frame)) - guard count == frame.count else { throw ManagedLineProcessError.closed } } func appendStandardOutput(_ data: Data) throws { @@ -563,14 +651,19 @@ private actor ManagedLineProcessState { func beginShutdown() { shutdownRequested = true - failReadyWaiters(ManagedLineProcessError.closed) + retireInput() if state == .starting || state == .running { state = .stopping } } func finishInput() async throws { - try await inputWriter?.finish() + retireInput() + await activeWrite?.value + guard !inputFinished, let inputWriter else { return } + inputFinished = true + inputObserver?(.finishStarted) + try await inputWriter.finish() } func runningExecution() -> ManagedLineExecution? { @@ -585,7 +678,9 @@ private actor ManagedLineProcessState { func signalChildGroup(_ signal: Int32) -> Bool { // The live supervisor pins the child identity. Its exit does not authorize signalling a reused group. guard !hasFinished(), let processID, processID > 1 else { return false } - return Darwin.kill(-processID, signal) == 0 || errno == ESRCH + let signalled = Darwin.kill(-processID, signal) == 0 || errno == ESRCH + if signalled { inputObserver?(.terminationSignalled(signal)) } + return signalled } func recordTerminationEscalation() { @@ -612,7 +707,7 @@ private actor ManagedLineProcessState { if state != .failed { state = .stopped } - failReadyWaiters(ManagedLineProcessError.closed) + retireInput() continuation.finish() } @@ -627,7 +722,7 @@ private actor ManagedLineProcessState { let launchError = ManagedLineProcessError.launchFailed( Self.safeMessage(error) ) - failReadyWaiters(launchError) + retireInput(error: launchError) continuation.finish(throwing: launchError) } @@ -637,7 +732,7 @@ private actor ManagedLineProcessState { let error = ManagedLineProcessError.terminationTimedOut(processID: processID) lastError = Self.safeMessage(error) stoppedAt = Date() - failReadyWaiters(error) + retireInput() continuation.finish(throwing: error) } @@ -663,7 +758,7 @@ private actor ManagedLineProcessState { exitCode: exitCode, signal: signal, terminationEscalated: terminationEscalated, - pendingWrites: pendingWrites, + pendingWrites: writeQueue.count + (activeWrite == nil ? 0 : 1), hasExited: hasFinished(), lastError: hasFinished() && !cleanupIsConfirmed() ? "Supervisor exited; child process-group cleanup is unconfirmed." : lastError, @@ -671,31 +766,6 @@ private actor ManagedLineProcessState { ) } - private func waitUntilReady() async throws { - switch state { - case .running: - return - case .starting: - try await withCheckedThrowingContinuation { continuation in - readyWaiters.append(continuation) - } - case .stopping, .stopped: - throw ManagedLineProcessError.closed - case .failed: - throw ManagedLineProcessError.launchFailed( - lastError ?? "Unknown launch failure." - ) - } - } - - private func failReadyWaiters(_ error: Error) { - let waiters = readyWaiters - readyWaiters.removeAll() - for waiter in waiters { - waiter.resume(throwing: error) - } - } - private static func safeMessage(_ error: Error) -> String { // Generic I/O errors can contain argv, environment values or protocol payloads. guard let error = error as? ManagedLineProcessError else { diff --git a/Tests/CodexAdapterTests/CodexAppServerProcessTransportTests.swift b/Tests/CodexAdapterTests/CodexAppServerProcessTransportTests.swift index 6e39081..257029b 100644 --- a/Tests/CodexAdapterTests/CodexAppServerProcessTransportTests.swift +++ b/Tests/CodexAdapterTests/CodexAppServerProcessTransportTests.swift @@ -267,29 +267,51 @@ struct CodexAppServerProcessTransportTests { func closeDoesNotWaitForBackpressuredProtocolWriter() async throws { let transport = try ManagedCodexAppServerTransport( configuration: .init( - executable: "/bin/sh", - arguments: ["-c", "trap '' TERM; while :; do /bin/sleep 1; done"], + executable: "/usr/bin/python3", + arguments: [ + "-c", + """ + import array, fcntl, os, select, signal, termios + signal.signal(signal.SIGTERM, signal.SIG_IGN) + select.select([0], [], []) + while True: + available = array.array("i", [0]) + fcntl.ioctl(0, termios.FIONREAD, available, True) + if available[0] >= os.fstat(0).st_blksize: + break + print("blocked", flush=True) + select.select([], [], []) + """, + ], workingDirectory: FileManager.default.temporaryDirectory, terminationGraceMilliseconds: 100, killGraceMilliseconds: 2_000, maximumMessageBytes: 8 * 1_024 * 1_024 ) ) - let running = try await waitForRunning(transport) - let processID = try #require(running.processID) let blockedWrite = Task { try await transport.sendLine(String(repeating: "x", count: 4 * 1_024 * 1_024)) } - try await Task.sleep(for: .milliseconds(100)) - let clock = ContinuousClock() - let started = clock.now + do { + var lines = transport.inboundLines.makeAsyncIterator() + // Child-observed bytes establish validation and a full pipe independently + // of queue accounting. The child never consumes its input. + try #require(try await lines.next() == "blocked") + let running = try await waitForRunning(transport) + let processID = try #require(running.processID) + let started = ContinuousClock.now - await transport.close() + await transport.close() - #expect(started.duration(to: clock.now) < .seconds(3)) - #expect(await waitForProcessExit(processID)) - if case .success = await blockedWrite.result { - Issue.record("A cancelled, incomplete protocol write must fail.") + #expect(started.duration(to: .now) < .seconds(3)) + #expect(await waitForProcessExit(processID)) + if case .success = await blockedWrite.result { + Issue.record("An incomplete protocol write must fail.") + } + } catch { + await transport.close() + _ = await blockedWrite.result + throw error } } diff --git a/Tests/CodexAdapterTests/ManagedLineProcessLifecycleTests.swift b/Tests/CodexAdapterTests/ManagedLineProcessLifecycleTests.swift index b3849d7..a683abc 100644 --- a/Tests/CodexAdapterTests/ManagedLineProcessLifecycleTests.swift +++ b/Tests/CodexAdapterTests/ManagedLineProcessLifecycleTests.swift @@ -8,59 +8,144 @@ import Testing struct ManagedLineProcessLifecycleTests { @Test func overlappingLargeFramesArriveIntactInFIFOOrder() async throws { - try await withGatedInputProcess { process, gate in + try await withGatedInputProcess { process, gate, probe in var lines = process.inboundLines.makeAsyncIterator() let capacityLine = try #require(try await lines.next()) let pipeCapacity = try #require(Int(capacityLine)) - let firstLine = "{\"id\":1,\"payload\":\"" + String(repeating: "a", count: 1_048_576) + "\"}" - let secondLine = "{\"id\":2,\"payload\":\"" + String(repeating: "b", count: 1_048_576) + "\"}" - try #require(firstLine.utf8.count > pipeCapacity && secondLine.utf8.count > pipeCapacity) - - let first = Task { try await process.sendLine(firstLine) } - try #require(try await lines.next() == "blocked") - try await waitForPendingWrites(1, in: process) - let second = Task { try await process.sendLine(secondLine) } + let frames = [ + "{\"id\":1,\"payload\":\"" + String(repeating: "a", count: 1_048_576) + "\"}", + "{\"id\":2,\"payload\":\"" + String(repeating: "b", count: 1_048_576) + "\"}", + String(repeating: "λ", count: 350_000), + ] + try #require(frames.allSatisfy { $0.utf8.count > pipeCapacity }) + + let first = Task { try await process.sendLine(frames[0]) } + try await requireBackpressure(in: probe, reading: &lines) + let second = Task { try await process.sendLine(frames[1]) } try await waitForPendingWrites(2, in: process) + let third = Task { try await process.sendLine(frames[2]) } + try await waitForPendingWrites(3, in: process) - try gate.write(contentsOf: Data([1])) + try gate.write(contentsOf: Data("R".utf8)) try await first.value try await second.value - #expect(try await lines.next() == firstLine) - #expect(try await lines.next() == secondLine) + try await third.value + for frame in frames { #expect(try await lines.next() == frame) } let running = await process.snapshot() #expect(running.state == .running && !running.hasExited) #expect(running.pendingWrites == 0) #expect(kill(try #require(running.processID), 0) == 0) + #expect(probe.events.filter { $0 == .writeStarted }.count == frames.count) } } @Test - func queuedWriteFailsClosedWhenProcessClosesDuringBlockedWrite() async throws { - try await withGatedInputProcess { process, _ in + func cancelledQueuedWriterLeavesBeforeTheActiveWriteUnblocks() async throws { + try await withGatedInputProcess { process, gate, probe in var lines = process.inboundLines.makeAsyncIterator() _ = try #require(try await lines.next()) - let first = Task { try await process.sendLine(String(repeating: "x", count: 1_048_576)) } - try #require(try await lines.next() == "blocked") - try await waitForPendingWrites(1, in: process) - let queued = Task { try await process.sendLine("queued frame") } + let frame = String(repeating: "a", count: 1_048_576) + let first = Task { try await process.sendLine(frame) } + try await requireBackpressure(in: probe, reading: &lines) + let cancelled = Task { + defer { probe.completeCaller(1) } + try await process.sendLine("cancelled frame") + } try await waitForPendingWrites(2, in: process) + let next = Task { try await process.sendLine("next frame") } + try await waitForPendingWrites(3, in: process) - let started = ContinuousClock.now - await process.close() - #expect(started.duration(to: .now) < .seconds(3)) - if case .success = await first.result { - Issue.record("A cancelled, incomplete protocol write must fail.") + cancelled.cancel() + try await waitFor("queued cancellation before opening the read gate") { probe.didComplete(1) } + await #expect(throws: CancellationError.self) { try await cancelled.value } + try await waitForPendingWrites(2, in: process) + #expect(probe.events == [.writeStarted]) + + try gate.write(contentsOf: Data("R".utf8)) + try await first.value + try await next.value + #expect(try await lines.next() == frame) + #expect(try await lines.next() == "next frame") + #expect(await process.snapshot().pendingWrites == 0) + } + } + + @Test + func cancellingTheActiveCallerPreservesItsFrameAndTheInput() async throws { + try await withGatedInputProcess { process, gate, probe in + var lines = process.inboundLines.makeAsyncIterator() + _ = try #require(try await lines.next()) + let frame = String(repeating: "a", count: 1_048_576) + let first = Task { + defer { probe.completeCaller(1) } + try await process.sendLine(frame) } - do { - try await queued.value - Issue.record("A queued frame must fail when the transport closes.") - } catch ManagedLineProcessError.closed { - } catch { - Issue.record("Expected the closed transport error, received \(error).") + try await requireBackpressure(in: probe, reading: &lines) + first.cancel() + let next = Task { try await process.sendLine("next frame") } + try await waitForPendingWrites(2, in: process) + #expect(!probe.didComplete(1)) + #expect(probe.events == [.writeStarted]) + + try gate.write(contentsOf: Data("R".utf8)) + try await first.value + try await next.value + #expect(try await lines.next() == frame) + #expect(try await lines.next() == "next frame") + #expect(probe.events.contains(.writeSettled(bytesWritten: frame.utf8.count + 1))) + #expect(await process.snapshot().state == .running) + #expect(await process.snapshot().pendingWrites == 0) + } + } + + @Test + func alreadyCancelledCallerNeverWrites() async throws { + try await withGatedInputProcess { process, gate, probe in + var lines = process.inboundLines.makeAsyncIterator() + _ = try #require(try await lines.next()) + let cancelled = Task { + withUnsafeCurrentTask { $0?.cancel() } + try await process.sendLine("cancelled frame") } - let stopped = await process.snapshot() - #expect(stopped.hasExited && stopped.cleanupConfirmed == true) - #expect(stopped.pendingWrites == 0) + await #expect(throws: CancellationError.self) { try await cancelled.value } + #expect(probe.events.isEmpty) + #expect(await process.snapshot().pendingWrites == 0) + + let frame = String(repeating: "a", count: 1_048_576) + let next = Task { try await process.sendLine(frame) } + try await requireBackpressure(in: probe, reading: &lines) + try gate.write(contentsOf: Data("R".utf8)) + try await next.value + #expect(try await lines.next() == frame) + } + } + + @Test + func processExitDuringWriteRetiresInputBeforeTheQueuedWrite() async throws { + try await withGatedInputProcess(retainInputAfterExit: true) { process, gate, probe in + var lines = process.inboundLines.makeAsyncIterator() + _ = try #require(try await lines.next()) + let frame = String(repeating: "x", count: 1_048_576) + let first = Task { try await process.sendLine(frame) } + try await requireBackpressure(in: probe, reading: &lines) + let queued = Task { try await process.sendLine("queued frame") } + try await waitForPendingWrites(2, in: process) + + try gate.write(contentsOf: Data("X".utf8)) + await expectClosed(first) + await expectClosed(queued) + let count = try #require( + probe.events.compactMap { event -> Int? in + if case .writeSettled(let count) = event { return count } + return nil + }.first) + #expect(count > 0 && count < frame.utf8.count + 1) + #expect(probe.events.filter { $0 == .writeStarted }.count == 1) + await expectClosed(Task { try await process.sendLine("later frame") }) + await process.close() + #expect(await process.snapshot().pendingWrites == 0) + #expect(await process.snapshot().cleanupConfirmed == true) + try requireFinishAfterWrite(in: probe) } } @@ -94,43 +179,49 @@ struct ManagedLineProcessLifecycleTests { } @Test - func closeCancelsBlockedWriteAndReapsTheEntireGroup() async throws { - let process = try ManagedLineProcess( - configuration: .init( - executable: "/bin/sh", - arguments: [ - "-c", - #"trap '' TERM; /bin/sh -c 'trap "" TERM; /bin/sleep 30' & printf '%s\n' "$!"; wait"#, - ], - workingDirectory: FileManager.default.temporaryDirectory, - terminationGraceMilliseconds: 100, maximumMessageBytes: 2_097_152)) - do { + func closeSignalsBeforeDrainingAndFinishesAfterTheWriteSettles() async throws { + try await withGatedInputProcess(ignoreTermination: true) { process, _, probe in var lines = process.inboundLines.makeAsyncIterator() - let ready = try #require(try await lines.next()) - let child = try #require(Int32(ready)) + _ = try #require(try await lines.next()) + let childLine = try #require(try await lines.next()) + let child = try #require(Int32(childLine)) let writing = Task { try await process.sendLine(String(repeating: "x", count: 1_048_576)) } - let deadline = ContinuousClock.now + .seconds(2) - while await process.snapshot().pendingWrites == 0, ContinuousClock.now < deadline { - try await Task.sleep(for: .milliseconds(10)) + try await requireBackpressure(in: probe, reading: &lines) + let queued = Task { + defer { probe.completeCaller(1) } + try await process.sendLine("queued frame") } - #expect(await process.snapshot().pendingWrites == 1) + try await waitForPendingWrites(2, in: process) + let started = ContinuousClock.now - async let first: Void = process.close() - async let second: Void = process.close() - _ = await (first, second) - #expect(started.duration(to: .now) < .seconds(3)) - if case .success = await writing.result { - Issue.record("A cancelled, incomplete protocol write must fail.") + let first = Task { await process.close() } + let second = Task { await process.close() } + try await waitFor("queued write rejected during termination") { probe.didComplete(1) } + await expectClosed(queued) + try await waitFor("TERM delivered during the blocked write") { + probe.events.contains(.terminationSignalled(SIGTERM)) } + #expect(started.duration(to: .now) < .seconds(3)) + await first.value + await second.value + #expect(started.duration(to: .now) < .seconds(3)) + if case .success = await writing.result { Issue.record("An incomplete frame must fail.") } + let events = probe.events + let term = try #require(events.firstIndex(of: .terminationSignalled(SIGTERM))) + let settled = try #require( + events.firstIndex { + if case .writeSettled = $0 { return true } + return false + }) + #expect(term < settled) + try requireFinishAfterWrite(in: probe) + #expect(events.filter { $0 == .writeStarted }.count == 1) let result = await process.snapshot() #expect(result.hasExited && result.terminationEscalated) #expect(result.pendingWrites == 0 && result.cleanupConfirmed == true) #expect(kill(child, 0) == -1 && errno == ESRCH) #expect(kill(try #require(result.processID), 0) == -1 && errno == ESRCH) #expect(kill(try #require(result.supervisorProcessID), 0) == -1 && errno == ESRCH) - } catch { - await process.close() - throw error } } @@ -237,35 +328,98 @@ struct ManagedLineProcessLifecycleTests { #expect(try (output.fileHandleForReading.readToEnd() ?? Data()).isEmpty) } - private func waitForPendingWrites(_ count: Int, in process: ManagedLineProcess) async throws { + private func waitFor(_ reason: String, until condition: () async -> Bool) async throws { let deadline = ContinuousClock.now + .seconds(10) - while await process.snapshot().pendingWrites != count { - try #require(ContinuousClock.now < deadline, "Expected \(count) pending writes.") - try await Task.sleep(for: .milliseconds(10)) + while !(await condition()) { + try #require(ContinuousClock.now < deadline, "Timed out waiting for \(reason).") + try Task.checkCancellation() + await Task.yield() + } + } + + private func waitForPendingWrites(_ count: Int, in process: ManagedLineProcess) async throws { + try await waitFor("\(count) pending writes") { await process.snapshot().pendingWrites == count } + } + + private func requireBackpressure( + in probe: InputProbe, reading lines: inout AsyncThrowingStream.Iterator + ) async throws { + // The child reports a full pipe without reading from it. The owner separately + // reports entry into write after validation; queue depth alone proves neither. + try #require(try await lines.next() == "blocked") + try await waitFor("admitted write") { probe.events.contains(.writeStarted) } + try #require(probe.events == [.writeStarted]) + } + + private func requireFinishAfterWrite(in probe: InputProbe) throws { + let events = probe.events + let settled = try #require( + events.firstIndex { + if case .writeSettled = $0 { return true } + return false + }) + let finish = try #require(events.firstIndex(of: .finishStarted)) + #expect(settled < finish) + } + + private func expectClosed(_ task: Task) async { + do { + try await task.value + Issue.record("Expected the closed transport error.") + } catch ManagedLineProcessError.closed { + } catch { + Issue.record("Expected the closed transport error, received \(error).") } } private func withGatedInputProcess( - _ body: (ManagedLineProcess, FileHandle) async throws -> Void + ignoreTermination: Bool = false, + retainInputAfterExit: Bool = false, + _ body: (ManagedLineProcess, FileHandle, InputProbe) async throws -> Void ) async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: directory) } let path = directory.appendingPathComponent("read-gate").path + let holderPath = directory.appendingPathComponent("holder.pid") try #require(mkfifo(path, 0o600) == 0) let descriptor = open(path, O_RDWR | O_NONBLOCK) try #require(descriptor >= 0) let gate = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) defer { try? gate.close() } + let probe = InputProbe() let process = try ManagedLineProcess( configuration: .init( executable: "/usr/bin/python3", arguments: [ "-c", """ - import array, fcntl, os, select, sys, termios + import array, fcntl, os, select, signal, subprocess, sys, termios + if sys.argv[2] == "True": + signal.signal(signal.SIGTERM, signal.SIG_IGN) + child = subprocess.Popen(["/bin/sleep", "30"]) + if sys.argv[3] == "True": + ready_read, ready_write = os.pipe() + holder = os.fork() + if holder == 0: + os.close(ready_read) + os.setsid() + os.close(1) + os.close(2) + os.write(ready_write, b"ready") + os.close(ready_write) + # Keep stdin open beyond supervisor exit, without consuming it. + select.select([], [], []) + os._exit(0) + os.close(ready_write) + os.read(ready_read, 5) + os.close(ready_read) + with open(sys.argv[4], "w") as pid_file: + pid_file.write(str(holder)) with open(sys.argv[1], "rb", buffering=0) as gate: print(os.fstat(0).st_blksize, flush=True) + if sys.argv[2] == "True": + print(child.pid, flush=True) select.select([0], [], []) while True: available = array.array("i", [0]) @@ -273,22 +427,56 @@ struct ManagedLineProcessLifecycleTests { if available[0] >= os.fstat(0).st_blksize: break print("blocked", flush=True) - gate.read(1) + command = gate.read(1) + if command == b"X": + os._exit(0) for line in sys.stdin.buffer: sys.stdout.buffer.write(line) sys.stdout.buffer.flush() """, - path, + path, ignoreTermination ? "True" : "False", retainInputAfterExit ? "True" : "False", + holderPath.path, ], workingDirectory: directory, - terminationGraceMilliseconds: 100, maximumMessageBytes: 2_097_152)) + terminationGraceMilliseconds: 100, maximumMessageBytes: 2_097_152), + inputObserver: { probe.record($0) }) do { - try await body(process, gate) + try await body(process, gate, probe) await process.close() + try await stopInputHolder(at: holderPath) #expect(await process.snapshot().cleanupConfirmed == true) } catch { await process.close() + try await stopInputHolder(at: holderPath) throw error } } + + private func stopInputHolder(at path: URL) async throws { + guard let text = try? String(contentsOf: path, encoding: .utf8), let pid = Int32(text) else { + return + } + try FileManager.default.removeItem(at: path) + #expect(kill(pid, SIGKILL) == 0) + try await waitFor("input holder exit") { kill(pid, 0) == -1 && errno == ESRCH } + } +} + +/// The lock protects observations made by the input owner and caller tasks. +private final class InputProbe: @unchecked Sendable { + private let lock = NSLock() + private var recordedEvents: [ManagedLineProcess.InputEvent] = [] + private var completedCallers: Set = [] + + var events: [ManagedLineProcess.InputEvent] { lock.withLock { recordedEvents } } + + func record(_ event: ManagedLineProcess.InputEvent) { + lock.withLock { recordedEvents.append(event) } + } + + func completeCaller(_ id: Int) { + _ = lock.withLock { completedCallers.insert(id) } + } + + func didComplete(_ id: Int) -> Bool { lock.withLock { completedCallers.contains(id) } } } From f8d3412c1c8362facf89821d4be01a3eb1996880 Mon Sep 17 00:00:00 2001 From: showxu <10173746+showxu@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:40:54 +0800 Subject: [PATCH 5/5] docs: move release facts to the release reference --- AGENTS.md | 3 +- Documentation/Architecture/Documentation.md | 8 +- Documentation/Architecture/Package.md | 28 +-- Documentation/Architecture/README.md | 4 +- .../Architecture/VersioningAndRelease.md | 105 --------- Documentation/README.md | 8 +- Documentation/Reference/Release.md | 218 ++++++++++++++---- Documentation/Reference/Workflows.md | 22 +- VERSIONING.md | 19 ++ 9 files changed, 225 insertions(+), 190 deletions(-) delete mode 100644 Documentation/Architecture/VersioningAndRelease.md create mode 100644 VERSIONING.md diff --git a/AGENTS.md b/AGENTS.md index 6430c9f..9c2451d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,8 @@ Use this check: ## Task Route - Before changing versions, dependencies, packaging or release workflows, read - `Documentation/Architecture/VersioningAndRelease.md` and use its existing project check entry points. + `VERSIONING.md` for version rules and `Documentation/Reference/Release.md` + for release facts and project check entry points. - For repository-native documentation placement, read `Documentation/README.md` before editing. diff --git a/Documentation/Architecture/Documentation.md b/Documentation/Architecture/Documentation.md index 640d275..975c9a0 100644 --- a/Documentation/Architecture/Documentation.md +++ b/Documentation/Architecture/Documentation.md @@ -6,9 +6,11 @@ CONTRIBUTING.md describes contributor checks. Current package facts belong in Documentation/Architecture/Package.md; commands, formats and operating detail belong in Documentation/Reference/. -Version and release rules belong in -Documentation/Architecture/VersioningAndRelease.md, and publication steps in -Documentation/Reference/Release.md. Durable transitions or accepted decisions +Version rules belong in the root +[VERSIONING.md](https://github.com/computer-mcp/plugin-codex/blob/master/VERSIONING.md), +which adopts the organization's rules and states repository differences. +Release facts and procedures belong in +[Release](../Reference/Release.md). Durable transitions or accepted decisions may have separate history records when needed. GitHub-specific configuration belongs in .github/. diff --git a/Documentation/Architecture/Package.md b/Documentation/Architecture/Package.md index 47c58da..674fadb 100644 --- a/Documentation/Architecture/Package.md +++ b/Documentation/Architecture/Package.md @@ -8,20 +8,16 @@ dependency. ## Dependencies -| Package | Baseline and requirement | Responsibility | -| --- | --- | --- | -| `computer-mcp/swift-sdk` transport fork | `exact: "0.13.1-computer-mcp.1"` | Standard northbound MCP transport, tools and results; stdio and complete POSIX frame writes | -| swift-codex | `exact: "0.4.1"` | App Server client and Exec client, each with its own lifecycle | -| swift-subprocess | `.upToNextMinor(from: "1.0.2")` | Child process launch and line I/O | -| swift-argument-parser | `from: "1.8.2"` | Named options, schema-comparison subcommand, validation and generated CLI help | -| GRDB | `from: "7.11.1"` | Codex approval, runtime/thread ownership, acceptance run, worktree lease and managed-worktree storage and transactions | - -These kinds follow the organization's -[VERSIONING.md](https://github.com/computer-mcp/.github/blob/master/VERSIONING.md#swift-dependencies). -Organization forks and dependencies below 1.0 use exact requirements. Process, -transport and security dependencies at 1.0 or later use the next minor bound; -other dependencies at 1.0 or later use the next major bound. Floors are the -resolved baseline; `Package.resolved` is committed and CI rejects drift. +| Package | Responsibility | +| --- | --- | +| `computer-mcp/swift-sdk` transport fork | Standard northbound MCP transport, tools and results; stdio and complete POSIX frame writes | +| swift-codex | App Server client and Exec client, each with its own lifecycle | +| swift-subprocess | Child process launch and line I/O | +| swift-argument-parser | Named options, schema-comparison subcommand, validation and generated CLI help | +| GRDB | Codex approval, runtime/thread ownership, acceptance run, worktree lease and managed-worktree storage and transactions | + +The [release reference](../Reference/Release.md#dependencies-and-verified-combinations) +records dependency requirements, selected revisions and compatibility checks. swift-crypto and swift-nio are transitive dependencies, owned by their declaring packages. @@ -53,9 +49,7 @@ lifetime rules: Shutdown preserves EOF delivery and frame/output limits. -`Scripts/verify-swift-codex-release-gate.sh` verifies that the manifest's exact -SDK version, resolved revision and public Git tag agree. CI requires this check -before packaging. Its dependency notices ship with the +Dependency notices ship with the adapter artifact, independently of host dependencies. Argument Parser keeps command structure and help in one declaration. Parsing diff --git a/Documentation/Architecture/README.md b/Documentation/Architecture/README.md index 51228e9..cb5879c 100644 --- a/Documentation/Architecture/README.md +++ b/Documentation/Architecture/README.md @@ -4,5 +4,5 @@ boundaries. [Documentation](Documentation.md) describes repository documentation roles and the API documentation scope. -[Versioning and Release](VersioningAndRelease.md) owns component version sources, -compatibility decisions, candidate evidence and release identity. +The [release reference](../Reference/Release.md) records component version +sources, compatibility, candidate evidence and publication mechanics. diff --git a/Documentation/Architecture/VersioningAndRelease.md b/Documentation/Architecture/VersioningAndRelease.md deleted file mode 100644 index 6a017ac..0000000 --- a/Documentation/Architecture/VersioningAndRelease.md +++ /dev/null @@ -1,105 +0,0 @@ -# Versioning and Release - -The organization's -[VERSIONING.md](https://github.com/computer-mcp/.github/blob/master/VERSIONING.md) -owns shared SemVer, plugin API, dependency requirements, release contents and -recovery rules. This repository owns native acceptance and its documented -CI artifact promotion exception. - -The root `computer-mcp-plugin.toml` declares `plugin_api = 1` and owns the adapter's version. -`Scripts/version.py generate` derives `BuildInfo`; `check` is read-only and -rejects drift. `update --kind … --reason …` changes the manifest and -refreshes the constant. `check --base COMMIT` rejects version regression when -HEAD has parents and the base is an available ancestor. Otherwise it checks the -current version only and reports why the comparison was skipped. -`check --tag vVERSION` verifies both the manifest version and the tag's commit. -CI checks the pull request base or previous `master` commit without changing files. -An actual packaged executable's `--version` must match -the byte-identical packaged manifest. Never infer a candidate version from an -installed adapter or another worktree. - -Compatible fixes increment the patch. While the adapter is `0.x`, new public -capabilities and incompatible changes increment the minor; describe breaking -changes and migration in the release notes. Version arithmetic does not prove -compatibility: review and behavior tests support each declared bump. Documents, -CI and cleanup alone do not require a plugin release. - -`Package.swift` declares the SDK compatibility requirement; `Package.resolved` -records the selected commit. The SDK's public tag owns its release identity. -`Scripts/verify-swift-codex-release-gate.sh` verifies this relationship instead -of introducing another SDK version declaration. The protocol import provenance -records the schema baseline. Integration receipts separately record the real -Codex executable version; these versions need not be equal. - -Direct dependency requirements follow the organization kinds: forks and versions -below 1.0 use `exact:`, process/transport/security boundaries at 1.0 or later use -`.upToNextMinor(from:)`, and other dependencies at 1.0 or later use `from:`. -The adopted baselines are MCP Swift SDK fork `0.13.1-computer-mcp.1` and -swift-codex `0.4.1` (exact), swift-subprocess `1.0.2` (next minor), and -swift-argument-parser `1.8.2` and GRDB `7.11.1` (next major). The committed lock -records the selected revisions; CI resolves and rejects lockfile drift. -[Package](Package.md#dependencies) owns each dependency's responsibility. - -`Scripts/package.py --output …` checks the declared/generated versions and lock, -builds the archive, validates its executable and emits a file/digest receipt. -The CI workflow (`ci.yml`) runs the same source checks and publishes an -immutable CI candidate artifact. Check the relocated candidate with a standard -MCP client and `Scripts/check-workflow.py --adapter … --codex …`. The latter -uses an isolated home and fixed loopback model for native approvals, turns, -cancellation, reconnection and owned-process cleanup. It does not prove real -model authentication. Host integration uses this repository's `Scripts/check-host.py` against the -installed Computer MCP CLI, relocated adapter bytes and installed vendor. Full -acceptance includes authenticated model execution; fixture-only evidence cannot -replace it. [Release](../Reference/Release.md#runtime-acceptance) owns the commands -and prerequisites. - -Accept the complete host/plugin/SDK combination before delivery. Codex's native -builds cannot be reproduced byte for byte, so `release.yml` is the organization's -documented exception to its reusable release workflow. It promotes the accepted -CI archives and native build receipts without rebuilding. A dry run checks source, -latest successful CI attempt, artifact digests, manifest, `plugin_api`, archive -names and native inventories, and assembles the same content without tag checks, -Release writes or catalog secrets. Publication additionally requires a signed -annotated tag verified by GitHub at the accepted commit and matching the manifest -version. Repository release immutability must be verified enabled. - -The schema 1 `release-receipt.json` follows the organization contract with -`reproduction: ci-artifact-promotion`, every source CI artifact, and each archive's -`native_receipt` name, size and SHA-256. Native `.receipt.json` -files remain Release assets. Deterministic `SHA256SUMS` covers the archives, -native receipts and unified receipt. The receipt has no timestamps or local paths. - -Verification has read-only contents/actions access and no secrets. Publication -alone has contents write permission and executes no builds. It checks the exact -verified bundle artifact ID, helper digest and asset digests, creates or resumes -a commit-bound draft, uploads the receipt first, then the archives and native -receipts, then checksums. It publishes only after every expected asset's API -size and digest matches and requires an immutable public Release. Only a separate -dependent catalog job receives the catalog secret. - -A complete draft receipt freezes the original CI run, historical attempt, -artifact IDs/digests and release workflow ref/commit. Recovery refetches those -artifacts and reproduces the same receipt and checksums even after CI reruns. -Expected incomplete `starter` assets may be replaced only in a draft; completed -conflicts and unexpected names fail. If frozen artifacts expire, delete the -unpublished draft, obtain fresh successful CI artifacts and dispatch again. -A public Release is verified against its complete frozen asset set without -writes or reliance on CI retention. Notification failures need only the notifier -rerun. Candidate retries keep the intended version and use a new CI identity -only before provenance freezes or after deleting the unpublished draft. -`CHANGELOG.md` records each public release. See -[Installation](../Reference/Installation.md) for packaging, installation, -upgrade and rollback commands, and [Release](../Reference/Release.md) for -publication commands and recovery. - -Reusable successful evidence must match source, dependency lock, check definition, -toolchain, target configuration and artifact digest. Missing or changed inputs -require the affected checks again. A source test is not installed-package proof. -Preserve failed attempts and distinguish fixture, product and external failures. -Regular checks and result judgment are scripted; a model does not approve release. - -Temporary fixture directories and package staging belong to their creating run. -Delete only inactive, reconciled outputs; preserve unique content, necessary -failure/delivery evidence and the previous rollback package. Runtime databases, -credentials, existing user App Servers and unrelated source changes remain owned -by the user. Private run paths and progress stay outside normative documentation. diff --git a/Documentation/README.md b/Documentation/README.md index 8c4e7ce..75854e9 100644 --- a/Documentation/README.md +++ b/Documentation/README.md @@ -4,14 +4,14 @@ documentation roles. - [Package architecture](Architecture/Package.md): targets, dependencies, connection ownership, process teardown, and executable support boundaries. -- [Versioning and release](Architecture/VersioningAndRelease.md): version - authority, compatibility, candidate evidence and immutable delivery. +- [Version rules](https://github.com/computer-mcp/plugin-codex/blob/master/VERSIONING.md): + adopted organization rules and repository differences. - [Workflows](Reference/Workflows.md): App Server and Exec operations, approvals, events, persistence, shutdown and handoff. - [Host integration](Reference/HostIntegration.md): scoped callbacks, host authorization, managed registrations and failure recovery. - [Installation](Reference/Installation.md): relocatable artifacts, host settings, update/rollback/removal, and distribution boundaries. -- [Release](Reference/Release.md): runtime acceptance, publishing a release and notifying the - plugin catalog. +- [Release](Reference/Release.md): version sources, dependency compatibility, + candidate acceptance, publication, recovery and plugin catalog notification. The [root README](../README.md) is the public setup and command manual. diff --git a/Documentation/Reference/Release.md b/Documentation/Reference/Release.md index b5457a6..266c50a 100644 --- a/Documentation/Reference/Release.md +++ b/Documentation/Reference/Release.md @@ -1,19 +1,101 @@ # Release -This guide covers publishing an accepted release and notifying the official -plugin catalog. [Versioning and Release](../Architecture/VersioningAndRelease.md) -owns the version, acceptance and immutability rules. The organization's -[VERSIONING.md](https://github.com/computer-mcp/.github/blob/master/VERSIONING.md) -owns shared plugin API, release content and recovery requirements. - -Codex is the documented exception to the reusable organization release workflow: -its native builds are not byte reproducible. Its own `release.yml` promotes the -accepted CI artifacts and uses `reproduction: ci-artifact-promotion` under the -same release content and publication contract. Enable GitHub release immutability -in the repository settings before running publication or its dry run; the -workflow refuses when that setting is disabled or cannot be verified. - -## Runtime acceptance +Release status: publication and catalog notification are implemented by +[release.yml](https://github.com/computer-mcp/plugin-codex/blob/master/.github/workflows/release.yml) +and [notify-catalog.yml](https://github.com/computer-mcp/plugin-codex/blob/master/.github/workflows/notify-catalog.yml). +Candidate runtime acceptance and Release notes require an operator. + +Version rules are in this repository's +[VERSIONING.md](https://github.com/computer-mcp/plugin-codex/blob/master/VERSIONING.md), +which adopts the organization's rules and records the native artifact promotion +exception. This reference records release facts, procedures and checks. + +## Components and Version Sources + +| Shipped component | Version meaning | Version source | Mirrors and derived fields | Update and read-only check | +| --- | --- | --- | --- | --- | +| Codex plugin and `codex-mcp-adapter` | Package release identity | Top-level `version` in [computer-mcp-plugin.toml](../../computer-mcp-plugin.toml) | `CodexAdapterBuildInfo.version`, executable `--version`, packaged manifest | [Metadata entry points](#derived-metadata-and-drift-checks) | + +The packaged manifest preserves the source manifest's exact bytes. Candidate +identity comes from that manifest and the extracted executable, rather than an +installed adapter or a different worktree. + +## Compatibility Surface + +Consumers depend on the MCP tool names, input schemas, results and errors; stdio +transport; CLI commands, options, output and exit codes; configuration fields; +adapter state and protocol resource formats; and the package's platform and +Swift toolchain floors. The [manual](../../README.md), +[workflows](Workflows.md), [host integration](HostIntegration.md), +[installation](Installation.md) and [package architecture](../Architecture/Package.md) +describe those interfaces. `Package.swift` declares the deployment and Swift +tools floors. + +The manifest declares `plugin_api = 1` and owns host-version bounds, archive +names, platforms and architectures. The SDK's schema declarations and adoption +inventory are separate from executable support and host authorization. +Compatibility review and behavior tests establish what a candidate supports; +version arithmetic alone does not establish compatibility. + +## Dependencies and Verified Combinations + +`Package.swift` declares dependency compatibility requirements; +`Package.resolved` records the selected versions and revisions. Current direct +requirements are: + +| Dependency | Requirement | +| --- | --- | +| MCP Swift SDK organization fork (`computer-mcp/swift-sdk`) | `exact: "0.13.1-computer-mcp.1"` | +| swift-codex | `exact: "0.4.1"` | +| swift-subprocess | `.upToNextMinor(from: "1.0.2")` | +| swift-argument-parser | `from: "1.8.2"` | +| GRDB | `from: "7.11.1"` | + +The [package dependency overview](../Architecture/Package.md#dependencies) +records their architectural responsibilities. CI resolves the committed lock +and rejects drift. `Scripts/verify-swift-codex-release-gate.sh` checks the +declared exact swift-codex version and resolved revision against its public Git +tag, accepting the tag's peeled commit when it is annotated. The SDK's public +tag owns its release identity. + +Protocol import provenance records the vendor schema baseline from the locked +SDK. Integration receipts separately record the real Codex executable version; +these versions need not match. Accept the complete host, plugin, SDK and vendor +combination before delivery. The runtime report identifies the tested binaries +and results; a source test or schema inventory does not establish that +combination's runtime acceptance. + +## Derived Metadata and Drift Checks + +`python3 Scripts/version.py update --kind --reason ` +updates the manifest and generated constant. After an explicit manifest edit, +`python3 Scripts/version.py generate` regenerates +`Sources/CodexAdapter/CodexAdapterBuildInfo.swift`. + +`python3 Scripts/version.py check` is read-only and rejects generated metadata +drift. Its `--binary ` option checks the actual executable's `--version`. +`check --base ` rejects version regression when HEAD has parents and the +base is an available ancestor; otherwise it checks the current version and +reports why comparison was skipped. CI supplies the pull request base or +previous `master` commit. `check --tag ` verifies the manifest version and +the tag's commit. + +`node Scripts/import-schema.mjs --check` verifies bundled protocol resources +against the locked SDK commit; [Protocol inputs](../../README.md#protocol-inputs) +owns regeneration instructions. Packaging checks the manifest, generated +version and dependency lock, validates the built executable's version and +architecture, and emits the archive's file/digest receipt. Archive verification +checks the relocated bytes before runtime acceptance. + +## Candidate, Acceptance and Publication + +CI runs source checks and `Scripts/package.py`, then verifies and relocates the +archive with `Scripts/check-package.py`. It uploads the ZIP and native build +receipt as the commit-named candidate artifact. `release.yml` promotes those +accepted CI bytes and receipts without rebuilding. Verification, publication +and catalog notification are separate jobs. + +### Runtime acceptance Use macOS with the selected installed `computer-mcp` CLI, a separately installed vendor Codex, Python 3.11 or newer, Git and the verified release archive. The @@ -36,6 +118,11 @@ python3 Scripts/check-host.py \ --mode full --auth-file /absolute/path/to/vendor/auth.json --model ``` +`check-workflow.py` exercises the relocated adapter through standard MCP with +an isolated home and fixed loopback model, covering native approvals, turns, +cancellation, reconnection and owned-process cleanup. It does not establish +real model authentication. + `check-host.py` uses only the host's public `serve stdio` command and standard MCP. It starts private host instances with explicit workspace selection, host configuration and databases. It does not contact the normal App control socket @@ -90,10 +177,10 @@ Pure driver logic and argument validation run without a host or vendor: python3 -m unittest discover -s Tests -p 'test_host_acceptance.py' -v ``` -## Publish +### Publish -1. Confirm the manifest's `version` has not been released and the reviewed - `master` commit has successful `push` CI from `.github/workflows/ci.yml`. +1. Select the reviewed `master` commit with successful `push` CI from + `.github/workflows/ci.yml`. Each declared archive selects the exact CI artifact `-` from that run's latest successful attempt. 2. Assemble and check the candidate without Release writes: @@ -104,14 +191,14 @@ python3 -m unittest discover -s Tests -p 'test_host_acceptance.py' -v ``` The dry run checks the source manifest, positive integer `plugin_api`, - declared archive set, CI attempt and artifact digests, native inventories - and byte-identical manifests. It omits tag and signature checks and reads - no catalog secret. Inspect the `assets/` directory in the - `plugin-release--` Actions artifact. + declared archive set, CI attempt and artifact digests, native inventories, + byte-identical manifests and repository release immutability. It omits tag + and signature checks and reads no catalog secret. Inspect the `assets/` + directory in the `plugin-release--` Actions artifact. 3. Accept those exact archive bytes through the runtime checks above and record - the results and limits in the draft Release notes. Create and push a signed - annotated `vX.Y.Z` tag directly on the accepted commit. An operator-created - draft must target its full commit SHA; otherwise publication creates a draft. + the results and limits in the draft Release notes. Prepare the release tag + under VERSIONING. An operator-created draft must target its full commit SHA; + otherwise publication creates a draft. 4. Publish with: ```sh @@ -119,29 +206,28 @@ python3 -m unittest discover -s Tests -p 'test_host_acceptance.py' -v -f source_run=RUN_ID -f release_tag=vX.Y.Z -F publish=true ``` - GitHub must verify the tag signature as valid; the tag's commit and manifest - version must match. The verification job has only `contents: read` and - `actions: read`, executes the release workflow's revision, and treats the - accepted source and CI files as data. Only the publishing job has - `contents: write`; it downloads the verified bundle by its exact artifact ID, - checks the helper and file digests, and runs no builds. + Verification checks GitHub's tag signature result, the tag's commit and the + manifest version, and refuses if repository release immutability is disabled + or cannot be verified. The verification job has only `contents: read` and + `actions: read`, receives no secrets, executes the release workflow's + revision, and treats the accepted source and CI files as data. Only the + publishing job has `contents: write`; it downloads the verified bundle by its + exact artifact ID, checks the helper and file digests, and runs no builds. The Release assets are exactly the manifest's `compatibility.artifacts` ZIPs, their `.receipt.json` native build receipts, `release-receipt.json` and `SHA256SUMS`. The native receipts remain inputs to `check-package.py`. -The unified schema 1 receipt contains the plugin ID, version, `plugin_api`, -compatibility, tag and commit; sorted archive names, sizes and SHA-256 digests; -each archive's `native_receipt` name, size and SHA-256; the CI run ID, attempt -and artifact IDs/digests; the release workflow ref and commit; and reproduction -`ci-artifact-promotion`. It uses deterministic sorted-key JSON with no timestamps. -`SHA256SUMS` covers every ZIP and receipt, sorted by filename. +`Scripts/plugin_release.py` assembles the schema 1 receipt and checksums under +the organization's +[release content contract](https://github.com/computer-mcp/.github/blob/master/VERSIONING.md#plugin-release-contents) +and [Codex promotion contract](https://github.com/computer-mcp/.github/blob/master/VERSIONING.md#codex-promotion-exception). Publication uploads the unified receipt first to freeze provenance, then the archives and native receipts, then checksums. Every asset's API size and SHA-256 must match before the draft becomes public. The publication response must report `immutable: true`. Release notes remain operator-owned. -## Catalog notification +### Catalog notification `notify-catalog.yml` runs when a release is published, edited, released, unpublished or deleted, and on manual dispatch. It asks the website to reconcile @@ -165,7 +251,7 @@ release-event workflows. `release.yml` calls `notify-catalog.yml` as a separate dependent job only after confirmed publication. Only that job receives `CATALOG_APP_PRIVATE_KEY`; dry runs never notify the catalog. -## Retry +### Retry Use **Re-run failed jobs** after a failed publication. A publish-only retry uses the original bundle artifact ID and digest outputs, including after a workflow @@ -180,9 +266,9 @@ Release is a draft. Matching completed assets are retained. Unexpected names, conflicting digests or sizes, or an invalid historical attempt fail without replacement. If a recorded artifact expired or disappeared, delete the unpublished draft, obtain fresh successful CI artifacts, and dispatch publication -again to start a new assembly. Published releases are never modified: a rerun -checks the exact asset set against the frozen receipt, requires `immutable: true`, -and reports it already published even after CI artifact retention expires. +again to start a new assembly. For an existing public Release, a rerun checks +the exact asset set against the frozen receipt, requires `immutable: true`, +and reports it already published without writes or reliance on CI retention. For a missed or failed catalog notification, rerun only the notifier or dispatch: @@ -199,3 +285,53 @@ hourly schedule also repairs missed notifications. The website's [catalog publication and notification contract](https://github.com/computer-mcp/computer-mcp.github.io/blob/master/docs/plugin-catalog.md) covers provenance, credentials, retry bounds and deployment. + +## Evidence Reuse and Invalidation + +Successful evidence is reusable when its source, dependency lock, check +definition, toolchain, environment, target configuration and artifact digest +match, with verified provenance. The release helper verifies CI run and attempt +identity and artifact digests; an operator reviews native receipts, runtime +reports and the accepted host combination. Missing or changed inputs require +the affected checks and their dependent stages again. + +Changes to compiled source, dependencies or packaging inputs require a new +archive, including changes to the shipped manual. Local rechecks after +checker-only changes can use unchanged archive bytes with the new checker +revision recorded separately. +Publication still uses source CI artifacts for the exact accepted commit and +the provenance frozen in the draft receipt. A new CI identity can be selected +before provenance freezes or after deleting an unpublished draft, as described +in [Retry](#retry). + +Source tests do not replace installed-package or authenticated runtime evidence. +Preserve failed results and distinguish fixture, product and external failures. +Routine checks and result judgments are scripted; release approval remains an +operator decision. + +## Entry Points and Artifact Retention + +| Operation | Existing command or manual procedure | Required access | +| --- | --- | --- | +| Version update and drift checks | [Metadata entry points](#derived-metadata-and-drift-checks) | Local source write for updates; read-only checks | +| Source validation | [Contributing checks](../../CONTRIBUTING.md#validation) | Local toolchain; dependency resolution and public SDK tag lookup | +| Release workflow validation | `python3 Scripts/check-workflow.py --check-release-workflow` and `python3 -m unittest discover -s Tests -p 'test_*.py'` | Local files; no host, vendor or credentials | +| Candidate build | `python3 Scripts/package.py --output dist --configuration release` | Local build tools and resolved dependencies; new output directory | +| Archive and runtime acceptance | [Runtime acceptance](#runtime-acceptance) | Verified archive, installed host and vendor; authorized file-store login for full mode | +| Dry run and publication | [Publish](#publish) | Actions dispatch; verified immutable-release setting; publication job has contents write access | +| Interrupted-run recovery and catalog status | [Retry](#retry) | Actions access; catalog authority isolated to the notifier | +| Install, upgrade and rollback | [Installation](Installation.md) | Host owner authority | +| Cleanup | Manual inventory and removal after confirming inactivity and cleanup receipts | Ownership of each disposable output | + +CI retains source candidate artifacts for 14 days. Keep the native build +receipts, runtime reports, host acceptance evidence and previous rollback +package needed for delivery. Frozen provenance recovery depends on the +recorded CI artifacts until publication; [Retry](#retry) covers unavailable +artifacts and verification of a complete public Release. + +Fixture directories and package staging belong to their creating run. Remove +inactive, reconciled temporary outputs from `dist/`, `.build/` and private +fixture directories; preserve unique source changes, required failure or +delivery evidence and rollback artifacts. Runtime databases, credentials, +existing user App Servers and unrelated changes remain user-owned. Keep private +run paths and progress outside normative documentation. diff --git a/Documentation/Reference/Workflows.md b/Documentation/Reference/Workflows.md index e6cc000..00a09b7 100644 --- a/Documentation/Reference/Workflows.md +++ b/Documentation/Reference/Workflows.md @@ -260,20 +260,8 @@ without claiming control over an external client. ## Release delivery -`release.yml` uses the documented Codex exception to the organization's -[VERSIONING.md](https://github.com/computer-mcp/.github/blob/master/VERSIONING.md): -native archives are promoted from accepted CI artifacts because their builds -are not byte reproducible. Read-only verification assembles the declared ZIPs, -native build receipts, unified schema 1 `release-receipt.json` and `SHA256SUMS`. -Only publication has contents write permission; it uploads the receipt first, -verifies all asset digests and publishes an immutable Release. Only the dependent -catalog notifier receives the catalog secret. `publish: false` checks and -assembles without tag/signature checks, Release writes or catalog credentials. - -A complete draft receipt freezes CI and workflow provenance across retries. -Recovery uses its artifact IDs and digests; only draft `starter` assets may be -replaced. Completed conflicts fail. If frozen artifacts expire, delete the -unpublished draft, obtain fresh successful CI artifacts and publish again. -Published releases are checked without modification, and notification failures -need only a notifier rerun. [Release](Release.md#publish) owns the commands, -content details and [recovery steps](Release.md#retry). +The [release reference](Release.md) owns candidate acceptance, publication, +catalog notification, receipts and recovery. This repository's +[VERSIONING.md](https://github.com/computer-mcp/plugin-codex/blob/master/VERSIONING.md) +adopts the organization rules and records the native artifact promotion +exception. diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 0000000..6c8ad8d --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,19 @@ +# Versioning + +This repository adopts the organization's +[VERSIONING.md](https://github.com/computer-mcp/.github/blob/master/VERSIONING.md) +with the following differences. + +## Native artifact promotion + +Codex's native archives cannot be reproduced byte for byte. This repository +uses its own release workflow to promote accepted CI archives and native build +receipts under the organization's Codex promotion contract, with +`reproduction: ci-artifact-promotion`, instead of the reusable rebuild workflow. + +## Release notes + +Breaking changes include migration guidance in the Release notes. + +The [release reference](Documentation/Reference/Release.md) owns this +repository's release facts, mechanics and checks.