From 697972e1869499bc18df04db19e5694dc81951a3 Mon Sep 17 00:00:00 2001 From: George Dumitrescu Date: Sun, 20 Sep 2026 20:54:40 +0300 Subject: [PATCH] ci: refuse a schema change an older lerd cannot read Definitions here reach every install within a day, whatever version of lerd is running, and there is no version gate on the way in. An old binary will read a definition written long after it shipped, so the schema may only grow, and the moment to catch a change that breaks that is the pull request rather than the day after it ships. The guard flattens every document to a set of paths and types, compares the branch against what is published, and fails on the two changes an older binary cannot survive: a key that disappeared, and a key whose type moved under it. A value no published definition has used before is reported as a warning instead, because widening the accepted values of a key breaks an old switch exactly as retyping does, but only the author knows whether the binaries in the field already understand it. Both the types and the closed value sets come from the published tree itself, so nothing here needs updating as the schema grows. --- .github/scripts/schema_guard.py | 143 +++++++++++++++++++++++++++++ .github/workflows/schema-guard.yml | 30 ++++++ 2 files changed, 173 insertions(+) create mode 100755 .github/scripts/schema_guard.py create mode 100644 .github/workflows/schema-guard.yml diff --git a/.github/scripts/schema_guard.py b/.github/scripts/schema_guard.py new file mode 100755 index 0000000..4051259 --- /dev/null +++ b/.github/scripts/schema_guard.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Guard the store schema against changes an older lerd cannot read. + +A store definition reaches every install within a day, whatever version of lerd +it runs, so the schema may only grow. This compares the YAML in a pull request +against what is already published and refuses the two changes that break a +binary older than the definition: a key that disappeared, and a key whose type +moved under it. A value never before seen for a key that has always held a small +closed set is reported as a warning, since widening an enum breaks an old +binary's switch exactly as retyping does, but only the definition's author can +say whether the binary already knows the new value. + +Usage: schema_guard.py [glob] +""" + +import sys +import pathlib +import yaml + +# A key whose published values number no more than this across the whole store +# is treated as a closed set, so a value outside it is worth a second look. +CLOSED_SET_MAX = 8 + + +def type_name(value): + if isinstance(value, bool): + return "bool" + if isinstance(value, dict): + return "map" + if isinstance(value, list): + return "list" + if isinstance(value, (int, float)): + return "number" + if value is None: + return "null" + return "string" + + +def walk(node, prefix, types, values): + """Flatten a document into path -> type and path -> observed scalar values. + + List elements collapse onto one `[]` path: the store cares that a list holds + maps or strings, not what sits at index three. + """ + kind = type_name(node) + if prefix: + types.setdefault(prefix, set()).add(kind) + if isinstance(node, dict): + for key, child in node.items(): + walk(child, f"{prefix}.{key}" if prefix else str(key), types, values) + elif isinstance(node, list): + for child in node: + walk(child, f"{prefix}[]", types, values) + elif prefix and kind in ("string", "bool", "number"): + values.setdefault(prefix, set()).add(str(node)) + + +def load(path): + with open(path, "r", encoding="utf-8") as handle: + return yaml.safe_load(handle) or {} + + +def profile(root, pattern): + """Map every YAML file under root to its flattened type and value profile.""" + out = {} + for path in sorted(pathlib.Path(root).rglob(pattern)): + rel = str(path.relative_to(root)) + types, values = {}, {} + try: + walk(load(path), "", types, values) + except yaml.YAMLError as err: + out[rel] = ("unparseable", err) + continue + out[rel] = (types, values) + return out + + +def closed_sets(profiles): + """Union every file's observed values per path, keeping the small ones.""" + merged = {} + for entry in profiles.values(): + if entry[0] == "unparseable": + continue + for path, vals in entry[1].items(): + merged.setdefault(path, set()).update(vals) + return {p: v for p, v in merged.items() if len(v) <= CLOSED_SET_MAX} + + +def main(): + if len(sys.argv) < 3: + print(__doc__) + return 2 + published_dir, candidate_dir = sys.argv[1], sys.argv[2] + pattern = sys.argv[3] if len(sys.argv) > 3 else "*.yaml" + + published = profile(published_dir, pattern) + candidate = profile(candidate_dir, pattern) + enums = closed_sets(published) + + failures, warnings = [], [] + + for rel, entry in published.items(): + if rel not in candidate: + warnings.append(f"{rel}: no longer published, an install that refetches it gets a 404") + continue + if entry[0] == "unparseable" or candidate[rel][0] == "unparseable": + continue + old_types, _ = entry + new_types, new_values = candidate[rel] + + for path, kinds in sorted(old_types.items()): + if path not in new_types: + failures.append(f"{rel}: key `{path}` was removed, an older lerd still reads it") + continue + if kinds != new_types[path] and not kinds & new_types[path]: + failures.append( + f"{rel}: key `{path}` changed type from {'/'.join(sorted(kinds))} " + f"to {'/'.join(sorted(new_types[path]))}" + ) + + for path, vals in sorted(new_values.items()): + if path not in enums: + continue + for value in sorted(vals - enums[path]): + warnings.append( + f"{rel}: key `{path}` takes the new value `{value}`; published values are " + f"{', '.join(sorted(enums[path]))}. Confirm every supported lerd understands it" + ) + + for line in warnings: + print(f"warning: {line}") + for line in failures: + print(f"error: {line}") + + if failures: + print(f"\n{len(failures)} change(s) an older lerd cannot read.") + return 1 + print(f"\nschema guard passed ({len(published)} published file(s) checked, {len(warnings)} warning(s)).") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/schema-guard.yml b/.github/workflows/schema-guard.yml new file mode 100644 index 0000000..c8bcd6d --- /dev/null +++ b/.github/workflows/schema-guard.yml @@ -0,0 +1,30 @@ +name: Schema guard + +on: + pull_request: + +# A store definition reaches every install within a day, whatever version of +# lerd it runs, so a pull request may only grow the schema. This refuses a key +# that disappeared or changed type, and reports a value no published definition +# has used before. +jobs: + schema-guard: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - run: pip install pyyaml + + - name: Extract the published tree + run: | + mkdir -p "$RUNNER_TEMP/published" + git archive "origin/${{ github.base_ref }}" | tar -x -C "$RUNNER_TEMP/published" + + - name: Compare this pull request against it + run: python3 .github/scripts/schema_guard.py "$RUNNER_TEMP/published" .