From 172db821d3ef237281b57d7376cf007cf438da31 Mon Sep 17 00:00:00 2001 From: Hubert P Date: Fri, 2 Oct 2026 17:37:37 +0200 Subject: [PATCH 1/5] feat(deploy): replace a latest placeholder in the installer selector What changed - Accept `release=latest` in the shared assets' playoffs/install.sh, alongside a pinned tag or commit. - Keep the deployed selector strict: each deploy writes the tag or commit SHA, and verification rejects a public selector still at latest. Why The shared assets no longer need a commit for every Play release just to keep a parseable pinned value; each deploy supplies the real reference. How to verify 1. Run pyright on scripts/release and `just package-check`. 2. Feed replace_selector a selector with `release=latest`; expect only that line to become the given tag or SHA. --- docs/deployment.md | 3 ++- .../play/scripts/release/publish_play.py | 19 ++++++++++++++----- scripts/release/publish_play.py | 19 ++++++++++++++----- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/docs/deployment.md b/docs/deployment.md index cacee3a..35c9db9 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -35,7 +35,8 @@ 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. +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. Run the read-only production release gate at any time: diff --git a/plugins/play/skills/play/scripts/release/publish_play.py b/plugins/play/skills/play/scripts/release/publish_play.py index 8476a30..bdf254c 100644 --- a/plugins/play/skills/play/scripts/release/publish_play.py +++ b/plugins/play/skills/play/scripts/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/publish_play.py b/scripts/release/publish_play.py index 8476a30..bdf254c 100644 --- a/scripts/release/publish_play.py +++ b/scripts/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: From 3653ef91aa27dffd3ec790007a33df25b8863b48 Mon Sep 17 00:00:00 2001 From: Hubert P Date: Fri, 2 Oct 2026 17:38:01 +0200 Subject: [PATCH 2/5] chore(release): move release scripts to .github/release What changed - Move publish-play, publish_play.py, and tag_play.py from scripts/release to .github/release. - Point the deploy workflow, justfile recipes, and docs at the new path. - Stop shipping the release scripts in the plugin payload. Why The scripts only serve CI deployment and maintainer tagging, so they belong with the workflow rather than in the installed skill. How to verify 1. Run `just package-check` and `just release-tag-check`. 2. Run `just release-check` from a clean checkout of main. --- .../scripts => .github}/release/publish-play | 0 .../release/publish_play.py | 0 .../scripts => .github}/release/tag_play.py | 2 +- .github/workflows/deploy.yml | 2 +- docs/deployment.md | 2 +- justfile | 8 +- plugins/play/skills/play/justfile | 8 +- scripts/lib/play/package.py | 1 - scripts/release/publish-play | 5 - scripts/release/publish_play.py | 311 ------------------ scripts/release/tag_play.py | 93 ------ 11 files changed, 11 insertions(+), 421 deletions(-) rename {plugins/play/skills/play/scripts => .github}/release/publish-play (100%) rename {plugins/play/skills/play/scripts => .github}/release/publish_play.py (100%) rename {plugins/play/skills/play/scripts => .github}/release/tag_play.py (98%) delete mode 100755 scripts/release/publish-play delete mode 100644 scripts/release/publish_play.py delete mode 100644 scripts/release/tag_play.py 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 100% rename from plugins/play/skills/play/scripts/release/publish_play.py rename to .github/release/publish_play.py diff --git a/plugins/play/skills/play/scripts/release/tag_play.py b/.github/release/tag_play.py similarity index 98% rename from plugins/play/skills/play/scripts/release/tag_play.py rename to .github/release/tag_play.py index 2f31586..9507034 100644 --- a/plugins/play/skills/play/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] diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6703236..15c3b20 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/docs/deployment.md b/docs/deployment.md index 35c9db9..05849f6 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -46,4 +46,4 @@ just release-check 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`. +`.github/release/publish-play check --environment staging`. diff --git a/justfile b/justfile index 135e241..f122d59 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 @@ -84,15 +84,15 @@ test: check # Verify that the stable public installer selects the current Play release. release-check: - scripts/release/publish-play check + .github/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" + python3 .github/release/tag_play.py --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 + python3 .github/release/tag_play.py --commit "$commit" --dry-run # Measure warm typed-controller transition latency without model or external I/O. benchmark-controller iterations="1000": diff --git a/plugins/play/skills/play/justfile b/plugins/play/skills/play/justfile index 135e241..f122d59 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 @@ -84,15 +84,15 @@ test: check # Verify that the stable public installer selects the current Play release. release-check: - scripts/release/publish-play check + .github/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" + python3 .github/release/tag_play.py --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 + python3 .github/release/tag_play.py --commit "$commit" --dry-run # Measure warm typed-controller transition latency without model or external I/O. benchmark-controller iterations="1000": diff --git a/scripts/lib/play/package.py b/scripts/lib/play/package.py index 931df20..65254d7 100644 --- a/scripts/lib/play/package.py +++ b/scripts/lib/play/package.py @@ -28,7 +28,6 @@ "scripts/bin", "scripts/harness", "scripts/lib/play", - "scripts/release", "ui/thinking-orbs", ) EXCLUDED = { diff --git a/scripts/release/publish-play b/scripts/release/publish-play deleted file mode 100755 index f884f9e..0000000 --- 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 bdf254c..0000000 --- a/scripts/release/publish_play.py +++ /dev/null @@ -1,311 +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})$") -# 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): - """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, *, pattern: re.Pattern[str] = SELECTOR_PATTERN) -> str: - matches = pattern.findall(selector) - if len(matches) != 1: - 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, pattern=SOURCE_SELECTOR_PATTERN) - if current == expected: - return selector - return SOURCE_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/scripts/release/tag_play.py b/scripts/release/tag_play.py deleted file mode 100644 index 2f31586..0000000 --- a/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()) From cb3a044a2b3c06e9cf6d6854aa5f479f4a7807e9 Mon Sep 17 00:00:00 2001 From: Hubert P Date: Fri, 2 Oct 2026 17:43:51 +0200 Subject: [PATCH 3/5] feat(release): tag new versions from CI instead of just recipes What changed - Add a Tag Play release workflow that runs on every push to main and tags the pushed commit when its VERSION has no tag yet. - Report `version_unchanged` when the version's tag already sits on an earlier main commit, so merges without a bump succeed without tagging. - Remove the release-check, release-tag, and release-tag-check recipes. - Document the CI tagging outcomes and the direct publish-play check. Why Merging a version bump should produce its tag without a maintainer remembering to run a local command. How to verify 1. Merge a PR that keeps VERSION; expect `version_unchanged`. 2. Merge a version bump; expect `tagged` and a pushed vX.Y.Z tag. --- .github/release/tag_play.py | 53 +++++++++++++++++++------------ .github/workflows/tag-release.yml | 44 +++++++++++++++++++++++++ docs/deployment.md | 46 ++++++++++++--------------- justfile | 12 ------- plugins/play/skills/play/justfile | 12 ------- 5 files changed, 97 insertions(+), 70 deletions(-) create mode 100644 .github/workflows/tag-release.yml diff --git a/.github/release/tag_play.py b/.github/release/tag_play.py index 9507034..c2a5359 100644 --- a/.github/release/tag_play.py +++ b/.github/release/tag_play.py @@ -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/tag-release.yml b/.github/workflows/tag-release.yml new file mode 100644 index 0000000..30c00f1 --- /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/docs/deployment.md b/docs/deployment.md index 05849f6..2f6fdb5 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -9,22 +9,21 @@ The [Deploy Play workflow](../.github/workflows/deploy.yml) publishes the instal 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. +a branch and merge them 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 @@ -36,14 +35,9 @@ Both environments read the shared installer assets, change only the Play selecto 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. 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. - -Run the read-only production release gate at any time: - -```bash -just release-check -``` +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 -`.github/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 f122d59..ed650b7 100644 --- a/justfile +++ b/justfile @@ -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: - .github/release/publish-play check - -# Derive and push the version tag for a merged commit; never push main. -release-tag $commit="origin/main": - python3 .github/release/tag_play.py --commit "$commit" - -# Preview release tagging without creating or pushing a tag. -release-tag-check $commit="origin/main": - python3 .github/release/tag_play.py --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 f122d59..ed650b7 100644 --- a/plugins/play/skills/play/justfile +++ b/plugins/play/skills/play/justfile @@ -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: - .github/release/publish-play check - -# Derive and push the version tag for a merged commit; never push main. -release-tag $commit="origin/main": - python3 .github/release/tag_play.py --commit "$commit" - -# Preview release tagging without creating or pushing a tag. -release-tag-check $commit="origin/main": - python3 .github/release/tag_play.py --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 From 3b5f4c552ba0144c5c27e4f292e00a7c791930fe Mon Sep 17 00:00:00 2001 From: Hubert P Date: Fri, 2 Oct 2026 17:55:44 +0200 Subject: [PATCH 4/5] chore(release): derive every version copy from VERSION What changed - Have `just package` write VERSION into the plugin manifests, plugins/play/package.json, pyproject.toml, and uv.lock. - Fail `just package-check` (and so `just check`) when any of them disagrees with VERSION. - Build the search User-Agent from VERSION at runtime. - Read VERSION in the bootstrap and install tests instead of hardcoding the release. - Show a vX.Y.Z placeholder in the README's pinned-install example. Why A release bump touched 16 files by hand, and a missed manifest or test string went unnoticed. Now a bump edits VERSION and runs `just package`. How to verify 1. Set VERSION to a new x.y.z and run `just package-check`; expect each stale copy listed. 2. Run `just package`, then `just test`; expect a clean check and pass. --- README.md | 6 +-- docs/deployment.md | 6 ++- .../play/scripts/lib/play/search_transport.py | 12 ++++- scripts/lib/play/package.py | 53 ++++++++++++++++++- scripts/lib/play/search_transport.py | 12 ++++- tests/harness/test_bootstrap.py | 11 ++-- tests/harness/test_install_all.py | 11 ++-- 7 files changed, 94 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 8e2a129..7604477 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 2f6fdb5..de2333c 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -8,8 +8,10 @@ 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. +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`: 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 b817f1b..97053ea 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/scripts/lib/play/package.py b/scripts/lib/play/package.py index 65254d7..614e079 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 @@ -34,6 +35,20 @@ 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): @@ -61,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 @@ -90,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 b817f1b..97053ea 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/tests/harness/test_bootstrap.py b/tests/harness/test_bootstrap.py index f9ba3fa..155f1d5 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] @@ -310,7 +313,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"]) @@ -2583,7 +2586,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", ) ], @@ -2660,7 +2663,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 +2696,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() diff --git a/tests/harness/test_install_all.py b/tests/harness/test_install_all.py index 8fed7fe..7f6a21f 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() From 215f98a72c0ac25b7078110b9ff316d2944bb221 Mon Sep 17 00:00:00 2001 From: Hubert P Date: Fri, 2 Oct 2026 18:07:34 +0200 Subject: [PATCH 5/5] ci: run just test on every PR and push to main What changed - Add a CI workflow that installs Node, uv, just, and typos, then runs `just test`, which runs `just check` first. - Make Codex detectable in the bootstrap apply tests with a fake executable on PATH instead of the machine's installed Codex. - Set DISPLAY in the guided-install tests that expect headed sign-in. - Give the preflight readiness test a temporary home with a rote skill. Why Nothing checked PRs before merge. On a clean runner, five tests failed because they read the developer's installed harnesses, display, and skills rather than setting them up. How to verify 1. Run `just test` locally; expect a pass. 2. Run the suite with an empty HOME, no DISPLAY, and a minimal PATH; expect the same pass. --- .github/workflows/ci.yml | 45 +++++++++++++++++++++++++++++ tests/harness/test_bootstrap.py | 14 +++++++++ tests/integration/test_preflight.py | 9 +++++- 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4c13cd1 --- /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/tests/harness/test_bootstrap.py b/tests/harness/test_bootstrap.py index 155f1d5..274ff5d 100644 --- a/tests/harness/test_bootstrap.py +++ b/tests/harness/test_bootstrap.py @@ -96,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") @@ -779,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"], @@ -2599,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=""), @@ -2708,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, @@ -2791,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/integration/test_preflight.py b/tests/integration/test_preflight.py index 065a9f3..3287c91 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"])