From a0ac48ff5342005416b6c47c8dffd45cf609bd80 Mon Sep 17 00:00:00 2001 From: Chris Purcell <168346341+chrisdpurcell@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:26:16 -0400 Subject: [PATCH] feat(cli): add `packages check-release --staged` for mid-train trees Between a landed payload cut and release prep, `.standards/`, the catalog projection and the project version lag the catalog by design, so check-release reported PC-RELEASE-LEVEL, PC-RELEASE-PROJECTION and PC-RELEASE-PROJECT-VERSION and exited 1 on a tree that was correct for its phase. `--staged` labels exactly those three codes expected pre-bump and exits 0 when nothing else is found; every other code still fails. JSON additively gains `staged` and `expected_pre_bump`. Without the flag output and exit status are unchanged. Refs #227 Refs #236 --- CHANGELOG.md | 4 + docs/usage.md | 7 +- src/project_standards/package_contract/cli.py | 95 +++++-- tests/package_contract/test_cli.py | 250 +++++++++++++++++- 4 files changed, 334 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a593a1d..42c34161 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version ## [Unreleased] +### Added + +- **`project-standards packages check-release --staged` reports a mid-train working tree without a false red.** Between a landed payload cut and release prep, `.standards/`, the catalog projection, and the project version legitimately lag the catalog, so the command reported `PC-RELEASE-LEVEL`, `PC-RELEASE-PROJECTION`, and `PC-RELEASE-PROJECT-VERSION` and exited `1` on a tree that was correct for its phase ([#227](https://github.com/L3DigitalNet/project-standards/issues/227), [#236](https://github.com/L3DigitalNet/project-standards/issues/236)). `--staged` labels exactly those three codes expected pre-bump — still printed, prefixed `EXPECTED-PRE-BUMP` — and exits `0` when nothing else is found; every other code, including `PC-RELEASE-PAYLOAD-MUTATED`, `PC-CATALOG-DIGEST-REPLACED`, and `PC-RELEASE-PACKAGE-CURRENT`, still fails. Under `--json` the object additively gains `staged` and `expected_pre_bump`. Without the flag, output and exit status are unchanged. + ### Changed - **`scripts/verify.sh` stops a battery at the first red lane and sizes the `--full` compatibility lane for the machine that runs it** ([#236](https://github.com/L3DigitalNet/project-standards/issues/236)). `--fail-fast` skips every remaining serial lane once one has come back red and is the default for `--full`, where roughly 35 minutes of compatibility matrix ran after the ordinary lane had already failed on the 2026-09-01 train; `--keep-going` restores the run-every-lane behaviour and stays the default for the fast gate, whose three lanes are already running when the first red appears. A lane cut short is reported in the lane table as `skipped (--fail-fast)`, never omitted. `VERIFY_FULL_COMPAT_WORKERS` now defaults to `16` instead of a literal tuned for the retired 21-core workstation. Repository tooling only: no package, payload, or consumer-visible byte changes. diff --git a/docs/usage.md b/docs/usage.md index 5f1d9fde..20b5f5d1 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -526,7 +526,7 @@ Exit status: `0` group help displayed · `2` missing or unrecognized verb. Compare every previously released payload and catalog selection with a tagged baseline, then classify the proposed change under ADR 0024. ```text -project-standards packages check-release --baseline [--root ] [--previous-version ] [--json] +project-standards packages check-release --baseline [--root ] [--previous-version ] [--staged] [--json] ``` Options: @@ -534,11 +534,12 @@ Options: - **`--baseline `** — Released Git tag or commit to compare. Required. Option-like and unresolved refs are rejected. - **`--root `** — Repository root. Default: the current directory. - **`--previous-version `** — Baseline tool SemVer. Required when `` is not a `vMAJOR.MINOR.PATCH` tag; otherwise derived from the tag. -- **`--json`** — Emit classification and stable findings as JSON. +- **`--staged`** — Report a mid-train working tree. Exactly `PC-RELEASE-LEVEL`, `PC-RELEASE-PROJECTION`, and `PC-RELEASE-PROJECT-VERSION` are labelled expected pre-bump rather than fatal, because a landed payload cut legitimately precedes the release-prep version bump and projection refresh. Every other code — including `PC-RELEASE-PAYLOAD-MUTATED`, `PC-CATALOG-DIGEST-REPLACED`, and `PC-RELEASE-PACKAGE-CURRENT` — still fails. Expected findings are still printed, prefixed `EXPECTED-PRE-BUMP` instead of `ERROR`. Omitting the flag leaves output and exit status exactly as they were. +- **`--json`** — Emit classification and stable findings as JSON. With `--staged`, the object additionally carries `"staged": true` and `"expected_pre_bump"`, the sorted codes that were labelled expected; `"ok"` then reports whether any other finding remains. The command reads the baseline through argument-vector Git calls and only loads catalog-declared family and payload paths. It never changes versions, catalogs, tags, or payloads. -Exit status: `0` allowed `patch`, `minor`, or `major` classification · `1` forbidden transition or current package findings · `2` invalid invocation, unsafe ref/root, or unavailable baseline evidence. +Exit status: `0` allowed `patch`, `minor`, or `major` classification, or a `--staged` run whose only findings are expected pre-bump · `1` forbidden transition or current package findings · `2` invalid invocation, unsafe ref/root, or unavailable baseline evidence. ### `spec` diff --git a/src/project_standards/package_contract/cli.py b/src/project_standards/package_contract/cli.py index 8004e038..4b4251af 100644 --- a/src/project_standards/package_contract/cli.py +++ b/src/project_standards/package_contract/cli.py @@ -48,6 +48,19 @@ _PACKAGES_COMMAND_HELP = { "check-release": "compare working payloads with a released tag", } +# The exact codes a correct mid-train tree reports before the release-prep bump: +# `.standards/catalog.toml`, `.standards/lock.toml`, the catalog projection, and +# pyproject's version all lag a landed payload cut by design. `--staged` labels +# these and nothing else as expected; every other release code stays fatal, so a +# real regression (payload mutation, digest replacement) cannot hide behind the +# flag. Keep this set in step with docs/usage.md's `--staged` entry. +_STAGED_EXPECTED_CODES = frozenset( + { + "PC-RELEASE-LEVEL", + "PC-RELEASE-PROJECTION", + "PC-RELEASE-PROJECT-VERSION", + } +) class _ArgparseError(Exception): @@ -149,20 +162,36 @@ def _validated_repositories( return repositories, tuple(sort_findings(findings)) -def _format_findings(findings: tuple[PackageFinding, ...]) -> str: +def _format_findings( + findings: tuple[PackageFinding, ...], + *, + expected_codes: frozenset[str] = frozenset(), +) -> str: + """Render findings one per line, labelling `expected_codes` as non-fatal. + + `expected_codes` is empty on every path except `check-release --staged`, so + the default rendering — and therefore the byte-for-byte output of every + other command — is unchanged. + """ if not findings: return "OK package repository" lines: list[str] = [] for finding in findings: version = f"@{finding.version}" if finding.version else "" + label = "EXPECTED-PRE-BUMP" if finding.code in expected_codes else "ERROR" lines.append( - f"ERROR {finding.code} {finding.standard_id}{version} " + f"{label} {finding.code} {finding.standard_id}{version} " f"{finding.identity}: {finding.message}" ) return "\n".join(lines) -def _emit_findings(findings: tuple[PackageFinding, ...], *, json_mode: bool) -> int: +def _emit_findings( + findings: tuple[PackageFinding, ...], + *, + json_mode: bool, + expected_codes: frozenset[str] = frozenset(), +) -> int: if json_mode: print( json.dumps( @@ -172,7 +201,7 @@ def _emit_findings(findings: tuple[PackageFinding, ...], *, json_mode: bool) -> ) else: stream = sys.stderr if findings else sys.stdout - print(_format_findings(findings), file=stream) + print(_format_findings(findings, expected_codes=expected_codes), file=stream) return 1 if findings else 0 @@ -348,9 +377,12 @@ def _run_check_release(argv: list[str]) -> int: parser.add_argument("--baseline", required=True) parser.add_argument("--previous-version") parser.add_argument("--json", action="store_true") + parser.add_argument("--staged", action="store_true") try: args = parser.parse_args(argv) json_mode = cast("bool", args.json) + staged = cast("bool", args.staged) + expected_codes = _STAGED_EXPECTED_CODES if staged else frozenset[str]() root = _safe_root(cast("Path", args.root)) baseline = cast("str", args.baseline) previous_version = _previous_version( @@ -369,30 +401,57 @@ def _run_check_release(argv: list[str]) -> int: repository, distribution_version=current_version, ) - if consistency_findings: - return _emit_findings(consistency_findings, json_mode=json_mode) + # A consistency finding outside the staged set means the tree is wrong for + # any phase, so `--staged` stops here exactly as the unstaged run does; only + # a purely expected pre-bump result continues into classification, where the + # baseline comparison is the evidence that matters mid-train. + if consistency_findings and any( + finding.code not in expected_codes for finding in consistency_findings + ): + return _emit_findings( + consistency_findings, json_mode=json_mode, expected_codes=expected_codes + ) + carried = consistency_findings if staged else () previous = load_git_release_snapshot(root, baseline, previous_major) result = classify_catalog_diff( previous, _release_snapshot(repository), ToolVersions(previous=previous_version, current=current_version), ) + findings = tuple(sort_findings([*carried, *result.findings])) + blocking = tuple(finding for finding in findings if finding.code not in expected_codes) + # `--staged` exits on the findings that survive the expected set rather than on + # the classification: mid-train the classification is legitimately `forbidden` + # because pyproject still carries the released version. Unstaged keeps the + # historical rule verbatim. + failed = ( + bool(blocking) if staged else result.classification is ReleaseClassification.FORBIDDEN + ) if json_mode: - print( - json.dumps( - { - "ok": result.classification is not ReleaseClassification.FORBIDDEN, - "classification": result.classification.value, - "findings": findings_to_jsonable(result.findings), - }, - indent=2, + document: dict[str, object] = { + "ok": not failed, + "classification": result.classification.value, + "findings": findings_to_jsonable(findings), + } + if staged: + document["staged"] = True + document["expected_pre_bump"] = sorted( + {finding.code for finding in findings if finding.code in expected_codes} ) - ) + print(json.dumps(document, indent=2)) else: print(f"Release classification: {result.classification.value}") - if result.findings: - print(_format_findings(result.findings), file=sys.stderr) - return 1 if result.classification is ReleaseClassification.FORBIDDEN else 0 + if staged and not failed: + print( + f"Staged: {len(findings) - len(blocking)} expected pre-bump finding(s); " + "no release-blocking finding." + ) + if findings: + print( + _format_findings(findings, expected_codes=expected_codes), + file=sys.stderr, + ) + return 1 if failed else 0 except _ArgparseError as exc: return _emit_error("--json" in argv, "bad_args", str(exc)) except (OSError, ValueError, PackageContractError) as exc: diff --git a/tests/package_contract/test_cli.py b/tests/package_contract/test_cli.py index c6f8a04d..6994527c 100644 --- a/tests/package_contract/test_cli.py +++ b/tests/package_contract/test_cli.py @@ -2,6 +2,7 @@ import json import os +import re import shutil import subprocess from collections.abc import Iterable @@ -16,7 +17,10 @@ from project_standards.package_contract.repository import ( PackageRepository, ) -from tests.package_contract.helpers import copy_minimal_repository +from tests.package_contract.helpers import ( + copy_minimal_repository, + refresh_declared_file_digest, +) _FIXTURE = Path(__file__).resolve().parents[1] / "fixtures/package_contract/valid/minimal" @@ -526,3 +530,247 @@ def test_top_level_dispatch_and_help_preserve_existing_groups( assert main(["packages", "--help"]) == 0 assert "check-release" in capsys.readouterr().out + + +def _staged_consistency_findings() -> tuple[PackageFinding, ...]: + """Return the two consistency findings a correct mid-train tree still reports. + + `.standards/` and the catalog projection are refreshed by release prep, not by + the payload cut, so a staged tree legitimately carries exactly these codes. + """ + return ( + PackageFinding( + code="PC-RELEASE-PROJECTION", + severity="error", + standard_id="project-standards", + version="", + path="src/project_standards/payloads", + identity="projection", + message="a generated catalog projection is stale", + hint="regenerate the catalog projection from the candidate catalog", + ), + PackageFinding( + code="PC-RELEASE-PROJECT-VERSION", + severity="error", + standard_id="project-standards", + version="5.2.0", + path="README.md", + identity="line:1:project-release", + message="release-current project version is stale", + hint="refresh release-current prose", + ), + ) + + +def _staged_consistency_stub( + _root: Path, + _repository: PackageRepository, + *, + distribution_version: str, +) -> tuple[PackageFinding, ...]: + del distribution_version + return _staged_consistency_findings() + + +def _mutate_released_payload(repository: Path) -> None: + """Change a released payload file and re-declare it everywhere it is pinned. + + Digests are refreshed through payload.toml, standard.toml and the catalog so the + working tree is internally valid; only the comparison with the tagged baseline + can object, which is what makes PC-RELEASE-PAYLOAD-MUTATED the finding under test + rather than a repository-integrity or graph finding raised earlier. + """ + payload_root = repository / "standards/demo/versions/1.2" + (payload_root / "README.md").write_text("# Demo (mutated)\n", encoding="utf-8") + refresh_declared_file_digest(repository / "standards/demo", "README.md") + aggregate = re.findall( + r'digest = "(sha256:[0-9a-f]{64})"', + (repository / "standards/demo/standard.toml").read_text(encoding="utf-8"), + )[-1] + catalog_path = repository / "catalogs/5.toml" + catalog_path.write_text( + re.sub( + r'digest = "sha256:[0-9a-f]{64}"', + f'digest = "{aggregate}"', + catalog_path.read_text(encoding="utf-8"), + ), + encoding="utf-8", + ) + + +def test_packages_check_release_staged__only_expected_pre_bump_codes__exits_zero( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + repository = tmp_path / "repository" + shutil.copytree(_FIXTURE, repository) + _create_released_fixture(repository) + # 5.3.0 against a 5.2.0 baseline with an unchanged catalog is the mid-train + # shape: no package-version advance yet, so classification is forbidden with + # PC-RELEASE-LEVEL — the exact red `--staged` exists to reclassify. + monkeypatch.setattr(package_cli, "package_version", lambda: "5.3.0") + monkeypatch.setattr(package_cli, "validate_release_consistency", _staged_consistency_stub) + + assert ( + run_packages( + [ + "check-release", + "--root", + str(repository), + "--baseline", + "v5.2.0", + "--staged", + "--json", + ] + ) + == 0 + ) + payload = json.loads(capsys.readouterr().out) + assert payload["ok"] is True + assert payload["staged"] is True + assert payload["classification"] == "forbidden" + assert payload["expected_pre_bump"] == [ + "PC-RELEASE-LEVEL", + "PC-RELEASE-PROJECT-VERSION", + "PC-RELEASE-PROJECTION", + ] + assert sorted(finding["code"] for finding in payload["findings"]) == [ + "PC-RELEASE-LEVEL", + "PC-RELEASE-PROJECT-VERSION", + "PC-RELEASE-PROJECTION", + ] + + assert ( + run_packages( + [ + "check-release", + "--root", + str(repository), + "--baseline", + "v5.2.0", + "--staged", + ] + ) + == 0 + ) + captured = capsys.readouterr() + assert "3 expected pre-bump finding(s)" in captured.out + assert captured.err.count("EXPECTED-PRE-BUMP ") == 3 + assert "ERROR " not in captured.err + + +def test_packages_check_release_staged__payload_mutation__still_exits_one( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + repository = tmp_path / "repository" + shutil.copytree(_FIXTURE, repository) + _create_released_fixture(repository) + _mutate_released_payload(repository) + monkeypatch.setattr(package_cli, "package_version", lambda: "5.3.0") + monkeypatch.setattr(package_cli, "validate_release_consistency", _staged_consistency_stub) + + assert ( + run_packages( + [ + "check-release", + "--root", + str(repository), + "--baseline", + "v5.2.0", + "--staged", + "--json", + ] + ) + == 1 + ) + payload = json.loads(capsys.readouterr().out) + assert payload["ok"] is False + assert "PC-RELEASE-PAYLOAD-MUTATED" in {finding["code"] for finding in payload["findings"]} + assert "PC-RELEASE-PAYLOAD-MUTATED" not in payload["expected_pre_bump"] + + +def test_packages_check_release_staged__non_expected_consistency_finding__exits_one( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + repository = tmp_path / "repository" + shutil.copytree(_FIXTURE, repository) + _create_released_fixture(repository) + monkeypatch.setattr(package_cli, "package_version", lambda: "5.2.1") + stale = PackageFinding( + code="PC-RELEASE-PACKAGE-CURRENT", + severity="error", + standard_id="demo", + version="1.2", + path="README.md", + identity="line:1:package-current", + message="package-current prose names a superseded version", + hint="refresh package-current prose", + ) + + def package_current_finding( + _root: Path, + _repository: PackageRepository, + *, + distribution_version: str, + ) -> tuple[PackageFinding, ...]: + del distribution_version + return (stale,) + + monkeypatch.setattr(package_cli, "validate_release_consistency", package_current_finding) + + assert ( + run_packages( + [ + "check-release", + "--root", + str(repository), + "--baseline", + "v5.2.0", + "--staged", + "--json", + ] + ) + == 1 + ) + payload = json.loads(capsys.readouterr().out) + assert payload["ok"] is False + assert [finding["code"] for finding in payload["findings"]] == ["PC-RELEASE-PACKAGE-CURRENT"] + + +def test_packages_check_release__without_staged__output_is_unchanged( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Pin that `--staged` is inert when absent: same JSON keys, same exit code.""" + repository = tmp_path / "repository" + shutil.copytree(_FIXTURE, repository) + _create_released_fixture(repository) + monkeypatch.setattr(package_cli, "package_version", lambda: "5.3.0") + monkeypatch.setattr(package_cli, "validate_release_consistency", _staged_consistency_stub) + + assert ( + run_packages( + [ + "check-release", + "--root", + str(repository), + "--baseline", + "v5.2.0", + "--json", + ] + ) + == 1 + ) + payload = json.loads(capsys.readouterr().out) + assert set(payload) == {"ok", "findings"} + assert payload["ok"] is False + assert [finding["code"] for finding in payload["findings"]] == [ + "PC-RELEASE-PROJECTION", + "PC-RELEASE-PROJECT-VERSION", + ]