diff --git a/plugins/play/skills/play/scripts/release/publish-play b/.github/release/publish-play similarity index 100% rename from plugins/play/skills/play/scripts/release/publish-play rename to .github/release/publish-play diff --git a/plugins/play/skills/play/scripts/release/publish_play.py b/.github/release/publish_play.py similarity index 93% rename from plugins/play/skills/play/scripts/release/publish_play.py rename to .github/release/publish_play.py index 8476a301..bdf254c0 100644 --- a/plugins/play/skills/play/scripts/release/publish_play.py +++ b/.github/release/publish_play.py @@ -31,6 +31,11 @@ VERSION_PATTERN = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+") REFERENCE_PATTERN = re.compile(r"v[0-9]+\.[0-9]+\.[0-9]+|[a-f0-9]{40}") SELECTOR_PATTERN = re.compile(rf"(?m)^release=({REFERENCE_PATTERN.pattern})$") +# The shared assets keep a `latest` placeholder that each deploy replaces with its reference. +SELECTOR_PLACEHOLDER = "latest" +SOURCE_SELECTOR_PATTERN = re.compile( + rf"(?m)^release=({SELECTOR_PLACEHOLDER}|{REFERENCE_PATTERN.pattern})$" +) class ReleaseError(RuntimeError): @@ -62,20 +67,24 @@ def release_tag(version_text: str) -> str: return f"v{version}" -def selector_release(selector: str) -> str: - matches = SELECTOR_PATTERN.findall(selector) +def selector_release(selector: str, *, pattern: re.Pattern[str] = SELECTOR_PATTERN) -> str: + matches = pattern.findall(selector) if len(matches) != 1: - raise ReleaseError("selector must contain exactly one release tag or commit assignment") + expected = ( + "release tag, commit, or latest" if pattern is SOURCE_SELECTOR_PATTERN + else "release tag or commit" + ) + raise ReleaseError(f"selector must contain exactly one {expected} assignment") return matches[0] def replace_selector(selector: str, expected: str) -> str: if not REFERENCE_PATTERN.fullmatch(expected): raise ReleaseError("selector reference must be a release tag or full commit SHA") - current = selector_release(selector) + current = selector_release(selector, pattern=SOURCE_SELECTOR_PATTERN) if current == expected: return selector - return SELECTOR_PATTERN.sub(f"release={expected}", selector, count=1) + return SOURCE_SELECTOR_PATTERN.sub(f"release={expected}", selector, count=1) def fetch_text(url: str, *, headers: dict[str, str] | None = None) -> str: diff --git a/scripts/release/tag_play.py b/.github/release/tag_play.py similarity index 74% rename from scripts/release/tag_play.py rename to .github/release/tag_play.py index 2f315862..c2a53593 100644 --- a/scripts/release/tag_play.py +++ b/.github/release/tag_play.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Sequence -from .publish_play import ReleaseError, git, release_tag +from publish_play import ReleaseError, git, release_tag ROOT = Path(__file__).resolve().parents[2] @@ -23,44 +23,57 @@ def remote_tag_commit(repository: Path, remote: str, tag_ref: str) -> str | None return refs.get(f"{tag_ref}^{{}}", refs.get(tag_ref)) +def is_ancestor(repository: Path, ancestor: str, descendant: str) -> bool: + try: + git(repository, "merge-base", "--is-ancestor", ancestor, descendant) + except ReleaseError: + return False + return True + + def tag_release( repository: Path, *, revision: str = "origin/main", dry_run: bool = False, ) -> dict[str, object]: git(repository, "fetch", "--no-tags", "origin", f"refs/heads/main:{MAIN_REF}") target = MAIN_REF if revision == "origin/main" else revision commit = git(repository, "rev-parse", "--verify", "--end-of-options", f"{target}^{{commit}}") - try: - git(repository, "merge-base", "--is-ancestor", commit, MAIN_REF) - except ReleaseError as error: - raise ReleaseError(f"release commit {commit} must already belong to origin/main") from error + if not is_ancestor(repository, commit, MAIN_REF): + raise ReleaseError(f"release commit {commit} must already belong to origin/main") version = git(repository, "show", f"{commit}:VERSION").strip() tag = release_tag(version) tag_ref = f"refs/tags/{tag}" - local_object = git(repository, "tag", "--list", "--format=%(objectname)", tag) - if local_object: - local_commit = git(repository, "rev-parse", "--verify", f"{local_object}^{{commit}}") - if local_commit != commit: - raise ReleaseError(f"local {tag} points to {local_commit}, expected {commit}; refusing to move it") + receipt: dict[str, object] = { + "version": version, + "tag": tag, + "commit": commit, + "remote": "origin", + "dry_run": dry_run, + } + local_object = git(repository, "tag", "--list", "--format=%(objectname)", tag) + local_commit = ( + git(repository, "rev-parse", "--verify", f"{local_object}^{{commit}}") if local_object else None + ) # Inspect the push destination, which may differ from origin's fetch URL. push_urls = git(repository, "remote", "get-url", "--push", "--all", "origin").splitlines() if len(push_urls) != 1: raise ReleaseError("origin must have exactly one push URL for release tagging") destination = push_urls[0] remote_commit = remote_tag_commit(repository, destination, tag_ref) - if remote_commit is not None and remote_commit != commit: - raise ReleaseError(f"remote {tag} points to {remote_commit}, expected {commit}; refusing to move it") - receipt: dict[str, object] = { - "status": "already_tagged" if remote_commit is not None else "ready", - "version": version, - "tag": tag, - "commit": commit, - "remote": "origin", - "dry_run": dry_run, - } - if dry_run or remote_commit is not None: + if remote_commit == commit: + return {**receipt, "status": "already_tagged"} + tagged_commit = remote_commit or local_commit + if tagged_commit is not None and tagged_commit != commit: + # A commit that kept VERSION unchanged is not a new release; the tag stays on its release. + if is_ancestor(repository, tagged_commit, commit): + return {**receipt, "status": "version_unchanged", "tagged_commit": tagged_commit} + where = "remote" if remote_commit else "local" + raise ReleaseError(f"{where} {tag} points to {tagged_commit}, expected {commit}; refusing to move it") + + receipt["status"] = "ready" + if dry_run: return receipt if not local_object: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..4c13cd1b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +# A newer push to the same PR or branch supersedes the running check. +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Check out the commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Set up Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: '24' + package-manager-cache: false + + - name: Set up uv + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + + - name: Install just and typos + uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22 + with: + tool: just@1.46.0,typos@1.45.1 + + - name: Install journey viewer dependencies + run: npm ci + + # `just test` runs `just check` first, then the unit tests. + - name: Run checks and tests + run: just test diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6703236c..15c3b204 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -59,7 +59,7 @@ jobs: CF_ACCESS_CLIENT_ID: ${{ secrets.CF_ACCESS_CLIENT_ID }} CF_ACCESS_CLIENT_SECRET: ${{ secrets.CF_ACCESS_CLIENT_SECRET }} run: | - scripts/release/publish-play publish --environment "$DEPLOYMENT_ENVIRONMENT" > deployment.json + .github/release/publish-play publish --environment "$DEPLOYMENT_ENVIRONMENT" > deployment.json cat deployment.json { echo '```json' diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml new file mode 100644 index 00000000..30c00f11 --- /dev/null +++ b/.github/workflows/tag-release.yml @@ -0,0 +1,44 @@ +name: Tag Play release +run-name: Tag Play release for ${{ github.sha }} + +on: + push: + branches: [main] + +permissions: + contents: read + +# Tag merges in push order so an earlier version bump is never tagged after a later one. +concurrency: + group: play-release-tag + cancel-in-progress: false + queue: max + +jobs: + tag: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: write + steps: + - name: Check out the merged commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + + # Pushes only the vX.Y.Z tag for a new VERSION; a merge that keeps VERSION is a no-op. + - name: Tag a new VERSION + env: + GIT_AUTHOR_NAME: github-actions[bot] + GIT_AUTHOR_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com + run: | + python3 .github/release/tag_play.py --commit "$GITHUB_SHA" > release-tag.json + cat release-tag.json + { + echo '```json' + cat release-tag.json + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/README.md b/README.md index 8e2a129d..76044774 100644 --- a/README.md +++ b/README.md @@ -775,11 +775,11 @@ explicitly disabled Codex Play skill remains a user choice: the report asks you ### Pin or inspect the installer -Pin both the script and downloaded archive to the same release: +Pin both the script and downloaded archive to the same release tag (replace `vX.Y.Z`): ```bash -curl -fsSL https://raw.githubusercontent.com/modiqo/play/v0.4.105/install.sh \ - | env PLAY_INSTALL_REF=v0.4.105 sh +curl -fsSL https://raw.githubusercontent.com/modiqo/play/vX.Y.Z/install.sh \ + | env PLAY_INSTALL_REF=vX.Y.Z sh ``` To inspect the small bootstrap before running it: diff --git a/docs/deployment.md b/docs/deployment.md index cacee3ab..de2333cd 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -8,23 +8,24 @@ The [Deploy Play workflow](../.github/workflows/deploy.yml) publishes the instal | Production | A repository admin selects **Actions → Deploy Play → Run workflow → Tags → vX.Y.Z** | `https://getrote.dev/playoffs/install.sh` | The dispatched `vX.Y.Z` tag, which must match its `VERSION` | Published changes must carry a new plugin version; a push that keeps the same version is not a -reliable cache invalidation mechanism. Prepare the version bump and package metadata changes on -a branch and merge them through a PR. After the PR is merged, create the release tag with: - -```bash -just release-tag-check # Preview the tag and commit without creating or pushing it. -just release-tag # Create and push the tag for freshly fetched origin/main. -# Or pin the merged commit explicitly: -just release-tag -``` - -The helper reads `VERSION` from the selected commit, derives `vX.Y.Z`, and requires that commit -to belong to `origin/main`. It works from any checkout branch and ignores uncommitted version -edits. It creates an annotated tag and pushes only that tag, without changing or pushing `main`. -An existing local or remote tag pointing elsewhere is rejected; a remote tag already pointing to -the selected commit returns `already_tagged`. A matching local tag can be pushed again after a -failed push. The helper never moves an existing tag or bumps versions itself. Tag publication -does not trigger production deployment; an admin still runs **Deploy Play** manually. +reliable cache invalidation mechanism. `VERSION` is the only version to edit: on a branch, set it +and run `just package`, which writes it into the plugin manifests, `plugins/play/package.json`, +`pyproject.toml`, and `uv.lock` and rebuilds the plugin payload. `just check` fails while any of +them disagrees with `VERSION`. Merge the bump through a PR. + +The [Tag Play release workflow](../.github/workflows/tag-release.yml) runs on every push to `main` +and tags releases itself. It reads `VERSION` from the pushed commit and derives `vX.Y.Z`: + +| Existing `vX.Y.Z` tag | Result | +| --- | --- | +| None | Creates an annotated tag on the pushed commit and pushes only that tag (`tagged`) | +| On the pushed commit | Nothing to do (`already_tagged`) | +| On an earlier `main` commit | `VERSION` did not change, so this push is not a release (`version_unchanged`) | +| On any other commit | Fails; the workflow never moves an existing tag | + +Each run records its JSON receipt in the workflow summary. The workflow never bumps versions or +pushes `main`. Tag publication does not trigger production deployment; an admin still runs +**Deploy Play** manually from the new tag. Production runs only from a release tag. A dispatch from a branch, including `main`, is skipped. The deploy checks out the tag's commit and fails unless the tag name matches that commit's @@ -35,14 +36,10 @@ only tags created after this rule was added can be deployed to production. Both environments read the shared installer assets, change only the Play selector in a temporary directory, and deploy to `getrote-dev` (`staging` for preview, `main` for production). They wait for the selected environment's installer to serve the expected revision and record a JSON receipt in -the workflow summary. No commit or push is made to the shared assets repository. - -Run the read-only production release gate at any time: - -```bash -just release-check -``` +the workflow summary. The selector's single `release=` line may hold the `latest` placeholder or a +pinned tag or commit; each deploy replaces it with the deployed tag or commit SHA. No commit or +push is made to the shared assets repository. -The gate prints a `ready` JSON receipt only when the public installer selects the current Play tag. -To check staging from a clean checkout of a merged commit, run -`scripts/release/publish-play check --environment staging`. +To confirm what an installer serves, run the read-only gate from a clean checkout of `main`: +`.github/release/publish-play check` for production, or add `--environment staging`. It prints a +`ready` JSON receipt only when the installer selects the checked-out release. diff --git a/justfile b/justfile index 135e2419..ed650b76 100644 --- a/justfile +++ b/justfile @@ -11,7 +11,7 @@ check: package-check uv run pyright scripts/lib/play/cli.py scripts/bin/play tests/foundation/test_cli.py uv run pyright scripts/lib/play/audit scripts/bin/play-audit scripts/bin/play-audit-corpus tests/foundation/test_audit.py uv run pyright scripts/lib/play/journey_effects.py - uv run pyright scripts/release/publish_play.py scripts/release/tag_play.py + uv run pyright .github/release/publish_play.py .github/release/tag_play.py uv run pyright scripts/lib/play/email_login.py scripts/lib/play/search_transport.py tests/foundation/test_email_login.py tests/awareness/test_search_transport.py scripts/bin/play-question choose_creator_path --harness codex --context-json '{"match":{"summary":"Existing Plays cover 1 of 2 outcomes: daily calendar: modiqo/check-calendar-meetings (full); weather in san francisco: modiqo/weather-updates-for-cities (partial, missing san, francisco).","play_choices":[{"id":"use:modiqo/check-calendar-meetings","reference":"modiqo/check-calendar-meetings","label":"Use modiqo/check-calendar-meetings","description":"full match for daily calendar.","parameters":{},"recommended":true,"sub_outcome":"daily calendar","classification":"full","uncovered_terms":[]}]}}' --check scripts/bin/play-question choose_creator_path --harness claude --context-json '{"match":{"summary":"Existing Plays cover 1 of 2 outcomes: daily calendar: modiqo/check-calendar-meetings (full); weather in san francisco: modiqo/weather-updates-for-cities (partial, missing san, francisco).","play_choices":[{"id":"use:modiqo/check-calendar-meetings","reference":"modiqo/check-calendar-meetings","label":"Use modiqo/check-calendar-meetings","description":"full match for daily calendar.","parameters":{},"recommended":true,"sub_outcome":"daily calendar","classification":"full","uncovered_terms":[]}]}}' --check @@ -82,18 +82,6 @@ ui-check: test: check uv run python3 -m unittest discover -s tests -p 'test_*.py' -# Verify that the stable public installer selects the current Play release. -release-check: - scripts/release/publish-play check - -# Derive and push the version tag for a merged commit; never push main. -release-tag $commit="origin/main": - python3 -m scripts.release.tag_play --commit "$commit" - -# Preview release tagging without creating or pushing a tag. -release-tag-check $commit="origin/main": - python3 -m scripts.release.tag_play --commit "$commit" --dry-run - # Measure warm typed-controller transition latency without model or external I/O. benchmark-controller iterations="1000": uv run scripts/bin/play-machine benchmark --iterations {{iterations}} --json diff --git a/plugins/play/skills/play/justfile b/plugins/play/skills/play/justfile index 135e2419..ed650b76 100644 --- a/plugins/play/skills/play/justfile +++ b/plugins/play/skills/play/justfile @@ -11,7 +11,7 @@ check: package-check uv run pyright scripts/lib/play/cli.py scripts/bin/play tests/foundation/test_cli.py uv run pyright scripts/lib/play/audit scripts/bin/play-audit scripts/bin/play-audit-corpus tests/foundation/test_audit.py uv run pyright scripts/lib/play/journey_effects.py - uv run pyright scripts/release/publish_play.py scripts/release/tag_play.py + uv run pyright .github/release/publish_play.py .github/release/tag_play.py uv run pyright scripts/lib/play/email_login.py scripts/lib/play/search_transport.py tests/foundation/test_email_login.py tests/awareness/test_search_transport.py scripts/bin/play-question choose_creator_path --harness codex --context-json '{"match":{"summary":"Existing Plays cover 1 of 2 outcomes: daily calendar: modiqo/check-calendar-meetings (full); weather in san francisco: modiqo/weather-updates-for-cities (partial, missing san, francisco).","play_choices":[{"id":"use:modiqo/check-calendar-meetings","reference":"modiqo/check-calendar-meetings","label":"Use modiqo/check-calendar-meetings","description":"full match for daily calendar.","parameters":{},"recommended":true,"sub_outcome":"daily calendar","classification":"full","uncovered_terms":[]}]}}' --check scripts/bin/play-question choose_creator_path --harness claude --context-json '{"match":{"summary":"Existing Plays cover 1 of 2 outcomes: daily calendar: modiqo/check-calendar-meetings (full); weather in san francisco: modiqo/weather-updates-for-cities (partial, missing san, francisco).","play_choices":[{"id":"use:modiqo/check-calendar-meetings","reference":"modiqo/check-calendar-meetings","label":"Use modiqo/check-calendar-meetings","description":"full match for daily calendar.","parameters":{},"recommended":true,"sub_outcome":"daily calendar","classification":"full","uncovered_terms":[]}]}}' --check @@ -82,18 +82,6 @@ ui-check: test: check uv run python3 -m unittest discover -s tests -p 'test_*.py' -# Verify that the stable public installer selects the current Play release. -release-check: - scripts/release/publish-play check - -# Derive and push the version tag for a merged commit; never push main. -release-tag $commit="origin/main": - python3 -m scripts.release.tag_play --commit "$commit" - -# Preview release tagging without creating or pushing a tag. -release-tag-check $commit="origin/main": - python3 -m scripts.release.tag_play --commit "$commit" --dry-run - # Measure warm typed-controller transition latency without model or external I/O. benchmark-controller iterations="1000": uv run scripts/bin/play-machine benchmark --iterations {{iterations}} --json diff --git a/plugins/play/skills/play/scripts/lib/play/search_transport.py b/plugins/play/skills/play/scripts/lib/play/search_transport.py index b817f1bd..97053eaa 100644 --- a/plugins/play/skills/play/scripts/lib/play/search_transport.py +++ b/plugins/play/skills/play/scripts/lib/play/search_transport.py @@ -14,6 +14,9 @@ from .commands import CommandError +PLAY_ROOT = Path(__file__).resolve().parents[3] + + REGISTRIES = { "https://roteprod.registry.modiqo.ai": "production", "https://vovpajqyrnhpzbzpzrny.supabase.co": "production", @@ -31,6 +34,13 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): return None +def play_version() -> str: + try: + return (PLAY_ROOT / "VERSION").read_text(encoding="utf-8").strip() + except OSError: + return "unknown" + + def registry_config() -> dict: override = os.environ.get("ROTE_REGISTRY_ENV") if override: @@ -60,7 +70,7 @@ def request_search(query: str, *, public: bool, org: str | None, limit: int, tim environment = REGISTRIES.get(str(config.get("url", ""))) if environment is None: raise CommandError("Shared search requires a production or staging registry.") - headers = {"Content-Type": "application/json", "X-Modiqo-Registry-Environment": environment, "User-Agent": "modiqo-play/0.4.105"} + headers = {"Content-Type": "application/json", "X-Modiqo-Registry-Environment": environment, "User-Agent": f"modiqo-play/{play_version()}"} if not public: try: identity = run_rote(["rote", "whoami", "--check"], text=True, capture_output=True, check=False, timeout=timeout_seconds) diff --git a/plugins/play/skills/play/scripts/release/tag_play.py b/plugins/play/skills/play/scripts/release/tag_play.py deleted file mode 100644 index 2f315862..00000000 --- a/plugins/play/skills/play/scripts/release/tag_play.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Create and push the version tag for a commit already merged into origin/main.""" - -from __future__ import annotations - -import argparse -import json -from pathlib import Path -from typing import Sequence - -from .publish_play import ReleaseError, git, release_tag - - -ROOT = Path(__file__).resolve().parents[2] -MAIN_REF = "refs/remotes/origin/main" - - -def remote_tag_commit(repository: Path, remote: str, tag_ref: str) -> str | None: - refs = {} - for line in git(repository, "ls-remote", "--tags", remote, tag_ref, f"{tag_ref}^{{}}").splitlines(): - object_id, name = line.split() - refs[name] = object_id - # Annotated tags advertise their target separately from the tag object itself. - return refs.get(f"{tag_ref}^{{}}", refs.get(tag_ref)) - - -def tag_release( - repository: Path, *, revision: str = "origin/main", dry_run: bool = False, -) -> dict[str, object]: - git(repository, "fetch", "--no-tags", "origin", f"refs/heads/main:{MAIN_REF}") - target = MAIN_REF if revision == "origin/main" else revision - commit = git(repository, "rev-parse", "--verify", "--end-of-options", f"{target}^{{commit}}") - try: - git(repository, "merge-base", "--is-ancestor", commit, MAIN_REF) - except ReleaseError as error: - raise ReleaseError(f"release commit {commit} must already belong to origin/main") from error - - version = git(repository, "show", f"{commit}:VERSION").strip() - tag = release_tag(version) - tag_ref = f"refs/tags/{tag}" - local_object = git(repository, "tag", "--list", "--format=%(objectname)", tag) - if local_object: - local_commit = git(repository, "rev-parse", "--verify", f"{local_object}^{{commit}}") - if local_commit != commit: - raise ReleaseError(f"local {tag} points to {local_commit}, expected {commit}; refusing to move it") - - # Inspect the push destination, which may differ from origin's fetch URL. - push_urls = git(repository, "remote", "get-url", "--push", "--all", "origin").splitlines() - if len(push_urls) != 1: - raise ReleaseError("origin must have exactly one push URL for release tagging") - destination = push_urls[0] - remote_commit = remote_tag_commit(repository, destination, tag_ref) - if remote_commit is not None and remote_commit != commit: - raise ReleaseError(f"remote {tag} points to {remote_commit}, expected {commit}; refusing to move it") - - receipt: dict[str, object] = { - "status": "already_tagged" if remote_commit is not None else "ready", - "version": version, - "tag": tag, - "commit": commit, - "remote": "origin", - "dry_run": dry_run, - } - if dry_run or remote_commit is not None: - return receipt - - if not local_object: - git(repository, "tag", "--annotate", tag, commit, "--message", f"Release Play {tag}") - local_object = git(repository, "rev-parse", "--verify", tag_ref) - - # Pin the object and destination ref. Never push branches, other tags, or force updates, - # even when the caller has configured push.followTags or remote.origin.push. - git(repository, "push", "--no-follow-tags", destination, f"{local_object}:{tag_ref}") - if remote_tag_commit(repository, destination, tag_ref) != commit: - raise ReleaseError(f"remote {tag} did not resolve to {commit} after pushing") - receipt["status"] = "tagged" - return receipt - - -def main(argv: Sequence[str] | None = None) -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--commit", default="origin/main", help="merged commit or ref (default: origin/main)") - parser.add_argument("--dry-run", action="store_true", help="validate and report without creating or pushing a tag") - args = parser.parse_args(argv) - try: - receipt = tag_release(ROOT, revision=args.commit, dry_run=args.dry_run) - except (OSError, ReleaseError) as error: - parser.exit(1, f"play-release-tag: {error}\n") - print(json.dumps(receipt, indent=2, sort_keys=True)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/lib/play/package.py b/scripts/lib/play/package.py index 931df20b..614e0791 100644 --- a/scripts/lib/play/package.py +++ b/scripts/lib/play/package.py @@ -4,6 +4,7 @@ import argparse import filecmp +import re import shutil import stat import tempfile @@ -28,13 +29,26 @@ "scripts/bin", "scripts/harness", "scripts/lib/play", - "scripts/release", "ui/thinking-orbs", ) EXCLUDED = { Path("scripts/bin/package-plugin"), Path("scripts/lib/play/package.py"), } +VERSION_FILE = ROOT / "VERSION" +_JSON_VERSION = re.compile(r'(?m)^( "version": ")([^"]*)(",?)$') +# Each file carries VERSION in exactly one place; packaging rewrites it from VERSION. +VERSIONED_FILES = { + Path("plugins/play/.claude-plugin/plugin.json"): _JSON_VERSION, + Path("plugins/play/.codex-plugin/plugin.json"): _JSON_VERSION, + Path("plugins/play/.cursor-plugin/plugin.json"): _JSON_VERSION, + Path("plugins/play/.kimi-plugin/plugin.json"): _JSON_VERSION, + Path("plugins/play/package.json"): _JSON_VERSION, + Path("pyproject.toml"): re.compile(r'(?m)^(version = ")([^"]*)(")$'), + Path("uv.lock"): re.compile( + r'(?m)^(\[\[package\]\]\nname = "modiqo-play-controller"\nversion = ")([^"]*)(")$' + ), +} class PackageError(RuntimeError): @@ -62,6 +76,39 @@ def source_files() -> Iterable[tuple[Path, Path]]: yield source, relative +def play_version() -> str: + version = VERSION_FILE.read_text(encoding="utf-8").strip() + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version): + raise PackageError(f"VERSION must be semantic x.y.z; found {version!r}") + return version + + +def versioned_text(relative: Path, version: str) -> tuple[str, str]: + """Return the file's current version and its text rewritten to `version`.""" + text = (ROOT / relative).read_text(encoding="utf-8") + pattern = VERSIONED_FILES[relative] + matches = pattern.findall(text) + if len(matches) != 1: + raise PackageError(f"{relative} must contain exactly one version assignment") + return matches[0][1], pattern.sub(lambda match: f"{match[1]}{version}{match[3]}", text, count=1) + + +def version_differences(version: str) -> list[str]: + messages = [] + for relative in VERSIONED_FILES: + current, _ = versioned_text(relative, version) + if current != version: + messages.append(f"version {relative} is {current}, expected {version} from VERSION") + return messages + + +def sync_versions(version: str) -> None: + for relative in VERSIONED_FILES: + current, text = versioned_text(relative, version) + if current != version: + (ROOT / relative).write_text(text, encoding="utf-8") + + def materialize(destination: Path) -> None: for source, relative in source_files(): target = destination / relative @@ -91,11 +138,14 @@ def differences(expected: Path, actual: Path) -> list[str]: def build(check: bool = False) -> None: + version = play_version() + if not check: + sync_versions(version) with tempfile.TemporaryDirectory(prefix="play-plugin-") as temporary: expected = Path(temporary) / "play" materialize(expected) if check: - found = differences(expected, TARGET) + found = version_differences(version) + differences(expected, TARGET) if found: raise PackageError("plugin payload differs:\n " + "\n ".join(found)) print(f"Play plugin payload is current ({len(list(source_files()))} files)") diff --git a/scripts/lib/play/search_transport.py b/scripts/lib/play/search_transport.py index b817f1bd..97053eaa 100644 --- a/scripts/lib/play/search_transport.py +++ b/scripts/lib/play/search_transport.py @@ -14,6 +14,9 @@ from .commands import CommandError +PLAY_ROOT = Path(__file__).resolve().parents[3] + + REGISTRIES = { "https://roteprod.registry.modiqo.ai": "production", "https://vovpajqyrnhpzbzpzrny.supabase.co": "production", @@ -31,6 +34,13 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): return None +def play_version() -> str: + try: + return (PLAY_ROOT / "VERSION").read_text(encoding="utf-8").strip() + except OSError: + return "unknown" + + def registry_config() -> dict: override = os.environ.get("ROTE_REGISTRY_ENV") if override: @@ -60,7 +70,7 @@ def request_search(query: str, *, public: bool, org: str | None, limit: int, tim environment = REGISTRIES.get(str(config.get("url", ""))) if environment is None: raise CommandError("Shared search requires a production or staging registry.") - headers = {"Content-Type": "application/json", "X-Modiqo-Registry-Environment": environment, "User-Agent": "modiqo-play/0.4.105"} + headers = {"Content-Type": "application/json", "X-Modiqo-Registry-Environment": environment, "User-Agent": f"modiqo-play/{play_version()}"} if not public: try: identity = run_rote(["rote", "whoami", "--check"], text=True, capture_output=True, check=False, timeout=timeout_seconds) diff --git a/scripts/release/publish-play b/scripts/release/publish-play deleted file mode 100755 index f884f9e6..00000000 --- a/scripts/release/publish-play +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/sh -set -eu - -release_root=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -exec python3 "$release_root/publish_play.py" "$@" diff --git a/scripts/release/publish_play.py b/scripts/release/publish_play.py deleted file mode 100644 index 8476a301..00000000 --- a/scripts/release/publish_play.py +++ /dev/null @@ -1,302 +0,0 @@ -"""Publish a Play commit to staging or a release tag to production.""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import io -import os -import re -import shutil -import subprocess -import time -import tarfile -import tempfile -import urllib.error -import urllib.request -from pathlib import Path -from typing import Sequence - - -ROOT = Path(__file__).resolve().parents[2] -ASSETS_REPOSITORY = "https://github.com/modiqo/rote-releases.git" -PUBLIC_SELECTOR = "https://getrote.dev/playoffs/install.sh" -PAGES_PROJECT = "getrote-dev" -DEPLOYMENTS = { - "staging": ("staging", "https://stg.getrote.dev/playoffs/install.sh"), - "production": ("main", PUBLIC_SELECTOR), -} -SELECTOR_RELATIVE = Path("playoffs/install.sh") -VERSION_PATTERN = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+") -REFERENCE_PATTERN = re.compile(r"v[0-9]+\.[0-9]+\.[0-9]+|[a-f0-9]{40}") -SELECTOR_PATTERN = re.compile(rf"(?m)^release=({REFERENCE_PATTERN.pattern})$") - - -class ReleaseError(RuntimeError): - """Raised when a release precondition or verification fails.""" - - -def run(command: Sequence[str], *, cwd: Path) -> str: - result = subprocess.run( - list(command), - cwd=cwd, - text=True, - capture_output=True, - check=False, - ) - if result.returncode != 0: - detail = result.stderr.strip() or result.stdout.strip() or "command failed" - raise ReleaseError(f"{' '.join(command)}: {detail}") - return result.stdout.strip() - - -def git(repository: Path, *arguments: str) -> str: - return run(("git", *arguments), cwd=repository) - - -def release_tag(version_text: str) -> str: - version = version_text.strip() - if not VERSION_PATTERN.fullmatch(version): - raise ReleaseError(f"VERSION must be semantic x.y.z; found {version!r}") - return f"v{version}" - - -def selector_release(selector: str) -> str: - matches = SELECTOR_PATTERN.findall(selector) - if len(matches) != 1: - raise ReleaseError("selector must contain exactly one release tag or commit assignment") - return matches[0] - - -def replace_selector(selector: str, expected: str) -> str: - if not REFERENCE_PATTERN.fullmatch(expected): - raise ReleaseError("selector reference must be a release tag or full commit SHA") - current = selector_release(selector) - if current == expected: - return selector - return SELECTOR_PATTERN.sub(f"release={expected}", selector, count=1) - - -def fetch_text(url: str, *, headers: dict[str, str] | None = None) -> str: - request = urllib.request.Request( - url, - headers={ - "Cache-Control": "no-cache", - "User-Agent": "modiqo-play-release/1", - }, - ) - for name, value in (headers or {}).items(): - request.add_unredirected_header(name, value) - try: - with urllib.request.urlopen(request, timeout=20) as response: - if headers and response.geturl() != url: - raise ReleaseError(f"verification request to {url} was redirected; check staging credentials") - return response.read().decode("utf-8") - except (OSError, UnicodeError, urllib.error.URLError) as error: - raise ReleaseError(f"could not read {url}: {error}") from error - - -def require_clean_tracked(repository: Path) -> None: - changed = git(repository, "status", "--porcelain", "--untracked-files=no") - if changed: - raise ReleaseError(f"tracked files are dirty in {repository}:\n{changed}") - - -def validate_play_commit(play_root: Path) -> tuple[str, str]: - version = (play_root / "VERSION").read_text(encoding="utf-8").strip() - release_tag(version) - require_clean_tracked(play_root) - git(play_root, "fetch", "origin", "main", "--tags") - commit = git(play_root, "rev-parse", "HEAD") - run(("git", "merge-base", "--is-ancestor", commit, "origin/main"), cwd=play_root) - return version, commit - - -def validate_play_release(play_root: Path, *, require_tag_checkout: bool = False) -> tuple[str, str]: - version, commit = validate_play_commit(play_root) - tag = release_tag(version) - if require_tag_checkout: - # Production deploys run from the dispatched tag, so the checkout must be that tag. - dispatched = os.environ.get("GITHUB_REF") - if dispatched is not None and dispatched != f"refs/tags/{tag}": - raise ReleaseError(f"Play production deploy was dispatched from {dispatched}, expected refs/tags/{tag}") - if commit != git(play_root, "rev-parse", "--verify", f"{tag}^{{commit}}"): - raise ReleaseError(f"Play production checkout must match {tag}") - elif commit != git(play_root, "rev-parse", "origin/main"): - raise ReleaseError("Play production checkout must match origin/main") - run(("git", "merge-base", "--is-ancestor", tag, "origin/main"), cwd=play_root) - tagged_version = git(play_root, "show", f"{tag}:VERSION").strip() - if tagged_version != version: - raise ReleaseError(f"{tag} contains VERSION {tagged_version}, expected {version}") - remote_version = fetch_text( - f"https://raw.githubusercontent.com/modiqo/play/{tag}/VERSION" - ).strip() - if remote_version != version: - raise ReleaseError( - f"GitHub {tag} contains VERSION {remote_version}, expected {version}" - ) - return version, tag - - -def deployment_target(environment: str) -> tuple[str, str]: - try: - return DEPLOYMENTS[environment] - except KeyError as error: - raise ReleaseError(f"unknown deployment environment: {environment}") from error - - -def selector_headers(environment: str) -> dict[str, str]: - if environment != "staging": - return {} - client_id = os.environ.get("CF_ACCESS_CLIENT_ID", "") - client_secret = os.environ.get("CF_ACCESS_CLIENT_SECRET", "") - if not client_id or not client_secret: - raise ReleaseError( - "staging verification requires CF_ACCESS_CLIENT_ID and CF_ACCESS_CLIENT_SECRET" - ) - return { - "CF-Access-Client-Id": client_id, - "CF-Access-Client-Secret": client_secret, - } - - -def validate_deployment( - play_root: Path, environment: str, *, publishing: bool = False, -) -> tuple[str, str]: - deployment_target(environment) - if environment == "staging": - return validate_play_commit(play_root) - return validate_play_release(play_root, require_tag_checkout=publishing) - - -def stage_assets(destination: Path, archive: bytes) -> None: - """Extract public assets without Git metadata or external filesystem links.""" - with tarfile.open(fileobj=io.BytesIO(archive), mode="r:gz") as bundle: - for member in bundle.getmembers(): - parts = Path(member.name).parts[1:] - if not parts: - continue - relative = Path(*parts) - if relative.is_absolute() or ".." in relative.parts or ".git" in relative.parts: - raise ReleaseError("unsafe path in installer asset archive") - target = destination / relative - if member.isdir(): - target.mkdir(parents=True, exist_ok=True) - elif member.isfile(): - source = bundle.extractfile(member) - if source is None: - raise ReleaseError("missing installer asset content") - target.parent.mkdir(parents=True, exist_ok=True) - target.write_bytes(source.read()) - else: - raise ReleaseError("installer archive contains an unsupported link or file") - - -def download_assets(destination: Path) -> str: - revision = run(("git", "ls-remote", ASSETS_REPOSITORY, "refs/heads/main"), cwd=ROOT).split()[0] - if not re.fullmatch(r"[a-f0-9]{40}", revision): - raise ReleaseError("invalid installer assets revision") - request = urllib.request.Request( - f"https://codeload.github.com/modiqo/rote-releases/tar.gz/{revision}", - headers={"User-Agent": "modiqo-play-release/1"}, - ) - with urllib.request.urlopen(request, timeout=30) as response: - archive = response.read(20_000_001) - if len(archive) > 20_000_000: - raise ReleaseError("installer assets archive exceeds the size limit") - stage_assets(destination, archive) - return revision - - -def wait_for_public_selector( - expected: str, *, public_selector: str = PUBLIC_SELECTOR, timeout_seconds: int = 60, - headers: dict[str, str] | None = None, -) -> str: - deadline = time.monotonic() + timeout_seconds - last = "unavailable" - while time.monotonic() < deadline: - try: - cache_buster = time.time_ns() - body = fetch_text(f"{public_selector}?release-check={cache_buster}", headers=headers) - last = selector_release(body) - if last == expected: - return body - except ReleaseError as error: - last = str(error) - time.sleep(2) - raise ReleaseError( - f"{public_selector} did not reach {expected} within {timeout_seconds}s; found {last}" - ) - - -def check(play_root: Path, *, environment: str = "production") -> dict[str, object]: - _, public_selector = deployment_target(environment) - headers = selector_headers(environment) - version, reference = validate_deployment(play_root, environment) - body = wait_for_public_selector( - reference, public_selector=public_selector, timeout_seconds=10, headers=headers, - ) - return { - "status": "ready", - "version": version, - "environment": environment, - "reference": reference, - "tag": reference if environment == "production" else None, - "public_selector": public_selector, - "selector_sha256": hashlib.sha256(body.encode()).hexdigest(), - } - - -def publish(play_root: Path, *, environment: str = "production") -> dict[str, object]: - branch, public_selector = deployment_target(environment) - headers = selector_headers(environment) - version, reference = validate_deployment(play_root, environment, publishing=True) - if shutil.which("npx") is None: - raise ReleaseError("npx is required to deploy the Cloudflare Pages project") - play_commit = git(play_root, "rev-parse", "HEAD") - with tempfile.TemporaryDirectory(prefix="play-release-") as temporary: - staged = Path(temporary) - assets_revision = download_assets(staged) - selector = staged / SELECTOR_RELATIVE - if not selector.is_file(): - raise ReleaseError("installer assets are missing the Play selector") - selector.write_text(replace_selector(selector.read_text(), reference), encoding="utf-8") - run(("/bin/sh", "-n", str(selector)), cwd=staged) - deployment = run( - ("npx", "--yes", "wrangler@4.137.0", "pages", "deploy", str(staged), - "--project-name", PAGES_PROJECT, "--branch", branch, - "--commit-hash", play_commit, "--commit-message", f"release: select Play {reference}", - "--commit-dirty=false"), - cwd=play_root, - ) - wait_for_public_selector(reference, public_selector=public_selector, headers=headers) - deployment_url_match = re.search(r"https://[a-z0-9]+\.getrote-dev\.pages\.dev", deployment) - return { - "status": "published", "version": version, "environment": environment, - "reference": reference, "tag": reference if environment == "production" else None, - "play_commit": play_commit, "assets_revision": assets_revision, - "deployment_url": deployment_url_match.group(0) if deployment_url_match else None, - "public_selector": public_selector, - } - - -def main(argv: Sequence[str] | None = None) -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("action", choices=("check", "publish")) - parser.add_argument("--environment", choices=tuple(DEPLOYMENTS), default="production") - args = parser.parse_args(argv) - try: - payload = ( - check(ROOT, environment=args.environment) - if args.action == "check" - else publish(ROOT, environment=args.environment) - ) - except (OSError, ReleaseError) as error: - parser.exit(1, f"play-release: {error}\n") - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/harness/test_bootstrap.py b/tests/harness/test_bootstrap.py index f9ba3fa0..274ff5d9 100644 --- a/tests/harness/test_bootstrap.py +++ b/tests/harness/test_bootstrap.py @@ -69,6 +69,9 @@ ) +PLAY_VERSION = (Path(__file__).resolve().parents[2] / "VERSION").read_text(encoding="utf-8").strip() + + ROOT = Path(__file__).resolve().parents[2] @@ -93,6 +96,16 @@ def tearDown(self) -> None: self.environment_patch.stop() self.temporary.cleanup() + def put_codex_on_path(self) -> None: + """Make Codex detectable without depending on the machine's installed harnesses.""" + bin_dir = self.home / "bin" + bin_dir.mkdir(exist_ok=True) + codex = bin_dir / "codex" + codex.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + codex.chmod(0o755) + # setUp's environment patch restores PATH in tearDown. + os.environ["PATH"] = f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}" + def test_native_windows_is_rejected_but_wsl_is_supported(self) -> None: with self.assertRaisesRegex(BootstrapError, "native Windows is not supported"): _require_supported_os(platform="win32", os_name="nt") @@ -310,7 +323,7 @@ def test_plan_selects_top_k_and_skips_current_skill_providers( self.assertEqual(["codex", "claude"], plan["selected_harnesses"]) self.assertEqual("not_installed", plan["play"]["update_status"]) - self.assertEqual("0.4.105", plan["play"]["target_version"]) + self.assertEqual(PLAY_VERSION, plan["play"]["target_version"]) convergence = next(action for action in plan["actions"] if action["id"] == "converge_rote_skills") self.assertIsNone(convergence["command"]) self.assertEqual([], convergence["targets"]) @@ -776,6 +789,7 @@ def test_claude_tulving_mcp_keeps_the_resolved_binary_path(self) -> None: def test_apply_without_remote_approval_stops_and_writes_both_reports( self, _resolve_rote: MagicMock ) -> None: + self.put_codex_on_path() report = apply( ROOT, requested=["codex"], @@ -2583,7 +2597,7 @@ def test_preflight_with_activation_failure_remains_failed(self) -> None: Step( "verify_play_plugin", "completed", - "Play 0.4.105 is installed and enabled.", + f"Play {PLAY_VERSION} is installed and enabled.", target="codex", ) ], @@ -2596,6 +2610,7 @@ def test_apply_existing_rote_updates_converges_installs_hooks_and_verifies( verify_prompt_intercept: MagicMock, _converge_marketplace: MagicMock, ) -> None: + self.put_codex_on_path() runner = MagicMock() runner.side_effect = [ MagicMock(returncode=0, stdout="version: 1.0.0\n", stderr=""), @@ -2660,7 +2675,7 @@ def test_apply_existing_rote_updates_converges_installs_hooks_and_verifies( "record-play-install", "playoffs", "fresh", - "0.4.105", + PLAY_VERSION, "codex", ], commands, @@ -2693,7 +2708,7 @@ def test_apply_existing_rote_updates_converges_installs_hooks_and_verifies( ) _converge_marketplace.assert_called_once() self.assertEqual( - "0.4.105", _converge_marketplace.call_args.kwargs["expected_version"] + PLAY_VERSION, _converge_marketplace.call_args.kwargs["expected_version"] ) verify_prompt_intercept.assert_called_once() @@ -2705,6 +2720,7 @@ def test_apply_existing_rote_updates_converges_installs_hooks_and_verifies( @patch("scripts.lib.play.bootstrap._confirm", return_value=True) @patch("scripts.lib.play.bootstrap.apply") @patch("scripts.lib.play.bootstrap.build_plan") + @patch.dict(os.environ, {"DISPLAY": ":0"}) # Expect the headed sign-in offered on desktops. def test_guided_install_uses_separate_consent_for_optional_tulving( self, build: MagicMock, @@ -2788,6 +2804,7 @@ def test_guided_install_uses_separate_consent_for_optional_tulving( @patch("scripts.lib.play.bootstrap._confirm", return_value=True) @patch("scripts.lib.play.bootstrap.apply") @patch("scripts.lib.play.bootstrap.build_plan") + @patch.dict(os.environ, {"DISPLAY": ":0"}) # Expect the headed sign-in offered on desktops. def test_guided_install_ignores_remembered_provider_and_can_exit_at_sign_in( self, build: MagicMock, diff --git a/tests/harness/test_install_all.py b/tests/harness/test_install_all.py index 8fed7fe9..7f6a21fd 100644 --- a/tests/harness/test_install_all.py +++ b/tests/harness/test_install_all.py @@ -18,6 +18,7 @@ ROOT = Path(__file__).resolve().parents[2] +PLAY_VERSION = (ROOT / "VERSION").read_text(encoding="utf-8").strip() INSTALLER = ROOT / "scripts" / "harness" / "install-all" PROFILE = ROOT / "scripts" / "harness" / "play-profile" @@ -86,7 +87,7 @@ def setUp(self) -> None: " printf '%s\\n' '{\"marketplaces\":[]}'\n" "elif [ \"${1:-}\" = plugin ] && [ \"${2:-}\" = list ]; then\n" " if [ -f \"$marker\" ]; then\n" - " printf '%s\\n' '{\"installed\":[{\"pluginId\":\"play@play-skills\",\"version\":\"0.4.105\",\"enabled\":true}],\"available\":[]}'\n" + " printf '%s\\n' '{\"installed\":[{\"pluginId\":\"play@play-skills\",\"version\":\"" + PLAY_VERSION + "\",\"enabled\":true}],\"available\":[]}'\n" " else\n" " printf '%s\\n' '{\"installed\":[],\"available\":[]}'\n" " fi\n" @@ -103,7 +104,7 @@ def setUp(self) -> None: " printf '%s\\n' '[]'\n" "elif [ \"${1:-}\" = plugin ] && [ \"${2:-}\" = list ]; then\n" " if [ -f \"$marker\" ]; then\n" - " printf '%s\\n' '[{\"id\":\"play@play-skills\",\"version\":\"0.4.105\",\"enabled\":true,\"scope\":\"user\"}]'\n" + " printf '%s\\n' '[{\"id\":\"play@play-skills\",\"version\":\"" + PLAY_VERSION + "\",\"enabled\":true,\"scope\":\"user\"}]'\n" " else\n" " printf '%s\\n' '[]'\n" " fi\n" @@ -373,7 +374,7 @@ def test_portable_copy_is_stable_and_idempotent(self) -> None: self.run_installer("install", "--copy") installed = (install_home / "skill").resolve() - self.assertEqual("0.4.105", (installed / "VERSION").read_text().strip()) + self.assertEqual(PLAY_VERSION, (installed / "VERSION").read_text().strip()) marker = json.loads((installed / ".play-install.json").read_text()) self.assertEqual("play.portable-install/v1", marker["schema"]) for root in self.roots.values(): @@ -564,7 +565,7 @@ def test_local_bootstrap_exercises_portable_install(self) -> None: self.assertIn("› Checking Play, Rote, and Tulving updates", result.stderr) self.assertIn("✓ Verifying Codex", result.stderr) self.assertIn("╭─ ◆ Review setup", result.stdout) - self.assertIn("Version: 0.4.105", result.stdout) + self.assertIn(f"Version: {PLAY_VERSION}", result.stdout) self.assertIn("╭─ ◆ Play setup", result.stdout) self.assertIn("Status: READY", result.stdout) self.assertIn("OS: ", result.stdout) @@ -635,7 +636,7 @@ def test_curl_bootstrap_handles_clean_current_update_and_repair(self) -> None: self.assertIn("READY TO APPLY · UPDATE", update_result.stdout) self.assertEqual("update", update_report["play"]["install_state"]) self.assertTrue(update_report["backup"]["has_previous_state"]) - self.assertEqual("0.4.105", (installed / "VERSION").read_text().strip()) + self.assertEqual(PLAY_VERSION, (installed / "VERSION").read_text().strip()) missing = installed / "scripts" / "bin" / "play-digest" missing.unlink() diff --git a/tests/integration/test_preflight.py b/tests/integration/test_preflight.py index 065a9f32..3287c912 100644 --- a/tests/integration/test_preflight.py +++ b/tests/integration/test_preflight.py @@ -50,7 +50,14 @@ def test_ready_requires_identity_and_play_capability( MagicMock(returncode=0, stdout="rote play\nUSAGE\n", stderr=""), ] - payload = inspect("claude") + # Install the rote skill in a temporary home rather than relying on the machine's own. + with tempfile.TemporaryDirectory() as temporary: + claude_home = Path(temporary) / ".claude" + rote = claude_home / "skills" / "rote" + rote.mkdir(parents=True) + (rote / "SKILL.md").write_text("---\nname: rote\n---\n") + with patch.dict(os.environ, {"HOME": temporary, "CLAUDE_CONFIG_DIR": str(claude_home)}): + payload = inspect("claude") self.assertTrue(payload["ready"]) self.assertEqual([], payload["setup_commands"])