* chore(release): unify package versions * fix(release): harden version synchronization
263 lines
9.5 KiB
Python
263 lines
9.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Keep every pdf-inspector package on one release version."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
PRERELEASE_IDENTIFIER = (
|
|
r"(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)"
|
|
)
|
|
SEMVER = re.compile(
|
|
r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
|
|
rf"(?:-{PRERELEASE_IDENTIFIER}(?:\.{PRERELEASE_IDENTIFIER})*)?"
|
|
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
|
|
)
|
|
VERSION_LINE = re.compile(r'^(\s*version\s*=\s*")[^"]+(".*)$')
|
|
SECTION_LINE = re.compile(r"^\s*\[([^]]+)]\s*$")
|
|
PLATFORM_PACKAGES = (
|
|
"@firecrawl/pdf-inspector-linux-x64-gnu",
|
|
"@firecrawl/pdf-inspector-linux-x64-musl",
|
|
"@firecrawl/pdf-inspector-linux-arm64-gnu",
|
|
"@firecrawl/pdf-inspector-linux-arm64-musl",
|
|
"@firecrawl/pdf-inspector-darwin-arm64",
|
|
"@firecrawl/pdf-inspector-win32-x64-msvc",
|
|
)
|
|
TOML_VERSIONS = (
|
|
("Rust crate", Path("Cargo.toml"), "package"),
|
|
("Python package", Path("pyproject.toml"), "project"),
|
|
("NAPI crate", Path("napi/Cargo.toml"), "package"),
|
|
("WASM package", Path("wasm/Cargo.toml"), "package"),
|
|
)
|
|
LOCK_VERSIONS = (
|
|
("NAPI lock: core", Path("napi/Cargo.lock"), "pdf-inspector"),
|
|
("NAPI lock: binding", Path("napi/Cargo.lock"), "pdf-inspector-napi"),
|
|
("WASM lock: core", Path("wasm/Cargo.lock"), "pdf-inspector"),
|
|
("WASM lock: binding", Path("wasm/Cargo.lock"), "pdf-inspector-wasm"),
|
|
)
|
|
SITE_WASM_VERSION = re.compile(
|
|
r"(@firecrawl/pdf-inspector-wasm@)([^/\"]+)(/pdf_inspector_wasm\.js)"
|
|
)
|
|
|
|
|
|
def _read_section_version(path: Path, section: str) -> str:
|
|
active = False
|
|
for line in path.read_text(encoding="utf-8").splitlines():
|
|
section_match = SECTION_LINE.match(line)
|
|
if section_match:
|
|
active = section_match.group(1) == section
|
|
elif active:
|
|
version_match = VERSION_LINE.match(line)
|
|
if version_match:
|
|
return line.split('"', 2)[1]
|
|
raise ValueError(f"No version found in [{section}] of {path}")
|
|
|
|
|
|
def _write_section_version(path: Path, section: str, version: str) -> None:
|
|
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
active = False
|
|
for index, line in enumerate(lines):
|
|
section_match = SECTION_LINE.match(line)
|
|
if section_match:
|
|
active = section_match.group(1) == section
|
|
elif active:
|
|
version_match = VERSION_LINE.match(line)
|
|
if version_match:
|
|
newline = "\n" if line.endswith("\n") else ""
|
|
replacement = (
|
|
f"{version_match.group(1)}{version}"
|
|
f"{version_match.group(2).rstrip()}"
|
|
)
|
|
lines[index] = (
|
|
f"{replacement}{newline}"
|
|
)
|
|
path.write_text("".join(lines), encoding="utf-8")
|
|
return
|
|
raise ValueError(f"No version found in [{section}] of {path}")
|
|
|
|
|
|
def _package_block(lines: list[str], package: str) -> tuple[int, int]:
|
|
for start, line in enumerate(lines):
|
|
if line.strip() != "[[package]]":
|
|
continue
|
|
end = next(
|
|
(
|
|
index
|
|
for index in range(start + 1, len(lines))
|
|
if lines[index].strip() == "[[package]]"
|
|
),
|
|
len(lines),
|
|
)
|
|
if any(line.strip() == f'name = "{package}"' for line in lines[start:end]):
|
|
return start, end
|
|
raise ValueError(f"No lockfile entry found for {package}")
|
|
|
|
|
|
def _read_lock_version(path: Path, package: str) -> str:
|
|
lines = path.read_text(encoding="utf-8").splitlines()
|
|
start, end = _package_block(lines, package)
|
|
for line in lines[start:end]:
|
|
version_match = VERSION_LINE.match(line)
|
|
if version_match:
|
|
return line.split('"', 2)[1]
|
|
raise ValueError(f"No version found for {package} in {path}")
|
|
|
|
|
|
def _write_lock_version(path: Path, package: str, version: str) -> None:
|
|
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
start, end = _package_block(lines, package)
|
|
for index in range(start, end):
|
|
version_match = VERSION_LINE.match(lines[index])
|
|
if version_match:
|
|
newline = "\n" if lines[index].endswith("\n") else ""
|
|
lines[index] = (
|
|
f'{version_match.group(1)}{version}{version_match.group(2).rstrip()}'
|
|
f"{newline}"
|
|
)
|
|
path.write_text("".join(lines), encoding="utf-8")
|
|
return
|
|
raise ValueError(f"No version found for {package} in {path}")
|
|
|
|
|
|
def _node_versions(root: Path) -> dict[str, str]:
|
|
package = json.loads((root / "napi/package.json").read_text(encoding="utf-8"))
|
|
versions = {"Node package": package["version"]}
|
|
optional = package.get("optionalDependencies", {})
|
|
for dependency in PLATFORM_PACKAGES:
|
|
if dependency not in optional:
|
|
raise ValueError(f"Missing Node optional dependency: {dependency}")
|
|
versions[f"Node optional dependency: {dependency}"] = optional[dependency]
|
|
return versions
|
|
|
|
|
|
def _bun_versions(root: Path) -> dict[str, str]:
|
|
text = (root / "napi/bun.lock").read_text(encoding="utf-8")
|
|
versions = {}
|
|
for dependency in PLATFORM_PACKAGES:
|
|
match = re.search(
|
|
rf'"{re.escape(dependency)}": "([^"]+)"[,]', text
|
|
)
|
|
if not match:
|
|
raise ValueError(f"Missing Bun lock dependency: {dependency}")
|
|
versions[f"Bun lock: {dependency}"] = match.group(1)
|
|
return versions
|
|
|
|
|
|
def _site_wasm_version(root: Path) -> str:
|
|
text = (root / "site/index.html").read_text(encoding="utf-8")
|
|
match = SITE_WASM_VERSION.search(text)
|
|
if not match:
|
|
raise ValueError("Missing pinned WASM package URL in site/index.html")
|
|
return match.group(2)
|
|
|
|
|
|
def package_versions(root: Path = ROOT) -> dict[str, str]:
|
|
versions = {
|
|
label: _read_section_version(root / relative, section)
|
|
for label, relative, section in TOML_VERSIONS
|
|
}
|
|
versions.update(_node_versions(root))
|
|
versions.update(_bun_versions(root))
|
|
versions["Website WASM module"] = _site_wasm_version(root)
|
|
versions.update(
|
|
{
|
|
label: _read_lock_version(root / relative, package)
|
|
for label, relative, package in LOCK_VERSIONS
|
|
}
|
|
)
|
|
return versions
|
|
|
|
|
|
def check_versions(root: Path = ROOT) -> str:
|
|
versions = package_versions(root)
|
|
expected = versions["Rust crate"]
|
|
if not SEMVER.fullmatch(expected):
|
|
raise ValueError(f"Rust crate has an invalid semantic version: {expected}")
|
|
mismatches = {
|
|
label: version for label, version in versions.items() if version != expected
|
|
}
|
|
if mismatches:
|
|
details = "\n".join(
|
|
f" - {label}: {version}" for label, version in mismatches.items()
|
|
)
|
|
raise ValueError(f"Expected every package to use {expected}:\n{details}")
|
|
return expected
|
|
|
|
|
|
def set_versions(version: str, root: Path = ROOT) -> None:
|
|
if not SEMVER.fullmatch(version):
|
|
raise ValueError(f"Invalid semantic version: {version}")
|
|
|
|
# Validate every expected location before writing the first file. This
|
|
# prevents a stale manifest or generated file from leaving a partial bump.
|
|
package_versions(root)
|
|
|
|
for _, relative, section in TOML_VERSIONS:
|
|
_write_section_version(root / relative, section, version)
|
|
|
|
package_path = root / "napi/package.json"
|
|
package = json.loads(package_path.read_text(encoding="utf-8"))
|
|
package["version"] = version
|
|
optional = package.get("optionalDependencies", {})
|
|
for dependency in PLATFORM_PACKAGES:
|
|
if dependency not in optional:
|
|
raise ValueError(f"Missing Node optional dependency: {dependency}")
|
|
optional[dependency] = version
|
|
package_path.write_text(json.dumps(package, indent=2) + "\n", encoding="utf-8")
|
|
|
|
bun_path = root / "napi/bun.lock"
|
|
bun_text = bun_path.read_text(encoding="utf-8")
|
|
for dependency in PLATFORM_PACKAGES:
|
|
pattern = rf'("{re.escape(dependency)}": ")[^"]+("[,])'
|
|
bun_text, count = re.subn(
|
|
pattern, rf"\g<1>{version}\g<2>", bun_text, count=1
|
|
)
|
|
if count != 1:
|
|
raise ValueError(f"Missing Bun lock dependency: {dependency}")
|
|
bun_path.write_text(bun_text, encoding="utf-8")
|
|
|
|
site_path = root / "site/index.html"
|
|
site_text = site_path.read_text(encoding="utf-8")
|
|
site_text, count = SITE_WASM_VERSION.subn(
|
|
rf"\g<1>{version}\g<3>", site_text, count=1
|
|
)
|
|
if count != 1:
|
|
raise ValueError("Missing pinned WASM package URL in site/index.html")
|
|
site_path.write_text(site_text, encoding="utf-8")
|
|
|
|
for _, relative, package_name in LOCK_VERSIONS:
|
|
_write_lock_version(root / relative, package_name, version)
|
|
|
|
check_versions(root)
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("version", nargs="?", help="new shared semantic version")
|
|
parser.add_argument(
|
|
"--check", action="store_true", help="fail if package versions have diverged"
|
|
)
|
|
arguments = parser.parse_args()
|
|
if arguments.check == bool(arguments.version):
|
|
parser.error("provide either a version or --check")
|
|
|
|
try:
|
|
if arguments.check:
|
|
version = check_versions()
|
|
print(f"All packages use {version}")
|
|
else:
|
|
set_versions(arguments.version)
|
|
print(f"Updated all packages to {arguments.version}")
|
|
except ValueError as error:
|
|
parser.exit(1, f"{error}\n")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|