mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-25 21:20:00 +00:00
150042fe98
Put published highlights and optional screenshot comparisons in the post-update dialog, with grouped details available on demand. Reuse inspected images from merged pull requests or capture fresh release views, then publish versioned PNG sidecars. Serve screenshots through the authenticated release endpoint to preserve the existing image policy.
461 lines
18 KiB
Python
Executable File
461 lines
18 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Validate and render the model-selected visual release-note plan."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
MAX_CAPTURES = 3
|
|
MAX_STEPS = 12
|
|
ALLOWED_LOCATOR_KINDS = {"role", "text", "label", "testid"}
|
|
ALLOWED_ACTIONS = {"click", "wait"}
|
|
ALLOWED_ROLES = {
|
|
"button",
|
|
"checkbox",
|
|
"dialog",
|
|
"heading",
|
|
"link",
|
|
"menuitem",
|
|
"option",
|
|
"radio",
|
|
"row",
|
|
"tab",
|
|
}
|
|
ID_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
PR_IMAGE_PATTERN = re.compile(
|
|
r"(?:https://github\.com/user-attachments/assets/"
|
|
r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}"
|
|
r"|https://raw\.githubusercontent\.com/rcourtman/Pulse/[0-9a-f]{40}/"
|
|
r"(?:docs/images|frontend-modern/browser-verification/screenshots)/[a-zA-Z0-9_-]+\.(?:png|jpg|jpeg|webp))"
|
|
)
|
|
PR_PATTERN = re.compile(r"https://github\.com/rcourtman/Pulse/pull/[1-9][0-9]*")
|
|
|
|
|
|
class PlanError(ValueError):
|
|
pass
|
|
|
|
|
|
def _text(value: Any, field: str, *, maximum: int, required: bool = True) -> str:
|
|
if not isinstance(value, str):
|
|
raise PlanError(f"{field} must be a string")
|
|
value = value.strip()
|
|
if required and not value:
|
|
raise PlanError(f"{field} must not be empty")
|
|
if len(value) > maximum:
|
|
raise PlanError(f"{field} must be {maximum} characters or fewer")
|
|
if any(ord(character) < 32 for character in value):
|
|
raise PlanError(f"{field} must be one line without control characters")
|
|
if ";" in value or "\u2014" in value:
|
|
raise PlanError(f"{field} must not contain semicolons or em dashes")
|
|
return value
|
|
|
|
|
|
def _public_text(value: Any, field: str, *, maximum: int, required: bool = True) -> str:
|
|
value = _text(value, field, maximum=maximum, required=required)
|
|
if any(character in value for character in ("[", "]", "<", ">", "|")):
|
|
raise PlanError(f"{field} must be plain text without Markdown or HTML delimiters")
|
|
return value
|
|
|
|
|
|
def _locator(value: Any, field: str) -> dict[str, Any]:
|
|
if not isinstance(value, dict):
|
|
raise PlanError(f"{field} must be an object")
|
|
allowed = {"kind", "value", "role", "name", "exact", "nth"}
|
|
unknown = set(value) - allowed
|
|
if unknown:
|
|
raise PlanError(f"{field} has unsupported fields: {', '.join(sorted(unknown))}")
|
|
kind = value.get("kind")
|
|
if kind not in ALLOWED_LOCATOR_KINDS:
|
|
raise PlanError(f"{field}.kind must be one of {', '.join(sorted(ALLOWED_LOCATOR_KINDS))}")
|
|
normalized: dict[str, Any] = {"kind": kind}
|
|
if kind == "role":
|
|
role = _text(value.get("role"), f"{field}.role", maximum=32)
|
|
if role not in ALLOWED_ROLES:
|
|
raise PlanError(f"{field}.role is not supported")
|
|
normalized["role"] = role
|
|
normalized["name"] = _text(value.get("name"), f"{field}.name", maximum=120)
|
|
else:
|
|
normalized["value"] = _text(value.get("value"), f"{field}.value", maximum=160)
|
|
exact = value.get("exact", True)
|
|
if not isinstance(exact, bool):
|
|
raise PlanError(f"{field}.exact must be a boolean")
|
|
normalized["exact"] = exact
|
|
nth = value.get("nth", 0)
|
|
if not isinstance(nth, int) or isinstance(nth, bool) or not 0 <= nth <= 20:
|
|
raise PlanError(f"{field}.nth must be an integer from 0 to 20")
|
|
normalized["nth"] = nth
|
|
return normalized
|
|
|
|
|
|
def _state(value: Any, field: str) -> dict[str, Any]:
|
|
if not isinstance(value, dict):
|
|
raise PlanError(f"{field} must be an object")
|
|
if "image_url" in value:
|
|
if set(value) != {"image_url", "pull_request_url"}:
|
|
raise PlanError(f"{field} reused image needs only image_url and pull_request_url")
|
|
image_url = value.get("image_url")
|
|
pull_request_url = value.get("pull_request_url")
|
|
if not isinstance(image_url, str) or not PR_IMAGE_PATTERN.fullmatch(image_url):
|
|
raise PlanError(f"{field}.image_url must be a public GitHub attachment or immutable Pulse screenshot")
|
|
if not isinstance(pull_request_url, str) or not PR_PATTERN.fullmatch(pull_request_url):
|
|
raise PlanError(f"{field}.pull_request_url must identify a Pulse pull request")
|
|
return {"image_url": image_url, "pull_request_url": pull_request_url}
|
|
unknown = set(value) - {"route", "steps", "ready"}
|
|
if unknown:
|
|
raise PlanError(f"{field} has unsupported fields: {', '.join(sorted(unknown))}")
|
|
route = _text(value.get("route"), f"{field}.route", maximum=240)
|
|
if (
|
|
not route.startswith("/")
|
|
or route.startswith("//")
|
|
or "://" in route
|
|
or "\\" in route
|
|
):
|
|
raise PlanError(f"{field}.route must be a same-origin absolute path")
|
|
steps = value.get("steps", [])
|
|
if not isinstance(steps, list) or len(steps) > MAX_STEPS:
|
|
raise PlanError(f"{field}.steps must be a list with at most {MAX_STEPS} entries")
|
|
normalized_steps = []
|
|
for index, step in enumerate(steps):
|
|
step_field = f"{field}.steps[{index}]"
|
|
if not isinstance(step, dict):
|
|
raise PlanError(f"{step_field} must be an object")
|
|
unknown_step = set(step) - {"action", "locator"}
|
|
if unknown_step:
|
|
raise PlanError(
|
|
f"{step_field} has unsupported fields: {', '.join(sorted(unknown_step))}"
|
|
)
|
|
action = step.get("action")
|
|
if action not in ALLOWED_ACTIONS:
|
|
raise PlanError(f"{step_field}.action must be click or wait")
|
|
normalized_steps.append(
|
|
{"action": action, "locator": _locator(step.get("locator"), f"{step_field}.locator")}
|
|
)
|
|
if value.get("ready") is None:
|
|
raise PlanError(f"{field}.ready must identify visible content in the captured view")
|
|
return {
|
|
"route": route,
|
|
"steps": normalized_steps,
|
|
"ready": _locator(value["ready"], f"{field}.ready"),
|
|
}
|
|
|
|
|
|
def validate_plan(raw: Any) -> dict[str, Any]:
|
|
if not isinstance(raw, dict):
|
|
raise PlanError("visual plan must be a JSON object")
|
|
unknown = set(raw) - {"schema_version", "decision", "captures"}
|
|
if unknown:
|
|
raise PlanError(f"visual plan has unsupported fields: {', '.join(sorted(unknown))}")
|
|
if raw.get("schema_version") != 1:
|
|
raise PlanError("visual plan schema_version must be 1")
|
|
decision = _text(raw.get("decision"), "visual plan decision", maximum=500)
|
|
captures = raw.get("captures")
|
|
if not isinstance(captures, list) or len(captures) > MAX_CAPTURES:
|
|
raise PlanError(f"visual plan captures must be a list with at most {MAX_CAPTURES} entries")
|
|
|
|
normalized_captures = []
|
|
seen_ids: set[str] = set()
|
|
for index, capture in enumerate(captures):
|
|
field = f"captures[{index}]"
|
|
if not isinstance(capture, dict):
|
|
raise PlanError(f"{field} must be an object")
|
|
unknown_capture = set(capture) - {
|
|
"id",
|
|
"title",
|
|
"description",
|
|
"viewport",
|
|
"before",
|
|
"after",
|
|
}
|
|
if unknown_capture:
|
|
raise PlanError(
|
|
f"{field} has unsupported fields: {', '.join(sorted(unknown_capture))}"
|
|
)
|
|
capture_id = _text(capture.get("id"), f"{field}.id", maximum=48)
|
|
if not ID_PATTERN.fullmatch(capture_id):
|
|
raise PlanError(f"{field}.id must be lower-case words separated by hyphens")
|
|
if capture_id in seen_ids:
|
|
raise PlanError(f"duplicate capture id: {capture_id}")
|
|
seen_ids.add(capture_id)
|
|
|
|
viewport = capture.get("viewport")
|
|
if not isinstance(viewport, dict) or set(viewport) != {"width", "height"}:
|
|
raise PlanError(f"{field}.viewport must contain only width and height")
|
|
width = viewport.get("width")
|
|
height = viewport.get("height")
|
|
if not isinstance(width, int) or isinstance(width, bool) or not 320 <= width <= 1920:
|
|
raise PlanError(f"{field}.viewport.width must be an integer from 320 to 1920")
|
|
if not isinstance(height, int) or isinstance(height, bool) or not 568 <= height <= 1440:
|
|
raise PlanError(f"{field}.viewport.height must be an integer from 568 to 1440")
|
|
|
|
before = capture.get("before")
|
|
normalized_captures.append(
|
|
{
|
|
"id": capture_id,
|
|
"title": _public_text(capture.get("title"), f"{field}.title", maximum=90),
|
|
"description": _public_text(
|
|
capture.get("description", ""),
|
|
f"{field}.description",
|
|
maximum=240,
|
|
required=False,
|
|
),
|
|
"viewport": {"width": width, "height": height},
|
|
"before": None if before is None else _state(before, f"{field}.before"),
|
|
"after": _state(capture.get("after"), f"{field}.after"),
|
|
}
|
|
)
|
|
return {
|
|
"schema_version": 1,
|
|
"decision": decision,
|
|
"captures": normalized_captures,
|
|
}
|
|
|
|
|
|
def load_plan(path: str) -> dict[str, Any]:
|
|
if path == "-":
|
|
raw_text = sys.stdin.read()
|
|
else:
|
|
raw_text = Path(path).read_text(encoding="utf-8")
|
|
try:
|
|
raw = json.loads(raw_text)
|
|
except json.JSONDecodeError as exc:
|
|
raise PlanError(f"visual plan is not valid JSON: {exc}") from exc
|
|
return validate_plan(raw)
|
|
|
|
|
|
def runtime_count(plan: dict[str, Any], side: str) -> int:
|
|
"""Reused PR images need no old/new runtime build."""
|
|
return sum(
|
|
bool(capture[side] and "route" in capture[side])
|
|
for capture in plan["captures"]
|
|
)
|
|
|
|
|
|
def asset_names(plan: dict[str, Any]) -> list[str]:
|
|
names: list[str] = []
|
|
for capture in plan["captures"]:
|
|
if capture["before"] is not None:
|
|
names.append(f"release-note-{capture['id']}-before.png")
|
|
names.append(f"release-note-{capture['id']}-now.png")
|
|
return names
|
|
|
|
|
|
def json_schema() -> dict[str, Any]:
|
|
locator_position = {
|
|
"exact": {"type": "boolean"},
|
|
"nth": {"type": "integer", "minimum": 0, "maximum": 20},
|
|
}
|
|
locator = {
|
|
"anyOf": [
|
|
{
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"required": ["kind", "role", "name", "exact", "nth"],
|
|
"properties": {
|
|
"kind": {"const": "role", "type": "string"},
|
|
"role": {"enum": sorted(ALLOWED_ROLES), "type": "string"},
|
|
"name": {"type": "string", "minLength": 1, "maxLength": 120},
|
|
**locator_position,
|
|
},
|
|
},
|
|
{
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"required": ["kind", "value", "exact", "nth"],
|
|
"properties": {
|
|
"kind": {
|
|
"enum": sorted(ALLOWED_LOCATOR_KINDS - {"role"}),
|
|
"type": "string",
|
|
},
|
|
"value": {"type": "string", "minLength": 1, "maxLength": 160},
|
|
**locator_position,
|
|
},
|
|
},
|
|
]
|
|
}
|
|
state = {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"required": ["route", "steps", "ready"],
|
|
"properties": {
|
|
"route": {"type": "string", "minLength": 1, "maxLength": 240},
|
|
"steps": {
|
|
"type": "array",
|
|
"maxItems": MAX_STEPS,
|
|
"items": {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"required": ["action", "locator"],
|
|
"properties": {
|
|
"action": {"enum": sorted(ALLOWED_ACTIONS)},
|
|
"locator": locator,
|
|
},
|
|
},
|
|
},
|
|
"ready": locator,
|
|
},
|
|
}
|
|
image_state = {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"required": ["image_url", "pull_request_url"],
|
|
"properties": {
|
|
"image_url": {"type": "string", "pattern": "^" + PR_IMAGE_PATTERN.pattern + "$"},
|
|
"pull_request_url": {"type": "string", "pattern": "^" + PR_PATTERN.pattern + "$"},
|
|
},
|
|
}
|
|
visual_state = {"anyOf": [state, image_state]}
|
|
return {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"required": ["schema_version", "decision", "captures"],
|
|
"properties": {
|
|
"schema_version": {"const": 1, "type": "integer"},
|
|
"decision": {"type": "string", "minLength": 1, "maxLength": 500},
|
|
"captures": {
|
|
"type": "array",
|
|
"maxItems": MAX_CAPTURES,
|
|
"items": {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"required": [
|
|
"id",
|
|
"title",
|
|
"description",
|
|
"viewport",
|
|
"before",
|
|
"after",
|
|
],
|
|
"properties": {
|
|
"id": {
|
|
"type": "string",
|
|
"maxLength": 48,
|
|
"pattern": ID_PATTERN.pattern,
|
|
},
|
|
"title": {"type": "string", "minLength": 1, "maxLength": 90},
|
|
"description": {"type": "string", "maxLength": 240},
|
|
"viewport": {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"required": ["width", "height"],
|
|
"properties": {
|
|
"width": {
|
|
"type": "integer",
|
|
"minimum": 320,
|
|
"maximum": 1920,
|
|
},
|
|
"height": {
|
|
"type": "integer",
|
|
"minimum": 568,
|
|
"maximum": 1440,
|
|
},
|
|
},
|
|
},
|
|
"before": {"anyOf": [state, image_state, {"type": "null"}]},
|
|
"after": visual_state,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def render_markdown(plan: dict[str, Any], repository: str, tag: str) -> str:
|
|
if not plan["captures"]:
|
|
return ""
|
|
base = f"https://github.com/{repository}/releases/download/{tag}"
|
|
lines = ["## See the difference", ""]
|
|
for capture in plan["captures"]:
|
|
lines.extend([f"### {capture['title']}", ""])
|
|
if capture["description"]:
|
|
lines.extend([capture["description"], ""])
|
|
now_name = f"release-note-{capture['id']}-now.png"
|
|
if capture["before"] is None:
|
|
lines.extend(
|
|
[f"![{capture['title']}]({base}/{now_name})", ""]
|
|
)
|
|
continue
|
|
before_name = f"release-note-{capture['id']}-before.png"
|
|
lines.extend(
|
|
[
|
|
"| Before | Now |",
|
|
"| --- | --- |",
|
|
(
|
|
f"| ![{capture['title']} before]({base}/{before_name}) "
|
|
f"| ![{capture['title']} now]({base}/{now_name}) |"
|
|
),
|
|
"",
|
|
]
|
|
)
|
|
return "\n".join(lines).rstrip() + "\n"
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
|
|
validate_parser = subparsers.add_parser("validate")
|
|
validate_parser.add_argument("--plan", required=True)
|
|
validate_parser.add_argument("--output")
|
|
|
|
count_parser = subparsers.add_parser("count")
|
|
count_parser.add_argument("--plan", required=True)
|
|
|
|
before_count_parser = subparsers.add_parser("before-count")
|
|
before_count_parser.add_argument("--plan", required=True)
|
|
|
|
runtime_count_parser = subparsers.add_parser("runtime-count")
|
|
runtime_count_parser.add_argument("--plan", required=True)
|
|
runtime_count_parser.add_argument("--side", required=True, choices=("before", "after"))
|
|
|
|
subparsers.add_parser("schema")
|
|
|
|
assets_parser = subparsers.add_parser("assets")
|
|
assets_parser.add_argument("--plan", required=True)
|
|
|
|
render_parser = subparsers.add_parser("render")
|
|
render_parser.add_argument("--plan", required=True)
|
|
render_parser.add_argument("--repository", required=True)
|
|
render_parser.add_argument("--tag", required=True)
|
|
render_parser.add_argument("--output")
|
|
|
|
args = parser.parse_args()
|
|
try:
|
|
if args.command == "schema":
|
|
sys.stdout.write(json.dumps(json_schema(), separators=(",", ":")) + "\n")
|
|
return 0
|
|
plan = load_plan(args.plan)
|
|
if args.command == "validate":
|
|
output = json.dumps(plan, indent=2) + "\n"
|
|
elif args.command == "count":
|
|
output = f"{len(plan['captures'])}\n"
|
|
elif args.command == "before-count":
|
|
output = f"{sum(capture['before'] is not None for capture in plan['captures'])}\n"
|
|
elif args.command == "runtime-count":
|
|
output = f"{runtime_count(plan, args.side)}\n"
|
|
elif args.command == "assets":
|
|
output = "".join(f"{name}\n" for name in asset_names(plan))
|
|
else:
|
|
if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", args.repository):
|
|
raise PlanError("repository must be in owner/name form")
|
|
if not re.fullmatch(r"v[0-9A-Za-z][0-9A-Za-z._-]*", args.tag):
|
|
raise PlanError("tag is not a safe release tag")
|
|
output = render_markdown(plan, args.repository, args.tag)
|
|
if getattr(args, "output", None):
|
|
Path(args.output).write_text(output, encoding="utf-8")
|
|
else:
|
|
sys.stdout.write(output)
|
|
return 0
|
|
except (OSError, PlanError) as exc:
|
|
print(f"release-note visuals: {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|