From 03139bbb5c25bb719e667d31dc9f6cf288921db6 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Sat, 22 Aug 2026 17:25:53 +0200 Subject: [PATCH 1/2] docs(multi-repo): gate a template file-sync on a purely additive diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Writing a shared file over each consumer destroys repo-specific content silently. Nothing errors and nothing is reported; the loss surfaces when someone misses what used to be there. The sweep should decide per repo, and a removal should stop it rather than be applied. Two details in the snippet are load-bearing, and both obvious shorter forms are wrong. They are in the text because the first version of this change got them wrong and an adversarial pass caught it: `grep -cE '^-[^-]'` looks correct — it skips the `--- file` header — but it also skips every removed BLANK line and every removed line whose own first character is `-`, which is what a YAML list item at column 0 looks like. Both then count as zero removals and the repo lands in the FIRST branch: reported as already current, never flagged, never opened. Measured: cur='on:\n- push\n- local\n' against tpl='on:\n- push\n' scores +0/-0 under the short form and +0/-1 under `tail -n +3 | grep -c '^-'`. And `diff`'s exit status has to be read before the counts are trusted. With the file absent from the consumer, diff exits 2 with empty stdout, both counts are 0, and `|| true` swallows it — so the repositories MISSING the shared file are exactly the ones reported as synced. `|| true` is still needed on `grep -c` (no matches exits 1, fatal under set -e); it just must not be the only status handling. The section also carries the two things the gate must not skip: read `intentional-drift:` as a key rather than grepping the manifest, since those manifests mention managed files in prose and one such comment reads "byte-identical, drift-enforced checks.yml" while the key lists other files entirely; and distrust an implausible classification rate, because a dry run claiming most of a fleet has opted out of one shared file is a broken query. The 21-repository observation is scoped to what was actually measured: that sweep ran the short pattern, so "the other twenty removed nothing" holds only for removals that pattern can see. Assisted-by: claude-code:claude-opus-5 Agent-Session: https://claude.ai/code/session_0144iD1P22LotW8rxmxrNGro Agent-Host: 0493f0 Signed-off-by: Sebastian Mendel --- .../references/multi-repo-operations.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/skills/github-project/references/multi-repo-operations.md b/skills/github-project/references/multi-repo-operations.md index 39869a3..f388511 100644 --- a/skills/github-project/references/multi-repo-operations.md +++ b/skills/github-project/references/multi-repo-operations.md @@ -6,6 +6,38 @@ Batch and fleet-wide operations — releases, rebases, lint fixes, config rollou Batch ops amplify small mistakes linearly. A version-bump ordering bug that affects 1 repo is a nuisance; across 30 repos it's 30 broken release workflows. Before executing on more than 3 repos, produce a dry-run manifest and get explicit approval. +### Copying a template file across repos: gate on a purely additive diff + +Syncing a shared file (`checks.yml`, a lint config, a workflow) by writing the template over each consumer destroys repo-specific content **silently**. Nothing errors, nothing is reported, and the loss only surfaces when someone misses the thing that used to be there. + +Make the sweep decide per repo, and let removals stop it: + +```bash +d=$(diff -u "$current" "$template" 2>/dev/null); rc=$? +[ "$rc" -ge 2 ] && { echo "$REPO: CANNOT COMPARE (file missing?)"; return; } + +# Drop the two `---`/`+++` header lines, then count every marked line. +added=$(printf '%s\n' "$d" | tail -n +3 | grep -c '^+' || true) +removed=$(printf '%s\n' "$d" | tail -n +3 | grep -c '^-' || true) + +if [ "$added" = 0 ] && [ "$removed" = 0 ]; then echo "$REPO: already current" +elif [ "$removed" != 0 ]; then echo "$REPO: MANUAL +$added/-$removed"; printf '%s\n' "$d" | tail -n +3 | grep '^-' +else apply_and_open_pr +fi +``` + +Two details in that snippet are load-bearing, and the obvious shorter forms are wrong: + +- **Strip the header, do not exclude a second character.** `grep -cE '^-[^-]'` looks right — it skips the `--- file` header — but it also skips every removed **blank** line and every removed line whose own first character is `-`, which is what a YAML list item at column 0 looks like. Both then count as zero removals and the repo lands in the *first* branch: reported as already current, never flagged, never opened. The gate fails exactly in the case it exists for. (`cur='on:\n- push\n- local\n'` vs `tpl='on:\n- push\n'` scores `+0/-0` under the short form and `+0/-1` under this one.) +- **Check `diff`'s exit status before trusting the counts.** If the consumer does not have the file yet, `diff` exits 2 with empty stdout, both counts are 0, and `|| true` swallows the failure — so the repositories that are *missing* the shared file are the ones reported as synced. `|| true` is still required on the `grep -c` (no matches exits 1, fatal under `set -e`), it just must not be the only status handling. + +Removals are not automatically wrong — they are **unclassified**. In a 21-repository sweep on 2026-08-21 one repository came back non-additive (`+24/-1`) and the removed line turned out to be a stale action pin the template updates, not local customisation; it was then done as its own PR with the replaced line quoted in the body. That sweep used the short pattern above, so "the other twenty removed nothing" is only established for removals that pattern can see. + +Two things the gate must not skip: + +- **Honour declared exemptions by reading the key, not the file.** `intentional-drift:` lives in `.github/template.yaml`, and those manifests also mention managed files in prose — a comment reading *"byte-identical, drift-enforced checks.yml"* matches a grep for `checks.yml` and means the opposite. Use `yq '.["intentional-drift"]'` and test for the path. +- **Distrust an implausible classification rate.** If a dry run reports that most of the fleet has deliberately opted out of one shared file, the query is broken, not the fleet. Read one matched item in full before believing the aggregate. + ### Dry-run manifest format Emit a table — one row per repo, one column per step. Do not execute until the user approves the plan. From 4b8ab52fac7d1dd1b92cd391f2609a1829b61add Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Sat, 22 Aug 2026 18:33:35 +0200 Subject: [PATCH 2/2] docs(multi-repo): make the snippet's function context explicit The gate uses an early `return`, which needs a function. Shown as a bare block it breaks when pasted into a loop, where `continue` is the right word. Wrapped as `sync_one()` with the two inputs named, and the alternative stated. Verified by running the published snippet verbatim against five fixtures: removed blank lines and a removed YAML list item both reach MANUAL (+0/-2 and +0/-1), a purely additive diff reaches the apply branch, identical files report already current, and an absent consumer file reports CANNOT COMPARE. Assisted-by: claude-code:claude-opus-5 Agent-Session: https://claude.ai/code/session_0144iD1P22LotW8rxmxrNGro Agent-Host: 0493f0 Signed-off-by: Sebastian Mendel --- skills/github-project/references/multi-repo-operations.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/skills/github-project/references/multi-repo-operations.md b/skills/github-project/references/multi-repo-operations.md index f388511..98e73cc 100644 --- a/skills/github-project/references/multi-repo-operations.md +++ b/skills/github-project/references/multi-repo-operations.md @@ -10,9 +10,10 @@ Batch ops amplify small mistakes linearly. A version-bump ordering bug that affe Syncing a shared file (`checks.yml`, a lint config, a workflow) by writing the template over each consumer destroys repo-specific content **silently**. Nothing errors, nothing is reported, and the loss only surfaces when someone misses the thing that used to be there. -Make the sweep decide per repo, and let removals stop it: +Make the sweep decide per repo, and let removals stop it. As one function per repository — the early `return` below needs that, and a paste into a bare loop wants `continue` instead: ```bash +sync_one() { # $current = the consumer's copy, $template = the shared source d=$(diff -u "$current" "$template" 2>/dev/null); rc=$? [ "$rc" -ge 2 ] && { echo "$REPO: CANNOT COMPARE (file missing?)"; return; } @@ -24,6 +25,7 @@ if [ "$added" = 0 ] && [ "$removed" = 0 ]; then echo "$REPO: already current" elif [ "$removed" != 0 ]; then echo "$REPO: MANUAL +$added/-$removed"; printf '%s\n' "$d" | tail -n +3 | grep '^-' else apply_and_open_pr fi +} ``` Two details in that snippet are load-bearing, and the obvious shorter forms are wrong: