Skip to content

Commit 21d72c2

Browse files
authored
fix: derive minimum template compatibility fixture
1 parent da7d379 commit 21d72c2

9 files changed

Lines changed: 55 additions & 185 deletions

docs/BAD_RELEASE_REPONSE_RUNBOOK.md

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ The guiding rule is: immutable release history is not rewritten. Recovery happen
1818
Use this operational runbook before and after public launch. Do not defer recovery practices until after public release. Before treating the release process as ready, ensure:
1919

2020
- `template-contract.yml` records `accepted_action` with action tag and SHA.
21-
- `minimum_compatible_template_version` and `protected_template_refs` are accurate.
21+
- `minimum_compatible_template_version` is accurate.
2222
- `make template-compat-e2e` gates action releases against current and minimum compatible templates.
2323
- `make template-release-gates` gates template publication against the accepted action release.
2424
- Template release notes state compatibility resets explicitly.
@@ -54,7 +54,7 @@ First identify the failure layer:
5454
4. Template acceptance PR merged but template release/publication failed.
5555
5. Generated template release exists but publication state is incomplete.
5656
6. Generated template was published with wrong contents/provenance.
57-
7. Released action breaks an older protected template.
57+
7. Released action breaks an older supported template.
5858
8. Released template gives new users broken setup.
5959

6060
Record:
@@ -164,7 +164,7 @@ Corrective release example:
164164
Corrects the generated template provenance for vA.B.C. Users who copied the previous generated template during the affected window should update from vA.B.D or verify `.reponomics/template-provenance.json`.
165165
```
166166

167-
## 7. Released Action Breaks An Older Protected Template
167+
## 7. Released Action Breaks An Older Supported Template
168168

169169
Impact: compatibility promise violated.
170170

@@ -216,13 +216,12 @@ Use only when supporting older templates is intentionally no longer viable.
216216
Required steps:
217217

218218
1. Open a PR changing `minimum_compatible_template_version`.
219-
2. Update `protected_template_refs`.
220-
3. Explain why older templates are no longer supported.
221-
4. Add migration guidance.
222-
5. Add release notes under `## Template release notes`.
223-
6. Run `make template-compat-e2e`.
224-
7. Run `make template-release-gates`.
225-
8. Use a SemVer bump appropriate to user impact. While the project is pre-public and remains on `v0`, a breaking reset does not require a major version bump.
219+
2. Explain why older templates are no longer supported.
220+
3. Add migration guidance.
221+
4. Add release notes under `## Template release notes`.
222+
5. Run `make template-compat-e2e`.
223+
6. Run `make template-release-gates`.
224+
7. Use a SemVer bump appropriate to user impact. While the project is pre-public and remains on `v0`, a breaking reset does not require a major version bump.
226225

227226
Example:
228227

@@ -276,7 +275,7 @@ Use one or more scenarios:
276275
2. Template acceptance PR merged, but `template-release.yml` failed.
277276
3. Source template tag exists, but `template-release.yml` failed before generated publication.
278277
4. Generated template publication completed with wrong provenance.
279-
5. Action patch release breaks the minimum compatible protected template.
278+
5. Action patch release breaks the minimum compatible template.
280279
6. Template release gives new copied repositories broken setup.
281280

282281
For each scenario, record:

scripts/accept_action_release.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,6 @@ def accept_action_release(
117117
payload["compatible_action_major"] = action_major
118118
payload["default_action_ref"] = expected_default_ref
119119
payload["accepted_action"] = next_action
120-
if not payload.get("protected_template_refs"):
121-
payload["minimum_compatible_template_version"] = payload["template_version"]
122120

123121
next_text = yaml.safe_dump(payload, sort_keys=False)
124122
contract_path.write_text(next_text, encoding="utf-8")

scripts/prepare_template_release.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,6 @@ def prepare_template_release(
6565
previous_version = contract.template_version
6666
next_version = bump_version(previous_version, release_type)
6767
payload["template_version"] = next_version
68-
if not payload.get("protected_template_refs"):
69-
payload["minimum_compatible_template_version"] = next_version
7068
contract_path.write_text(yaml.safe_dump(payload, sort_keys=False), encoding="utf-8")
7169
try:
7270
template_contract.validate_local_contract(root)

scripts/template_compat_e2e.py

Lines changed: 36 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,12 @@ class GeneratedTemplate:
218218
source_commit: str
219219

220220

221+
@dataclass(frozen=True)
222+
class TemplateRefFixture:
223+
ref: str
224+
template_version: str
225+
226+
221227
def _load_mapping(path: Path) -> dict[str, Any]:
222228
payload = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
223229
if not isinstance(payload, dict):
@@ -345,38 +351,35 @@ def _current_template(
345351
)
346352

347353

354+
def _template_ref_for_version(template_version: str) -> str:
355+
return f"reponomics-dashboard-v{template_version}"
356+
357+
348358
def _build_template_from_ref(
349-
protected_ref: template_contract.ProtectedTemplateRef,
359+
fixture: TemplateRefFixture,
350360
*,
351361
base_python: Path,
352362
work_root: Path,
353363
) -> GeneratedTemplate:
354364
source_dir = work_root / "source"
355365
output_dir = work_root / "generated-template"
356-
source_commit = _checkout_ref(protected_ref.ref, source_dir)
357-
if source_commit != protected_ref.source_commit:
358-
raise TemplateCompatibilityError(
359-
(
360-
f"{protected_ref.ref}: expected source commit "
361-
+ f"{protected_ref.source_commit}, got {source_commit}"
362-
)
363-
)
366+
source_commit = _checkout_ref(fixture.ref, source_dir)
364367

365368
source_contract = _load_mapping(source_dir / "template-contract.yml")
366369
source_version = str(source_contract.get("template_version") or "")
367-
if source_version != protected_ref.template_version:
370+
if source_version != fixture.template_version:
368371
raise TemplateCompatibilityError(
369372
(
370-
f"{protected_ref.ref}: contract version {source_version!r} does not "
371-
+ f"match protected version {protected_ref.template_version}"
373+
f"{fixture.ref}: contract version {source_version!r} does not "
374+
+ f"match fixture version {fixture.template_version}"
372375
)
373376
)
374377

375378
build_python = _install_isolated_python_env(
376379
source_dir=source_dir,
377380
venv_dir=work_root / "template-build-runtime",
378381
base_python=base_python,
379-
label=f"{protected_ref.ref} template build",
382+
label=f"{fixture.ref} template build",
380383
)
381384
_git_output(
382385
[
@@ -389,20 +392,20 @@ def _build_template_from_ref(
389392
)
390393
provenance = _load_mapping(output_dir / ".reponomics" / "template-provenance.json")
391394
provenance_template_version = str(provenance.get("template", {}).get("version") or "")
392-
if provenance_template_version != protected_ref.template_version:
395+
if provenance_template_version != fixture.template_version:
393396
raise TemplateCompatibilityError(
394-
f"{protected_ref.ref}: generated provenance does not match template version"
397+
f"{fixture.ref}: generated provenance does not match template version"
395398
)
396-
if str(provenance.get("source", {}).get("commit") or "") != protected_ref.source_commit:
399+
if str(provenance.get("source", {}).get("commit") or "") != source_commit:
397400
raise TemplateCompatibilityError(
398-
f"{protected_ref.ref}: generated provenance does not match source commit"
401+
f"{fixture.ref}: generated provenance does not match source commit"
399402
)
400403

401404
return GeneratedTemplate(
402-
name=protected_ref.ref,
405+
name=fixture.ref,
403406
repo_dir=output_dir,
404-
template_version=protected_ref.template_version,
405-
source_commit=protected_ref.source_commit,
407+
template_version=fixture.template_version,
408+
source_commit=source_commit,
406409
)
407410

408411

@@ -627,22 +630,27 @@ def run_compatibility_checks(
627630
except Exception as exc:
628631
failures.append(f"current template: {exc}")
629632

630-
protected_refs = [] if current_template_only else list(contract.protected_template_refs)
633+
template_fixtures: list[TemplateRefFixture] = []
634+
if not current_template_only:
635+
template_fixtures.append(
636+
TemplateRefFixture(
637+
ref=_template_ref_for_version(contract.minimum_compatible_template_version),
638+
template_version=contract.minimum_compatible_template_version,
639+
)
640+
)
631641
for extra_ref in extra_template_refs or []:
632-
protected_refs.append(
633-
template_contract.ProtectedTemplateRef(
642+
template_fixtures.append(
643+
TemplateRefFixture(
634644
ref=extra_ref,
635645
template_version=extra_ref.removeprefix("reponomics-dashboard-v"),
636-
source_commit=_ensure_git_ref(extra_ref),
637-
status="required",
638646
)
639647
)
640648

641-
for protected_ref in protected_refs:
649+
for template_fixture in template_fixtures:
642650
work_root = Path(tempfile.mkdtemp(prefix="template-", dir=compat_root))
643651
try:
644652
generated_template = _build_template_from_ref(
645-
protected_ref,
653+
template_fixture,
646654
base_python=base_python,
647655
work_root=work_root,
648656
)
@@ -662,7 +670,7 @@ def run_compatibility_checks(
662670
if keep_temp:
663671
print(f"Kept compatibility work tree: {work_root}")
664672
except Exception as exc:
665-
failures.append(f"{protected_ref.ref}: {exc}")
673+
failures.append(f"{template_fixture.ref}: {exc}")
666674
finally:
667675
if not keep_temp:
668676
_remove_worktree(work_root / "source")

scripts/template_contract.py

Lines changed: 0 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -52,23 +52,11 @@
5252
ACTION_REF_RE = re.compile(r"reponomics/reponomics-dashboard-action@[^\s'\"<>)\]}]+")
5353
INPUT_EXPR_RE = re.compile(r"\$\{\{\s*inputs\.([A-Za-z0-9_-]+)\s*\}\}")
5454
GIT_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
55-
TEMPLATE_RELEASE_REF_RE = re.compile(
56-
r"^reponomics-dashboard-v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$"
57-
)
58-
5955

6056
class TemplateContractError(RuntimeError):
6157
"""Raised when the local action/template contract is invalid."""
6258

6359

64-
@dataclass(frozen=True)
65-
class ProtectedTemplateRef:
66-
ref: str
67-
template_version: str
68-
source_commit: str
69-
status: str = "required"
70-
71-
7260
@dataclass(frozen=True)
7361
class AcceptedActionRelease:
7462
repository: str
@@ -86,7 +74,6 @@ class TemplateContract:
8674
compatible_action_major: int
8775
accepted_action: AcceptedActionRelease
8876
minimum_compatible_template_version: str
89-
protected_template_refs: tuple[ProtectedTemplateRef, ...]
9077
managed_docs_namespace: Path
9178

9279
@property
@@ -122,11 +109,6 @@ def load_contract(root: Path = ROOT) -> TemplateContract:
122109
payload.get("accepted_action"),
123110
path=path,
124111
)
125-
protected_template_refs = _parse_protected_template_refs(
126-
payload.get("protected_template_refs"),
127-
path=path,
128-
)
129-
130112
if not SEMVER_RE.fullmatch(template_version):
131113
raise TemplateContractError(f"template_version must be SemVer, got {template_version!r}")
132114
if not SEMVER_RE.fullmatch(minimum_compatible_template_version):
@@ -161,11 +143,6 @@ def load_contract(root: Path = ROOT) -> TemplateContract:
161143
)
162144
if managed_docs_namespace.as_posix() != "docs/reponomics":
163145
raise TemplateContractError("managed_docs_namespace must be docs/reponomics")
164-
_validate_protected_template_refs(
165-
protected_template_refs,
166-
minimum_compatible_template_version=minimum_compatible_template_version,
167-
template_version=template_version,
168-
)
169146

170147
return TemplateContract(
171148
template_version=template_version,
@@ -174,7 +151,6 @@ def load_contract(root: Path = ROOT) -> TemplateContract:
174151
compatible_action_major=compatible_action_major,
175152
accepted_action=accepted_action,
176153
minimum_compatible_template_version=minimum_compatible_template_version,
177-
protected_template_refs=tuple(protected_template_refs),
178154
managed_docs_namespace=managed_docs_namespace,
179155
)
180156

@@ -211,57 +187,6 @@ def validate_local_contract(root: Path = ROOT) -> TemplateContract:
211187
return contract
212188

213189

214-
def _parse_protected_template_refs(
215-
raw_refs: object,
216-
*,
217-
path: Path,
218-
) -> list[ProtectedTemplateRef]:
219-
if not isinstance(raw_refs, list):
220-
raise TemplateContractError(f"{path} must declare protected_template_refs as a list")
221-
222-
protected: list[ProtectedTemplateRef] = []
223-
for index, raw_ref in enumerate(raw_refs):
224-
if not isinstance(raw_ref, dict):
225-
raise TemplateContractError(
226-
f"protected_template_refs[{index}] must be a YAML object"
227-
)
228-
ref = str(raw_ref.get("ref") or "")
229-
template_version = str(raw_ref.get("template_version") or "")
230-
source_commit = str(raw_ref.get("source_commit") or "")
231-
status = str(raw_ref.get("status") or "required")
232-
233-
if not TEMPLATE_RELEASE_REF_RE.fullmatch(ref):
234-
raise TemplateContractError(
235-
f"protected_template_refs[{index}].ref must be reponomics-dashboard-vX.Y.Z"
236-
)
237-
if not SEMVER_RE.fullmatch(template_version):
238-
raise TemplateContractError(
239-
f"protected_template_refs[{index}].template_version must be SemVer"
240-
)
241-
if ref != f"reponomics-dashboard-v{template_version}":
242-
raise TemplateContractError(
243-
f"protected_template_refs[{index}] ref must match template_version"
244-
)
245-
if not GIT_SHA_RE.fullmatch(source_commit):
246-
raise TemplateContractError(
247-
"protected_template_refs"
248-
+ f"[{index}].source_commit must be a 40-character commit SHA"
249-
)
250-
if status != "required":
251-
raise TemplateContractError(
252-
f"protected_template_refs[{index}].status must be required"
253-
)
254-
protected.append(
255-
ProtectedTemplateRef(
256-
ref=ref,
257-
template_version=template_version,
258-
source_commit=source_commit,
259-
status=status,
260-
)
261-
)
262-
return protected
263-
264-
265190
def _parse_accepted_action_release(
266191
raw_action: object,
267192
*,
@@ -309,29 +234,6 @@ def _validate_accepted_action_release(
309234
raise TemplateContractError("accepted_action.sha must be a 40-character commit SHA")
310235

311236

312-
def _validate_protected_template_refs(
313-
protected_template_refs: list[ProtectedTemplateRef],
314-
*,
315-
minimum_compatible_template_version: str,
316-
template_version: str,
317-
) -> None:
318-
seen_versions: set[str] = set()
319-
for protected in protected_template_refs:
320-
if protected.template_version in seen_versions:
321-
raise TemplateContractError(
322-
f"duplicate protected template version: {protected.template_version}"
323-
)
324-
seen_versions.add(protected.template_version)
325-
if (
326-
minimum_compatible_template_version != template_version
327-
and minimum_compatible_template_version not in seen_versions
328-
):
329-
raise TemplateContractError(
330-
"historical minimum_compatible_template_version must be covered "
331-
+ "by protected_template_refs"
332-
)
333-
334-
335237
def render_managed_docs_snapshot(
336238
*,
337239
contract: TemplateContract | None = None,

template-contract.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,4 @@ accepted_action:
1010
sha: 07708b602d253512c37cf773912375784882947d
1111
default_ref: v0
1212
minimum_compatible_template_version: 0.16.0
13-
protected_template_refs: []
1413
managed_docs_namespace: docs/reponomics

0 commit comments

Comments
 (0)