feat(release): enforce aligned trusted publications

This commit is contained in:
2026-07-21 12:25:06 +02:00
parent c34892f6ea
commit a15a74c54c
18 changed files with 1359 additions and 51 deletions

View File

@@ -151,6 +151,24 @@ forcing every repository to the same tag, but channel publication must preserve
the unchanged package versions, validate interface compatibility, sign the the unchanged package versions, validate interface compatibility, sign the
updated catalog, and keep the published keyring healthy. updated catalog, and keep the published keyring healthy.
Release integration also enforces repository and composition version alignment
and generates a CycloneDX SBOM from the resolved Python environment and the
release WebUI lockfile. Catalog publication should attach that immutable SBOM
and its digest to the corresponding Core/composition release.
Addresses and Notifications have current WebUI source contributions but are
not part of the pinned v0.1.8 WebUI release composition because their v0.1.8
tags predate those packages. They re-enter the release composition only through
new, immutable module tags whose backend, manifest, frontend, and lock metadata
pass the alignment gate.
Candidate publication verifies signatures against the already published
keyring, not against keys supplied only by the candidate. A changed keyring
must have its canonical SHA-256 embedded in the signed catalog. The public
module-directory files are regenerated from that verified catalog and keyring
at publication time; candidate-supplied directory files are never copied as
authoritative provenance.
## Published Module Directory ## Published Module Directory
The target release repository is an online, browsable module directory. The The target release repository is an online, browsable module directory. The

View File

