mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
ci: close the escape hatches — the FEP process is the only route
The gate's purpose is to make a non-conforming change *not merge*, so the person merging must stop and decide whether to take it through the format process. The escape hatches defeated exactly that: a developer who did not want to write a FEP could name their key `x-whatever`, or append an entry to feedpak-spec-exceptions.yml with any issue link, and merge. Both were self-serve and in-repo. That is a speed bump with a signed excuse note, not a gate. The relief valve is the FEP process itself, not something in this repo. The spec's governance already says so: "A change is not part of the format until it lands here." Removed the `x-` prefix bypass. It was invented here, not in the spec — the spec reserves no experimental namespace. Its "unknown keys are reserved for forward-compatibility" rule is about *tolerating* other implementations' keys, not a licence for core to mint its own. feedpak-spec-exceptions.yml is now a CLOSED grandfather list. A new check (allowlist-closed) diffs it against the base branch and fails any PR that ADDS an entry; removal stays allowed, so the list can only shrink. Deleting an entry does not by itself pass the gate — key-coverage still fails while core reads the key, so the entry goes when the code goes. Every failure message now points at the FEP process and at bumping .feedpak-spec-ref to the merged spec SHA, which is the one supported way a new manifest key reaches core. CI fetches the base branch to diff the allowlist; the bootstrap flag covers the one case with no baseline — the PR introducing the gate. Signed-off-by: topkoa <topkoa@gmail.com>
This commit is contained in:
@@ -58,10 +58,15 @@ PACK_GLOBS = ["content/starter/*.feedpak", "docs/**/*.sloppak", "docs/**/*.feedp
|
||||
|
||||
EXCEPTIONS_FILE = REPO / "feedpak-spec-exceptions.yml"
|
||||
|
||||
# Keys under this prefix are reserved for pre-spec experimentation and are
|
||||
# always permitted. Anything else undeclared must be listed in the exceptions
|
||||
# file with a tracking issue, or the build fails.
|
||||
EXPERIMENTAL_PREFIX = "x-"
|
||||
# How a new manifest key gets into core. There is no in-repo shortcut, by design:
|
||||
# the spec's own governance says "a change is not part of the format until it
|
||||
# lands here", and the FEP process is how it lands.
|
||||
FEP = (
|
||||
"A new manifest key must go through the feedpak Enhancement Proposal process "
|
||||
"(https://github.com/got-feedback/feedpak-spec/blob/main/CONTRIBUTING.md): land a PR on "
|
||||
"feedpak-spec that updates the normative spec, the JSON Schemas, an example, and the "
|
||||
"changelog together — then bump .feedpak-spec-ref to the merged SHA in this PR."
|
||||
)
|
||||
|
||||
|
||||
def _fail(msg: str) -> None:
|
||||
@@ -117,24 +122,22 @@ def keys_touched(path: Path) -> tuple[set[str], set[str]]:
|
||||
return reads, writes
|
||||
|
||||
|
||||
def load_exceptions() -> dict[str, str]:
|
||||
"""Map of allowlisted key -> tracking issue URL."""
|
||||
if not EXCEPTIONS_FILE.exists():
|
||||
return {}
|
||||
def _parse_exceptions(text: str, origin: str) -> dict[str, str]:
|
||||
"""Parse an exceptions document into {key: tracking issue}."""
|
||||
import yaml # runtime dep (PyYAML is already in requirements.txt)
|
||||
|
||||
data = yaml.safe_load(EXCEPTIONS_FILE.read_text(encoding="utf-8")) or {}
|
||||
data = yaml.safe_load(text) or {}
|
||||
out: dict[str, str] = {}
|
||||
for entry in data.get("exceptions") or []:
|
||||
key, issue = entry.get("key"), entry.get("issue")
|
||||
if not key or not issue:
|
||||
_fail(f"{EXCEPTIONS_FILE.name}: every exception needs both 'key' and 'issue'")
|
||||
_fail(f"{origin}: every exception needs both 'key' and 'issue'")
|
||||
sys.exit(1)
|
||||
# A duplicate would silently take the last issue link, quietly retargeting
|
||||
# the debt this file exists to track. Fail instead.
|
||||
if key in out:
|
||||
_fail(
|
||||
f"{EXCEPTIONS_FILE.name}: '{key}' is listed more than once. "
|
||||
f"{origin}: '{key}' is listed more than once. "
|
||||
f"Keep one entry per key so the tracking issue is unambiguous."
|
||||
)
|
||||
sys.exit(1)
|
||||
@@ -142,6 +145,52 @@ def load_exceptions() -> dict[str, str]:
|
||||
return out
|
||||
|
||||
|
||||
def load_exceptions() -> dict[str, str]:
|
||||
"""Map of grandfathered key -> tracking issue URL, as of this working tree."""
|
||||
if not EXCEPTIONS_FILE.exists():
|
||||
return {}
|
||||
return _parse_exceptions(
|
||||
EXCEPTIONS_FILE.read_text(encoding="utf-8"), EXCEPTIONS_FILE.name
|
||||
)
|
||||
|
||||
|
||||
def check_allowlist_closed(baseline: Path | None, bootstrap: bool) -> bool:
|
||||
"""The allowlist is CLOSED: it may shrink, never grow.
|
||||
|
||||
`feedpak-spec-exceptions.yml` grandfathers keys that predate this gate. It is
|
||||
not a way to merge a new one. Without this check the gate would be a speed
|
||||
bump with a signed excuse note — anyone could append an entry and route
|
||||
around the FEP process from inside this repo, which is exactly the drift that
|
||||
produced #933.
|
||||
|
||||
So: removing an entry is fine (that's the debt being paid down); adding one
|
||||
fails the build, and the error points at the FEP process instead.
|
||||
"""
|
||||
if bootstrap:
|
||||
print(" allowlist-closed: bootstrapping (no baseline on the base branch) — skipped")
|
||||
return True
|
||||
if baseline is None:
|
||||
print(" allowlist-closed: no baseline supplied (local run) — skipped")
|
||||
return True
|
||||
|
||||
base_keys = set(
|
||||
_parse_exceptions(baseline.read_text(encoding="utf-8"), f"{EXCEPTIONS_FILE.name} (base)")
|
||||
)
|
||||
now_keys = set(load_exceptions())
|
||||
added = sorted(now_keys - base_keys)
|
||||
removed = sorted(base_keys - now_keys)
|
||||
|
||||
for key in added:
|
||||
_fail(
|
||||
f"{EXCEPTIONS_FILE.name}: this PR ADDS an exception for '{key}'. The allowlist is "
|
||||
f"closed — it grandfathers keys that predate this gate and may only shrink. {FEP}"
|
||||
)
|
||||
if removed:
|
||||
print(f" allowlist shrank (debt paid down): {', '.join(removed)}")
|
||||
print(f" allowlist-closed: {'FAILED' if added else 'OK'}")
|
||||
return not added
|
||||
|
||||
|
||||
def check_key_coverage(spec: Path) -> bool:
|
||||
"""Layer 1 — core must not read or write a manifest key the spec does not declare."""
|
||||
schema = json.loads((spec / "schemas" / "manifest.schema.json").read_text(encoding="utf-8"))
|
||||
@@ -165,25 +214,16 @@ def check_key_coverage(spec: Path) -> bool:
|
||||
ok = True
|
||||
|
||||
def _undeclared(keys: set[str]) -> list[str]:
|
||||
flagged = {k for k in (keys - declared) if not k.startswith(EXPERIMENTAL_PREFIX)}
|
||||
return sorted(flagged - set(exceptions))
|
||||
return sorted((keys - declared) - set(exceptions))
|
||||
|
||||
for key in _undeclared(reads):
|
||||
_fail(
|
||||
f"core reads manifest key '{key}', which the feedpak spec does not define. "
|
||||
f"Add it to the spec (github.com/got-feedback/feedpak-spec) before merging, "
|
||||
f"rename it to '{EXPERIMENTAL_PREFIX}{key}' if it is deliberately pre-spec, or "
|
||||
f"record it in {EXCEPTIONS_FILE.name} with a tracking issue."
|
||||
)
|
||||
_fail(f"core reads manifest key '{key}', which the feedpak spec does not define. {FEP}")
|
||||
ok = False
|
||||
|
||||
for key in _undeclared(writes):
|
||||
_fail(
|
||||
f"core writes manifest key '{key}', which the feedpak spec does not define — "
|
||||
f"that puts non-spec surface into every pack we emit. Add it to the spec "
|
||||
f"(github.com/got-feedback/feedpak-spec) before merging, rename it to "
|
||||
f"'{EXPERIMENTAL_PREFIX}{key}' if it is deliberately pre-spec, or record it in "
|
||||
f"{EXCEPTIONS_FILE.name} with a tracking issue."
|
||||
f"core writes manifest key '{key}', which the feedpak spec does not define — that "
|
||||
f"puts non-spec surface into every pack we emit. {FEP}"
|
||||
)
|
||||
ok = False
|
||||
|
||||
@@ -298,6 +338,18 @@ def main() -> int:
|
||||
type=Path,
|
||||
help="path to a feedpak-spec checkout (CI pins the SHA in .feedpak-spec-ref)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--baseline-exceptions",
|
||||
type=Path,
|
||||
help="the exceptions file as it exists on the base branch. Supplied by CI so the "
|
||||
"allowlist can be proven to have not grown. Omit for a local run.",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--bootstrap-allowlist",
|
||||
action="store_true",
|
||||
help="the base branch has no exceptions file yet (this PR introduces the gate), so "
|
||||
"there is nothing to diff against. CI passes this only in that case.",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
spec = args.spec.resolve()
|
||||
@@ -305,14 +357,16 @@ def main() -> int:
|
||||
_fail(f"{spec} does not look like a feedpak-spec checkout")
|
||||
return 1
|
||||
|
||||
print("[1/3] key-coverage — core reads only keys the spec declares")
|
||||
print("[1/4] key-coverage — core reads/writes only keys the spec declares")
|
||||
ok1 = check_key_coverage(spec)
|
||||
print("[2/3] forward — core ingests the spec's example packs")
|
||||
ok2 = check_forward(spec)
|
||||
print("[3/3] reverse — committed packs satisfy the reference validator")
|
||||
ok3 = check_reverse(spec)
|
||||
print("[2/4] allowlist-closed — the grandfather list may shrink, never grow")
|
||||
ok2 = check_allowlist_closed(args.baseline_exceptions, args.bootstrap_allowlist)
|
||||
print("[3/4] forward — core ingests the spec's example packs")
|
||||
ok3 = check_forward(spec)
|
||||
print("[4/4] reverse — committed packs satisfy the reference validator")
|
||||
ok4 = check_reverse(spec)
|
||||
|
||||
if ok1 and ok2 and ok3:
|
||||
if ok1 and ok2 and ok3 and ok4:
|
||||
print("\nfeedpak spec conformance: OK")
|
||||
return 0
|
||||
print("\nfeedpak spec conformance: FAILED")
|
||||
|
||||
Reference in New Issue
Block a user