diff --git a/docs/PACKAGE_REGISTRY_RELEASES.md b/docs/PACKAGE_REGISTRY_RELEASES.md index ce3a178..b13dbc1 100644 --- a/docs/PACKAGE_REGISTRY_RELEASES.md +++ b/docs/PACKAGE_REGISTRY_RELEASES.md @@ -72,6 +72,20 @@ The source gate validates `pyproject.toml`, the module version declaration checks run only after the candidate tags and immutable WebUI lock have been created locally. +Release-lock regeneration resolves a fresh immutable lock from the reviewed +candidate manifests; it does not seed resolution from the previous release +lock. This prevents removed transitive packages and stale peer metadata from +blocking or contaminating the new release. Candidate resolution also uses an +isolated temporary npm cache, so a locally replaced tag cannot reuse metadata +from a failed, unpushed release attempt. + +Modules that retain the same WebUI package identity in both a root publish +manifest and `webui/package.json` use the WebUI manifest as the canonical peer +contract. The coordinated release synchronizes `peerDependencies` and +`peerDependenciesMeta` into the publish manifest before creating the module +tag, then synchronizes each lockfile root from the final package metadata. A +distinct root package remains independent. + It builds one wheel and, where applicable, one npm tarball. The workflow records the source tag, source commit, filename, size, and SHA-256 in `package-artifacts.json` before publishing. Gitea rejects a second upload of the diff --git a/packages/govoplan-meta/pyproject.toml b/packages/govoplan-meta/pyproject.toml index 88b2a7d..e264579 100644 --- a/packages/govoplan-meta/pyproject.toml +++ b/packages/govoplan-meta/pyproject.toml @@ -4,82 +4,82 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan" -version = "0.1.14" +version = "0.1.15" description = "Developer convenience package for a versioned GovOPlaN composition" readme = "README.md" requires-python = ">=3.12" license = { text = "AGPL-3.0-or-later" } dependencies = [ - "govoplan-core[server]==0.1.14", - "govoplan-tenancy==0.1.8", - "govoplan-organizations==0.1.8", - "govoplan-identity==0.1.8", - "govoplan-idm==0.1.8", - "govoplan-access==0.1.8", - "govoplan-admin==0.1.8", - "govoplan-policy==0.1.8", - "govoplan-audit==0.1.8", - "govoplan-dashboard==0.1.8", - "govoplan-files==0.1.8", - "govoplan-mail==0.1.10", - "govoplan-campaign==0.1.11", - "govoplan-calendar==0.1.8", - "govoplan-docs==0.1.8", - "govoplan-ops==0.1.8", + "govoplan-core[server]==0.1.15", + "govoplan-tenancy==0.1.15", + "govoplan-organizations==0.1.15", + "govoplan-identity==0.1.15", + "govoplan-idm==0.1.15", + "govoplan-access==0.1.15", + "govoplan-admin==0.1.15", + "govoplan-policy==0.1.15", + "govoplan-audit==0.1.15", + "govoplan-dashboard==0.1.15", + "govoplan-files==0.1.15", + "govoplan-mail==0.1.15", + "govoplan-campaign==0.1.15", + "govoplan-calendar==0.1.15", + "govoplan-docs==0.1.15", + "govoplan-ops==0.1.15", ] [project.optional-dependencies] full = [ - "govoplan-addresses==0.1.9", - "govoplan-approvals==0.1.14", - "govoplan-assets==0.1.8", - "govoplan-booking==0.1.8", - "govoplan-cases==0.1.8", - "govoplan-certificates==0.1.8", - "govoplan-committee==0.1.8", - "govoplan-connectors==0.1.14", - "govoplan-consultation==0.1.8", - "govoplan-contracts==0.1.8", - "govoplan-dataflow==0.1.14", - "govoplan-datasources==0.1.14", - "govoplan-decisions==0.1.14", - "govoplan-dist-lists==0.1.14", - "govoplan-encryption==0.1.14", - "govoplan-evaluation==0.1.8", - "govoplan-facilities==0.1.8", - "govoplan-forms==0.1.14", - "govoplan-forms-runtime==0.1.14", - "govoplan-grants==0.1.8", - "govoplan-helpdesk==0.1.8", - "govoplan-identity-trust==0.1.14", - "govoplan-inspections==0.1.8", - "govoplan-learning==0.1.8", - "govoplan-mandates==0.1.14", - "govoplan-notifications==0.1.8", - "govoplan-parties==0.1.14", - "govoplan-permits==0.1.8", - "govoplan-poll==0.1.11", - "govoplan-portal==0.1.8", - "govoplan-postbox==0.1.2", - "govoplan-procurement==0.1.8", - "govoplan-projects==0.1.14", - "govoplan-records==0.1.8", - "govoplan-reporting==0.1.14", - "govoplan-resources==0.1.8", - "govoplan-rest==0.1.7", - "govoplan-risk-compliance==0.1.8", - "govoplan-scheduling==0.1.11", - "govoplan-search==0.1.14", - "govoplan-services==0.1.14", - "govoplan-soap==0.1.7", - "govoplan-templates==0.1.14", - "govoplan-tickets==0.1.8", - "govoplan-transparency==0.1.8", - "govoplan-views==0.1.0", - "govoplan-voting==0.1.14", - "govoplan-wiki==0.1.14", - "govoplan-workflow==0.1.14", - "govoplan-workflow-engine==0.1.14", + "govoplan-addresses==0.1.15", + "govoplan-approvals==0.1.15", + "govoplan-assets==0.1.15", + "govoplan-booking==0.1.15", + "govoplan-cases==0.1.15", + "govoplan-certificates==0.1.15", + "govoplan-committee==0.1.15", + "govoplan-connectors==0.1.15", + "govoplan-consultation==0.1.15", + "govoplan-contracts==0.1.15", + "govoplan-dataflow==0.1.15", + "govoplan-datasources==0.1.15", + "govoplan-decisions==0.1.15", + "govoplan-dist-lists==0.1.15", + "govoplan-encryption==0.1.15", + "govoplan-evaluation==0.1.15", + "govoplan-facilities==0.1.15", + "govoplan-forms==0.1.15", + "govoplan-forms-runtime==0.1.15", + "govoplan-grants==0.1.15", + "govoplan-helpdesk==0.1.15", + "govoplan-identity-trust==0.1.15", + "govoplan-inspections==0.1.15", + "govoplan-learning==0.1.15", + "govoplan-mandates==0.1.15", + "govoplan-notifications==0.1.15", + "govoplan-parties==0.1.15", + "govoplan-permits==0.1.15", + "govoplan-poll==0.1.15", + "govoplan-portal==0.1.15", + "govoplan-postbox==0.1.15", + "govoplan-procurement==0.1.15", + "govoplan-projects==0.1.15", + "govoplan-records==0.1.15", + "govoplan-reporting==0.1.15", + "govoplan-resources==0.1.15", + "govoplan-rest==0.1.15", + "govoplan-risk-compliance==0.1.15", + "govoplan-scheduling==0.1.15", + "govoplan-search==0.1.15", + "govoplan-services==0.1.15", + "govoplan-soap==0.1.15", + "govoplan-templates==0.1.15", + "govoplan-tickets==0.1.15", + "govoplan-transparency==0.1.15", + "govoplan-views==0.1.15", + "govoplan-voting==0.1.15", + "govoplan-wiki==0.1.15", + "govoplan-workflow==0.1.15", + "govoplan-workflow-engine==0.1.15", ] [project.urls] diff --git a/requirements-release.txt b/requirements-release.txt index c3803f3..e524d05 100644 --- a/requirements-release.txt +++ b/requirements-release.txt @@ -1,18 +1,18 @@ # Whole-product release install from immutable, independently versioned module tags. # Only add a module after its referenced tag has been published. ../govoplan-core[server] -govoplan-tenancy @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tenancy.git@v0.1.8 -govoplan-organizations @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git@v0.1.8 -govoplan-identity @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-identity.git@v0.1.8 -govoplan-idm @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git@v0.1.8 -govoplan-access @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git@v0.1.8 -govoplan-admin @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git@v0.1.8 -govoplan-policy @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git@v0.1.8 -govoplan-audit @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git@v0.1.8 -govoplan-dashboard @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git@v0.1.8 -govoplan-files @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git@v0.1.8 -govoplan-mail @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git@v0.1.10 -govoplan-campaign @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git@v0.1.11 -govoplan-calendar @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git@v0.1.8 -govoplan-docs @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git@v0.1.8 -govoplan-ops @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git@v0.1.8 +govoplan-tenancy @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tenancy.git@v0.1.15 +govoplan-organizations @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git@v0.1.15 +govoplan-identity @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-identity.git@v0.1.15 +govoplan-idm @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git@v0.1.15 +govoplan-access @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git@v0.1.15 +govoplan-admin @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git@v0.1.15 +govoplan-policy @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git@v0.1.15 +govoplan-audit @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git@v0.1.15 +govoplan-dashboard @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git@v0.1.15 +govoplan-files @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git@v0.1.15 +govoplan-mail @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git@v0.1.15 +govoplan-campaign @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git@v0.1.15 +govoplan-calendar @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git@v0.1.15 +govoplan-docs @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git@v0.1.15 +govoplan-ops @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git@v0.1.15 diff --git a/tests/test_release_entrypoint_gates.py b/tests/test_release_entrypoint_gates.py index 4eea92a..f1a316c 100644 --- a/tests/test_release_entrypoint_gates.py +++ b/tests/test_release_entrypoint_gates.py @@ -77,8 +77,24 @@ class ReleaseEntrypointGateTests(unittest.TestCase): self.assertIn("^manifest\\s*=\\s*ModuleManifest", script) self.assertIn("could not update module version declaration", script) self.assertIn("update_package_init_versions", script) + self.assertIn("synchronize-webui-package-metadata.py", script) + self.assertIn('"peerDependenciesMeta",', script) + self.assertLess( + script.index('synchronize-webui-package-metadata.py" --repo "$repo"'), + script.index('synchronize_lockfile_root "$package_path"', script.index('synchronize-webui-package-metadata.py" --repo "$repo"')), + ) self.assertNotIn("could not update ModuleManifest.version", script) + def test_release_lock_refreshes_candidate_govoplan_metadata(self) -> None: + script = (META_ROOT / "tools" / "release" / "generate-release-lock.sh").read_text() + + self.assertEqual(2, script.count('"npm_config_cache=$TMP_DIR/npm-cache"')) + self.assertNotIn( + 'cp "$WEBUI/package-lock.release.json" "$TMP_DIR/package-lock.json"', + script, + ) + self.assertIn('cp "$WEBUI/package.release.json" "$TMP_DIR/package.json"', script) + def test_source_catalog_generator_enforces_explicit_repo_versions(self) -> None: script = (META_ROOT / "tools" / "release" / "generate-release-catalog.py").read_text() diff --git a/tests/test_sync_webui_package_metadata.py b/tests/test_sync_webui_package_metadata.py new file mode 100644 index 0000000..1429751 --- /dev/null +++ b/tests/test_sync_webui_package_metadata.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +META_ROOT = Path(__file__).resolve().parents[1] +SCRIPT = META_ROOT / "tools" / "release" / "synchronize-webui-package-metadata.py" + + +class SynchronizeWebuiPackageMetadataTests(unittest.TestCase): + def test_copies_peer_contract_without_changing_publish_paths(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repo = Path(directory) + (repo / "webui").mkdir() + (repo / "package.json").write_text( + json.dumps( + { + "name": "@govoplan/example-webui", + "exports": {".": "./webui/src/index.ts"}, + "peerDependencies": {"vite": "^6"}, + } + ) + ) + (repo / "webui" / "package.json").write_text( + json.dumps( + { + "name": "@govoplan/example-webui", + "peerDependencies": {"vite": "^7"}, + "peerDependenciesMeta": {"vite": {"optional": True}}, + } + ) + ) + + subprocess.run( + [sys.executable, str(SCRIPT), "--repo", str(repo)], + check=True, + capture_output=True, + text=True, + ) + + package = json.loads((repo / "package.json").read_text()) + self.assertEqual({"vite": "^7"}, package["peerDependencies"]) + self.assertEqual({"vite": {"optional": True}}, package["peerDependenciesMeta"]) + self.assertEqual({".": "./webui/src/index.ts"}, package["exports"]) + + def test_leaves_distinct_root_and_webui_packages_separate(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repo = Path(directory) + (repo / "webui").mkdir() + (repo / "package.json").write_text(json.dumps({"name": "@govoplan/one"})) + (repo / "webui" / "package.json").write_text(json.dumps({"name": "@govoplan/two"})) + + subprocess.run( + [sys.executable, str(SCRIPT), "--repo", str(repo)], + check=True, + capture_output=True, + text=True, + ) + + root = json.loads((repo / "package.json").read_text()) + self.assertEqual("@govoplan/one", root["name"]) + self.assertNotIn("peerDependencies", root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/release/generate-release-lock.sh b/tools/release/generate-release-lock.sh index 7ef6384..b6587f2 100644 --- a/tools/release/generate-release-lock.sh +++ b/tools/release/generate-release-lock.sh @@ -94,9 +94,6 @@ cleanup() { trap cleanup EXIT cp "$WEBUI/package.release.json" "$TMP_DIR/package.json" -if [[ -f "$WEBUI/package-lock.release.json" ]]; then - cp "$WEBUI/package-lock.release.json" "$TMP_DIR/package-lock.json" -fi echo "Generating release lockfile from $WEBUI/package.release.json" echo "Temporary workspace: $TMP_DIR" @@ -120,7 +117,10 @@ GIT_ENV+=("GIT_CONFIG_COUNT=$git_config_count") ( cd "$TMP_DIR" - "${GIT_ENV[@]}" PATH="$(dirname "$NPM_BIN"):$PATH" "$NPM_BIN" install --package-lock-only --ignore-scripts + "${GIT_ENV[@]}" \ + "npm_config_cache=$TMP_DIR/npm-cache" \ + PATH="$(dirname "$NPM_BIN"):$PATH" \ + "$NPM_BIN" install --package-lock-only --ignore-scripts mapfile -t GIT_PACKAGES < <( PATH="$(dirname "$NODE_BIN"):$PATH" "$NODE_BIN" <<'NODE' const fs = require("fs"); @@ -136,7 +136,10 @@ NODE ) if [[ "${#GIT_PACKAGES[@]}" -gt 0 ]]; then echo "Refreshing git package lock entries: ${GIT_PACKAGES[*]}" - "${GIT_ENV[@]}" PATH="$(dirname "$NPM_BIN"):$PATH" "$NPM_BIN" update --package-lock-only --ignore-scripts "${GIT_PACKAGES[@]}" + "${GIT_ENV[@]}" \ + "npm_config_cache=$TMP_DIR/npm-cache" \ + PATH="$(dirname "$NPM_BIN"):$PATH" \ + "$NPM_BIN" update --package-lock-only --ignore-scripts "${GIT_PACKAGES[@]}" fi ) diff --git a/tools/release/push-release-tag.sh b/tools/release/push-release-tag.sh index 34ef495..74ff361 100644 --- a/tools/release/push-release-tag.sh +++ b/tools/release/push-release-tag.sh @@ -413,6 +413,11 @@ if project_name != "govoplan-core": peers["@govoplan/core-webui"] = f"^{new_version}" path.write_text(json.dumps(data, indent=2) + "\n") PYCODE + done + + "$PYTHON" "$META_ROOT/tools/release/synchronize-webui-package-metadata.py" --repo "$repo" + for package_path in "$repo/package.json" "$repo/webui/package.json"; do + [[ -f "$package_path" ]] || continue synchronize_lockfile_root "$package_path" "${package_path%package.json}package-lock.json" done @@ -461,7 +466,19 @@ if not isinstance(version, str) or not version: lock["version"] = version packages = lock.get("packages") if isinstance(packages, dict) and isinstance(packages.get(""), dict): - packages[""]["version"] = version + root = packages[""] + root["version"] = version + for group in ( + "dependencies", + "devDependencies", + "optionalDependencies", + "peerDependencies", + "peerDependenciesMeta", + ): + if group in package: + root[group] = package[group] + else: + root.pop(group, None) lock_path.write_text(json.dumps(lock, indent=2) + "\n") PYCODE } diff --git a/tools/release/synchronize-webui-package-metadata.py b/tools/release/synchronize-webui-package-metadata.py new file mode 100644 index 0000000..a84a9c6 --- /dev/null +++ b/tools/release/synchronize-webui-package-metadata.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Synchronize duplicated publish and development WebUI package contracts.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + + +SYNCHRONIZED_KEYS = ("peerDependencies", "peerDependenciesMeta") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", type=Path, required=True) + args = parser.parse_args() + + root_path = args.repo / "package.json" + webui_path = args.repo / "webui" / "package.json" + if not root_path.exists() or not webui_path.exists(): + return 0 + + root = _load(root_path) + webui = _load(webui_path) + root_name = root.get("name") + webui_name = webui.get("name") + if not isinstance(root_name, str) or root_name != webui_name: + return 0 + + changed = False + for key in SYNCHRONIZED_KEYS: + if key in webui: + value = webui[key] + if root.get(key) != value: + root[key] = value + changed = True + elif key in root: + del root[key] + changed = True + + if changed: + root_path.write_text(json.dumps(root, indent=2) + "\n") + print(f"Synchronized WebUI peer metadata in {root_path}") + return 0 + + +def _load(path: Path) -> dict[str, object]: + payload = json.loads(path.read_text()) + if not isinstance(payload, dict): + raise SystemExit(f"package metadata must be an object: {path}") + return payload + + +if __name__ == "__main__": + raise SystemExit(main())