|
| 1 | +"""Bump the OTA schema version for individual schemas or all at once. |
| 2 | +
|
| 3 | +Usage: |
| 4 | + python .github/scripts/bump-schema-version.py zigpy --apply # Auto-increment zigpy |
| 5 | + python .github/scripts/bump-schema-version.py z2m v3 --apply # Set z2m to v3 |
| 6 | + python .github/scripts/bump-schema-version.py all --apply # Auto-increment all |
| 7 | + python .github/scripts/bump-schema-version.py all v2 --apply # Set all to v2 |
| 8 | + python .github/scripts/bump-schema-version.py zigpy # Dry-run |
| 9 | +""" |
| 10 | + |
| 11 | +from __future__ import annotations |
| 12 | + |
| 13 | +import argparse |
| 14 | +import json |
| 15 | +import os |
| 16 | +import re |
| 17 | +import sys |
| 18 | +from pathlib import Path |
| 19 | + |
| 20 | +REPO_ROOT = Path(__file__).resolve().parent.parent.parent |
| 21 | +SCHEMA_JSON_PATH = REPO_ROOT / ".github" / "SCHEMA.json" |
| 22 | + |
| 23 | +SCHEMAS = ("zigpy", "z2m", "markdown") |
| 24 | + |
| 25 | +# Files that contain schema version references (besides SCHEMA.json itself) |
| 26 | +TARGET_FILES = [ |
| 27 | + "README.md", |
| 28 | + "CLAUDE.md", |
| 29 | +] |
| 30 | + |
| 31 | + |
| 32 | +# Suffix templates per schema: (filename_suffix, beta_filename_suffix) |
| 33 | +SCHEMA_SUFFIXES = { |
| 34 | + "zigpy": ("_ota.json", "_ota_beta.json"), |
| 35 | + "z2m": ("_ota.json", "_ota_beta.json"), |
| 36 | + "markdown": (".md", "_beta.md"), |
| 37 | +} |
| 38 | + |
| 39 | + |
| 40 | +def generate_schema_entries(schema: str, version: str) -> dict[str, str]: |
| 41 | + """Generate SCHEMA.json entries for a single schema at the given version.""" |
| 42 | + suffix, beta_suffix = SCHEMA_SUFFIXES[schema] |
| 43 | + prefix = f"{schema}_{version}" |
| 44 | + return { |
| 45 | + f"{schema}_version": version, |
| 46 | + f"{schema}_schema_key": prefix, |
| 47 | + f"{schema}_filename": f"{prefix}{suffix}", |
| 48 | + f"{schema}_beta_filename": f"{prefix}{beta_suffix}", |
| 49 | + } |
| 50 | + |
| 51 | + |
| 52 | +def detect_current_version(schema: str) -> str: |
| 53 | + """Read current version for a specific schema from SCHEMA.json.""" |
| 54 | + if not SCHEMA_JSON_PATH.exists(): |
| 55 | + print("Error: SCHEMA.json file not found", file=sys.stderr) |
| 56 | + sys.exit(1) |
| 57 | + data = json.loads(SCHEMA_JSON_PATH.read_text()) |
| 58 | + key = f"{schema}_version" |
| 59 | + if key not in data: |
| 60 | + print(f"Error: '{key}' not found in SCHEMA.json", file=sys.stderr) |
| 61 | + sys.exit(1) |
| 62 | + return data[key] |
| 63 | + |
| 64 | + |
| 65 | +def validate_version(version: str) -> None: |
| 66 | + """Validate that a version string matches the expected format (v<number>).""" |
| 67 | + if not re.fullmatch(r"v(\d+)", version): |
| 68 | + print( |
| 69 | + f"Error: Invalid version '{version}' (expected format: v<number>)", |
| 70 | + file=sys.stderr, |
| 71 | + ) |
| 72 | + sys.exit(1) |
| 73 | + |
| 74 | + |
| 75 | +def increment_version(version: str) -> str: |
| 76 | + """Increment a version string like 'v1' to 'v2'.""" |
| 77 | + validate_version(version) |
| 78 | + num = int(version[1:]) |
| 79 | + return f"v{num + 1}" |
| 80 | + |
| 81 | + |
| 82 | +def bump_file( |
| 83 | + path: Path, |
| 84 | + bumps: list[tuple[str, str, str]], |
| 85 | + *, |
| 86 | + apply: bool, |
| 87 | +) -> int: |
| 88 | + """Replace schema version references in a single file for all bumps. |
| 89 | +
|
| 90 | + Each bump is a (schema, old_version, new_version) tuple. |
| 91 | + Returns the total number of replacements made. |
| 92 | + """ |
| 93 | + if not path.exists(): |
| 94 | + print(f" SKIP {path.relative_to(REPO_ROOT)} (file not found)") |
| 95 | + return 0 |
| 96 | + |
| 97 | + content = path.read_text() |
| 98 | + total = 0 |
| 99 | + |
| 100 | + for schema, old_ver, new_ver in bumps: |
| 101 | + old = f"{schema}_{old_ver}" |
| 102 | + new = f"{schema}_{new_ver}" |
| 103 | + # Use word boundary (\b) to avoid substring matches (e.g. v1 inside v10) |
| 104 | + pattern = re.compile(re.escape(old) + r"(?!\d)") |
| 105 | + content, count = pattern.subn(new, content) |
| 106 | + total += count |
| 107 | + |
| 108 | + rel = path.relative_to(REPO_ROOT) |
| 109 | + if total == 0: |
| 110 | + print(f" SKIP {rel} (no matches)") |
| 111 | + else: |
| 112 | + print(f" {'WRITE' if apply else 'WOULD'} {rel} ({total} replacements)") |
| 113 | + if apply: |
| 114 | + path.write_text(content) |
| 115 | + |
| 116 | + return total |
| 117 | + |
| 118 | + |
| 119 | +def validate_no_remaining(bumps: list[tuple[str, str, str]]) -> list[str]: |
| 120 | + """Check that no target files still contain old version references.""" |
| 121 | + issues = [] |
| 122 | + |
| 123 | + for rel in TARGET_FILES: |
| 124 | + path = REPO_ROOT / rel |
| 125 | + if not path.exists(): |
| 126 | + continue |
| 127 | + content = path.read_text() |
| 128 | + for schema, old_ver, _new_ver in bumps: |
| 129 | + old = f"{schema}_{old_ver}" |
| 130 | + if re.search(re.escape(old) + r"(?!\d)", content): |
| 131 | + issues.append(f" {rel} still contains '{old}'") |
| 132 | + |
| 133 | + return issues |
| 134 | + |
| 135 | + |
| 136 | +def main() -> None: |
| 137 | + parser = argparse.ArgumentParser( |
| 138 | + description="Bump OTA schema version across the repository." |
| 139 | + ) |
| 140 | + parser.add_argument( |
| 141 | + "schema", |
| 142 | + choices=[*SCHEMAS, "all"], |
| 143 | + help="Which schema to bump (zigpy, z2m, markdown, or all)", |
| 144 | + ) |
| 145 | + parser.add_argument( |
| 146 | + "new_version", |
| 147 | + nargs="?", |
| 148 | + default=None, |
| 149 | + help="New schema version (e.g. v2). Omit to auto-increment.", |
| 150 | + ) |
| 151 | + parser.add_argument( |
| 152 | + "--apply", |
| 153 | + action="store_true", |
| 154 | + help="Actually write changes (default is dry-run)", |
| 155 | + ) |
| 156 | + args = parser.parse_args() |
| 157 | + |
| 158 | + if args.new_version: |
| 159 | + validate_version(args.new_version) |
| 160 | + |
| 161 | + schemas_to_bump = list(SCHEMAS) if args.schema == "all" else [args.schema] |
| 162 | + |
| 163 | + # Collect bumps: [(schema, old_version, new_version), ...] |
| 164 | + bumps: list[tuple[str, str, str]] = [] |
| 165 | + for schema in schemas_to_bump: |
| 166 | + old = detect_current_version(schema) |
| 167 | + new = args.new_version or increment_version(old) |
| 168 | + if old == new: |
| 169 | + print(f"Skipping {schema}: already at {old}") |
| 170 | + continue |
| 171 | + bumps.append((schema, old, new)) |
| 172 | + |
| 173 | + if not bumps: |
| 174 | + print("Nothing to bump.") |
| 175 | + sys.exit(2) |
| 176 | + |
| 177 | + # Write to GITHUB_OUTPUT if running in GitHub Actions |
| 178 | + github_output = os.environ.get("GITHUB_OUTPUT") |
| 179 | + if github_output: |
| 180 | + with open(github_output, "a") as f: |
| 181 | + labels = [] |
| 182 | + titles = [] |
| 183 | + details = [] |
| 184 | + for schema, old, new in bumps: |
| 185 | + f.write(f"{schema}_old_version={old}\n") |
| 186 | + f.write(f"{schema}_new_version={new}\n") |
| 187 | + labels.append(f"{schema}-{new}") |
| 188 | + titles.append(f"{schema} {new}") |
| 189 | + details.append(f"{schema} {old} \u2192 {new}") |
| 190 | + f.write(f"bump_label={'-'.join(labels)}\n") |
| 191 | + f.write(f"bump_title={', '.join(titles)}\n") |
| 192 | + f.write("bump_details<<EOF\n") |
| 193 | + f.write("Bumps the following OTA schema versions:\n\n") |
| 194 | + for detail in details: |
| 195 | + f.write(f"- {detail}\n") |
| 196 | + f.write("EOF\n") |
| 197 | + |
| 198 | + mode = "APPLY" if args.apply else "DRY-RUN" |
| 199 | + for schema, old, new in bumps: |
| 200 | + print(f" {schema}: {old} -> {new}") |
| 201 | + print(f" [{mode}]\n") |
| 202 | + |
| 203 | + # Update SCHEMA.json |
| 204 | + schema_data = json.loads(SCHEMA_JSON_PATH.read_text()) |
| 205 | + for schema, _old, new in bumps: |
| 206 | + schema_data.update(generate_schema_entries(schema, new)) |
| 207 | + action = "WRITE" if args.apply else "WOULD" |
| 208 | + print(f" {action} .github/SCHEMA.json") |
| 209 | + if args.apply: |
| 210 | + SCHEMA_JSON_PATH.write_text(json.dumps(schema_data, indent=2) + "\n") |
| 211 | + |
| 212 | + # Update target files |
| 213 | + total = 0 |
| 214 | + for rel in TARGET_FILES: |
| 215 | + path = REPO_ROOT / rel |
| 216 | + total += bump_file(path, bumps, apply=args.apply) |
| 217 | + |
| 218 | + print(f"\nTotal: {total} replacements across {len(TARGET_FILES)} target files") |
| 219 | + |
| 220 | + if args.apply: |
| 221 | + issues = validate_no_remaining(bumps) |
| 222 | + if issues: |
| 223 | + print("\nWarning: old version references still found:") |
| 224 | + for issue in issues: |
| 225 | + print(issue) |
| 226 | + sys.exit(1) |
| 227 | + else: |
| 228 | + print("\nValidation passed: no remaining old version references.") |
| 229 | + else: |
| 230 | + print("\nRe-run with --apply to write changes.") |
| 231 | + |
| 232 | + |
| 233 | +if __name__ == "__main__": |
| 234 | + main() |
0 commit comments