|
| 1 | +#!/usr/bin/env bash |
| 2 | +# |
| 3 | +# Caller-workflow drift check — does a consumer's copied caller still match the |
| 4 | +# devkit template it was copied from? |
| 5 | +# |
| 6 | +# WHY THIS EXISTS |
| 7 | +# `make link-workflows` COPIES templates/{plugin-e2e,devkit-sync}.caller.yml |
| 8 | +# into a consumer's .github/workflows/. `devkit-sync` then keeps the SUBMODULE |
| 9 | +# pin current but deliberately never writes .github/workflows/ (its |
| 10 | +# GITHUB_TOKEN may not push workflow files). So the vendored devkit advances |
| 11 | +# while the copied caller stands still, and nothing anywhere notices. |
| 12 | +# |
| 13 | +# That is not hypothetical. Two live examples: |
| 14 | +# * share-chat's caller predated the `capture_all_traces` dispatch input by |
| 15 | +# weeks — the input existed in the reusable workflow, the repo just could |
| 16 | +# not reach it, and no run ever failed to say so. |
| 17 | +# * `actions: write` (count-based artifact retention, DEC-074) had to be |
| 18 | +# hand-propagated to four repos in four separate PRs, because a reusable |
| 19 | +# workflow's permissions are CAPPED BY THE CALLER's grant. The prune step |
| 20 | +# degraded to a warning instead of failing — silent, again. |
| 21 | +# Both failure modes are invisible at runtime. A checker is the only detector. |
| 22 | +# |
| 23 | +# WHAT COUNTS AS DRIFT (and why not a byte diff) |
| 24 | +# Byte-for-byte would be trivially correct and useless: every comment reflow in |
| 25 | +# the devkit — e.g. the DEC-079 rewrite of the devkit-sync header — would fire |
| 26 | +# on all consumers at once, and a checker that cries wolf gets `|| true`'d |
| 27 | +# within a week. So this compares the EXECUTABLE CONTRACT of the YAML: |
| 28 | +# |
| 29 | +# compared name, triggers (`on`), permissions, job ids, `uses` refs, |
| 30 | +# `with` inputs, `secrets` (inherit vs the explicit key set), |
| 31 | +# dispatch input names/types/defaults |
| 32 | +# ignored comments, whitespace, quoting, key order, scalar-list order |
| 33 | +# (`branches: [main, master]` is a set), and the human-prose |
| 34 | +# `description:` of a dispatch input |
| 35 | +# |
| 36 | +# Rationale for that line: everything on the left changes what CI DOES, and |
| 37 | +# both real incidents above are on the left. Everything on the right changes |
| 38 | +# only how the file reads. Ignoring `description` is the one judgement call — |
| 39 | +# it is UI prose in the Actions run dialog, and letting a wording fix fail four |
| 40 | +# repos is exactly how the signal gets ignored. |
| 41 | +# |
| 42 | +# Drift is reported in three flavours, all fatal, because they rot differently: |
| 43 | +# missing template has it, consumer does not — the silent-rot case above |
| 44 | +# changed both have it, values differ — a local edit or a stale value |
| 45 | +# extra consumer has it, template does not — a local edit that the NEXT |
| 46 | +# `make link-workflows` will silently delete |
| 47 | +# |
| 48 | +# USAGE |
| 49 | +# check-caller-drift.sh [consumer-repo-root ...] # default: . |
| 50 | +# |
| 51 | +# In consumer CI (the vendored copy, no args) it checks the repo it runs in |
| 52 | +# against its own pinned devkit templates — so the check advances exactly when |
| 53 | +# the submodule does. Locally, pass repo roots to sweep the fleet: |
| 54 | +# check-caller-drift.sh ~/src/.../agent-zero-plugin-* |
| 55 | +# |
| 56 | +# Env: |
| 57 | +# TEMPLATES_DIR devkit templates/ dir. Auto-detected when unset, in order: |
| 58 | +# <script>/../../templates (vendored: e2e/ci/ -> devkit root), |
| 59 | +# <script>/../templates, then each consumer's tests/_testkit. |
| 60 | +# GITHUB_ACTIONS when "true", drift is emitted as ::error:: annotations too. |
| 61 | +# |
| 62 | +# Exit: 0 no drift · 1 drift found · 2 usage/setup error. |
| 63 | +set -euo pipefail |
| 64 | + |
| 65 | +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
| 66 | + |
| 67 | +# The two files this check owns. Consumer basename -> template basename. |
| 68 | +# Anything else in .github/workflows/ (a repo's own unit.yml, say) is not ours. |
| 69 | +CALLERS=( |
| 70 | + "plugin-e2e.yml:plugin-e2e.caller.yml" |
| 71 | + "devkit-sync.yml:devkit-sync.caller.yml" |
| 72 | +) |
| 73 | + |
| 74 | +consumers=("$@") |
| 75 | +[ ${#consumers[@]} -eq 0 ] && consumers=(".") |
| 76 | + |
| 77 | +# Resolve the templates dir once if it is fleet-wide; otherwise resolve per |
| 78 | +# consumer from its own vendored devkit, so each repo is judged against the |
| 79 | +# devkit ref IT pinned — not against whatever happens to be checked out here. |
| 80 | +resolve_templates() { |
| 81 | + local consumer="$1" |
| 82 | + if [ -n "${TEMPLATES_DIR:-}" ]; then echo "$TEMPLATES_DIR"; return; fi |
| 83 | + local cand |
| 84 | + for cand in \ |
| 85 | + "$SCRIPT_DIR/../../templates" \ |
| 86 | + "$SCRIPT_DIR/../templates" \ |
| 87 | + "$consumer/tests/_testkit/templates" |
| 88 | + do |
| 89 | + [ -d "$cand" ] && { (cd "$cand" && pwd); return; } |
| 90 | + done |
| 91 | + return 1 |
| 92 | +} |
| 93 | + |
| 94 | +compare() { |
| 95 | + # compare <template.yml> <consumer.yml> <label> |
| 96 | + # Prints one line per drifting path; exits 1 via its own return code. |
| 97 | + TPL="$1" CUR="$2" LABEL="$3" python3 - <<'PY' |
| 98 | +import os, re, sys |
| 99 | +import yaml |
| 100 | +
|
| 101 | +tpl_path, cur_path, label = os.environ["TPL"], os.environ["CUR"], os.environ["LABEL"] |
| 102 | +
|
| 103 | +# Prose-only paths. Everything NOT matched here is part of the contract. |
| 104 | +IGNORE = [ |
| 105 | + re.compile(r"^on\.workflow_dispatch\.inputs\.[^.]+\.description$"), |
| 106 | +] |
| 107 | +
|
| 108 | +def load(path): |
| 109 | + with open(path, encoding="utf-8") as fh: |
| 110 | + doc = yaml.safe_load(fh) or {} |
| 111 | + # YAML 1.1 resolves a bare `on:` key to the boolean True. Every workflow |
| 112 | + # file hits this; normalise before anything else touches the tree. |
| 113 | + if True in doc: |
| 114 | + doc["on"] = doc.pop(True) |
| 115 | + return doc |
| 116 | +
|
| 117 | +def flatten(node, prefix=""): |
| 118 | + """Dotted-path -> scalar. Scalar lists collapse to a sorted canonical string |
| 119 | + because trigger lists (`branches`, `paths`) are sets, not sequences; lists |
| 120 | + holding structure keep their index, where order can matter.""" |
| 121 | + out = {} |
| 122 | + if isinstance(node, dict): |
| 123 | + for k, v in node.items(): |
| 124 | + out.update(flatten(v, f"{prefix}.{k}" if prefix else str(k))) |
| 125 | + elif isinstance(node, list): |
| 126 | + if all(not isinstance(i, (dict, list)) for i in node): |
| 127 | + out[prefix] = "[" + ", ".join(sorted(str(i) for i in node)) + "]" |
| 128 | + else: |
| 129 | + for i, v in enumerate(node): |
| 130 | + out.update(flatten(v, f"{prefix}[{i}]")) |
| 131 | + else: |
| 132 | + out[prefix] = node |
| 133 | + return out |
| 134 | +
|
| 135 | +def contract(path): |
| 136 | + return {k: v for k, v in flatten(load(path)).items() |
| 137 | + if not any(rx.match(k) for rx in IGNORE)} |
| 138 | +
|
| 139 | +tpl, cur = contract(tpl_path), contract(cur_path) |
| 140 | +
|
| 141 | +rows = [] |
| 142 | +for key in sorted(set(tpl) | set(cur)): |
| 143 | + if key not in cur: |
| 144 | + rows.append(("missing", key, tpl[key], None)) |
| 145 | + elif key not in tpl: |
| 146 | + rows.append(("extra", key, None, cur[key])) |
| 147 | + elif tpl[key] != cur[key]: |
| 148 | + rows.append(("changed", key, tpl[key], cur[key])) |
| 149 | +
|
| 150 | +if not rows: |
| 151 | + sys.exit(0) |
| 152 | +
|
| 153 | +print(f" {label}: {len(rows)} drifted path(s)") |
| 154 | +for kind, key, want, got in rows: |
| 155 | + if kind == "missing": |
| 156 | + print(f" - [missing] {key}\n template: {want!r}\n consumer: <absent>") |
| 157 | + elif kind == "extra": |
| 158 | + print(f" - [extra] {key}\n template: <absent>\n consumer: {got!r}") |
| 159 | + else: |
| 160 | + print(f" - [changed] {key}\n template: {want!r}\n consumer: {got!r}") |
| 161 | +sys.exit(1) |
| 162 | +PY |
| 163 | +} |
| 164 | + |
| 165 | +drifted=0 |
| 166 | +checked=0 |
| 167 | + |
| 168 | +for consumer in "${consumers[@]}"; do |
| 169 | + [ -d "$consumer" ] || { echo "::error::not a directory: $consumer" >&2; exit 2; } |
| 170 | + name="$(basename "$(cd "$consumer" && pwd)")" |
| 171 | + |
| 172 | + # A repo with no caller at all is not this check's business — it never adopted |
| 173 | + # the devkit. Skipping beats failing every unrelated repo in a fleet sweep. |
| 174 | + have_any=0 |
| 175 | + for pair in "${CALLERS[@]}"; do |
| 176 | + [ -f "$consumer/.github/workflows/${pair%%:*}" ] && have_any=1 |
| 177 | + done |
| 178 | + [ "$have_any" -eq 1 ] || { echo "== $name: no devkit caller workflows — skipped"; continue; } |
| 179 | + |
| 180 | + templates="$(resolve_templates "$consumer")" || { |
| 181 | + echo "::error::could not locate devkit templates/ for $name (set TEMPLATES_DIR)" >&2 |
| 182 | + exit 2 |
| 183 | + } |
| 184 | + |
| 185 | + echo "== $name (templates: $templates)" |
| 186 | + repo_drift=0 |
| 187 | + for pair in "${CALLERS[@]}"; do |
| 188 | + caller="${pair%%:*}"; template="${pair##*:}" |
| 189 | + cur="$consumer/.github/workflows/$caller" |
| 190 | + tpl="$templates/$template" |
| 191 | + [ -f "$tpl" ] || { echo "::error::missing template $tpl" >&2; exit 2; } |
| 192 | + if [ ! -f "$cur" ]; then |
| 193 | + # Half-adopted: one caller present, the other never copied or deleted. |
| 194 | + echo " $caller: NOT PRESENT (template exists) — run 'make link-workflows'" |
| 195 | + repo_drift=1; continue |
| 196 | + fi |
| 197 | + checked=$((checked + 1)) |
| 198 | + compare "$tpl" "$cur" "$caller" || repo_drift=1 |
| 199 | + done |
| 200 | + |
| 201 | + if [ "$repo_drift" -eq 1 ]; then |
| 202 | + drifted=$((drifted + 1)) |
| 203 | + [ "${GITHUB_ACTIONS:-}" = "true" ] && \ |
| 204 | + echo "::error::$name: caller workflows drifted from the devkit templates — run 'make link-workflows'" |
| 205 | + else |
| 206 | + echo " in sync" |
| 207 | + fi |
| 208 | +done |
| 209 | + |
| 210 | +echo |
| 211 | +if [ "$drifted" -gt 0 ]; then |
| 212 | + echo "DRIFT: $drifted repo(s) out of sync across $checked caller file(s)." |
| 213 | + echo "Fix: run 'make link-workflows' in each, review the diff, and commit." |
| 214 | + exit 1 |
| 215 | +fi |
| 216 | +echo "OK: $checked caller file(s) match their devkit templates." |
0 commit comments