@@ -0,0 +1,99 @@
from __future__ import annotations
import json
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
META_ROOT = Path(__file__).resolve().parents[1]
RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release"
if str(RELEASE_TOOLS_ROOT) not in sys.path:
sys.path.insert(0, str(RELEASE_TOOLS_ROOT))
from govoplan_release.publisher import publish_catalog_candidate # noqa: E402
class ReleaseCatalogPublicationTests(unittest.TestCase):
def test_publication_requires_an_existing_trust_anchor(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
candidate = self._candidate(root, key="candidate-key")
web_root = root / "website"
web_root.mkdir()
with patch(
"govoplan_release.publisher.validate_module_package_catalog",
return_value={"valid": True, "warnings": [], "error": None},
):
result = publish_catalog_candidate(
candidate_dir=candidate,
web_root=web_root,
workspace_root=root,
)
self.assertEqual("blocked", result.status)
self.assertIn("publication trust anchor is missing", " ".join(result.notes))
def test_publication_rejects_rebinding_an_existing_key_id(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
candidate = self._candidate(root, key="replacement-key")
web_root = root / "website"
target_keyring = web_root / "public" / "catalogs" / "v1" / "keyring.json"
target_keyring.parent.mkdir(parents=True)
target_keyring.write_text(json.dumps(self._keyring("trusted-key")))
with patch(
"govoplan_release.publisher.validate_module_package_catalog",
return_value={"valid": True, "warnings": [], "error": None},
):
result = publish_catalog_candidate(
candidate_dir=candidate,
web_root=web_root,
workspace_root=root,
)
self.assertEqual("blocked", result.status)
self.assertIn("changes the public key", " ".join(result.notes))
@staticmethod
def _candidate(root: Path, *, key: str) -> Path:
candidate = root / "candidate"
channel = candidate / "channels"
channel.mkdir(parents=True)
channel.joinpath("stable.json").write_text(
json.dumps(
{
"channel": "stable",
"core_release": {
"version": "1.2.3",
"python_ref": (
"govoplan-core @ git+ssh://git@example.test/acme/"
"govoplan-core.git@v1.2.3"
),
},
"modules": [],
}
)
)
candidate.joinpath("keyring.json").write_text(json.dumps(ReleaseCatalogPublicationTests._keyring(key)))
return candidate
@staticmethod
def _keyring(key: str) -> dict[str, object]:
return {
"keys": [
{
"key_id": "release-key",
"status": "active",
"public_key": key,
}
]
}
if __name__ == "__main__":
unittest.main()

View File

@@ -51,6 +51,22 @@ class ReleaseDoctorTests(unittest.TestCase):
self.assertEqual("repo-dirty", findings[0].check_id) self.assertEqual("repo-dirty", findings[0].check_id)
self.assertIn("git status --short", [item.command for item in findings[0].commands]) self.assertIn("git status --short", [item.command for item in findings[0].commands])
def test_repo_version_mismatch_blocks_release(self) -> None:
doctor = load_doctor_module()
repo = doctor.RepoState(
name="govoplan-files",
path="/tmp/govoplan-files",
exists=True,
pyproject_version="0.1.9",
package_version="0.1.8",
webui_package_version="0.1.9",
)
findings = doctor.repo_findings((repo,), target_version="0.1.9", target_tag="v0.1.9")
mismatch = next(item for item in findings if item.check_id == "repo-version-mismatch")
self.assertEqual("blocker", mismatch.severity)
def test_dubious_ownership_errors_suggest_safe_directory_command(self) -> None: def test_dubious_ownership_errors_suggest_safe_directory_command(self) -> None:
doctor = load_doctor_module() doctor = load_doctor_module()
repo = doctor.RepoState( repo = doctor.RepoState(

View File

@@ -0,0 +1,60 @@
from __future__ import annotations
import json
from pathlib import Path
import unittest
META_ROOT = Path(__file__).resolve().parents[1]
class ReleaseEntrypointGateTests(unittest.TestCase):
def test_lockstep_release_includes_every_registered_module_and_connector(self) -> None:
script = (META_ROOT / "tools" / "release" / "push-release-tag.sh").read_text()
registry = json.loads((META_ROOT / "repositories.json").read_text())
expected = {
item["name"]
for item in registry["repositories"]
if item["category"] not in {"system", "website"}
}
missing = sorted(repo for repo in expected if f'$PARENT/{repo}"' not in script)
self.assertEqual([], missing)
def test_lockstep_release_runs_source_and_full_gates_before_remote_push(self) -> None:
script = (META_ROOT / "tools" / "release" / "push-release-tag.sh").read_text()
workflow = script.rsplit("\nconfirm_release\n", 1)[1]
source_gate = workflow.index("run_version_alignment_gate source")
first_commit = workflow.index('run git -C "$repo" commit')
lock_generation = workflow.index("generate_release_lock")
full_gate = workflow.index("run_version_alignment_gate", source_gate + 1)
first_push = workflow.index('run git -C "$repo" push')
self.assertLess(source_gate, first_commit)
self.assertLess(first_commit, lock_generation)
self.assertLess(lock_generation, full_gate)
self.assertLess(full_gate, first_push)
def test_source_catalog_generator_enforces_explicit_repo_versions(self) -> None:
script = (META_ROOT / "tools" / "release" / "generate-release-catalog.py").read_text()
gate = script.index("selected_repository_version_issues(")
write = script.index("output.write_text(")
self.assertLess(gate, write)
def test_candidate_publication_uses_existing_keyring_as_trust_anchor(self) -> None:
publisher = (META_ROOT / "tools" / "release" / "govoplan_release" / "publisher.py").read_text()
self.assertIn("candidate_catalog_version_issues(candidate_payload)", publisher)
self.assertIn("trusted_keys_from_keyring(\n target_keyring_payload", publisher)
self.assertIn("trusted_keys=publication_trust", publisher)
self.assertNotIn("trusted_keys=candidate_keys", publisher)
self.assertIn("write_module_directory(", publisher)
self.assertNotIn("shutil.copytree(candidate_modules", publisher)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,184 @@
from __future__ import annotations
import json
from pathlib import Path
import sys
import tempfile
import unittest
META_ROOT = Path(__file__).resolve().parents[1]
RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release"
if str(RELEASE_TOOLS_ROOT) not in sys.path:
sys.path.insert(0, str(RELEASE_TOOLS_ROOT))
from govoplan_release.version_alignment import ( # noqa: E402
candidate_catalog_version_issues,
release_composition_issues,
repository_version_issues,
selected_repository_version_issues,
)
from govoplan_release.model import RepositorySnapshot, RepositorySpec, VersionSnapshot # noqa: E402
from govoplan_release.selective_planner import build_unit # noqa: E402
from govoplan_release.selective_catalog import enforce_selected_version_alignment # noqa: E402
class VersionAlignmentTests(unittest.TestCase):
def test_candidate_catalog_refs_and_selected_units_must_match_versions(self) -> None:
payload = {
"core_release": {
"version": "1.2.3",
"python_ref": "govoplan-core @ git+ssh://git@example.test/acme/govoplan-core.git@v1.2.3",
},
"modules": [
{
"module_id": "files",
"version": "1.2.3",
"python_ref": "govoplan-files @ git+ssh://git@example.test/acme/govoplan-files.git@v1.2.4",
"webui_package": "@govoplan/files-webui",
"webui_ref": "git+ssh://git@example.test/acme/govoplan-files.git#v1.2.3",
}
],
"release": {
"selected_units": [
{"repo": "govoplan-files", "version": "1.2.5", "tag": "v1.2.6"},
]
},
}
issues = candidate_catalog_version_issues(payload)
messages = {issue.message for issue in issues}
self.assertIn("Python ref tag must match the catalog entry version", messages)
self.assertIn("selected-unit tag must match its version", messages)
self.assertIn("selected-unit version must match the catalog entry", messages)
def test_catalog_candidate_gate_rejects_selected_repo_drift(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
workspace = Path(tmp)
repo = workspace / "govoplan-files"
(repo / "webui").mkdir(parents=True)
(repo / "pyproject.toml").write_text('[project]\nname="govoplan-files"\nversion="1.2.3"\n')
(repo / "webui" / "package.json").write_text('{"version":"1.2.4"}\n')
with self.assertRaisesRegex(ValueError, "Version alignment gate failed"):
enforce_selected_version_alignment(
repo_versions={"govoplan-files": "1.2.5"},
workspace=workspace,
)
def test_catalog_candidate_gate_rejects_requested_version_that_differs_from_source(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
workspace = Path(tmp)
repo = workspace / "govoplan-files"
(repo / "webui").mkdir(parents=True)
(repo / "pyproject.toml").write_text('[project]\nname="govoplan-files"\nversion="1.2.3"\n')
(repo / "webui" / "package.json").write_text('{"version":"1.2.3"}\n')
with self.assertRaisesRegex(ValueError, "source version must match the requested release version"):
enforce_selected_version_alignment(
repo_versions={"govoplan-files": "1.2.4"},
workspace=workspace,
)
def test_selected_repository_gate_reports_missing_version_metadata(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
workspace = Path(tmp)
(workspace / "govoplan-files").mkdir()
issues = selected_repository_version_issues(
repo_versions={"govoplan-files": "1.2.3"},
workspace=workspace,
)
self.assertEqual(1, len(issues))
self.assertEqual("repository has no version metadata", issues[0].message)
def test_selective_release_unit_is_blocked_by_version_drift(self) -> None:
repo = RepositorySnapshot(
spec=RepositorySpec(
name="govoplan-example",
category="module",
subtype="domain",
remote="git@example.test:acme/govoplan-example.git",
path="govoplan-example",
),
absolute_path="/tmp/govoplan-example",
exists=True,
is_git=True,
has_head=True,
versions=VersionSnapshot(pyproject="1.2.3", webui_package="1.2.4", manifests=("1.2.3",)),
)
unit = build_unit(repo, target_version="1.2.5", contracts=None)
self.assertEqual("blocked", unit.status)
self.assertIn("version metadata is not aligned", unit.blockers[0])
def test_repository_versions_and_lockfiles_must_match(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / "govoplan-example"
(root / "src" / "govoplan_example" / "backend").mkdir(parents=True)
(root / "webui").mkdir()
(root / "pyproject.toml").write_text('[project]\nname = "govoplan-example"\nversion = "1.2.3"\n')
(root / "package.json").write_text('{"name":"@govoplan/example","version":"1.2.3"}\n')
(root / "webui" / "package.json").write_text('{"name":"@govoplan/example-webui","version":"1.2.4"}\n')
(root / "webui" / "package-lock.json").write_text(
json.dumps({"packages": {"": {"version": "1.2.2"}}})
)
(root / "src" / "govoplan_example" / "backend" / "manifest.py").write_text(
'manifest = ModuleManifest(\n version="1.2.3",\n)\n'
)
issues = repository_version_issues(root)
self.assertEqual(2, len(issues))
self.assertEqual({"webui/package.json", "webui/package-lock.json"}, {issue.source for issue in issues})
def test_public_runtime_version_must_match_package_metadata(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / "govoplan-example"
package = root / "src" / "govoplan_example"
package.mkdir(parents=True)
(root / "pyproject.toml").write_text(
'[project]\nname = "govoplan-example"\nversion = "1.2.3"\n'
)
(package / "__init__.py").write_text('__version__ = "1.2.2"\n')
issues = repository_version_issues(root)
self.assertEqual(1, len(issues))
self.assertEqual("package __init__.py 1", issues[0].source)
self.assertEqual("1.2.3", issues[0].expected)
self.assertEqual("1.2.2", issues[0].actual)
def test_release_backend_and_frontend_refs_must_match(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
meta = Path(tmp) / "govoplan"
core = Path(tmp) / "govoplan-core"
meta.mkdir()
(core / "webui").mkdir(parents=True)
(meta / "requirements-release.txt").write_text(
"govoplan-example @ git+ssh://git@example.test/acme/govoplan-example.git@v1.2.3\n"
)
(core / "webui" / "package.release.json").write_text(
json.dumps(
{
"dependencies": {
"@govoplan/example-webui": (
"git+ssh://git@example.test/acme/govoplan-example.git#v1.2.4"
)
}
}
)
)
issues = release_composition_issues(meta, core_root=core)
self.assertEqual(1, len(issues))
self.assertEqual("1.2.3", issues[0].expected)
self.assertEqual("1.2.4", issues[0].actual)
if __name__ == "__main__":
unittest.main()

View File

@@ -92,6 +92,7 @@ install_cloned_webui_dependencies() {
} }
run_step "Validate release refs and installed package metadata" 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" "$META_ROOT/tools/checks/check-contracts.py" --no-impact
"$PYTHON" - <<'PY' "$PYTHON" - <<'PY'
from __future__ import annotations from __future__ import annotations
@@ -208,6 +209,12 @@ snapshot = registry.validate()
print("Registry modules:", ", ".join(manifest.id for manifest in snapshot.manifests)) print("Registry modules:", ", ".join(manifest.id for manifest in snapshot.manifests))
PY 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" run_step "Run SQLite migration smoke for release modules"
"$PYTHON" - <<'PY' "$PYTHON" - <<'PY'
from __future__ import annotations from __future__ import annotations

View 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())

View File

@@ -19,9 +19,11 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
META_ROOT = Path(__file__).resolve().parents[2] META_ROOT = Path(__file__).resolve().parents[2]
CORE_ROOT = Path(os.environ.get("GOVOPLAN_CORE_ROOT", META_ROOT.parent / "govoplan-core")).resolve() CORE_ROOT = Path(os.environ.get("GOVOPLAN_CORE_ROOT", META_ROOT.parent / "govoplan-core")).resolve()
sys.path.insert(0, str(CORE_ROOT / "src")) sys.path.insert(0, str(CORE_ROOT / "src"))
sys.path.insert(0, str(META_ROOT / "tools" / "release"))
from govoplan_core.core.modules import ModuleManifest # noqa: E402 from govoplan_core.core.modules import ModuleManifest # noqa: E402
from govoplan_core.server.registry import available_module_manifests # noqa: E402 from govoplan_core.server.registry import available_module_manifests # noqa: E402
from govoplan_release.version_alignment import selected_repository_version_issues # noqa: E402
GITEA_BASE = "git+ssh://git@git.add-ideas.de/add-ideas" GITEA_BASE = "git+ssh://git@git.add-ideas.de/add-ideas"
@@ -196,6 +198,19 @@ def main() -> int:
args = parser.parse_args() args = parser.parse_args()
version = args.version.removeprefix("v") version = args.version.removeprefix("v")
version_issues = selected_repository_version_issues(
repo_versions={
"govoplan-core": version,
**{module.repo: version for module in CATALOG_MODULES},
},
workspace=CORE_ROOT.parent,
)
if version_issues:
details = "; ".join(
f"{issue.repo}: {issue.source}={issue.actual!r}, expected {issue.expected!r} ({issue.message})"
for issue in version_issues
)
parser.error(f"version alignment gate failed: {details}")
tag = f"v{version}" tag = f"v{version}"
generated_at = datetime.now(tz=UTC) generated_at = datetime.now(tz=UTC)
sequence = args.sequence if args.sequence is not None else int(generated_at.strftime("%Y%m%d%H%M")) sequence = args.sequence if args.sequence is not None else int(generated_at.strftime("%Y%m%d%H%M"))

View File

@@ -17,6 +17,10 @@ the regenerated release lockfile.
Options: Options:
--npm <path> npm executable to use. --npm <path> npm executable to use.
--core-root <path> govoplan-core checkout. Defaults to ../govoplan-core. --core-root <path> govoplan-core checkout. Defaults to ../govoplan-core.
--local-git-repo <path>
Resolve that repository's release tag from its local Git
object store while preserving the published Git URL in
the lockfile. May be repeated.
-h, --help Show this help. -h, --help Show this help.
USAGE USAGE
} }
@@ -32,6 +36,7 @@ CORE_ROOT="$(cd "$CORE_ROOT" && pwd)"
WEBUI="$CORE_ROOT/webui" WEBUI="$CORE_ROOT/webui"
NPM_BIN="${NPM:-}" NPM_BIN="${NPM:-}"
NODE_BIN="${NODE:-}" NODE_BIN="${NODE:-}"
LOCAL_GIT_REPOS=()
if [[ -z "$NPM_BIN" ]]; then if [[ -z "$NPM_BIN" ]]; then
if [[ -x "/home/zemion/.nvm/versions/node/v22.22.3/bin/npm" ]]; then if [[ -x "/home/zemion/.nvm/versions/node/v22.22.3/bin/npm" ]]; then
@@ -61,6 +66,11 @@ while [[ $# -gt 0 ]]; do
WEBUI="$CORE_ROOT/webui" WEBUI="$CORE_ROOT/webui"
shift 2 shift 2
;; ;;
--local-git-repo)
[[ $# -ge 2 ]] || fail "missing value for $1"
LOCAL_GIT_REPOS+=("$(cd "$2" && pwd)")
shift 2
;;
-h|--help) -h|--help)
usage usage
exit 0 exit 0
@@ -90,9 +100,27 @@ fi
echo "Generating release lockfile from $WEBUI/package.release.json" echo "Generating release lockfile from $WEBUI/package.release.json"
echo "Temporary workspace: $TMP_DIR" echo "Temporary workspace: $TMP_DIR"
GIT_ENV=(env)
git_config_count=0
for local_repo in "${LOCAL_GIT_REPOS[@]}"; do
[[ -d "$local_repo/.git" ]] || fail "local Git release source is not a repository: $local_repo"
remote_url="$(git -C "$local_repo" remote get-url origin)"
remote_url="${remote_url#git+}"
if [[ "$remote_url" != *://* && "$remote_url" =~ ^([^@]+@)?([^:]+):(.+)$ ]]; then
remote_url="ssh://${BASH_REMATCH[1]}${BASH_REMATCH[2]}/${BASH_REMATCH[3]}"
fi
GIT_ENV+=(
"GIT_CONFIG_KEY_${git_config_count}=url.file://$local_repo/.insteadOf"
"GIT_CONFIG_VALUE_${git_config_count}=$remote_url"
)
git_config_count=$((git_config_count + 1))
done
GIT_ENV+=("GIT_CONFIG_COUNT=$git_config_count")
( (
cd "$TMP_DIR" cd "$TMP_DIR"
PATH="$(dirname "$NPM_BIN"):$PATH" "$NPM_BIN" install --package-lock-only --ignore-scripts "${GIT_ENV[@]}" PATH="$(dirname "$NPM_BIN"):$PATH" "$NPM_BIN" install --package-lock-only --ignore-scripts
mapfile -t GIT_PACKAGES < <( mapfile -t GIT_PACKAGES < <(
PATH="$(dirname "$NODE_BIN"):$PATH" "$NODE_BIN" <<'NODE' PATH="$(dirname "$NODE_BIN"):$PATH" "$NODE_BIN" <<'NODE'
const fs = require("fs"); const fs = require("fs");
@@ -108,7 +136,7 @@ NODE
) )
if [[ "${#GIT_PACKAGES[@]}" -gt 0 ]]; then if [[ "${#GIT_PACKAGES[@]}" -gt 0 ]]; then
echo "Refreshing git package lock entries: ${GIT_PACKAGES[*]}" echo "Refreshing git package lock entries: ${GIT_PACKAGES[*]}"
PATH="$(dirname "$NPM_BIN"):$PATH" "$NPM_BIN" update --package-lock-only --ignore-scripts "${GIT_PACKAGES[@]}" "${GIT_ENV[@]}" PATH="$(dirname "$NPM_BIN"):$PATH" "$NPM_BIN" update --package-lock-only --ignore-scripts "${GIT_PACKAGES[@]}"
fi fi
) )

View File

@@ -99,6 +99,7 @@ def collect_versions(path: Path) -> VersionSnapshot:
package=read_json_version(path / "package.json"), package=read_json_version(path / "package.json"),
webui_package=read_json_version(path / "webui" / "package.json"), webui_package=read_json_version(path / "webui" / "package.json"),
manifests=read_manifest_versions(path), manifests=read_manifest_versions(path),
package_inits=read_package_init_versions(path),
) )
@@ -137,6 +138,21 @@ def read_manifest_versions(path: Path) -> tuple[str, ...]:
return tuple(versions) return tuple(versions)
def read_package_init_versions(path: Path) -> tuple[str, ...]:
"""Read public runtime versions declared by top-level Python packages."""
src = path / "src"
if not src.exists():
return ()
versions: list[str] = []
for package_init in sorted(src.glob("*/__init__.py")):
text = package_init.read_text(encoding="utf-8")
match = re.search(r'(?m)^__version__\s*=\s*["\']([^"\']+)["\']', text)
if match is not None:
versions.append(match.group(1))
return tuple(versions)
def git_text(path: Path, *args: str, timeout: int = 10) -> str: def git_text(path: Path, *args: str, timeout: int = 10) -> str:
result = git(path, *args, timeout=timeout) result = git(path, *args, timeout=timeout)
return result.stdout.strip() if result.returncode == 0 else "" return result.stdout.strip() if result.returncode == 0 else ""

View File

@@ -26,10 +26,17 @@ class VersionSnapshot:
package: str | None = None package: str | None = None
webui_package: str | None = None webui_package: str | None = None
manifests: tuple[str, ...] = () manifests: tuple[str, ...] = ()
package_inits: tuple[str, ...] = ()
@property @property
def primary(self) -> str | None: def primary(self) -> str | None:
return self.pyproject or self.package or self.webui_package or (self.manifests[0] if self.manifests else None) return (
self.pyproject
or self.package
or self.webui_package
or (self.manifests[0] if self.manifests else None)
or (self.package_inits[0] if self.package_inits else None)
)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)

View File

@@ -13,7 +13,9 @@ from govoplan_core.core.module_package_catalog import validate_module_package_ca
from .catalog import canonical_hash from .catalog import canonical_hash
from .model import CatalogPublishResult, CatalogPublishStep from .model import CatalogPublishResult, CatalogPublishStep
from .module_directory import write_module_directory
from .selective_catalog import trusted_keys_from_keyring from .selective_catalog import trusted_keys_from_keyring
from .version_alignment import candidate_catalog_version_issues
from .workspace import DEFAULT_WORKSPACE_ROOT, resolve_workspace_root, website_root from .workspace import DEFAULT_WORKSPACE_ROOT, resolve_workspace_root, website_root
@@ -41,7 +43,6 @@ def publish_catalog_candidate(
resolved_web_root = Path(web_root).expanduser().resolve() if web_root is not None else website_root(workspace) resolved_web_root = Path(web_root).expanduser().resolve() if web_root is not None else website_root(workspace)
candidate_catalog = candidate_root / "channels" / f"{channel}.json" candidate_catalog = candidate_root / "channels" / f"{channel}.json"
candidate_keyring = candidate_root / "keyring.json" candidate_keyring = candidate_root / "keyring.json"
candidate_modules = candidate_root / "modules"
target_catalog = resolved_web_root / "public" / "catalogs" / "v1" / "channels" / f"{channel}.json" target_catalog = resolved_web_root / "public" / "catalogs" / "v1" / "channels" / f"{channel}.json"
target_keyring = resolved_web_root / "public" / "catalogs" / "v1" / "keyring.json" target_keyring = resolved_web_root / "public" / "catalogs" / "v1" / "keyring.json"
target_modules = resolved_web_root / "public" / "catalogs" / "v1" / "modules" target_modules = resolved_web_root / "public" / "catalogs" / "v1" / "modules"
@@ -72,11 +73,36 @@ def publish_catalog_candidate(
validation_warnings: tuple[str, ...] = () validation_warnings: tuple[str, ...] = ()
if candidate_payload is not None and keyring_payload is not None: if candidate_payload is not None and keyring_payload is not None:
version_issues = candidate_catalog_version_issues(candidate_payload)
blockers.extend(
f"candidate version alignment: {issue.source}: {issue.actual!r}; "
f"expected {issue.expected!r} ({issue.message})"
for issue in version_issues
)
candidate_keys = trusted_keys_from_keyring(keyring_payload if isinstance(keyring_payload, dict) else {})
target_keyring_payload = read_json(target_keyring) if target_keyring.exists() else None
publication_trust = trusted_keys_from_keyring(
target_keyring_payload if isinstance(target_keyring_payload, dict) else {}
)
if not publication_trust:
blockers.append(
"publication trust anchor is missing; bootstrap a trusted website keyring through a separate reviewed process"
)
for key_id in sorted(set(candidate_keys) & set(publication_trust)):
if candidate_keys[key_id] != publication_trust[key_id]:
blockers.append(f"candidate keyring changes the public key for trusted key id {key_id!r}")
if candidate_keyring_hash != target_keyring_hash_before:
release = candidate_payload.get("release") if isinstance(candidate_payload, dict) else None
signed_keyring_hash = release.get("keyring_sha256") if isinstance(release, dict) else None
if signed_keyring_hash != candidate_keyring_hash:
blockers.append(
"candidate keyring differs from the publication trust anchor without a matching signed keyring_sha256"
)
validation = validate_module_package_catalog( validation = validate_module_package_catalog(
candidate_catalog, candidate_catalog,
require_trusted=True, require_trusted=True,
approved_channels=(channel,), approved_channels=(channel,),
trusted_keys=trusted_keys_from_keyring(keyring_payload if isinstance(keyring_payload, dict) else {}), trusted_keys=publication_trust,
) )
validation_valid = validation.get("valid") is True validation_valid = validation.get("valid") is True
validation_error = str(validation["error"]) if validation.get("error") else None validation_error = str(validation["error"]) if validation.get("error") else None
@@ -115,14 +141,12 @@ def publish_catalog_candidate(
steps.append( steps.append(
CatalogPublishStep( CatalogPublishStep(
id="copy", id="copy",
title="Copy candidate catalog, keyring, and module directory", title="Copy candidate catalog/keyring and derive the module directory",
detail=f"{candidate_root} -> {resolved_web_root / 'public' / 'catalogs' / 'v1'}", detail=f"{candidate_root} -> {resolved_web_root / 'public' / 'catalogs' / 'v1'}",
mutating=True, mutating=True,
status="planned" if not apply else "blocked" if blockers else "done", status="planned" if not apply else "blocked" if blockers else "done",
) )
) )
if not candidate_modules.exists():
notes.append("Candidate has no module-directory tree; only catalog and keyring will be copied.")
if build_web: if build_web:
steps.append( steps.append(
CatalogPublishStep( CatalogPublishStep(
@@ -224,10 +248,15 @@ def publish_catalog_candidate(
target_catalog.parent.mkdir(parents=True, exist_ok=True) target_catalog.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(candidate_catalog, target_catalog) shutil.copy2(candidate_catalog, target_catalog)
shutil.copy2(candidate_keyring, target_keyring) shutil.copy2(candidate_keyring, target_keyring)
if candidate_modules.exists():
if target_modules.exists(): if target_modules.exists():
shutil.rmtree(target_modules) shutil.rmtree(target_modules)
shutil.copytree(candidate_modules, target_modules) write_module_directory(
catalog_payload=candidate_payload,
keyring_payload=keyring_payload,
output_root=target_catalog.parent.parent,
channel=channel,
public_base_url=catalog_public_base_url(candidate_payload),
)
completed_steps = [replace_status(step, "done") if step.id == "copy" else step for step in steps] completed_steps = [replace_status(step, "done") if step.id == "copy" else step for step in steps]
if build_web: if build_web:
@@ -282,6 +311,14 @@ def publish_catalog_candidate(
) )
def catalog_public_base_url(payload: dict[str, object]) -> str:
release = payload.get("release")
catalog_url = release.get("catalog_url") if isinstance(release, dict) else None
if isinstance(catalog_url, str) and "/catalogs/" in catalog_url:
return catalog_url.split("/catalogs/", 1)[0]
return "https://govoplan.add-ideas.de"
def result( def result(
*, *,
status: str, status: str,

View File

@@ -19,6 +19,7 @@ from .contracts import collect_contracts
from .git_state import collect_repository_snapshot from .git_state import collect_repository_snapshot
from .model import CatalogEntryChange, SelectiveCatalogCandidate from .model import CatalogEntryChange, SelectiveCatalogCandidate
from .module_directory import write_module_directory from .module_directory import write_module_directory
from .version_alignment import selected_repository_version_issues
from .workspace import load_repository_specs, resolve_workspace_root, website_root from .workspace import load_repository_specs, resolve_workspace_root, website_root
GITEA_BASE = "git+ssh://git@git.add-ideas.de/add-ideas" GITEA_BASE = "git+ssh://git@git.add-ideas.de/add-ideas"
@@ -46,6 +47,7 @@ def build_selective_catalog_candidate(
raise ValueError("At least one signing key is required.") raise ValueError("At least one signing key is required.")
workspace = resolve_workspace_root(workspace_root) workspace = resolve_workspace_root(workspace_root)
enforce_selected_version_alignment(repo_versions=repo_versions, workspace=workspace)
web_root = website_root(workspace) web_root = website_root(workspace)
source_catalog = resolve_base_catalog(base_catalog=base_catalog, web_root=web_root, channel=channel) source_catalog = resolve_base_catalog(base_catalog=base_catalog, web_root=web_root, channel=channel)
payload = read_catalog(source_catalog) payload = read_catalog(source_catalog)
@@ -75,22 +77,24 @@ def build_selective_catalog_candidate(
repository_base=repository_base.rstrip("/"), repository_base=repository_base.rstrip("/"),
) )
candidate.pop("signature", None)
candidate.pop("signatures", None)
candidate["signatures"] = [signature(candidate, key_id=key_id, private_key=private_key) for key_id, private_key in parsed_keys]
output_root = resolve_output_dir(output_dir=output_dir, channel=channel, generated_at=generated_at) output_root = resolve_output_dir(output_dir=output_dir, channel=channel, generated_at=generated_at)
catalog_path = output_root / "channels" / f"{channel}.json" catalog_path = output_root / "channels" / f"{channel}.json"
keyring_path = output_root / "keyring.json" keyring_path = output_root / "keyring.json"
summary_path = output_root / "summary.json" if write_summary else None summary_path = output_root / "summary.json" if write_summary else None
catalog_path.parent.mkdir(parents=True, exist_ok=True)
catalog_path.write_text(json.dumps(candidate, indent=2, sort_keys=True) + "\n", encoding="utf-8")
keyring_payload = keyring_payload_for_candidate( keyring_payload = keyring_payload_for_candidate(
existing_keyring=web_root / "public" / "catalogs" / "v1" / "keyring.json", existing_keyring=web_root / "public" / "catalogs" / "v1" / "keyring.json",
signing_keys=parsed_keys, signing_keys=parsed_keys,
generated_at=generated_at, generated_at=generated_at,
) )
release["keyring_sha256"] = canonical_hash(keyring_payload)
candidate.pop("signature", None)
candidate.pop("signatures", None)
candidate["signatures"] = [signature(candidate, key_id=key_id, private_key=private_key) for key_id, private_key in parsed_keys]
catalog_path.parent.mkdir(parents=True, exist_ok=True)
catalog_path.write_text(json.dumps(candidate, indent=2, sort_keys=True) + "\n", encoding="utf-8")
keyring_path.write_text(json.dumps(keyring_payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") keyring_path.write_text(json.dumps(keyring_payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
module_directory_files = write_module_directory( module_directory_files = write_module_directory(
catalog_payload=candidate, catalog_payload=candidate,
@@ -159,6 +163,18 @@ def build_selective_catalog_candidate(
return result return result
def enforce_selected_version_alignment(*, repo_versions: dict[str, str], workspace: Path) -> None:
failures = [
f"{issue.repo}: {issue.source}={issue.actual!r}, expected {issue.expected!r} ({issue.message})"
for issue in selected_repository_version_issues(
repo_versions=repo_versions,
workspace=workspace,
)
]
if failures:
raise ValueError("Version alignment gate failed: " + "; ".join(failures))
def resolve_base_catalog(*, base_catalog: Path | str | None, web_root: Path, channel: str) -> Path | str: def resolve_base_catalog(*, base_catalog: Path | str | None, web_root: Path, channel: str) -> Path | str:
if base_catalog is not None: if base_catalog is not None:
value = str(base_catalog) value = str(base_catalog)

View File

@@ -76,6 +76,19 @@ def build_unit(repo: RepositorySnapshot, *, target_version: str | None, contract
blockers.append(f"repository is behind {repo.upstream}") blockers.append(f"repository is behind {repo.upstream}")
if repo.exists and repo.is_git and not repo.has_head: if repo.exists and repo.is_git and not repo.has_head:
blockers.append("repository has no initial commit") blockers.append("repository has no initial commit")
local_versions = tuple(
value
for value in (
repo.versions.pyproject,
repo.versions.package,
repo.versions.webui_package,
*repo.versions.manifests,
*repo.versions.package_inits,
)
if value
)
if len(set(local_versions)) > 1:
blockers.append("backend, manifest, and frontend version metadata is not aligned")
if repo.dirty: if repo.dirty:
warnings.append("uncommitted changes will need review before release") warnings.append("uncommitted changes will need review before release")
if repo.ahead: if repo.ahead:

View File

@@ -0,0 +1,572 @@
"""Version-alignment checks for independently released GovOPlaN packages."""
from __future__ import annotations
from dataclasses import dataclass
import json
from pathlib import Path
import re
import subprocess
from .git_state import collect_versions
from .workspace import load_repository_specs, resolve_repo_path
_PYTHON_RELEASE_REF = re.compile(
r"^(?P<package>govoplan-[a-z0-9-]+)\s+@\s+git\+ssh://.+/"
r"(?P<repo>govoplan-[a-z0-9-]+)\.git@v(?P<version>[^\s;]+)$"
)
_WEBUI_RELEASE_REF = re.compile(r"/(?P<repo>govoplan-[a-z0-9-]+)\.git#v(?P<version>[^#\s]+)$")
_CATALOG_PYTHON_REF = re.compile(r"/(?P<repo>govoplan-[a-z0-9-]+)\.git@v(?P<version>[^\s;]+)$")
_CATALOG_WEBUI_REF = re.compile(r"/(?P<repo>govoplan-[a-z0-9-]+)\.git#v(?P<version>[^#\s]+)$")
@dataclass(frozen=True, slots=True)
class VersionAlignmentIssue:
repo: str
source: str
expected: str
actual: str
message: str
def repository_version_issues(
repo_path: Path,
*,
expected_version: str | None = None,
include_lockfiles: bool = True,
) -> tuple[VersionAlignmentIssue, ...]:
"""Compare every version-bearing file in one source repository."""
versions = collect_versions(repo_path)
declared = {
"pyproject.toml": versions.pyproject,
"package.json": versions.package,
"webui/package.json": versions.webui_package,
"webui/package.release.json": _json_version(repo_path / "webui" / "package.release.json")
if (repo_path / "webui" / "package.release.json").exists()
else None,
}
for index, version in enumerate(versions.manifests, start=1):
declared[f"module manifest {index}"] = version
for index, version in enumerate(versions.package_inits, start=1):
declared[f"package __init__.py {index}"] = version
canonical_source, canonical_version = next(
((source, version) for source, version in declared.items() if version),
(None, None),
)
repo = repo_path.name
if canonical_source is None or canonical_version is None:
if expected_version is None:
return ()
return (
VersionAlignmentIssue(
repo=repo,
source="repository",
expected=expected_version.removeprefix("v"),
actual="",
message="repository has no version metadata",
),
)
issues = [
VersionAlignmentIssue(
repo=repo,
source=source,
expected=canonical_version,
actual=version,
message=f"{source} must match {canonical_source}",
)
for source, version in declared.items()
if version is not None and version != canonical_version
]
if expected_version is not None and canonical_version.removeprefix("v") != expected_version.removeprefix("v"):
issues.append(
VersionAlignmentIssue(
repo=repo,
source=canonical_source,
expected=expected_version.removeprefix("v"),
actual=canonical_version,
message="source version must match the requested release version",
)
)
if include_lockfiles:
issues.extend(
_lockfile_issues(
repo=repo,
package_path=repo_path / "package.json",
lock_path=repo_path / "package-lock.json",
)
)
issues.extend(
_lockfile_issues(
repo=repo,
package_path=repo_path / "webui" / "package.json",
lock_path=repo_path / "webui" / "package-lock.json",
)
)
issues.extend(
_lockfile_issues(
repo=repo,
package_path=repo_path / "webui" / "package.release.json",
lock_path=repo_path / "webui" / "package-lock.release.json",
)
)
return tuple(issues)
def selected_repository_version_issues(
*,
repo_versions: dict[str, str],
workspace: Path,
include_lockfiles: bool = True,
) -> tuple[VersionAlignmentIssue, ...]:
"""Validate registered selected worktrees against explicit release versions."""
specs = {spec.name: spec for spec in load_repository_specs(include_website=False)}
issues: list[VersionAlignmentIssue] = []
for repo, expected_version in sorted(repo_versions.items()):
spec = specs.get(repo)
if spec is None:
issues.append(
VersionAlignmentIssue(
repo=repo,
source="repository",
expected=expected_version.removeprefix("v"),
actual="",
message="repository is not registered",
)
)
continue
repo_path = resolve_repo_path(spec, workspace)
if not repo_path.exists():
issues.append(
VersionAlignmentIssue(
repo=repo,
source="repository",
expected=expected_version.removeprefix("v"),
actual="",
message="repository path is missing",
)
)
continue
issues.extend(
repository_version_issues(
repo_path,
expected_version=expected_version,
include_lockfiles=include_lockfiles,
)
)
return tuple(issues)
def release_composition_issues(meta_root: Path, *, core_root: Path) -> tuple[VersionAlignmentIssue, ...]:
"""Require backend and WebUI release references for a module to agree."""
python_refs = _python_release_refs(meta_root / "requirements-release.txt")
webui_refs = _webui_release_refs(core_root / "webui" / "package.release.json")
issues: list[VersionAlignmentIssue] = []
for repo in sorted(set(python_refs) & set(webui_refs)):
python_version = python_refs[repo]
webui_version = webui_refs[repo]
if python_version == webui_version:
continue
issues.append(
VersionAlignmentIssue(
repo=repo,
source="webui/package.release.json",
expected=python_version,
actual=webui_version,
message="frontend release ref must match the backend release ref",
)
)
return tuple(issues)
def candidate_catalog_version_issues(payload: object) -> tuple[VersionAlignmentIssue, ...]:
"""Validate versions, refs, and selected-unit provenance inside a catalog."""
if not isinstance(payload, dict):
return (
VersionAlignmentIssue(
repo="catalog",
source="catalog",
expected="object",
actual=type(payload).__name__,
message="candidate catalog must be a JSON object",
),
)
issues: list[VersionAlignmentIssue] = []
represented: dict[str, str] = {}
core_release = payload.get("core_release")
if isinstance(core_release, dict):
issues.extend(_catalog_entry_issues(core_release, source="core_release", represented=represented))
else:
issues.append(_catalog_shape_issue("core_release", "candidate catalog has no core release"))
modules = payload.get("modules")
if not isinstance(modules, list):
issues.append(_catalog_shape_issue("modules", "candidate catalog has no modules list"))
else:
for index, entry in enumerate(modules):
if not isinstance(entry, dict):
issues.append(_catalog_shape_issue(f"modules[{index}]", "module entry must be an object"))
continue
issues.extend(
_catalog_entry_issues(
entry,
source=f"modules[{index}]",
represented=represented,
)
)
release = payload.get("release")
if isinstance(release, dict):
issues.extend(_catalog_release_issues(release, represented=represented))
return tuple(issues)
def _catalog_entry_issues(
entry: dict[str, object],
*,
source: str,
represented: dict[str, str],
) -> list[VersionAlignmentIssue]:
issues: list[VersionAlignmentIssue] = []
version = entry.get("version")
normalized_version = version.removeprefix("v") if isinstance(version, str) and version else None
if normalized_version is None:
issues.append(_catalog_shape_issue(f"{source}.version", "catalog entry has no version"))
python_ref = entry.get("python_ref")
python_match = _CATALOG_PYTHON_REF.search(python_ref) if isinstance(python_ref, str) else None
if python_match is None:
issues.append(_catalog_shape_issue(f"{source}.python_ref", "Python ref must end in a version tag"))
repo = str(entry.get("python_package") or entry.get("module_id") or source)
else:
repo = python_match.group("repo")
ref_version = python_match.group("version").removeprefix("v")
if normalized_version is not None and ref_version != normalized_version:
issues.append(
VersionAlignmentIssue(
repo=repo,
source=f"{source}.python_ref",
expected=normalized_version,
actual=ref_version,
message="Python ref tag must match the catalog entry version",
)
)
if normalized_version is not None:
previous = represented.setdefault(repo, normalized_version)
if previous != normalized_version:
issues.append(
VersionAlignmentIssue(
repo=repo,
source=source,
expected=previous,
actual=normalized_version,
message="repository appears with conflicting catalog versions",
)
)
webui_package = entry.get("webui_package")
webui_ref = entry.get("webui_ref")
if bool(webui_package) != bool(webui_ref):
issues.append(_catalog_shape_issue(f"{source}.webui_ref", "WebUI package and ref must be declared together"))
if isinstance(webui_ref, str):
webui_match = _CATALOG_WEBUI_REF.search(webui_ref)
if webui_match is None:
issues.append(_catalog_shape_issue(f"{source}.webui_ref", "WebUI ref must end in a version tag"))
else:
webui_repo = webui_match.group("repo")
webui_version = webui_match.group("version").removeprefix("v")
if python_match is not None and webui_repo != repo:
issues.append(
VersionAlignmentIssue(
repo=repo,
source=f"{source}.webui_ref",
expected=repo,
actual=webui_repo,
message="backend and WebUI refs must use the same repository",
)
)
if normalized_version is not None and webui_version != normalized_version:
issues.append(
VersionAlignmentIssue(
repo=repo,
source=f"{source}.webui_ref",
expected=normalized_version,
actual=webui_version,
message="WebUI ref tag must match the catalog entry version",
)
)
return issues
def _catalog_release_issues(
release: dict[str, object],
*,
represented: dict[str, str],
) -> list[VersionAlignmentIssue]:
issues: list[VersionAlignmentIssue] = []
version = release.get("version")
tag = release.get("tag")
if isinstance(version, str) and isinstance(tag, str) and tag != f"v{version.removeprefix('v')}":
issues.append(
VersionAlignmentIssue(
repo="govoplan-core",
source="release.tag",
expected=f"v{version.removeprefix('v')}",
actual=tag,
message="release tag must match release version",
)
)
selected_units = release.get("selected_units")
if selected_units is None:
return issues
if not isinstance(selected_units, list):
issues.append(_catalog_shape_issue("release.selected_units", "selected units must be a list"))
return issues
seen: set[str] = set()
for index, unit in enumerate(selected_units):
if not isinstance(unit, dict):
issues.append(_catalog_shape_issue(f"release.selected_units[{index}]", "selected unit must be an object"))
continue
repo = unit.get("repo")
unit_version = unit.get("version")
unit_tag = unit.get("tag")
if not isinstance(repo, str) or not isinstance(unit_version, str) or not isinstance(unit_tag, str):
issues.append(_catalog_shape_issue(f"release.selected_units[{index}]", "selected unit requires repo, version, and tag"))
continue
normalized_version = unit_version.removeprefix("v")
if repo in seen:
issues.append(_catalog_shape_issue(f"release.selected_units[{index}].repo", "selected repository is duplicated"))
seen.add(repo)
if unit_tag != f"v{normalized_version}":
issues.append(
VersionAlignmentIssue(
repo=repo,
source=f"release.selected_units[{index}].tag",
expected=f"v{normalized_version}",
actual=unit_tag,
message="selected-unit tag must match its version",
)
)
represented_version = represented.get(repo)
if represented_version is None:
issues.append(
VersionAlignmentIssue(
repo=repo,
source=f"release.selected_units[{index}]",
expected=normalized_version,
actual="",
message="selected repository is not represented in the catalog",
)
)
elif represented_version != normalized_version:
issues.append(
VersionAlignmentIssue(
repo=repo,
source=f"release.selected_units[{index}].version",
expected=represented_version,
actual=normalized_version,
message="selected-unit version must match the catalog entry",
)
)
return issues
def _catalog_shape_issue(source: str, message: str) -> VersionAlignmentIssue:
return VersionAlignmentIssue(
repo="catalog",
source=source,
expected="valid value",
actual="",
message=message,
)
def _lockfile_issues(*, repo: str, package_path: Path, lock_path: Path) -> list[VersionAlignmentIssue]:
if not package_path.exists() or not lock_path.exists():
return []
package_payload = _json_object(package_path)
lock_payload = _json_object(lock_path)
package_version = _payload_version(package_payload)
lock_version = _lockfile_root_version(lock_path)
issues: list[VersionAlignmentIssue] = []
if package_version is None or lock_version is None or package_version == lock_version:
pass
else:
issues.append(
VersionAlignmentIssue(
repo=repo,
source=str(lock_path.relative_to(package_path.parents[1] if package_path.parent.name == "webui" else package_path.parent)),
expected=package_version,
actual=lock_version,
message=f"lockfile root version must match {package_path.name}",
)
)
lock_packages = lock_payload.get("packages")
lock_root = lock_packages.get("") if isinstance(lock_packages, dict) else None
if isinstance(lock_root, dict):
for group in ("dependencies", "devDependencies", "optionalDependencies", "peerDependencies"):
package_dependencies = package_payload.get(group) or {}
lock_dependencies = lock_root.get(group) or {}
if package_dependencies != lock_dependencies:
issues.append(
VersionAlignmentIssue(
repo=repo,
source=f"{lock_path.name}:{group}",
expected=json.dumps(package_dependencies, sort_keys=True),
actual=json.dumps(lock_dependencies, sort_keys=True),
message=f"lockfile root {group} must match {package_path.name}",
)
)
issues.extend(
_git_lock_resolution_issues(
repo=repo,
package_payload=package_payload,
lock_packages=lock_packages,
workspace=package_path.parents[2] if package_path.parent.name == "webui" else package_path.parent.parent,
lock_name=lock_path.name,
)
)
return issues
def _git_lock_resolution_issues(
*,
repo: str,
package_payload: dict[str, object],
lock_packages: dict[str, object],
workspace: Path,
lock_name: str,
) -> list[VersionAlignmentIssue]:
dependencies = package_payload.get("dependencies")
if not isinstance(dependencies, dict):
return []
issues: list[VersionAlignmentIssue] = []
for package_name, spec in dependencies.items():
if not isinstance(package_name, str) or not isinstance(spec, str):
continue
match = _CATALOG_WEBUI_REF.search(spec)
if match is None:
continue
source_repo = match.group("repo")
version = match.group("version").removeprefix("v")
locked = lock_packages.get(f"node_modules/{package_name}")
if not isinstance(locked, dict):
issues.append(
VersionAlignmentIssue(
repo=repo,
source=f"{lock_name}:{package_name}",
expected=version,
actual="",
message="release lock has no resolved GovOPlaN package",
)
)
continue
locked_version = locked.get("version")
if locked_version != version:
issues.append(
VersionAlignmentIssue(
repo=repo,
source=f"{lock_name}:{package_name}",
expected=version,
actual=str(locked_version or ""),
message="resolved package version must match its release ref",
)
)
expected_commit = _git_tag_commit(workspace / source_repo, f"v{version}")
resolved = locked.get("resolved")
resolved_commit = resolved.rsplit("#", 1)[-1] if isinstance(resolved, str) and "#" in resolved else None
if expected_commit is None or resolved_commit != expected_commit:
issues.append(
VersionAlignmentIssue(
repo=repo,
source=f"{lock_name}:{package_name}:resolved",
expected=expected_commit or f"local tag v{version}",
actual=resolved_commit or "",
message="resolved package commit must match the local release tag",
)
)
return issues
def _git_tag_commit(repo_path: Path, tag: str) -> str | None:
if not (repo_path / ".git").exists():
return None
result = subprocess.run(
["git", "-C", str(repo_path), "rev-list", "-n", "1", tag],
check=False,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
timeout=10,
)
value = result.stdout.strip()
return value if result.returncode == 0 and value else None
def _json_version(path: Path) -> str | None:
value = _payload_version(_json_object(path))
return value
def _payload_version(payload: dict[str, object]) -> str | None:
value = payload.get("version")
return value if isinstance(value, str) else None
def _json_object(path: Path) -> dict[str, object]:
payload = json.loads(path.read_text(encoding="utf-8"))
return payload if isinstance(payload, dict) else {}
def _lockfile_root_version(path: Path) -> str | None:
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
return None
packages = payload.get("packages")
if isinstance(packages, dict):
root = packages.get("")
if isinstance(root, dict) and isinstance(root.get("version"), str):
return root["version"]
value = payload.get("version")
return value if isinstance(value, str) else None
def _python_release_refs(path: Path) -> dict[str, str]:
if not path.exists():
return {}
refs: dict[str, str] = {}
for line in path.read_text(encoding="utf-8").splitlines():
match = _PYTHON_RELEASE_REF.match(line.strip())
if match is None:
continue
repo = match.group("repo")
refs[repo] = match.group("version")
return refs
def _webui_release_refs(path: Path) -> dict[str, str]:
if not path.exists():
return {}
payload = json.loads(path.read_text(encoding="utf-8"))
dependencies = payload.get("dependencies") if isinstance(payload, dict) else None
if not isinstance(dependencies, dict):
return {}
refs: dict[str, str] = {}
for spec in dependencies.values():
if not isinstance(spec, str):
continue
match = _WEBUI_RELEASE_REF.search(spec)
if match is not None:
refs[match.group("repo")] = match.group("version")
return refs

View File

@@ -194,6 +194,7 @@ run() {
} }
GEN_ARGS=( GEN_ARGS=(
env "GOVOPLAN_CORE_ROOT=$CORE_ROOT"
"$PYTHON" "$META_ROOT/tools/release/generate-release-catalog.py" "$PYTHON" "$META_ROOT/tools/release/generate-release-catalog.py"
--version "$VERSION" --version "$VERSION"
--channel "$CHANNEL" --channel "$CHANNEL"

View File

@@ -52,28 +52,10 @@ Options:
-h, --help Show this help. -h, --help Show this help.
Repos: Repos:
Installable release packages: Core, every registered release/module repository listed below in this script,
govoplan-access and the meta repository. Repositories with pyproject.toml are versioned and
govoplan-admin alignment-gated dynamically; remaining roadmap/scaffold repositories are
govoplan-tenancy committed and tagged without inventing package metadata.
govoplan-organizations
govoplan-identity
govoplan-idm
govoplan-policy
govoplan-audit
govoplan-dashboard
govoplan-files
govoplan-mail
govoplan-campaign
govoplan-calendar
govoplan-ops
govoplan-core
Roadmap/scaffold module repositories are tag-only until they have package
metadata: addresses, appointments, cases, connectors, dms, erp,
fit-connect, forms, identity-trust, ledger, notifications, payments,
portal, reporting, scheduling, search, tasks, templates, workflow, xoev,
xrechnung, and xta-osci.
Examples: Examples:
tools/release/push-release-tag.sh tools/release/push-release-tag.sh
@@ -121,12 +103,14 @@ PACKAGE_MODULE_REPOS=(
"$PARENT/govoplan-organizations" "$PARENT/govoplan-organizations"
"$PARENT/govoplan-permits" "$PARENT/govoplan-permits"
"$PARENT/govoplan-policy" "$PARENT/govoplan-policy"
"$PARENT/govoplan-poll"
"$PARENT/govoplan-procurement" "$PARENT/govoplan-procurement"
"$PARENT/govoplan-records" "$PARENT/govoplan-records"
"$PARENT/govoplan-resources" "$PARENT/govoplan-resources"
"$PARENT/govoplan-risk-compliance" "$PARENT/govoplan-risk-compliance"
"$PARENT/govoplan-tenancy" "$PARENT/govoplan-tenancy"
"$PARENT/govoplan-transparency" "$PARENT/govoplan-transparency"
"$PARENT/govoplan-evaluation"
) )
TAG_ONLY_MODULE_REPOS=( TAG_ONLY_MODULE_REPOS=(
"$PARENT/govoplan-addresses" "$PARENT/govoplan-addresses"
@@ -134,6 +118,7 @@ TAG_ONLY_MODULE_REPOS=(
"$PARENT/govoplan-cases" "$PARENT/govoplan-cases"
"$PARENT/govoplan-connectors" "$PARENT/govoplan-connectors"
"$PARENT/govoplan-dms" "$PARENT/govoplan-dms"
"$PARENT/govoplan-dist-lists"
"$PARENT/govoplan-erp" "$PARENT/govoplan-erp"
"$PARENT/govoplan-fit-connect" "$PARENT/govoplan-fit-connect"
"$PARENT/govoplan-forms" "$PARENT/govoplan-forms"
@@ -144,8 +129,10 @@ TAG_ONLY_MODULE_REPOS=(
"$PARENT/govoplan-portal" "$PARENT/govoplan-portal"
"$PARENT/govoplan-postbox" "$PARENT/govoplan-postbox"
"$PARENT/govoplan-reporting" "$PARENT/govoplan-reporting"
"$PARENT/govoplan-rest"
"$PARENT/govoplan-scheduling" "$PARENT/govoplan-scheduling"
"$PARENT/govoplan-search" "$PARENT/govoplan-search"
"$PARENT/govoplan-soap"
"$PARENT/govoplan-tasks" "$PARENT/govoplan-tasks"
"$PARENT/govoplan-templates" "$PARENT/govoplan-templates"
"$PARENT/govoplan-workflow" "$PARENT/govoplan-workflow"
@@ -154,10 +141,12 @@ TAG_ONLY_MODULE_REPOS=(
"$PARENT/govoplan-xta-osci" "$PARENT/govoplan-xta-osci"
) )
MODULE_REPOS=("${PACKAGE_MODULE_REPOS[@]}" "${TAG_ONLY_MODULE_REPOS[@]}") MODULE_REPOS=("${PACKAGE_MODULE_REPOS[@]}" "${TAG_ONLY_MODULE_REPOS[@]}")
PACKAGE_REPOS=("${PACKAGE_MODULE_REPOS[@]}" "$ROOT") SUPPORT_REPOS=("$META_ROOT")
PACKAGE_REPOS=()
REPOS=( REPOS=(
"${MODULE_REPOS[@]}" "${MODULE_REPOS[@]}"
"$ROOT" "$ROOT"
"${SUPPORT_REPOS[@]}"
) )
REMOTE="origin" REMOTE="origin"
@@ -379,6 +368,7 @@ if project_name != "govoplan-core":
peers["@govoplan/core-webui"] = f"^{new_version}" peers["@govoplan/core-webui"] = f"^{new_version}"
path.write_text(json.dumps(data, indent=2) + "\n") path.write_text(json.dumps(data, indent=2) + "\n")
PYCODE PYCODE
synchronize_lockfile_root "$package_path" "${package_path%package.json}package-lock.json"
done done
if [[ "$project_name" == "govoplan-core" && -f "$release_package_path" ]]; then if [[ "$project_name" == "govoplan-core" && -f "$release_package_path" ]]; then
@@ -401,9 +391,59 @@ for name, spec in list(dependencies.items()):
dependencies[name] = re.sub(r"#v\d+\.\d+\.\d+$", f"#v{new_version}", spec) dependencies[name] = re.sub(r"#v\d+\.\d+\.\d+$", f"#v{new_version}", spec)
path.write_text(json.dumps(data, indent=2) + "\n") path.write_text(json.dumps(data, indent=2) + "\n")
PYCODE PYCODE
synchronize_lockfile_root "$release_package_path" "$repo/webui/package-lock.release.json"
fi fi
} }
synchronize_lockfile_root() {
local package_path="$1"
local lock_path="$2"
[[ -f "$package_path" && -f "$lock_path" ]] || return 0
"$PYTHON" - "$package_path" "$lock_path" <<'PYCODE'
from __future__ import annotations
import json
from pathlib import Path
import sys
package_path = Path(sys.argv[1])
lock_path = Path(sys.argv[2])
package = json.loads(package_path.read_text())
lock = json.loads(lock_path.read_text())
version = package.get("version")
if not isinstance(version, str) or not version:
raise SystemExit(f"package version is missing from {package_path}")
lock["version"] = version
packages = lock.get("packages")
if isinstance(packages, dict) and isinstance(packages.get(""), dict):
packages[""]["version"] = version
lock_path.write_text(json.dumps(lock, indent=2) + "\n")
PYCODE
}
update_release_requirements() {
local version="$1"
"$PYTHON" - "$META_ROOT/requirements-release.txt" "$version" <<'PYCODE'
from __future__ import annotations
from pathlib import Path
import re
import sys
path = Path(sys.argv[1])
version = sys.argv[2]
text = path.read_text()
updated, count = re.subn(
r"(?m)^(govoplan-[a-z0-9-]+\s+@\s+git\+ssh://[^\s]+\.git)@v[^\s]+$",
rf"\1@v{version}",
text,
)
if count == 0:
raise SystemExit(f"no tagged GovOPlaN requirements found in {path}")
path.write_text(updated)
PYCODE
}
update_version_files() { update_version_files() {
local repo="$1" local repo="$1"
local version="$2" local version="$2"
@@ -427,7 +467,30 @@ generate_release_lock() {
fi fi
[[ -x "$META_ROOT/tools/release/generate-release-lock.sh" ]] || fail "missing executable release lock generator: $META_ROOT/tools/release/generate-release-lock.sh" [[ -x "$META_ROOT/tools/release/generate-release-lock.sh" ]] || fail "missing executable release lock generator: $META_ROOT/tools/release/generate-release-lock.sh"
run "$META_ROOT/tools/release/generate-release-lock.sh" --core-root "$ROOT" --npm "$NPM_BIN" local command=("$META_ROOT/tools/release/generate-release-lock.sh" --core-root "$ROOT" --npm "$NPM_BIN")
local repo
for repo in "${MODULE_REPOS[@]}"; do
command+=(--local-git-repo "$repo")
done
run "${command[@]}"
}
run_version_alignment_gate() {
local mode="${1:-full}"
local command=(
"$PYTHON"
"$META_ROOT/tools/checks/check-version-alignment.py"
--workspace-root "$PARENT"
--release-composition
)
if [[ "$mode" == "source" ]]; then
command+=(--source-metadata-only)
fi
local repo
for repo in "${PACKAGE_REPOS[@]}"; do
command+=(--repo-version "$(basename "$repo")=$TARGET_VERSION")
done
run "${command[@]}"
} }
run_migration_release_audit() { run_migration_release_audit() {
@@ -694,6 +757,15 @@ for repo in "${REPOS[@]}"; do
fi fi
done done
EFFECTIVE_TAG_ONLY_REPOS=()
for repo in "${REPOS[@]}"; do
if [[ "${REPO_KINDS[$repo]}" == "package" ]]; then
PACKAGE_REPOS+=("$repo")
else
EFFECTIVE_TAG_ONLY_REPOS+=("$repo")
fi
done
if [[ -z "$TARGET_VERSION" ]]; then if [[ -z "$TARGET_VERSION" ]]; then
BASE_VERSION="${OLD_VERSIONS[$ROOT]}" BASE_VERSION="${OLD_VERSIONS[$ROOT]}"
for repo in "${PACKAGE_REPOS[@]}"; do for repo in "${PACKAGE_REPOS[@]}"; do
@@ -744,9 +816,9 @@ echo " tag: $TAG"
echo " remote: $REMOTE" echo " remote: $REMOTE"
echo " commit message: $COMMIT_MESSAGE" echo " commit message: $COMMIT_MESSAGE"
echo " package repos: ${#PACKAGE_REPOS[@]} versioned" echo " package repos: ${#PACKAGE_REPOS[@]} versioned"
echo " tag-only module repos: ${#TAG_ONLY_MODULE_REPOS[@]} committed/tagged/pushed without package version files" echo " tag-only/support repos: ${#EFFECTIVE_TAG_ONLY_REPOS[@]} committed/tagged/pushed without package version files"
if [[ "$GENERATE_RELEASE_LOCK" -eq 1 ]]; then if [[ "$GENERATE_RELEASE_LOCK" -eq 1 ]]; then
echo " release lock: regenerate core webui/package-lock.release.json after module tags are pushed" echo " release lock: resolve core webui/package-lock.release.json from local reviewed module tags"
else else
echo " release lock: skipped" echo " release lock: skipped"
fi fi
@@ -780,9 +852,21 @@ for repo in "${PACKAGE_REPOS[@]}"; do
fi fi
done done
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "Would update $META_ROOT/requirements-release.txt to $TAG"
else
update_release_requirements "$TARGET_VERSION"
fi
refresh_development_webui_lock refresh_development_webui_lock
for repo in "${MODULE_REPOS[@]}"; do # Source metadata, release refs, and lock roots must agree before creating any
# release commit or tag. A second gate below verifies the fully resolved release
# lock before any remote mutation.
run_version_alignment_gate source
PRE_CORE_REPOS=("${MODULE_REPOS[@]}" "${SUPPORT_REPOS[@]}")
for repo in "${PRE_CORE_REPOS[@]}"; do
run git -C "$repo" add -A run git -C "$repo" add -A
if [[ "$DRY_RUN" -eq 0 ]]; then if [[ "$DRY_RUN" -eq 0 ]]; then
@@ -806,12 +890,13 @@ for repo in "${MODULE_REPOS[@]}"; do
run git -C "$repo" tag -a "$TAG" -m "$TAG_MESSAGE" run git -C "$repo" tag -a "$TAG" -m "$TAG_MESSAGE"
done done
for repo in "${MODULE_REPOS[@]}"; do
run git -C "$repo" push --atomic "$REMOTE" "HEAD:refs/heads/${BRANCHES[$repo]}" "refs/tags/$TAG"
done
generate_release_lock generate_release_lock
# npm resolves the new module tags from the local repositories. Nothing has
# been pushed yet; a failed lock or alignment check leaves only local,
# recoverable commits/tags.
run_version_alignment_gate
run git -C "$ROOT" add -A run git -C "$ROOT" add -A
if [[ "$DRY_RUN" -eq 0 ]]; then if [[ "$DRY_RUN" -eq 0 ]]; then
@@ -822,6 +907,10 @@ fi
run git -C "$ROOT" commit -m "$COMMIT_MESSAGE" run git -C "$ROOT" commit -m "$COMMIT_MESSAGE"
run git -C "$ROOT" tag -a "$TAG" -m "$TAG_MESSAGE" run git -C "$ROOT" tag -a "$TAG" -m "$TAG_MESSAGE"
for repo in "${PRE_CORE_REPOS[@]}"; do
run git -C "$repo" push --atomic "$REMOTE" "HEAD:refs/heads/${BRANCHES[$repo]}" "refs/tags/$TAG"
done
run git -C "$ROOT" push --atomic "$REMOTE" "HEAD:refs/heads/${BRANCHES[$ROOT]}" "refs/tags/$TAG" run git -C "$ROOT" push --atomic "$REMOTE" "HEAD:refs/heads/${BRANCHES[$ROOT]}" "refs/tags/$TAG"
if [[ "$PUBLISH_WEB_CATALOG" -eq 1 ]]; then if [[ "$PUBLISH_WEB_CATALOG" -eq 1 ]]; then

View File

@@ -381,7 +381,7 @@ def repo_findings(repos: tuple[RepoState, ...], *, target_version: str | None, t
findings.append( findings.append(
Finding( Finding(
check_id="repo-version-mismatch", check_id="repo-version-mismatch",
severity="warning", severity="blocker",
title=f"{repo.name} has inconsistent local version files", title=f"{repo.name} has inconsistent local version files",
detail=", ".join( detail=", ".join(
value value