Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down
55 changes: 34 additions & 21 deletions scripts/release/tag_play.py → .github/release/tag_play.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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:
Expand Down
45 changes: 45 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
44 changes: 44 additions & 0 deletions .github/workflows/tag-release.yml
Original file line number Diff line number Diff line change
@@ -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"
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
51 changes: 24 additions & 27 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <merged-commit-sha>
```

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
Expand All @@ -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.
14 changes: 1 addition & 13 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 1 addition & 13 deletions plugins/play/skills/play/justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion plugins/play/skills/play/scripts/lib/play/search_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
Loading