Skip to content

Commit 2d95d20

Browse files
committed
Improve zigpy-ota scripts
* Improve invalid YAML parsing * Add schema bump script * Add ota-edited label to edited issues * Add concurrency group to workflows * Add merge group trigger to workflows * Add last update time to bot success comments * Limit bot success comment to one on resubmits * Bump CI to latest Python 3.14 release * Update uv.lock
1 parent 7f029b7 commit 2d95d20

22 files changed

Lines changed: 1072 additions & 208 deletions

.github/SCHEMA.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"zigpy_version": "v1",
3+
"zigpy_schema_key": "zigpy_v1",
4+
"zigpy_filename": "zigpy_v1_ota.json",
5+
"zigpy_beta_filename": "zigpy_v1_ota_beta.json",
6+
"z2m_version": "v1",
7+
"z2m_schema_key": "z2m_v1",
8+
"z2m_filename": "z2m_v1_ota.json",
9+
"z2m_beta_filename": "z2m_v1_ota_beta.json",
10+
"markdown_version": "v1",
11+
"markdown_schema_key": "markdown_v1",
12+
"markdown_filename": "markdown_v1.md",
13+
"markdown_beta_filename": "markdown_v1_beta.md"
14+
}
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
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()

.github/scripts/update-version-pointers.sh

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,18 @@ set -euo pipefail
1818
# EVENT_ACTION - GitHub event action (empty for workflow_dispatch)
1919
# RELEASE_ASSETS - JSON array of release assets (only for release events)
2020

21-
# JSON asset filenames (stable channel)
22-
ZIGPY_JSON_FILENAME="zigpy_v1_ota.json"
23-
Z2M_JSON_FILENAME="z2m_v1_ota.json"
24-
MARKDOWN_FILENAME="markdown_v1.md"
25-
# JSON asset filenames (beta channel - includes stable + beta images)
26-
ZIGPY_JSON_BETA_FILENAME="zigpy_v1_ota_beta.json"
27-
Z2M_JSON_BETA_FILENAME="z2m_v1_ota_beta.json"
28-
MARKDOWN_BETA_FILENAME="markdown_v1_beta.md"
21+
# Read schema config from .github/SCHEMA.json (must be done before any branch switches)
22+
ZIGPY_JSON_FILENAME=$(jq -r '.zigpy_filename' .github/SCHEMA.json)
23+
Z2M_JSON_FILENAME=$(jq -r '.z2m_filename' .github/SCHEMA.json)
24+
MARKDOWN_FILENAME=$(jq -r '.markdown_filename' .github/SCHEMA.json)
25+
26+
ZIGPY_JSON_BETA_FILENAME=$(jq -r '.zigpy_beta_filename' .github/SCHEMA.json)
27+
Z2M_JSON_BETA_FILENAME=$(jq -r '.z2m_beta_filename' .github/SCHEMA.json)
28+
MARKDOWN_BETA_FILENAME=$(jq -r '.markdown_beta_filename' .github/SCHEMA.json)
29+
30+
ZIGPY_SCHEMA_KEY=$(jq -r '.zigpy_schema_key' .github/SCHEMA.json)
31+
Z2M_SCHEMA_KEY=$(jq -r '.z2m_schema_key' .github/SCHEMA.json)
32+
MARKDOWN_SCHEMA_KEY=$(jq -r '.markdown_schema_key' .github/SCHEMA.json)
2933

