Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -526,19 +526,20 @@ 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 <ref> [--root <path>] [--previous-version <version>] [--json]
project-standards packages check-release --baseline <ref> [--root <path>] [--previous-version <version>] [--staged] [--json]
```

Options:

- **`--baseline <ref>`** — Released Git tag or commit to compare. Required. Option-like and unresolved refs are rejected.
- **`--root <path>`** — Repository root. Default: the current directory.
- **`--previous-version <version>`** — Baseline tool SemVer. Required when `<ref>` 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`

Expand Down
95 changes: 77 additions & 18 deletions src/project_standards/package_contract/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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(
Expand All @@ -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


Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand Down
Loading