Skip to content

Commit e29c303

Browse files
committed
ci(staleness): tolerate in-window reusable pins; wire deliberate bump path
Stop the workflow-staleness gate being a treadmill. `standards` hosts reusable workflows that consumers pin by full SHA; the gate failed a consumer's CI whenever its pin != standards HEAD. Because HEAD moves often (observed live in stapeln: 5a93d9d -> d72fe5a -> 4ddc926 in ~30 min), every standards commit turned every consumer red until someone manually re-bumped the pins. Replace exact-HEAD equality in check-workflow-staleness.sh with a recency window, mirroring Hypatia HYP-S006 (registry-staleness, which tolerates drift for stale_after_days before escalating). A pin passes when it is a genuine ancestor of HEAD within the window: <= 50 commits behind OR <= 14 days old (both overridable via STALENESS_WINDOW_COMMITS / STALENESS_WINDOW_DAYS). It still fails when out of window (pins can't rot; known-bad historical pins stay blocked) or when not a real standards commit (forged/fork SHAs rejected — supply-chain integrity). In-window-but-behind pins emit a non-failing nudge. governance-reusable.yml now clones standards with --filter=tree:0 (full commit graph, minimal data) so the gate can do ancestry/age math. Wire the deliberate-bump half (the sha_bump_propagation path) as scripts/propagate-workflow-pins.sh: an audit-first helper that rewrites a consumer's standards pins to a target SHA and stages them on a branch, never committing/pushing (mirrors propagate-gitignore-67-68.sh). This is the concrete standards-side action the Hypatia sha_bump_propagation rule routes to; the gate no longer forces the bump. Verified end-to-end against real standards history with a stapeln-shaped consumer: a pin 3 commits behind HEAD passes the relaxed gate (with a nudge), while an out-of-window pin and a forged SHA both still fail. Hermetic regression suites added for both scripts (12 + 11 cases). Docs: docs/decisions/ADR-003-workflow-pin-staleness-window.adoc records the decision and the rejected alternatives (auto-PR-only; mutable @vn tag). Justfile gains staleness-check / staleness-propagate / staleness-test recipes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BKyi8Bht8xdXyajpkaTdYP
1 parent 4ddc926 commit e29c303

7 files changed

Lines changed: 760 additions & 103 deletions

.github/workflows/governance-reusable.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,12 @@ jobs:
2929
3030
- name: Clone standards repository
3131
run: |
32-
git clone --depth 1 https://github.com/hyperpolymath/standards.git "$HOME/standards"
32+
# Treeless partial clone: full commit graph (needed by the staleness
33+
# gate's ancestry / commits-behind / age math) without paying for
34+
# every tree+blob in history. Falls back to a deep clone if the
35+
# server rejects the partial-clone filter.
36+
git clone --filter=tree:0 https://github.com/hyperpolymath/standards.git "$HOME/standards" \
37+
|| git clone --depth 200 https://github.com/hyperpolymath/standards.git "$HOME/standards"
3338
3439
- name: Run staleness check
3540
run: |

Justfile

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,23 @@ topology: registry
2424
registry-check:
2525
@bash scripts/build-registry.sh --check
2626

27+
# Run the workflow-staleness gate against a repo (default: this one)
28+
staleness-check path=".":
29+
@bash scripts/check-workflow-staleness.sh "{{path}}"
30+
31+
# Audit (or --fix) consumer reusable-workflow SHA pins toward a target.
32+
# Read-only by default; pass extra args after the path, e.g.
33+
# just staleness-propagate ../stapeln --fix --to <sha>
34+
staleness-propagate path="." *args="":
35+
@bash scripts/propagate-workflow-pins.sh {{args}} "{{path}}"
36+
37+
# Regression tests for the staleness gate + the pin-propagation helper
38+
staleness-test:
39+
@echo "=== check-workflow-staleness ==="
40+
@bash scripts/tests/check-workflow-staleness-test.sh
41+
@echo "=== propagate-workflow-pins ==="
42+
@bash scripts/tests/propagate-workflow-pins-test.sh
43+
2744
# Aggregate compliance gate: registry drift (hard dep) + RSR self-audit (informational)
2845
validate: registry-check
2946
@echo "=== validate: RSR compliance gate ==="
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
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

Comments
 (0)