#!/usr/bin/env python3 """Propagate a new release version across every publishable surface. The repository's release contract spans ~25 files (see ``scripts/check-version-consistency.py``). release-please bumps the canonical root ``package.json`` and ``changelog.md``; this script propagates that version everywhere else so the release PR lands consistent. Each edit is already-applied tolerant and ``VERSION`` is written last, so an interrupted run can simply be re-run. It self-verifies by running the consistency checker. Usage: python scripts/apply-version.py v0.6.0-beta.2 """ from __future__ import annotations import importlib.util import re import subprocess import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] _spec = importlib.util.spec_from_file_location( "check_version_consistency", ROOT / "scripts" / "check-version-consistency.py" ) assert _spec is not None and _spec.loader is not None _checker = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(_checker) TAG_RE = re.compile(rf"^v{_checker.RELEASE_VERSION_PATTERN}$") CARGO_LOCKS = ( ("packages/core/ticks/Cargo.lock", "tick-engine"), ) def _replace_once(path: Path, pattern: str, replacement: str, already: str) -> None: """Apply one anchored substitution; tolerate an edit that already happened.""" text = path.read_text(encoding="utf-8") new_text, count = re.subn(pattern, replacement, text, count=1, flags=re.MULTILINE) if count == 1: path.write_text(new_text, encoding="utf-8") return if already in text: return raise SystemExit(f"apply-version: no match for {pattern!r} in {path.relative_to(ROOT)}") def _replace_all_tags(path: Path, old_tag: str, new_tag: str) -> None: """Replace both tag (v-prefixed) and bare mentions of the old version.""" text = path.read_text(encoding="utf-8") updated = text.replace(old_tag, new_tag).replace( old_tag.removeprefix("v"), new_tag.removeprefix("v") ) if updated != text: path.write_text(updated, encoding="utf-8") def _changelog_section(bare: str) -> str: """Extract the released section for *bare* from changelog.md, if present.""" text = (ROOT / "changelog.md").read_text(encoding="utf-8") match = re.search( rf"^## \[{re.escape(bare)}\][^\n]*\n(.*?)(?=^## \[|\Z)", text, re.MULTILINE | re.DOTALL ) return match.group(1).strip() if match else "" def _release_disclaimer(tag: str) -> str: """Return release-channel wording without labelling stable tags as beta.""" bare = tag.removeprefix("v") core, separator, prerelease = bare.partition("-") major = int(core.split(".", maxsplit=1)[0]) if separator: channel = "a beta prerelease" if prerelease.split(".", maxsplit=1)[0].lower() == "beta" else "a prerelease" elif major == 0: # Pre-1.0 versions are never "stable": 0.x is unstable by SemVer definition # and FlintTrade marks every 0.x release as a GitHub pre-release. channel = "a pre-release" else: channel = "a stable release" safety = ( " It is not production ready; use Explore and Practice modes before connecting any live broker workflow." if major == 0 or separator else "" ) return f"FlintTrade `{tag}` is {channel}.{safety}\n" def main() -> int: if len(sys.argv) != 2 or not TAG_RE.match(sys.argv[1]): print(__doc__) return 2 new_tag = sys.argv[1] new_bare = new_tag.removeprefix("v") old_tag = (ROOT / "VERSION").read_text(encoding="utf-8").strip() old_bare = old_tag.removeprefix("v") if old_tag != new_tag: _replace_once( ROOT / "flint.toml", rf'^version = "{re.escape(old_tag)}"$', f'version = "{new_tag}"', already=f'version = "{new_tag}"', ) for rel in _checker.PACKAGE_JSONS: _replace_once( ROOT / rel, rf'^(\s*)"version": "{re.escape(old_bare)}"', rf'\1"version": "{new_bare}"', already=f'"version": "{new_bare}"', ) for path in sorted((ROOT / "packages").glob("*/*/pyproject.toml")): _replace_once( path, rf'^version = "{re.escape(old_bare)}"$', f'version = "{new_bare}"', already=f'version = "{new_bare}"', ) for rel in _checker.CARGO_MANIFESTS: _replace_once( ROOT / rel, rf'^version = "{re.escape(old_bare)}"$', f'version = "{new_bare}"', already=f'version = "{new_bare}"', ) # Keep the committed lockfiles in step so --locked builds stay green. for lock, crate in CARGO_LOCKS: _replace_once( ROOT / lock, rf'^(name = "{crate}"\n)version = "{re.escape(old_bare)}"$', rf'\1version = "{new_bare}"', already=f'name = "{crate}"\nversion = "{new_bare}"', ) _replace_once( ROOT / "packages/core/ticks/python/tick_engine/__init__.py", rf'^__version__ = "{re.escape(old_bare)}"$', f'__version__ = "{new_bare}"', already=f'__version__ = "{new_bare}"', ) for rel in _checker.CURRENT_TAG_FILES: _replace_all_tags(ROOT / rel, old_tag, new_tag) # Last, so an interrupted run keeps the old tag and can be re-run. (ROOT / "VERSION").write_text(f"{new_tag}\n", encoding="utf-8") # Release note: create from the changelog section (release-please writes it # before this script runs in the release PR), or a stub the maintainer edits. note = ROOT / f"docs/releases/{new_tag}.md" if not note.exists(): body = _changelog_section(new_bare) or "_Release notes pending._" # The stability disclaimer is part of the release contract: # tests/test_restructure_goals.py pins "not production ready" in every # current release note while the project is pre-1.0. disclaimer = _release_disclaimer(new_tag) note.write_text(f"# FlintTrade {new_tag}\n\n{disclaimer}\n{body}\n", encoding="utf-8") generator = ROOT / "packages/apps/site/scripts/generate-content.mjs" gen_text = generator.read_text(encoding="utf-8") if f"docs/releases/{new_tag}.md" not in gen_text: entry = ( f" ['docs/releases/{new_tag}.md', 'releases/{new_tag}', 'Releases', " f"'FlintTrade {new_tag} release notes'],\n" ) gen_text, count = re.subn( r"^const releaseDocs = \[\n", f"const releaseDocs = [\n{entry}", gen_text, count=1, flags=re.MULTILINE ) if count != 1: raise SystemExit("apply-version: could not find releaseDocs in generate-content.mjs") generator.write_text(gen_text, encoding="utf-8") result = subprocess.run([sys.executable, str(ROOT / "scripts/check-version-consistency.py")], check=False) return result.returncode if __name__ == "__main__": sys.exit(main())