|
| 1 | +// SPDX-License-Identifier: CC-BY-SA-4.0 |
| 2 | +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) |
| 3 | += ADR-003: Workflow reusable-pin staleness — recency window, not exact-HEAD |
| 4 | +:adr-status: Accepted |
| 5 | +:adr-date: 2026-06-21 |
| 6 | +:adr-author: Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> |
| 7 | +:toc: |
| 8 | +:toc-placement: preamble |
| 9 | + |
| 10 | +== Status |
| 11 | + |
| 12 | +*Accepted* 2026-06-21. Supersedes the exact-HEAD comparison previously baked |
| 13 | +into `scripts/check-workflow-staleness.sh`. |
| 14 | + |
| 15 | +== Context |
| 16 | + |
| 17 | +`standards` hosts the estate's reusable workflows |
| 18 | +(`governance-reusable.yml`, `hypatia-scan-reusable.yml`, |
| 19 | +`scorecard-reusable.yml`, …). Consumer repos (stapeln, k9-svc, meta-a2ml, …) |
| 20 | +call them pinned by full commit SHA, e.g.: |
| 21 | + |
| 22 | +[source,yaml] |
| 23 | +---- |
| 24 | +uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@<sha> |
| 25 | +---- |
| 26 | + |
| 27 | +`scripts/check-workflow-staleness.sh` — run in every consumer via the |
| 28 | +governance reusable — *failed the consumer's CI whenever its pin != standards |
| 29 | +HEAD*. Because standards HEAD moves often (sometimes several times an hour), |
| 30 | +this turned every standards commit into a fleet-wide red: |
| 31 | + |
| 32 | +* Observed live in a stapeln session: standards HEAD moved |
| 33 | + `5a93d9d` → `d72fe5a` → `4ddc926` in ~30 minutes. Each move forced a manual |
| 34 | + bump of the same three pins (`governance` / `hypatia` / `scorecard`) in the |
| 35 | + consumer, just to get back to green. |
| 36 | +* The gate was supposed to *guard* against genuinely rotten pins (the |
| 37 | + pre-cache-fix Hypatia reusable; the SARIF-publishing Scorecard). Instead it |
| 38 | + *nagged* on every HEAD move and produced no safety — the consumer's pin was |
| 39 | + fine; standards had simply moved. |
| 40 | + |
| 41 | +The intent to automate the bump already exists as the Hypatia rule |
| 42 | +`lib/rules/sha_bump_propagation.ex`, but the propagation was never wired, so |
| 43 | +the gate just nagged. |
| 44 | + |
| 45 | +== Decision |
| 46 | + |
| 47 | +Replace exact-HEAD equality with a *recency window*, mirroring the estate's |
| 48 | +existing staleness idiom — Hypatia *HYP-S006 `registry-staleness`*, which |
| 49 | +tolerates drift for `stale_after_days: 14` before it escalates rather than |
| 50 | +failing on the first mismatch. |
| 51 | + |
| 52 | +A pinned SHA now *passes* `check-workflow-staleness.sh` when it is a genuine |
| 53 | +ancestor of standards HEAD that is *within the window*: |
| 54 | + |
| 55 | +* at most `STALENESS_WINDOW_COMMITS` commits behind HEAD (default *50*), *OR* |
| 56 | +* at most `STALENESS_WINDOW_DAYS` days old (default *14*, matching HYP-S006 |
| 57 | + `stale_after_days`). |
| 58 | + |
| 59 | +The union ("commits OR days") covers both failure shapes: a high-velocity |
| 60 | +burst (many commits, tiny wall-clock age) is saved by the days axis; a slow |
| 61 | +repo (few commits over a long span) is saved by the commits axis. |
| 62 | + |
| 63 | +A pin still *fails* when it is: |
| 64 | + |
| 65 | +* *out of the window* — older than both axes (so pins cannot rot forever, and |
| 66 | + the known-bad historical pins stay blocked); or |
| 67 | +* *not a published standards commit* — a forged, fork, or divergent SHA that |
| 68 | + is not an ancestor of HEAD (supply-chain integrity is preserved — the pin |
| 69 | + must still be a real standards mainline commit). |
| 70 | + |
| 71 | +A pin that is *ahead* of the gate's HEAD view (a race during a HEAD move) is |
| 72 | +treated as fresh. |
| 73 | + |
| 74 | +In-window-but-behind pins emit a non-failing `::notice` nudge pointing at the |
| 75 | +deliberate-bump path, so the gate informs without blocking. |
| 76 | + |
| 77 | +=== Wiring the deliberate bump (the `sha_bump_propagation` half) |
| 78 | + |
| 79 | +Relaxing the gate removes the *forced* treadmill bump. A *wanted* bump (pick |
| 80 | +up a fix early, or a scheduled cadence) lands via |
| 81 | +`scripts/propagate-workflow-pins.sh` — an audit-first helper that rewrites a |
| 82 | +consumer's standards pins to a target SHA and stages them on a branch, *never* |
| 83 | +committing or pushing (mirroring `scripts/propagate-gitignore-67-68.sh` and |
| 84 | +the estate "no unattended mutations" guardrail). This is the concrete |
| 85 | +standards-side action the Hypatia `sha_bump_propagation` rule routes to: |
| 86 | +the rule surfaces a `:review` finding "consumer pin is behind HEAD"; gitbot-fleet |
| 87 | +runs this script and opens a DRAFT PR. The gate decides nothing about *when* |
| 88 | +to bump; it only blocks pins that have fallen out of the window. |
| 89 | + |
| 90 | +=== Mechanics |
| 91 | + |
| 92 | +The gate needs the standards commit graph (ancestry, commits-behind, commit |
| 93 | +age), so `governance-reusable.yml` now clones standards with |
| 94 | +`git clone --filter=tree:0` (a treeless partial clone: full commit history, |
| 95 | +minimal data) instead of `--depth 1`, with a `--depth 200` fallback. |
| 96 | + |
| 97 | +== Alternatives considered |
| 98 | + |
| 99 | +[cols="1,4,2",options="header"] |
| 100 | +|=== |
| 101 | +| Option | Description | Verdict |
| 102 | + |
| 103 | +| *A — auto-propagation only* |
| 104 | +| A scheduled bot opens pin-bump PRs across all consumers on every HEAD move |
| 105 | + (wire `sha_bump_propagation` + gitbot-fleet), leaving the gate exact-HEAD. |
| 106 | +| *Rejected as the sole fix.* It does not stop the in-flight consumer PR going |
| 107 | + red — the red persists until the bump PR merges — so it fails the core |
| 108 | + requirement ("a consumer PR no longer fails purely because standards |
| 109 | + moved"). Also risks PR spam (one bump PR per consumer per HEAD move). |
| 110 | + Adopted as the *complementary* deliberate-bump path, not the gate. |
| 111 | + |
| 112 | +| *B — recency window* (chosen) |
| 113 | +| Tolerate any in-window ancestor pin at the gate. |
| 114 | +| *Chosen.* Directly satisfies the requirement, self-contained in standards, |
| 115 | + fully testable, and idiomatic (HYP-S006 window). Keeps supply-chain teeth |
| 116 | + (ancestor-only + bounded window). |
| 117 | + |
| 118 | +| *C — released `@vN` tag* |
| 119 | +| Move the reusables behind a moving tag (`@v1`) the consumers pin, advanced |
| 120 | + deliberately on release. |
| 121 | +| *Rejected.* Pinning a *mutable* tag is a supply-chain regression versus |
| 122 | + full-SHA pinning — a tag can be force-moved, breaking the "the SHA is the |
| 123 | + version" property the estate relies on (see `DEPENDABOT-POLICY.adoc`). It |
| 124 | + also needs a release/retag process and a one-off rewrite of every consumer. |
| 125 | + Heavier and weaker than B. |
| 126 | +|=== |
| 127 | + |
| 128 | +== Consequences |
| 129 | + |
| 130 | +* *Positive*: consumer CI no longer reds purely because standards moved; |
| 131 | + the treadmill is gone. The known-bad historical pins and forged SHAs remain |
| 132 | + blocked. The deliberate-bump path is now wired and audit-first. |
| 133 | +* *Trade-off*: a consumer may run a reusable up to ~50 commits / 14 days behind |
| 134 | + HEAD. This is the intended supply-chain posture: pins are still full SHAs of |
| 135 | + real standards commits, just allowed a bounded lag. Tighten per-repo via |
| 136 | + `STALENESS_WINDOW_COMMITS` / `STALENESS_WINDOW_DAYS` where a tighter SLA is |
| 137 | + warranted (e.g. set both low to require near-HEAD). |
| 138 | +* *Security-relevant reusables*: if a *security fix* lands in a reusable and |
| 139 | + must reach consumers faster than the window, drive it through the |
| 140 | + propagation path (open bump PRs immediately) rather than narrowing the gate |
| 141 | + globally. |
| 142 | + |
| 143 | +== Verification |
| 144 | + |
| 145 | +`scripts/tests/check-workflow-staleness-test.sh` (hermetic fixture history) |
| 146 | +covers fresh / ahead / in-window-by-commits / in-window-by-days / |
| 147 | +out-of-window / forged, plus the retired-enforcer and SARIF structural rules. |
| 148 | +`scripts/tests/propagate-workflow-pins-test.sh` covers audit/fix/idempotence |
| 149 | +and branch staging. End-to-end against real standards history, a |
| 150 | +stapeln-shaped consumer pinned 3 commits behind HEAD passes the relaxed gate |
| 151 | +(with a nudge), while an out-of-window pin and a forged SHA both still fail. |
| 152 | + |
| 153 | +== Cross-refs |
| 154 | + |
| 155 | +* `scripts/check-workflow-staleness.sh` — the windowed gate. |
| 156 | +* `scripts/propagate-workflow-pins.sh` — the audit-first deliberate-bump helper. |
| 157 | +* `hypatia-rules/registry-staleness.a2ml` (HYP-S006) — the window idiom this |
| 158 | + mirrors. |
| 159 | +* `hyperpolymath/hypatia` `lib/rules/sha_bump_propagation.ex` — the rule this |
| 160 | + wires. |
| 161 | +* `docs/DEPENDABOT-POLICY.adoc` — the "the SHA is the version" posture that |
| 162 | + rules out mutable-tag pinning (option C). |
| 163 | +* `scripts/propagate-gitignore-67-68.sh` — the audit-first propagation pattern |
| 164 | + this helper follows. |
0 commit comments