feat(release): enforce aligned trusted publications
This commit is contained in:
@@ -92,6 +92,7 @@ install_cloned_webui_dependencies() {
|
||||
}
|
||||
|
||||
run_step "Validate release refs and installed package metadata"
|
||||
"$PYTHON" "$META_ROOT/tools/checks/check-version-alignment.py" --release-composition
|
||||
"$PYTHON" "$META_ROOT/tools/checks/check-contracts.py" --no-impact
|
||||
"$PYTHON" - <<'PY'
|
||||
from __future__ import annotations
|
||||
@@ -208,6 +209,12 @@ snapshot = registry.validate()
|
||||
print("Registry modules:", ", ".join(manifest.id for manifest in snapshot.manifests))
|
||||
PY
|
||||
|
||||
run_step "Generate release dependency provenance"
|
||||
"$PYTHON" "$META_ROOT/tools/release/generate-release-sbom.py" \
|
||||
--python "$PYTHON" \
|
||||
--core-root "$ROOT" \
|
||||
--output "$WORK_ROOT/govoplan-sbom.cdx.json"
|
||||
|
||||
run_step "Run SQLite migration smoke for release modules"
|
||||
"$PYTHON" - <<'PY'
|
||||
from __future__ import annotations
|
||||
|
||||
130
tools/checks/check-version-alignment.py
Normal file
130
tools/checks/check-version-alignment.py
Normal file
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail when backend, manifest, frontend, lock, or release-ref versions drift."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import asdict
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(META_ROOT / "tools" / "release"))
|
||||
|
||||
from govoplan_release.version_alignment import ( # noqa: E402
|
||||
release_composition_issues,
|
||||
repository_version_issues,
|
||||
selected_repository_version_issues,
|
||||
)
|
||||
from govoplan_release.workspace import ( # noqa: E402
|
||||
load_repository_specs,
|
||||
resolve_repo_path,
|
||||
resolve_workspace_root,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--workspace-root", type=Path, default=None)
|
||||
parser.add_argument("--repo", action="append", default=[], help="Repository name to check; may be repeated.")
|
||||
parser.add_argument(
|
||||
"--repo-version",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="REPO=VERSION",
|
||||
help="Require a repository's aligned source metadata to equal VERSION; may be repeated.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--release-composition",
|
||||
action="store_true",
|
||||
help="Also compare tagged backend and WebUI references in the release composition.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--source-metadata-only",
|
||||
action="store_true",
|
||||
help="Skip lockfile checks for the pre-commit source gate; never use for publication.",
|
||||
)
|
||||
parser.add_argument("--json", action="store_true", help="Print machine-readable output.")
|
||||
args = parser.parse_args()
|
||||
|
||||
workspace_root = resolve_workspace_root(args.workspace_root)
|
||||
expected_versions = _parse_repo_versions(parser, args.repo_version)
|
||||
selected = {*args.repo, *expected_versions}
|
||||
known = {spec.name for spec in load_repository_specs()}
|
||||
unknown = sorted(selected - known)
|
||||
if unknown:
|
||||
parser.error(f"unknown repository name(s): {', '.join(unknown)}")
|
||||
|
||||
checked: list[str] = []
|
||||
issues = list(
|
||||
selected_repository_version_issues(
|
||||
repo_versions=expected_versions,
|
||||
workspace=workspace_root,
|
||||
include_lockfiles=not args.source_metadata_only,
|
||||
)
|
||||
)
|
||||
for spec in load_repository_specs():
|
||||
if selected and spec.name not in selected:
|
||||
continue
|
||||
repo_path = resolve_repo_path(spec, workspace_root)
|
||||
if not repo_path.exists():
|
||||
continue
|
||||
checked.append(spec.name)
|
||||
if spec.name in expected_versions:
|
||||
continue
|
||||
issues.extend(
|
||||
repository_version_issues(
|
||||
repo_path,
|
||||
include_lockfiles=not args.source_metadata_only,
|
||||
)
|
||||
)
|
||||
|
||||
if args.release_composition:
|
||||
issues.extend(
|
||||
release_composition_issues(
|
||||
META_ROOT,
|
||||
core_root=workspace_root / "govoplan-core",
|
||||
)
|
||||
)
|
||||
|
||||
payload = {
|
||||
"workspace_root": str(workspace_root),
|
||||
"repositories_checked": checked,
|
||||
"release_composition_checked": args.release_composition,
|
||||
"issues": [asdict(issue) for issue in issues],
|
||||
}
|
||||
if args.json:
|
||||
print(json.dumps(payload, indent=2, sort_keys=True))
|
||||
elif issues:
|
||||
print("Version alignment failed:", file=sys.stderr)
|
||||
for issue in issues:
|
||||
print(
|
||||
f"- {issue.repo}: {issue.source}: {issue.actual!r}; expected {issue.expected!r} "
|
||||
f"({issue.message})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
suffix = " including release composition" if args.release_composition else ""
|
||||
print(f"Version alignment passed for {len(checked)} repositories{suffix}.")
|
||||
return 1 if issues else 0
|
||||
|
||||
|
||||
def _parse_repo_versions(parser: argparse.ArgumentParser, values: list[str]) -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
for value in values:
|
||||
if "=" not in value:
|
||||
parser.error(f"--repo-version must use REPO=VERSION: {value}")
|
||||
repo, version = (item.strip() for item in value.split("=", 1))
|
||||
version = version.removeprefix("v")
|
||||
if not repo or not version:
|
||||
parser.error(f"--repo-version must use REPO=VERSION: {value}")
|
||||
if repo in result and result[repo] != version:
|
||||
parser.error(f"conflicting versions requested for {repo}")
|
||||
result[repo] = version
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user