import argparse
import json
import re
import sys
from pathlib import Path
from check_version_sync import check, manifest_version, release_line
ROOT = Path(__file__).resolve().parent.parent
_STRICT_VERSION = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
FOLLOW_UP = {
"crate": "merge the release PR — on merge it is tagged v{v} and the"
" crate publishes",
"node": "merge the release PR — the Node publish is dispatched"
" automatically on merge (for a rehearsal, run 'Node publish'"
" with dry_run before merging)",
"python": "merge the release PR — the Python publish is dispatched"
" automatically on merge (for a rehearsal, run it with"
" target=testpypi before merging)",
"all": "merge the release PR — on merge it is tagged v{v} and all"
" three packages publish",
"each": "merge the release PR — on merge the crate is tagged and all"
" three packages publish at their own new patches",
}
class ReleasePrepError(Exception):
def parse_version(version):
m = _STRICT_VERSION.match(version)
if not m:
raise ReleasePrepError(
f"version must be plain X.Y.Z (got {version!r}); stamp"
f" pre-releases by hand"
)
return tuple(int(part) for part in m.groups())
def stamp_manifest(path, version):
text, count = re.subn(
r'(?m)^version = "[^"]*"', f'version = "{version}"',
path.read_text(), count=1,
)
if count != 1:
raise ReleasePrepError(f"no [package] version found in {path}")
path.write_text(text)
def stamp_lock(path, name, version):
text, count = re.subn(
rf'(?ms)(\[\[package\]\]\nname = "{name}"\nversion = ")[^"]*(")',
rf"\g<1>{version}\g<2>", path.read_text(), count=1,
)
if count != 1:
raise ReleasePrepError(f"no {name} entry found in {path}")
path.write_text(text)
def stamp_node_package(path, version):
manifest = json.loads(path.read_text())
manifest["version"] = version
for pin in manifest.get("optionalDependencies", {}):
if pin.startswith("infx-"):
manifest["optionalDependencies"][pin] = version
path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n")
def current_versions(root):
return {
"crate": manifest_version(root / "Cargo.toml"),
"node": json.loads(
(root / "infino-node" / "package.json").read_text()
)["version"],
"python": manifest_version(root / "infino-python" / "Cargo.toml"),
}
def next_patch(version):
major, minor, patch = parse_version(version)
return f"{major}.{minor}.{patch + 1}"
def validate(root, package, version):
current = current_versions(root)
crate = current["crate"]
if package == "each":
if version is not None:
raise ReleasePrepError(
"a version does not apply to 'each' — every package moves"
" to its own next patch (their counters are independent)"
)
return {name: next_patch(cur) for name, cur in current.items()}
if version is None:
raise ReleasePrepError(f"a version is required for '{package}'")
requested = parse_version(version)
if package == "all":
if requested[2] != 0:
raise ReleasePrepError(
f"a coordinated release needs patch 0 (got {version}); the"
f" binding publish workflows fire only on a vX.Y.0 tag"
)
for name, cur in current.items():
if requested <= parse_version(cur):
raise ReleasePrepError(
f"coordinated version {version} does not move {name}"
f" forward (currently {cur})"
)
return {name: version for name in current}
if release_line(version) != release_line(crate):
raise ReleasePrepError(
f"a {package} patch must stay on the crate's release line"
f" {release_line(crate)} (got {version}); changing the line"
f" is a coordinated release (--package all)"
)
cur = current[package]
if requested <= parse_version(cur):
raise ReleasePrepError(
f"{package} is already at {cur}; the new version must move"
f" it strictly forward (got {version})"
)
return {package: version}
def prepare(root, package, version=None):
root = Path(root)
targets = validate(root, package, version)
changed = []
if "crate" in targets:
stamp_manifest(root / "Cargo.toml", targets["crate"])
stamp_lock(root / "Cargo.lock", "infino", targets["crate"])
stamp_lock(root / "infino-node" / "Cargo.lock", "infino",
targets["crate"])
stamp_lock(root / "infino-python" / "Cargo.lock", "infino",
targets["crate"])
changed += ["Cargo.toml", "Cargo.lock", "infino-node/Cargo.lock",
"infino-python/Cargo.lock"]
if "node" in targets:
stamp_node_package(root / "infino-node" / "package.json",
targets["node"])
stamp_manifest(root / "infino-node" / "Cargo.toml", targets["node"])
stamp_lock(root / "infino-node" / "Cargo.lock", "infino-node",
targets["node"])
changed += ["infino-node/package.json", "infino-node/Cargo.toml",
"infino-node/Cargo.lock"]
if "python" in targets:
stamp_manifest(root / "infino-python" / "Cargo.toml",
targets["python"])
stamp_lock(root / "infino-python" / "Cargo.lock", "infino-python",
targets["python"])
changed += ["infino-python/Cargo.toml", "infino-python/Cargo.lock"]
changed = list(dict.fromkeys(changed))
drift = check(root)
if drift:
raise ReleasePrepError(
"stamping left the tree inconsistent (bug in this script):\n "
+ "\n ".join(drift)
)
return changed
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--package", required=True,
choices=["crate", "node", "python", "each", "all"])
parser.add_argument("--version",
help="new version, plain X.Y.Z (omit for 'each')")
args = parser.parse_args()
try:
changed = prepare(ROOT, args.package, args.version)
except ReleasePrepError as error:
print(f"release-prep: {error}", file=sys.stderr)
return 1
stamped = current_versions(ROOT)
markers = {"crate": "Cargo.toml", "node": "infino-node/package.json",
"python": "infino-python/Cargo.toml"}
print("stamped:")
for name, marker in markers.items():
if marker in changed:
print(f" {name} -> {stamped[name]}")
for path in changed:
print(f" {path}")
slug = args.version if args.version else stamped["crate"]
print("\nnext steps:")
print(f" 1. commit on a branch and open the release PR, e.g.:")
print(f" git checkout -b release-{args.package}-{slug}")
print(f" git commit -am 'release: {args.package} {slug}'")
print(f" 2. {FOLLOW_UP[args.package].format(v=slug)}")
return 0
if __name__ == "__main__":
sys.exit(main())