3034
# ------------------------------------------------------------------------------
3135
# Functions
@@ -81,7 +85,10 @@ update_json() {
8185
--arg zigpy_url "$zigpy_url" \
8286
--arg z2m_url "$z2m_url" \
8387
--arg markdown_url "$markdown_url" \
84-
'.schemas.zigpy_v1.version = $version | .schemas.zigpy_v1.url = $zigpy_url | .schemas.z2m_v1.version = $version | .schemas.z2m_v1.url = $z2m_url | .schemas.markdown_v1.version = $version | .schemas.markdown_v1.url = $markdown_url' \
88+
--arg zigpy_key "$ZIGPY_SCHEMA_KEY" \
89+
--arg z2m_key "$Z2M_SCHEMA_KEY" \
90+
--arg md_key "$MARKDOWN_SCHEMA_KEY" \
91+
'.schemas[$zigpy_key].version = $version | .schemas[$zigpy_key].url = $zigpy_url | .schemas[$z2m_key].version = $version | .schemas[$z2m_key].url = $z2m_url | .schemas[$md_key].version = $version | .schemas[$md_key].url = $markdown_url' \
8592
"$file" > /tmp/"$(basename "$file")"
8693
mv /tmp/"$(basename "$file")" "$file"
8794
else
@@ -91,7 +98,10 @@ update_json() {
9198
--arg zigpy_url "$zigpy_url" \
9299
--arg z2m_url "$z2m_url" \
93100
--arg markdown_url "$markdown_url" \
94-
'{schemas: {zigpy_v1: {version: $version, url: $zigpy_url}, z2m_v1: {version: $version, url: $z2m_url}, markdown_v1: {version: $version, url: $markdown_url}}}' \
101+
--arg zigpy_key "$ZIGPY_SCHEMA_KEY" \
102+
--arg z2m_key "$Z2M_SCHEMA_KEY" \
103+
--arg md_key "$MARKDOWN_SCHEMA_KEY" \
104+
'{schemas: {($zigpy_key): {version: $version, url: $zigpy_url}, ($z2m_key): {version: $version, url: $z2m_url}, ($md_key): {version: $version, url: $markdown_url}}}' \
95105
> "$file"
96106
fi
97107
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
name: Bump schema version
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
schema:
7+
description: "Schema to bump"
8+
required: true
9+
type: choice
10+
options:
11+
- all
12+
- zigpy
13+
- z2m
14+
- markdown
15+
default: all
16+
new_version:
17+
description: "New schema version (e.g. v2). Leave empty to auto-increment."
18+
required: false
19+
type: string
20+
21+
env:
22+
PYTHON_VERSION_DEFAULT: "3.14"
23+
24+
jobs:
25+
bump-schema-version:
26+
name: Bump schema version and create PR
27+
runs-on: ubuntu-latest
28+
timeout-minutes: 5
29+
steps:
30+
- name: Checkout repository
31+
uses: actions/checkout@v6
32+
with:
33+
fetch-depth: 0
34+
token: ${{ secrets.BOT_ACCESS_TOKEN_WORKFLOWS }}
35+
36+
- name: Set up Python ${{ env.PYTHON_VERSION_DEFAULT }}
37+
uses: actions/setup-python@v6
38+
with:
39+
python-version: ${{ env.PYTHON_VERSION_DEFAULT }}
40+
41+
- name: Run bump-schema-version script
42+
id: bump
43+
env:
44+
INPUT_SCHEMA: ${{ inputs.schema }}
45+
INPUT_NEW_VERSION: ${{ inputs.new_version }}
46+
run: |
47+
rc=0
48+
python .github/scripts/bump-schema-version.py "$INPUT_SCHEMA" ${INPUT_NEW_VERSION:+"$INPUT_NEW_VERSION"} --apply || rc=$?
49+
if [ "$rc" -eq 2 ]; then
50+
echo "::notice::Nothing to bump — all schemas already at requested version"
51+
echo "skipped=true" >> "$GITHUB_OUTPUT"
52+
exit 0
53+
elif [ "$rc" -ne 0 ]; then
54+
exit "$rc"
55+
fi
56+
57+
- name: Configure git
58+
if: steps.bump.outputs.skipped != 'true'
59+
run: |
60+
git config --global user.name "zigpy-bot"
61+
git config --global user.email "247691930+zigpy-bot@users.noreply.github.com"
62+
63+
- name: Create PR
64+
if: steps.bump.outputs.skipped != 'true'
65+
env:
66+
GH_TOKEN: ${{ secrets.BOT_ACCESS_TOKEN_WORKFLOWS }}
67+
BUMP_LABEL: ${{ steps.bump.outputs.bump_label }}
68+
BUMP_TITLE: ${{ steps.bump.outputs.bump_title }}
69+
BUMP_DETAILS: ${{ steps.bump.outputs.bump_details }}
70+
run: |
71+
BRANCH_NAME="bot/bump-schema/${BUMP_LABEL}"
72+
73+
git checkout -B "$BRANCH_NAME"
74+
git add -A
75+
git diff --staged --quiet && { echo "No changes to commit"; exit 0; }
76+
git commit -m "Bump OTA schema: ${BUMP_TITLE}"
77+
git push --force-with-lease -u origin "$BRANCH_NAME"
78+
79+
# Create PR or update existing one
80+
if gh pr view "$BRANCH_NAME" --json state --jq '.state' 2>/dev/null | grep -q OPEN; then
81+
echo "PR already exists for $BRANCH_NAME, force-pushed update"
82+
else
83+
gh pr create \
84+
--title "Bump OTA schema: ${BUMP_TITLE}" \
85+
--body "$(cat <<EOF
86+
## OTA Schema Bump
87+
88+
${BUMP_DETAILS}
89+
90+
Generated by the \`bump-schema-version\` workflow. Please review the diff carefully before merging.
91+
EOF
92+
)"
93+
fi

0 commit comments

Comments
 (0)