From a1c39f0f43b6ad247e3c2d6535b3810f29b3aa26 Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Sun, 31 May 2026 20:19:25 -0400 Subject: [PATCH 1/9] docs(agents): add PR root-cause validation guidance --- AGENTS.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index dfc2761d..726a318b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,12 @@ subagents, skills, web/visualization UIs, and multi-provider LLM authentication. commit status is `success`, not `pending`/`failure` or absent — and read the review summary and any "Actionable comments posted: N" findings. Do not merge while CodeRabbit is still reviewing or on an unreviewed commit; surface unresolved actionable findings instead of merging past them. +- **When working on a PR or GitHub Actions failure, investigate and identify the root cause first.** + Provide the best-practice, most robust design solution; never provide fast fixes or workarounds. + This is a hard constraint. +- **For session validation, check current authoritative sources before finalizing conclusions.** Use + Context7 MCP documentation lookups and targeted web search to verify the latest updates, APIs, + CI/GitHub Actions behavior, dependency guidance, and best practices relevant to the task. ## Simplicity and scope discipline From 166098c5c5e37fc903d216b947b227071d61c7ec Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Sun, 31 May 2026 20:20:27 -0400 Subject: [PATCH 2/9] feat(release): enforce pythinker-review pin in dependency check --- .github/workflows/ci-pythinker-cli.yml | 5 +- .github/workflows/release-pythinker-cli.yml | 5 +- .../check_pythinker_dependency_versions.py | 2 + tests/test_release_py.py | 63 +++++++++++++++++++ 4 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 tests/test_release_py.py diff --git a/.github/workflows/ci-pythinker-cli.yml b/.github/workflows/ci-pythinker-cli.yml index 4c82a1e2..77c893c9 100644 --- a/.github/workflows/ci-pythinker-cli.yml +++ b/.github/workflows/ci-pythinker-cli.yml @@ -250,10 +250,11 @@ jobs: - name: Check dependency versions if: steps.version.outputs.bump == 'true' run: | - python scripts/check_pythinker_dependency_versions.py \ + uv run python scripts/check_pythinker_dependency_versions.py \ --root-pyproject pyproject.toml \ --pythinker-core-pyproject packages/pythinker-core/pyproject.toml \ - --pythinker-host-pyproject packages/pythinker-host/pyproject.toml + --pythinker-host-pyproject packages/pythinker-host/pyproject.toml \ + --pythinker-review-pyproject packages/pythinker-review/pyproject.toml - name: Check pythinker-code version alignment if: steps.version.outputs.bump == 'true' diff --git a/.github/workflows/release-pythinker-cli.yml b/.github/workflows/release-pythinker-cli.yml index 2ed824b4..0d2b3b7b 100644 --- a/.github/workflows/release-pythinker-cli.yml +++ b/.github/workflows/release-pythinker-cli.yml @@ -54,10 +54,11 @@ jobs: - name: Check dependency versions run: | - python scripts/check_pythinker_dependency_versions.py \ + uv run python scripts/check_pythinker_dependency_versions.py \ --root-pyproject pyproject.toml \ --pythinker-core-pyproject packages/pythinker-core/pyproject.toml \ - --pythinker-host-pyproject packages/pythinker-host/pyproject.toml + --pythinker-host-pyproject packages/pythinker-host/pyproject.toml \ + --pythinker-review-pyproject packages/pythinker-review/pyproject.toml # Hard release gate: every PyPI release must ship a matching README + # CHANGELOG update. README.md must contain a "What's New in " diff --git a/scripts/check_pythinker_dependency_versions.py b/scripts/check_pythinker_dependency_versions.py index dea75a7f..44604957 100644 --- a/scripts/check_pythinker_dependency_versions.py +++ b/scripts/check_pythinker_dependency_versions.py @@ -45,6 +45,7 @@ def main() -> int: parser.add_argument("--root-pyproject", type=Path, required=True) parser.add_argument("--pythinker-core-pyproject", type=Path, required=True) parser.add_argument("--pythinker-host-pyproject", type=Path, required=True) + parser.add_argument("--pythinker-review-pyproject", type=Path, required=True) args = parser.parse_args() try: @@ -65,6 +66,7 @@ def main() -> int: for name, pyproject_path in ( ("pythinker-core", args.pythinker_core_pyproject), ("pythinker-host", args.pythinker_host_pyproject), + ("pythinker-review", args.pythinker_review_pyproject), ): try: package_version = load_project_version(pyproject_path) diff --git a/tests/test_release_py.py b/tests/test_release_py.py new file mode 100644 index 00000000..4b1142f3 --- /dev/null +++ b/tests/test_release_py.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +DEP_CHECK = REPO_ROOT / "scripts" / "check_pythinker_dependency_versions.py" + + +def _write(tmp_path: Path, name: str, body: str) -> Path: + p = tmp_path / name + p.write_text(body, encoding="utf-8") + return p + + +def _run_dep_check(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(DEP_CHECK), *args], + capture_output=True, + text=True, + ) + + +def test_dep_check_passes_when_review_pin_matches(tmp_path: Path) -> None: + root = _write( + tmp_path, + "root.toml", + '[project]\nname="pythinker-code"\nversion="0.27.0"\n' + 'dependencies=["pythinker-core[contrib]==1.1.1","pythinker-host==1.0.0",' + '"pythinker-review==0.1.0"]\n', + ) + core = _write(tmp_path, "core.toml", '[project]\nname="pythinker-core"\nversion="1.1.1"\n') + host = _write(tmp_path, "host.toml", '[project]\nname="pythinker-host"\nversion="1.0.0"\n') + review = _write(tmp_path, "review.toml", '[project]\nname="pythinker-review"\nversion="0.1.0"\n') + result = _run_dep_check( + "--root-pyproject", str(root), + "--pythinker-core-pyproject", str(core), + "--pythinker-host-pyproject", str(host), + "--pythinker-review-pyproject", str(review), + ) + assert result.returncode == 0, result.stderr + + +def test_dep_check_fails_when_review_pin_drifts(tmp_path: Path) -> None: + root = _write( + tmp_path, + "root.toml", + '[project]\nname="pythinker-code"\nversion="0.27.0"\n' + 'dependencies=["pythinker-core[contrib]==1.1.1","pythinker-host==1.0.0",' + '"pythinker-review==0.1.0"]\n', + ) + core = _write(tmp_path, "core.toml", '[project]\nname="pythinker-core"\nversion="1.1.1"\n') + host = _write(tmp_path, "host.toml", '[project]\nname="pythinker-host"\nversion="1.0.0"\n') + review = _write(tmp_path, "review.toml", '[project]\nname="pythinker-review"\nversion="0.2.0"\n') + result = _run_dep_check( + "--root-pyproject", str(root), + "--pythinker-core-pyproject", str(core), + "--pythinker-host-pyproject", str(host), + "--pythinker-review-pyproject", str(review), + ) + assert result.returncode == 1 + assert "pythinker-review version mismatch" in result.stderr From b65f33e983b1facb9375a6e061da6014dc483541 Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Sun, 31 May 2026 20:23:25 -0400 Subject: [PATCH 3/9] feat(release): add release.py SSOT orchestration --- docs/en/release-notes/breaking-changes.md | 2 + scripts/release.py | 334 ++++++++++++++++++++++ tests/test_release_py.py | 159 +++++++++- 3 files changed, 485 insertions(+), 10 deletions(-) create mode 100644 scripts/release.py diff --git a/docs/en/release-notes/breaking-changes.md b/docs/en/release-notes/breaking-changes.md index cd2a7824..bd025f2d 100644 --- a/docs/en/release-notes/breaking-changes.md +++ b/docs/en/release-notes/breaking-changes.md @@ -2,6 +2,8 @@ This page documents breaking changes in Pythinker Code releases and provides migration guidance. +## Unreleased + ## 0.27.0 (2026-05-31) No breaking changes. This release is compatible with 0.26.0 user configuration, native installs, and session data. diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 00000000..cd90f529 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +"""Pythinker-code release orchestrator. + +Rewrites every version-derived file + uv.lock from the single source of +truth (pyproject.toml:3), runs the same gates CI runs, and opens a +release/X.Y.Z PR. It never pushes to main and never pushes the tag — the +maintainer pushes the tag(s) after the PR merges (C1). + +stdlib + shells out to git/gh/uv. The shipped agent gains zero runtime deps +(C3: CI/release-tooling exemption). +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +import tomllib +from datetime import date +from pathlib import Path + +import tomlkit + +REPO_ROOT = Path(__file__).resolve().parents[1] +ROOT_PYPROJECT = REPO_ROOT / "pyproject.toml" +CORE_PYPROJECT = REPO_ROOT / "packages" / "pythinker-core" / "pyproject.toml" +HOST_PYPROJECT = REPO_ROOT / "packages" / "pythinker-host" / "pyproject.toml" +REVIEW_PYPROJECT = REPO_ROOT / "packages" / "pythinker-review" / "pyproject.toml" + +# Single source for the three hand-authored changelog files. validate() asserts +# the `## Unreleased` anchor in ALL of them before any write, and rewrite() +# promotes the SAME list — defined once so the two can never drift (atomic +# Phase-2 guarantee: no partial-write if a docs file is missing its anchor). +CHANGELOG_FILES = ( + REPO_ROOT / "CHANGELOG.md", + REPO_ROOT / "docs" / "en" / "release-notes" / "changelog.md", + REPO_ROOT / "docs" / "en" / "release-notes" / "breaking-changes.md", +) + +SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") +_DEP_PIN_RE = re.compile( + r"^(?P[A-Za-z0-9._-]+)(?P\[[^\]]+\])?==(?P[^;\s]+)(?P.*)$" +) +_UNRELEASED_RE = re.compile(r"^## Unreleased[ \t]*$", re.MULTILINE) + + +class ReleaseError(Exception): + """Raised when a precondition or rewrite invariant fails.""" + + +def parse_semver(version: str) -> tuple[int, int, int]: + m = SEMVER_RE.match(version) + if not m: + raise ReleaseError(f"not a valid x.y.z version: {version!r}") + return (int(m.group(1)), int(m.group(2)), int(m.group(3))) + + +def assert_monotonic(*, current: str, target: str) -> None: + if parse_semver(target) <= parse_semver(current): + raise ReleaseError( + f"target version {target} must be strictly greater than current {current}" + ) + + +def read_project_version(pyproject_path: Path) -> str: + with pyproject_path.open("rb") as fh: + data = tomllib.load(fh) + version = data.get("project", {}).get("version") + if not isinstance(version, str) or not version: + raise ReleaseError(f"missing project.version in {pyproject_path}") + return version + + +def _dump_and_verify(path: Path, doc: tomlkit.TOMLDocument) -> None: + """Write `doc` then re-read with tomllib to confirm it parses.""" + path.write_text(tomlkit.dumps(doc), encoding="utf-8") + with path.open("rb") as fh: + tomllib.load(fh) # raises tomllib.TOMLDecodeError if we produced junk + + +def set_root_version(pyproject_path: Path, version: str) -> None: + parse_semver(version) + doc = tomlkit.parse(pyproject_path.read_text(encoding="utf-8")) + doc["project"]["version"] = version # type: ignore[index] + _dump_and_verify(pyproject_path, doc) + if read_project_version(pyproject_path) != version: + raise ReleaseError(f"parse-back failed: {pyproject_path} did not re-read as {version}") + + +def set_dependency_pin(pyproject_path: Path, name: str, version: str) -> None: + """Rewrite the `name[extras]==` pin in [project].dependencies, preserving extras.""" + parse_semver(version) + doc = tomlkit.parse(pyproject_path.read_text(encoding="utf-8")) + deps = doc["project"]["dependencies"] # type: ignore[index] + found = False + for i, dep in enumerate(deps): + m = _DEP_PIN_RE.match(str(dep)) + if m and m.group("name") == name: + extras = m.group("extras") or "" + rest = m.group("rest") or "" + deps[i] = f"{name}{extras}=={version}{rest}" + found = True + break + if not found: + raise ReleaseError(f"no `=={''}` pin for {name} in {pyproject_path}") + _dump_and_verify(pyproject_path, doc) + # parse-back assertion: the intended pin re-reads to the intended version + with pyproject_path.open("rb") as fh: + reread = tomllib.load(fh)["project"]["dependencies"] + expected = next((d for d in reread if d.split("==")[0].split("[")[0] == name), None) + if expected is None or expected.split("==", 1)[1].split(";")[0].strip() != version: + raise ReleaseError(f"parse-back failed: {name} pin in {pyproject_path} != {version}") + + +def promote_changelog(path: Path, version: str, *, release_date: str) -> None: + """Rename `## Unreleased` to `## X.Y.Z (DATE)`, preserving its body. + + Re-inserts a fresh empty `## Unreleased` above the promoted dated section. + """ + parse_semver(version) + text = path.read_text(encoding="utf-8") + m = _UNRELEASED_RE.search(text) + if m is None: + raise ReleaseError(f"no `## Unreleased` anchor in {path}") + # Replace the heading line in place, then prepend a new empty anchor. + dated = f"## {version} ({release_date})" + promoted = text[: m.start()] + dated + text[m.end() :] + new_text = promoted[: m.start()] + "## Unreleased\n\n" + promoted[m.start() :] + path.write_text(new_text, encoding="utf-8") + + +def rewrite_version_strings(text: str, *, old: str, new: str) -> str: + """Replace ONLY release-pattern occurrences of `old` with `new`. + + Deliberately skips `--version ` flag examples (the documented + §3 exception) so they stay shape-only — the lockstep test enforces this. + """ + o = re.escape(old) + patterns = [ + (rf"(What's New in ){o}", rf"\g<1>{new}"), + (rf"(pythinker-code==){o}", rf"\g<1>{new}"), + (rf"(PythinkerSetup-){o}(\.exe)", rf"\g<1>{new}\g<2>"), + (rf"(pythinker-code_){o}(_[a-z0-9]+\.deb)", rf"\g<1>{new}\g<2>"), + (rf"(pythinker-code-){o}(\.[a-z0-9_]+\.rpm)", rf"\g<1>{new}\g<2>"), + (rf"(releases/download/v){o}(/)", rf"\g<1>{new}\g<2>"), + ] + for pat, repl in patterns: + text = re.sub(pat, repl, text) + return text + + +def rewrite_version_in_files(paths: list[Path], *, old: str, new: str) -> None: + for path in paths: + original = path.read_text(encoding="utf-8") + path.write_text(rewrite_version_strings(original, old=old, new=new), encoding="utf-8") + + +def _run(cmd: list[str], *, dry_run: bool, check: bool = True) -> subprocess.CompletedProcess[str]: + if dry_run: + print(f"[dry-run] {' '.join(cmd)}") + return subprocess.CompletedProcess(cmd, 0, "", "") + print(f"$ {' '.join(cmd)}") + return subprocess.run(cmd, cwd=REPO_ROOT, text=True, check=check) + + +def _git_capture(cmd: list[str]) -> str: + return subprocess.run( + cmd, cwd=REPO_ROOT, text=True, capture_output=True, check=True + ).stdout.strip() + + +def validate(target: str) -> None: + """Phase 1 — fail loud, no writes.""" + parse_semver(target) + if _git_capture(["git", "status", "--porcelain"]): + raise ReleaseError("working tree is not clean; commit or stash first") + _git_capture(["git", "fetch", "origin"]) + local = _git_capture(["git", "rev-parse", "main"]) + remote = _git_capture(["git", "rev-parse", "origin/main"]) + if local != remote: + raise ReleaseError("local main != origin/main; rebase onto origin/main first") + assert_monotonic(current=read_project_version(ROOT_PYPROJECT), target=target) + # Assert the `## Unreleased` anchor in ALL changelog files BEFORE any write + # (same list rewrite() promotes) so Phase 2 cannot partially rewrite the tree. + for changelog in CHANGELOG_FILES: + if _UNRELEASED_RE.search(changelog.read_text(encoding="utf-8")) is None: + raise ReleaseError(f"{changelog} has no `## Unreleased` section") + # The primary CHANGELOG's body may legitimately be empty (CI-only/docs release): + # warn, do not abort. + primary = CHANGELOG_FILES[0].read_text(encoding="utf-8") + m = _UNRELEASED_RE.search(primary) + assert m is not None # guaranteed by the loop above + body = primary[m.end() :].split("\n## ", 1)[0].strip() + if not body: + print("warning: `## Unreleased` body is empty (CI-only/docs release?)") + + +def rewrite(target: str, *, bump_core: str | None, bump_host: str | None) -> None: + """Phase 2 — rewrite all derived files before regenerating uv.lock.""" + old = read_project_version(ROOT_PYPROJECT) + set_root_version(ROOT_PYPROJECT, target) + if bump_core: + set_root_version(CORE_PYPROJECT, bump_core) + set_dependency_pin(ROOT_PYPROJECT, "pythinker-core", bump_core) + if bump_host: + set_root_version(HOST_PYPROJECT, bump_host) + set_dependency_pin(ROOT_PYPROJECT, "pythinker-host", bump_host) + today = date.today().isoformat() + for changelog in CHANGELOG_FILES: + promote_changelog(changelog, target, release_date=today) + rewrite_version_in_files( + [ + REPO_ROOT / "README.md", + REPO_ROOT / "packages" / "linux-installer" / "README.md", + REPO_ROOT / "docs" / "en" / "guides" / "getting-started.md", + ], + old=old, + new=target, + ) + + +GATES = [ + [ + "uv", + "run", + "python", + "scripts/check_version_tag.py", + "--pyproject", + "pyproject.toml", + "--expected-version", + "{target}", + ], + [ + "uv", + "run", + "python", + "scripts/check_pythinker_dependency_versions.py", + "--root-pyproject", + "pyproject.toml", + "--pythinker-core-pyproject", + "packages/pythinker-core/pyproject.toml", + "--pythinker-host-pyproject", + "packages/pythinker-host/pyproject.toml", + "--pythinker-review-pyproject", + "packages/pythinker-review/pyproject.toml", + ], + ["uv", "sync", "--frozen", "--all-extras", "--all-packages"], + ["uv", "run", "pytest", "tests/test_version_lockstep.py", "-q"], +] + + +def run_gates(target: str) -> None: + """Phase 3 — the same gates CI runs; abort before push on any failure.""" + for tmpl in GATES: + cmd = [part.format(target=target) for part in tmpl] + result = subprocess.run(cmd, cwd=REPO_ROOT, text=True) + if result.returncode != 0: + raise ReleaseError(f"local gate failed: {' '.join(cmd)}") + # README/CHANGELOG fixed-string greps (grep -qF, not regex). + for needle, path in ( + (f"What's New in {target}", "README.md"), + (f"pythinker-code=={target}", "README.md"), + (f"## {target} (", "CHANGELOG.md"), + ): + if subprocess.run(["grep", "-qF", needle, path], cwd=REPO_ROOT).returncode != 0: + raise ReleaseError(f"expected string {needle!r} not found in {path}") + + +def open_pr(target: str, *, bump_core: str | None, bump_host: str | None, dry_run: bool) -> None: + """Phase 4 — branch + commit + push + PR (never main, C1).""" + branch = f"release/{target}" + _run(["git", "switch", "-c", branch], dry_run=dry_run) + _run(["git", "add", "-A"], dry_run=dry_run) + _run(["git", "commit", "-m", f"chore(release): prepare {target}"], dry_run=dry_run) + _run(["git", "push", "-u", "origin", branch], dry_run=dry_run) + _run( + [ + "gh", + "pr", + "create", + "--base", + "main", + "--head", + branch, + "--title", + f"chore(release): prepare {target}", + "--body", + f"Automated release prep for {target}. Tag after merge (C1).", + ], + dry_run=dry_run, + ) + print("\nAfter the PR merges and CodeRabbit status is success, push the tag(s):") + if bump_core: + print(f" git tag pythinker-core-{bump_core} && git push origin pythinker-core-{bump_core}") + if bump_host: + print(f" git tag pythinker-host-{bump_host} && git push origin pythinker-host-{bump_host}") + if bump_core or bump_host: + print(" # wait for the sub-package OIDC publish jobs to land on PyPI, THEN:") + print(f" git tag v{target} && git push origin v{target}") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Prepare a pythinker-code release PR.") + parser.add_argument("--set-version", required=True, help="target X.Y.Z") + parser.add_argument("--bump-core", default=None, help="new pythinker-core A.B.C") + parser.add_argument("--bump-host", default=None, help="new pythinker-host A.B.C") + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + + target = args.set_version + try: + validate(target) + if args.dry_run: + print( + f"[dry-run] would rewrite SSOT -> {target}" + + (f", core -> {args.bump_core}" if args.bump_core else "") + + (f", host -> {args.bump_host}" if args.bump_host else "") + ) + print("[dry-run] would run: uv lock; gates; branch+PR") + open_pr(target, bump_core=args.bump_core, bump_host=args.bump_host, dry_run=True) + return 0 + rewrite(target, bump_core=args.bump_core, bump_host=args.bump_host) + _run(["uv", "lock"], dry_run=False) + run_gates(target) + open_pr(target, bump_core=args.bump_core, bump_host=args.bump_host, dry_run=False) + except ReleaseError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_release_py.py b/tests/test_release_py.py index 4b1142f3..864214cb 100644 --- a/tests/test_release_py.py +++ b/tests/test_release_py.py @@ -1,12 +1,21 @@ from __future__ import annotations +import importlib.util import subprocess import sys +import tomllib from pathlib import Path +import pytest + REPO_ROOT = Path(__file__).resolve().parents[1] DEP_CHECK = REPO_ROOT / "scripts" / "check_pythinker_dependency_versions.py" +_spec = importlib.util.spec_from_file_location("release_tool", REPO_ROOT / "scripts" / "release.py") +assert _spec and _spec.loader +release_tool = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(release_tool) + def _write(tmp_path: Path, name: str, body: str) -> Path: p = tmp_path / name @@ -32,12 +41,18 @@ def test_dep_check_passes_when_review_pin_matches(tmp_path: Path) -> None: ) core = _write(tmp_path, "core.toml", '[project]\nname="pythinker-core"\nversion="1.1.1"\n') host = _write(tmp_path, "host.toml", '[project]\nname="pythinker-host"\nversion="1.0.0"\n') - review = _write(tmp_path, "review.toml", '[project]\nname="pythinker-review"\nversion="0.1.0"\n') + review = _write( + tmp_path, "review.toml", '[project]\nname="pythinker-review"\nversion="0.1.0"\n' + ) result = _run_dep_check( - "--root-pyproject", str(root), - "--pythinker-core-pyproject", str(core), - "--pythinker-host-pyproject", str(host), - "--pythinker-review-pyproject", str(review), + "--root-pyproject", + str(root), + "--pythinker-core-pyproject", + str(core), + "--pythinker-host-pyproject", + str(host), + "--pythinker-review-pyproject", + str(review), ) assert result.returncode == 0, result.stderr @@ -52,12 +67,136 @@ def test_dep_check_fails_when_review_pin_drifts(tmp_path: Path) -> None: ) core = _write(tmp_path, "core.toml", '[project]\nname="pythinker-core"\nversion="1.1.1"\n') host = _write(tmp_path, "host.toml", '[project]\nname="pythinker-host"\nversion="1.0.0"\n') - review = _write(tmp_path, "review.toml", '[project]\nname="pythinker-review"\nversion="0.2.0"\n') + review = _write( + tmp_path, "review.toml", '[project]\nname="pythinker-review"\nversion="0.2.0"\n' + ) result = _run_dep_check( - "--root-pyproject", str(root), - "--pythinker-core-pyproject", str(core), - "--pythinker-host-pyproject", str(host), - "--pythinker-review-pyproject", str(review), + "--root-pyproject", + str(root), + "--pythinker-core-pyproject", + str(core), + "--pythinker-host-pyproject", + str(host), + "--pythinker-review-pyproject", + str(review), ) assert result.returncode == 1 assert "pythinker-review version mismatch" in result.stderr + + +def test_parse_semver_accepts_xyz() -> None: + assert release_tool.parse_semver("0.28.0") == (0, 28, 0) + + +def test_parse_semver_rejects_non_xyz() -> None: + with pytest.raises(release_tool.ReleaseError): + release_tool.parse_semver("0.28") + with pytest.raises(release_tool.ReleaseError): + release_tool.parse_semver("v0.28.0") + + +def test_assert_monotonic_allows_increase() -> None: + release_tool.assert_monotonic(current="0.27.0", target="0.28.0") + + +def test_assert_monotonic_rejects_equal_or_lower() -> None: + with pytest.raises(release_tool.ReleaseError): + release_tool.assert_monotonic(current="0.27.0", target="0.27.0") + with pytest.raises(release_tool.ReleaseError): + release_tool.assert_monotonic(current="0.27.0", target="0.26.0") + + +def test_set_root_version_rewrites_and_parses_back(tmp_path: Path) -> None: + src = ( + '[project]\nname = "pythinker-code"\nversion = "0.27.0"\n' + "dependencies = [\n" + ' "pythinker-core[contrib]==1.1.1",\n' + ' "pythinker-host==1.0.0",\n' + ' "pythinker-review==0.1.0",\n' + "]\n" + ) + p = tmp_path / "pyproject.toml" + p.write_text(src, encoding="utf-8") + release_tool.set_root_version(p, "0.28.0") + assert release_tool.read_project_version(p) == "0.28.0" + + +def test_set_dependency_pin_updates_extras_form(tmp_path: Path) -> None: + src = ( + '[project]\nname = "x"\nversion = "0.1.0"\n' + 'dependencies = [\n "pythinker-core[contrib]==1.1.1",\n "rich==15.0.0",\n]\n' + ) + p = tmp_path / "pyproject.toml" + p.write_text(src, encoding="utf-8") + release_tool.set_dependency_pin(p, "pythinker-core", "1.2.0") + with p.open("rb") as fh: + deps = tomllib.load(fh)["project"]["dependencies"] + assert "pythinker-core[contrib]==1.2.0" in deps + assert "rich==15.0.0" in deps # untouched + + +def test_set_dependency_pin_rejects_missing(tmp_path: Path) -> None: + src = '[project]\nname="x"\nversion="0.1.0"\ndependencies=["rich==15.0.0"]\n' + p = tmp_path / "pyproject.toml" + p.write_text(src, encoding="utf-8") + with pytest.raises(release_tool.ReleaseError): + release_tool.set_dependency_pin(p, "pythinker-core", "1.2.0") + + +def test_promote_changelog_preserves_body_and_reinserts_unreleased(tmp_path: Path) -> None: + src = ( + "# Changelog\n\n" + "## Unreleased\n\n" + "- **Did a thing.** Detail line.\n\n" + "## 0.27.0 (2026-05-31)\n\n- Older entry.\n" + ) + p = tmp_path / "CHANGELOG.md" + p.write_text(src, encoding="utf-8") + release_tool.promote_changelog(p, "0.28.0", release_date="2026-06-01") + out = p.read_text(encoding="utf-8") + assert "## Unreleased\n" in out # empty anchor re-inserted + assert "## 0.28.0 (2026-06-01)\n" in out + assert "- **Did a thing.** Detail line." in out # authored body preserved + # the new dated section sits above the previous release + assert out.index("## 0.28.0 (2026-06-01)") < out.index("## 0.27.0 (2026-05-31)") + # the empty Unreleased anchor sits above the new dated section + assert out.index("## Unreleased") < out.index("## 0.28.0 (2026-06-01)") + + +def test_promote_changelog_empty_unreleased_is_ok(tmp_path: Path) -> None: + src = "# Changelog\n\n## Unreleased\n\n## 0.27.0 (2026-05-31)\n\n- Older.\n" + p = tmp_path / "CHANGELOG.md" + p.write_text(src, encoding="utf-8") + release_tool.promote_changelog(p, "0.28.0", release_date="2026-06-01") + out = p.read_text(encoding="utf-8") + assert "## 0.28.0 (2026-06-01)" in out + assert "## Unreleased" in out + + +def test_promote_changelog_missing_anchor_raises(tmp_path: Path) -> None: + p = tmp_path / "CHANGELOG.md" + p.write_text("# Changelog\n\n## 0.27.0 (2026-05-31)\n", encoding="utf-8") + with pytest.raises(release_tool.ReleaseError): + release_tool.promote_changelog(p, "0.28.0", release_date="2026-06-01") + + +def test_rewrite_version_strings_targets_only_release_patterns() -> None: + text = ( + "## 🆕 What's New in 0.27.0\n" + "pip install --upgrade pythinker-code==0.27.0\n" + "PythinkerSetup-0.27.0.exe\n" + "pythinker-code_0.27.0_amd64.deb\n" + "pythinker-code-0.27.0.x86_64.rpm\n" + "releases/download/v0.27.0/pythinker-code_0.27.0_arm64.deb\n" + "bash -s -- --version 0.27.0\n" # flag example: MUST be preserved + ) + out = release_tool.rewrite_version_strings(text, old="0.27.0", new="0.28.0") + assert "## 🆕 What's New in 0.28.0" in out + assert "pythinker-code==0.28.0" in out + assert "PythinkerSetup-0.28.0.exe" in out + assert "pythinker-code_0.28.0_amd64.deb" in out + assert "pythinker-code-0.28.0.x86_64.rpm" in out + assert "releases/download/v0.28.0/pythinker-code_0.28.0_arm64.deb" in out + # the flag example is the documented exception — untouched + assert "--version 0.27.0" in out + assert "--version 0.28.0" not in out From e1ff9254b1f297347dc9c4f1693068b52d2b88a2 Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Sun, 31 May 2026 20:24:13 -0400 Subject: [PATCH 4/9] test(release): add version lockstep guard for every PR --- tests/test_version_lockstep.py | 94 ++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 tests/test_version_lockstep.py diff --git a/tests/test_version_lockstep.py b/tests/test_version_lockstep.py new file mode 100644 index 00000000..f0c3050c --- /dev/null +++ b/tests/test_version_lockstep.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import re +import tomllib +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +SEMVER = r"\d+\.\d+\.\d+" + + +def _version(rel: str) -> str: + with (REPO_ROOT / rel).open("rb") as fh: + return tomllib.load(fh)["project"]["version"] + + +def _root_deps() -> list[str]: + with (REPO_ROOT / "pyproject.toml").open("rb") as fh: + return tomllib.load(fh)["project"]["dependencies"] + + +def _pin(name: str) -> str: + for dep in _root_deps(): + head = dep.split("==", 1) + if len(head) == 2 and head[0].split("[")[0] == name: + return head[1].split(";")[0].strip() + raise AssertionError(f"no =={''} pin for {name}") + + +VERSION = _version("pyproject.toml") + + +def test_version_is_semver() -> None: + assert re.fullmatch(SEMVER, VERSION), VERSION + + +def test_subpackage_pins_match_versions() -> None: + assert _pin("pythinker-core") == _version("packages/pythinker-core/pyproject.toml") + assert _pin("pythinker-host") == _version("packages/pythinker-host/pyproject.toml") + assert _pin("pythinker-review") == _version("packages/pythinker-review/pyproject.toml") + + +def test_review_is_frozen_at_0_1_0() -> None: + assert _pin("pythinker-review") == "0.1.0" + + +def test_readme_heading_and_pip_snippet() -> None: + readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") + assert f"What's New in {VERSION}" in readme + assert f"pythinker-code=={VERSION}" in readme + + +def test_changelog_has_dated_heading_for_version() -> None: + changelog = (REPO_ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + assert f"## {VERSION} (" in changelog + + +def test_asset_names_match_version_across_files() -> None: + files = [ + REPO_ROOT / "README.md", + REPO_ROOT / "packages" / "linux-installer" / "README.md", + REPO_ROOT / "docs" / "en" / "guides" / "getting-started.md", + ] + # Each asset shape, where present, must carry VERSION (never a stale one). + shape_res = [ + re.compile(rf"PythinkerSetup-({SEMVER})\.exe"), + re.compile(rf"pythinker-code_({SEMVER})_[a-z0-9]+\.deb"), + re.compile(rf"pythinker-code-({SEMVER})\.[a-z0-9_]+\.rpm"), + re.compile(rf"releases/download/v({SEMVER})/"), + ] + for path in files: + text = path.read_text(encoding="utf-8") + for rx in shape_res: + for found in rx.findall(text): + assert found == VERSION, f"{path}: {found} != {VERSION}" + + +def test_no_hardcoded_version_badge_in_readme() -> None: + # Guard the contract's "badges" clause: the only version-bearing badge is the + # shields.io-live PyPI badge (img.shields.io/pypi/v/...). Fail if a future edit + # hardcodes VERSION into a shields.io badge label/path, which would silently drift. + readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") + for line in readme.splitlines(): + if "img.shields.io" in line and re.search(rf"badge/[^)]*{re.escape(VERSION)}", line): + raise AssertionError(f"hardcoded-version badge found: {line!r}") + + +def test_install_flag_examples_are_valid_semver_shape_only() -> None: + # The documented §3 exception: `--version ` teaches flag syntax and + # is NOT lockstepped to VERSION — only asserted to be valid semver shape. + flag_re = re.compile(rf"--version ({SEMVER})") + for rel in ("README.md", "docs/en/guides/getting-started.md"): + text = (REPO_ROOT / rel).read_text(encoding="utf-8") + for found in flag_re.findall(text): + assert re.fullmatch(SEMVER, found), found From 89662f6dc0c501f93d2c4ce8b86faa049a20ed9b Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Sun, 31 May 2026 20:25:02 -0400 Subject: [PATCH 5/9] feat(update): add PYTHINKER_MANAGED channel hint --- src/pythinker_code/ui/shell/update.py | 22 +++++++++++++- tests/ui_and_conv/test_shell_update.py | 41 ++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/pythinker_code/ui/shell/update.py b/src/pythinker_code/ui/shell/update.py index 3493cdcb..db2fd2e0 100644 --- a/src/pythinker_code/ui/shell/update.py +++ b/src/pythinker_code/ui/shell/update.py @@ -61,6 +61,7 @@ _skipped_version_this_session: str | None = None NATIVE_INSTALLER_MARKER = "__pythinker_native_installer__" +MANAGED_CHANNEL_MARKER = "__pythinker_managed_channel__" class UpdateResult(Enum): @@ -96,6 +97,13 @@ def semver_tuple(version: str) -> tuple[int, int, int]: def _detect_upgrade_command() -> list[str]: """Pick the right upgrade argv based on how this interpreter was installed.""" + # Channel-managed installs (Docker/Nix/Scoop/WinGet) export PYTHINKER_MANAGED + # so the updater emits a channel-native hint instead of shelling pip/uv. + # Brew deliberately does NOT set it — its cellar path-sniff below is the + # load-bearing, behavior-unchanged path. + managed = os.environ.get("PYTHINKER_MANAGED") + if managed: + return [MANAGED_CHANNEL_MARKER, managed] exe = sys.executable.replace("\\", "/").lower() if "/cellar/pythinker-code/" in exe or "/homebrew/cellar/pythinker-code/" in exe: return ["brew", "upgrade", "pythinker-code"] @@ -615,7 +623,11 @@ async def _prompt_update_selection( def _update_prompt_text(current_version: str, latest_version: str) -> Text: upgrade_command = _detect_upgrade_command() - if upgrade_command == [NATIVE_INSTALLER_MARKER]: + if upgrade_command[:1] == [MANAGED_CHANNEL_MARKER]: + update_method = ( + f"managed by {upgrade_command[1]} — update via your {upgrade_command[1]} channel" + ) + elif upgrade_command == [NATIVE_INSTALLER_MARKER]: update_method = "downloads the native updater automatically" else: update_method = _format_upgrade_command(upgrade_command) @@ -1213,6 +1225,14 @@ def _print(message: str) -> None: return UpdateResult.UP_TO_DATE upgrade_command = _detect_upgrade_command() + if upgrade_command[:1] == [MANAGED_CHANNEL_MARKER]: + channel = upgrade_command[1] + _print( + f"[{_t.warning}]Pythinker is managed by your {channel} channel. " + f"Update {current_version} → {latest_version} via {channel} " + "(rebuild/repull the image or run the channel's upgrade command).[/]" + ) + return UpdateResult.UPDATE_AVAILABLE unavailable_reason = await _update_candidate_unavailable_reason( session, latest_version, upgrade_command ) diff --git a/tests/ui_and_conv/test_shell_update.py b/tests/ui_and_conv/test_shell_update.py index 77ba083a..6fcca22c 100644 --- a/tests/ui_and_conv/test_shell_update.py +++ b/tests/ui_and_conv/test_shell_update.py @@ -1194,3 +1194,44 @@ def test_consume_whats_new_suppressed_for_source_checkout(monkeypatch, tmp_path) monkeypatch.setattr(update, "_is_running_from_source_checkout", lambda: True) assert update.consume_whats_new() is None + + +def test_brew_unchanged_when_pythinker_managed_unset(monkeypatch): + monkeypatch.delenv("PYTHINKER_MANAGED", raising=False) + monkeypatch.setattr( + update.sys, + "executable", + "/opt/homebrew/Cellar/pythinker-code/0.27.0/libexec/bin/python", + ) + monkeypatch.setattr(update, "_is_native_build", lambda: False) + assert update._detect_upgrade_command() == ["brew", "upgrade", "pythinker-code"] + + +def test_brew_unchanged_even_with_native_marker(monkeypatch): + # The .pythinker-native marker also trips _is_native_build(); the cellar + # path-sniff must win first so brew installs stay on `brew upgrade`. + monkeypatch.delenv("PYTHINKER_MANAGED", raising=False) + monkeypatch.setattr( + update.sys, + "executable", + "/opt/homebrew/Cellar/pythinker-code/0.27.0/libexec/bin/python", + ) + monkeypatch.setattr(update, "_is_native_build", lambda: True) + assert update._detect_upgrade_command() == ["brew", "upgrade", "pythinker-code"] + + +def test_pythinker_managed_channel_short_circuits(monkeypatch): + monkeypatch.setenv("PYTHINKER_MANAGED", "docker") + monkeypatch.setattr(update.sys, "executable", "/usr/local/bin/python") + cmd = update._detect_upgrade_command() + assert cmd == [update.MANAGED_CHANNEL_MARKER, "docker"] + + +def test_update_prompt_text_renders_managed_channel_hint(monkeypatch): + # The contract requires a usable channel-native hint, not a raw marker. + monkeypatch.setenv("PYTHINKER_MANAGED", "docker") + monkeypatch.setattr(update.sys, "executable", "/usr/local/bin/python") + text = update._update_prompt_text("0.27.0", "0.28.0") + rendered = text.plain + assert "docker" in rendered + assert update.MANAGED_CHANNEL_MARKER not in rendered From 703550f9c39a9f49ddceca249f52122d26124709 Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Sun, 31 May 2026 20:25:25 -0400 Subject: [PATCH 6/9] test(release): assert changelog workflow skips release-prep PRs --- tests/test_release_update_pipeline.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_release_update_pipeline.py b/tests/test_release_update_pipeline.py index dc632b33..d6853b4b 100644 --- a/tests/test_release_update_pipeline.py +++ b/tests/test_release_update_pipeline.py @@ -126,3 +126,17 @@ def test_release_asset_wait_covers_all_updater_channels() -> None: 'version \\"${version}\\"', ): assert expected_readiness_marker in promote_workflow + + +def test_changelog_workflow_skips_release_prep_prs() -> None: + """release.py opens `release/X.Y.Z` PRs titled `chore(release): prepare X.Y.Z`. + + changelog-entry-required.yml MUST skip its required check for that shape, + or every release PR is blocked under branch protection. Assert both the + title guard and the head-branch guard so neither half silently regresses. + """ + wf = (WORKFLOWS / "changelog-entry-required.yml").read_text() + # Title guard: chore(release)* → skip. + assert '"chore(release)"*)' in wf, "missing chore(release) title skip" + # Head-branch guard: release/* → skip. + assert "release/*)" in wf, "missing release/* branch skip" From 4910cf211754883636ebc3f4b8fc8a41cb006661 Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Sun, 31 May 2026 20:25:39 -0400 Subject: [PATCH 7/9] docs(release): repoint release skill at scripts/release.py --- .agents/skills/release/SKILL.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index ee424f1e..b79ece1d 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -21,8 +21,11 @@ confirm_versions: |md major only changes by explicit manual decision. | update_files: |md - Update the relevant pyproject.toml (and rust/Cargo.toml if root version changes), - CHANGELOG.md (keep the Unreleased header), and breaking-changes.md in both languages. + Run `uv run python scripts/release.py --set-version X.Y.Z [--bump-core A.B.C --bump-host A.B.C]`. + It rewrites pyproject.toml:3, the sub-package pins, uv.lock, all three changelog files + (preserving the authored Unreleased body), and the README/asset names from the single + source of truth, then runs the local gates and opens the `release/X.Y.Z` PR. + There is no `--bump-review` (review is frozen at 0.1.0). | root_change: "Is the root package version changing?" sync_pythinker_code: |md @@ -32,7 +35,7 @@ sync_pythinker_code: |md sync_kagent: |md Sync rust/Cargo.toml workspace version to match the root package version. | -uv_sync: "Run uv sync." +uv_sync: "release.py already runs `uv lock` + `uv sync --frozen --all-extras --all-packages` as Phase-2/3 steps; no separate uv sync needed." gen_docs: |md Follow the gen-docs skill instructions to ensure docs are up to date. | From be56b79b49eb3077d203b6b67d212a7adbee04c5 Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Sun, 31 May 2026 20:32:40 -0400 Subject: [PATCH 8/9] docs(release): add changelog entry for release tooling --- CHANGELOG.md | 1 + docs/en/release-notes/changelog.md | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32ae8c04..0c2ac53a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **Release preparation now uses a version single source of truth.** `scripts/release.py` rewrites derived release files from `pyproject.toml`, verifies version lockstep on every PR, enforces the frozen `pythinker-review==0.1.0` pin, and managed-channel installs now show channel-native update guidance instead of trying to self-update. - CI and release workflows now run on Node.js 24-backed GitHub Actions, pin action revisions to immutable commits, and preflight optional website/tap GitHub App credentials with clear errors or notices instead of opaque token failures. - Release pipeline: migrate the pythinker-home website-sync dispatch to the org-owned `pythinker-release-bot` GitHub App and fail loud on an empty token; retire the dead pythinker-core API-docs gh-pages publish step; add exponential backoff to the native install scripts and fix the Windows installer's release-pagination cliff. - **Redesigned startup welcome banner.** The banner now uses a cleaner footer-chip layout: the "What's new / Update available" chip sits on the panel's bottom border, the headline/strapline/help lines align beside the robot logo, and the info grid drops its vertical separator. The robot art and palette are unchanged. diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index cef00ff4..3ca86fba 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -17,6 +17,7 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **Release preparation now uses a version single source of truth.** `scripts/release.py` rewrites derived release files from `pyproject.toml`, verifies version lockstep on every PR, enforces the frozen `pythinker-review==0.1.0` pin, and managed-channel installs now show channel-native update guidance instead of trying to self-update. - **Redesigned startup welcome banner.** The banner now uses a cleaner footer-chip layout: the "What's new / Update available" chip sits on the panel's bottom border, the headline/strapline/help lines align beside the robot logo, and the info grid drops its vertical separator. The robot art and palette are unchanged. - **Terminal-aware rendering for minimal and CI terminals.** The shell UI adapts to the terminal — ASCII glyph fallbacks for `TERM=dumb` and legacy Windows code pages, reduced-motion mode (`PYTHINKER_REDUCED_MOTION`), and `NO_COLOR`/`CLICOLOR` support that strips color cleanly — so output stays readable in CI logs, SSH panes, and bare terminals. - **Windows updates avoid encoded PowerShell.** Native updates now launch the signed Inno installer directly with Restart Manager flags instead of a `powershell.exe -EncodedCommand` helper, reducing antivirus command-line heuristic false positives. Windows bootstrap installs use visible `/SILENT` progress instead of fully suppressed setup, and the installer build signs bundled PE files plus Inno's setup/uninstaller/temp copies when signing credentials are configured. From 55392185ecf9a7e708112ce1ae91fa978e6fb161 Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Sun, 31 May 2026 20:49:08 -0400 Subject: [PATCH 9/9] fix(release): address CodeRabbit release tool findings --- .github/workflows/ci-pythinker-cli.yml | 7 ++++++ .github/workflows/release-pythinker-cli.yml | 5 ++++ docs/en/release-notes/changelog.md | 1 - scripts/release.py | 13 +++++++++- src/pythinker_code/ui/shell/update.py | 4 +++ tests/test_release_py.py | 11 +++++++++ tests/ui_and_conv/test_shell_update.py | 27 +++++++++++++++++++++ 7 files changed, 66 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-pythinker-cli.yml b/.github/workflows/ci-pythinker-cli.yml index 77c893c9..d57deb8d 100644 --- a/.github/workflows/ci-pythinker-cli.yml +++ b/.github/workflows/ci-pythinker-cli.yml @@ -212,6 +212,13 @@ jobs: python-version: "3.14" allow-prereleases: true + - name: Set up uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # pinned from v8.1.0 + with: + version: "0.8.5" + enable-cache: true + cache-dependency-glob: uv.lock + - name: Detect version bump id: version shell: python diff --git a/.github/workflows/release-pythinker-cli.yml b/.github/workflows/release-pythinker-cli.yml index 0d2b3b7b..7cb85b38 100644 --- a/.github/workflows/release-pythinker-cli.yml +++ b/.github/workflows/release-pythinker-cli.yml @@ -46,6 +46,11 @@ jobs: python-version: "3.14" allow-prereleases: true + - name: Set up uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # pinned from v8.1.0 + with: + version: "0.8.5" + - name: Check version tag run: | python scripts/check_version_tag.py \ diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 3ca86fba..cef00ff4 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -17,7 +17,6 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased -- **Release preparation now uses a version single source of truth.** `scripts/release.py` rewrites derived release files from `pyproject.toml`, verifies version lockstep on every PR, enforces the frozen `pythinker-review==0.1.0` pin, and managed-channel installs now show channel-native update guidance instead of trying to self-update. - **Redesigned startup welcome banner.** The banner now uses a cleaner footer-chip layout: the "What's new / Update available" chip sits on the panel's bottom border, the headline/strapline/help lines align beside the robot logo, and the info grid drops its vertical separator. The robot art and palette are unchanged. - **Terminal-aware rendering for minimal and CI terminals.** The shell UI adapts to the terminal — ASCII glyph fallbacks for `TERM=dumb` and legacy Windows code pages, reduced-motion mode (`PYTHINKER_REDUCED_MOTION`), and `NO_COLOR`/`CLICOLOR` support that strips color cleanly — so output stays readable in CI logs, SSH panes, and bare terminals. - **Windows updates avoid encoded PowerShell.** Native updates now launch the signed Inno installer directly with Restart Manager flags instead of a `powershell.exe -EncodedCommand` helper, reducing antivirus command-line heuristic false positives. Windows bootstrap installs use visible `/SILENT` progress instead of fully suppressed setup, and the installer build signs bundled PE files plus Inno's setup/uninstaller/temp copies when signing credentials are configured. diff --git a/scripts/release.py b/scripts/release.py index cd90f529..ed9f9654 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -180,6 +180,9 @@ def validate(target: str) -> None: remote = _git_capture(["git", "rev-parse", "origin/main"]) if local != remote: raise ReleaseError("local main != origin/main; rebase onto origin/main first") + head = _git_capture(["git", "rev-parse", "HEAD"]) + if head != remote: + raise ReleaseError("current HEAD is not origin/main; switch to main before release prep") assert_monotonic(current=read_project_version(ROOT_PYPROJECT), target=target) # Assert the `## Unreleased` anchor in ALL changelog files BEFORE any write # (same list rewrite() promotes) so Phase 2 cannot partially rewrite the tree. @@ -270,7 +273,7 @@ def run_gates(target: str) -> None: def open_pr(target: str, *, bump_core: str | None, bump_host: str | None, dry_run: bool) -> None: """Phase 4 — branch + commit + push + PR (never main, C1).""" branch = f"release/{target}" - _run(["git", "switch", "-c", branch], dry_run=dry_run) + _run(["git", "switch", "-c", branch, "origin/main"], dry_run=dry_run) _run(["git", "add", "-A"], dry_run=dry_run) _run(["git", "commit", "-m", f"chore(release): prepare {target}"], dry_run=dry_run) _run(["git", "push", "-u", "origin", branch], dry_run=dry_run) @@ -300,6 +303,11 @@ def open_pr(target: str, *, bump_core: str | None, bump_host: str | None, dry_ru print(f" git tag v{target} && git push origin v{target}") +def _format_called_process_error(exc: subprocess.CalledProcessError) -> str: + cmd = " ".join(str(part) for part in exc.cmd) if isinstance(exc.cmd, list) else str(exc.cmd) + return f"command failed ({exc.returncode}): {cmd}" + + def main() -> int: parser = argparse.ArgumentParser(description="Prepare a pythinker-code release PR.") parser.add_argument("--set-version", required=True, help="target X.Y.Z") @@ -324,6 +332,9 @@ def main() -> int: _run(["uv", "lock"], dry_run=False) run_gates(target) open_pr(target, bump_core=args.bump_core, bump_host=args.bump_host, dry_run=False) + except subprocess.CalledProcessError as exc: + print(f"error: {_format_called_process_error(exc)}", file=sys.stderr) + return 1 except ReleaseError as exc: print(f"error: {exc}", file=sys.stderr) return 1 diff --git a/src/pythinker_code/ui/shell/update.py b/src/pythinker_code/ui/shell/update.py index db2fd2e0..71a1f0ba 100644 --- a/src/pythinker_code/ui/shell/update.py +++ b/src/pythinker_code/ui/shell/update.py @@ -1227,6 +1227,10 @@ def _print(message: str) -> None: upgrade_command = _detect_upgrade_command() if upgrade_command[:1] == [MANAGED_CHANNEL_MARKER]: channel = upgrade_command[1] + try: + LATEST_VERSION_FILE.write_text(latest_version, encoding="utf-8") + except OSError: + logger.exception("Failed to cache latest version:") _print( f"[{_t.warning}]Pythinker is managed by your {channel} channel. " f"Update {current_version} → {latest_version} via {channel} " diff --git a/tests/test_release_py.py b/tests/test_release_py.py index 864214cb..df9760ff 100644 --- a/tests/test_release_py.py +++ b/tests/test_release_py.py @@ -180,6 +180,17 @@ def test_promote_changelog_missing_anchor_raises(tmp_path: Path) -> None: release_tool.promote_changelog(p, "0.28.0", release_date="2026-06-01") +def test_open_pr_dry_run_branches_from_origin_main(capsys: pytest.CaptureFixture[str]) -> None: + release_tool.open_pr("0.28.0", bump_core=None, bump_host=None, dry_run=True) + out = capsys.readouterr().out + assert "[dry-run] git switch -c release/0.28.0 origin/main" in out + + +def test_format_called_process_error_includes_returncode_and_command() -> None: + exc = subprocess.CalledProcessError(7, ["git", "fetch", "origin"]) + assert release_tool._format_called_process_error(exc) == "command failed (7): git fetch origin" + + def test_rewrite_version_strings_targets_only_release_patterns() -> None: text = ( "## 🆕 What's New in 0.27.0\n" diff --git a/tests/ui_and_conv/test_shell_update.py b/tests/ui_and_conv/test_shell_update.py index 6fcca22c..d7eaf006 100644 --- a/tests/ui_and_conv/test_shell_update.py +++ b/tests/ui_and_conv/test_shell_update.py @@ -529,6 +529,33 @@ async def test_update_candidate_waits_for_homebrew_formula_version(): assert "Homebrew formula is still publishing" in reason +@pytest.mark.asyncio +async def test_do_update_managed_check_only_caches_latest(monkeypatch, tmp_path): + latest_file = tmp_path / "latest.txt" + messages: list[str] = [] + + async def fake_get_latest(session) -> str: + return "999.0.0" + + async def fail_unavailable(session, latest_version: str, upgrade_command: list[str]) -> str: + raise AssertionError("managed channel must not run install-channel readiness checks") + + monkeypatch.setenv("PYTHINKER_MANAGED", "docker") + monkeypatch.setattr(update.sys, "executable", "/usr/local/bin/python") + monkeypatch.setattr(update, "LATEST_VERSION_FILE", latest_file) + monkeypatch.setattr(update, "_get_latest_version", fake_get_latest) + monkeypatch.setattr(update, "_update_candidate_unavailable_reason", fail_unavailable) + monkeypatch.setattr(update, "new_client_session", lambda timeout: _FakeSessionContext(object())) + + result = await update.do_update( + print_output=False, check_only=True, output_callback=messages.append + ) + + assert result is update.UpdateResult.UPDATE_AVAILABLE + assert latest_file.read_text(encoding="utf-8") == "999.0.0" + assert any("managed by your docker channel" in message for message in messages) + + @pytest.mark.asyncio async def test_do_update_does_not_cache_uninstallable_latest(monkeypatch, tmp_path): latest_file = tmp_path / "latest.txt"