Skip to content

Commit 923bbef

Browse files
committed
feat(ci): detect caller-template drift (DEC-080)
make link-workflows COPIES the caller templates into a consumer, and devkit-sync deliberately never writes .github/workflows/. So the vendored devkit advances while the copied caller stands still and nothing at runtime notices. Both known instances were silent by construction: - share-chat could not reach the capture_all_traces input for weeks, because its caller predated it; no run ever failed to say so - actions: write (DEC-074) needed hand-propagating to 4 repos in 4 PRs, and until then the prune degraded to a WARNING rather than failing The check compares the EXECUTABLE CONTRACT — triggers, dispatch inputs, permissions, job ids, uses refs, with inputs, secrets shape — and ignores comments, whitespace, quoting, key order and prose descriptions. That line is deliberate: a byte-diff would have fired on all four consumers the moment the devkit rewrote a header comment for DEC-079, and a checker that cries wolf gets || true-d. Templates resolve to the consumers own pinned tests/_testkit when available, so each repo is judged against the devkit ref IT pinned, not whatever main is. The self-test replays both real incidents as positive controls and pins the cosmetic tolerances as negative controls — so the tempting fix for a noisy checker (loosen until quiet) fails the suite instead of passing silently. 10/10 green. Verified against live main: 3 of 4 consumers in sync, share-chat drifted on 4 paths (the capture_all_traces input and its wiring) — the one genuine gap.
1 parent bfef261 commit 923bbef

3 files changed

Lines changed: 469 additions & 1 deletion

File tree

‎SPEC.md‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1212,8 +1212,27 @@ e2e had passed and the result was discarded and re-queued for nothing. The same
12121212
`existing` was always empty and every run created a brand-new PR instead of updating one. A force-push
12131213
alone is sufficient and non-destructive: an open PR follows its head ref and picks up the new commit.
12141214

1215+
**DEC-080 — Caller-template drift is detected, because nothing else can see it.**
1216+
`make link-workflows` *copies* the caller templates into a consumer's `.github/workflows/`, and
1217+
`devkit-sync` deliberately never writes that directory (its `GITHUB_TOKEN` may not push workflow
1218+
files). So the vendored devkit advances while the copied caller stands still, and **nothing at
1219+
runtime notices** — both known instances were silent by construction: share-chat could not reach the
1220+
`capture_all_traces` input for weeks because its caller predated it, and `actions: write` (DEC-074)
1221+
had to be hand-propagated to four repos in four PRs, with the prune degrading to a *warning* in the
1222+
meantime rather than failing.
1223+
`e2e/ci/check-caller-drift.sh` compares the **executable contract** — triggers, dispatch inputs,
1224+
permissions, job ids, `uses` refs, `with` inputs, and the secrets shape — and ignores comments,
1225+
whitespace, quoting, key order and prose descriptions. That line is deliberate: a byte-diff would
1226+
have fired on all four consumers the moment the devkit rewrote a header comment, and a checker that
1227+
cries wolf gets `|| true`'d. Templates resolve to the consumer's own pinned `tests/_testkit` when
1228+
available, so each repo is judged against the devkit ref *it* pinned rather than whatever `main`
1229+
happens to be.
1230+
The self-test is not decoration. It replays both real incidents as positive controls and pins the
1231+
cosmetic tolerances as negative controls, so the tempting "fix" for a noisy checker — loosening it
1232+
until it stops complaining — fails the suite instead of passing quietly.
1233+
12151234
> **Numbering note:** DEC-070–072 were originally introduced as code comments only. They are now
1216-
> written up above, so this section documents an unbroken 070–079. The lesson is worth keeping: a
1235+
> written up above, so this section documents an unbroken 070–080. The lesson is worth keeping: a
12171236
> decision that exists only as a code comment is invisible to anyone reading the SPEC, and all three
12181237
> of these were load-bearing (a gate's timeout budget, the image the whole fleet pulls, and a wait
12191238
> that was flaking half the runs).

‎e2e/ci/check-caller-drift.sh‎

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
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

Comments
 (0)