scripts/apply-version.py
Rama main · réplica de lectura del repositorio original.
#!/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())