diff --git a/.gitea/workflows/release-integration.yml b/.gitea/workflows/release-integration.yml new file mode 100644 index 0000000..7c2eba2 --- /dev/null +++ b/.gitea/workflows/release-integration.yml @@ -0,0 +1,48 @@ +name: Release Integration + +on: + workflow_dispatch: + push: + branches: + - main + +jobs: + release-integration: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Configure SSH for release dependencies + env: + GOVOPLAN_RELEASE_SSH_KEY_B64: ${{ secrets.GOVOPLAN_RELEASE_SSH_KEY_B64 }} + run: | + mkdir -p ~/.ssh + chmod 700 ~/.ssh + if [ -z "${GOVOPLAN_RELEASE_SSH_KEY_B64:-}" ]; then + echo "GOVOPLAN_RELEASE_SSH_KEY_B64 secret is required for git+ssh release dependencies." + exit 1 + fi + printf '%s' "$GOVOPLAN_RELEASE_SSH_KEY_B64" | base64 -d > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + echo 'git.add-ideas.de ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDe48IOof2fJS1dTbJtLWQnWnr+JorZXKIFdOAM9ct8G' > ~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts + - name: Install backend release integration dependencies + run: | + python -m venv .venv + .venv/bin/python -m pip install --upgrade pip + .venv/bin/python -m pip install -r requirements-release.txt + .venv/bin/python -m pip install '.[dev]' + - name: Install WebUI release dependencies + working-directory: webui + run: | + node -e "const fs=require('fs'); const pkg=JSON.parse(fs.readFileSync('package.json')); const rel=JSON.parse(fs.readFileSync('package.release.json')); for (const key of ['dependencies','devDependencies','peerDependencies','optionalDependencies','overrides']) if (rel[key]) pkg[key]=rel[key]; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');" + rm -f package-lock.json + npm cache clean --force + npm install --prefer-online + - name: Run release integration checks + run: bash scripts/check-release-integration.sh diff --git a/scripts/check-release-integration.sh b/scripts/check-release-integration.sh new file mode 100644 index 0000000..9ff47d6 --- /dev/null +++ b/scripts/check-release-integration.sh @@ -0,0 +1,230 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PYTHON="${PYTHON:-$ROOT/.venv/bin/python}" +NPM="${NPM:-/home/zemion/.nvm/versions/node/v22.22.3/bin/npm}" +NODE_BIN="$(dirname "$NPM")" +WEBUI_BIN="$ROOT/webui/node_modules/.bin" +NPM_USERCONFIG="$(mktemp "${TMPDIR:-/tmp}/govoplan-npmrc.XXXXXXXX")" +WORK_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/govoplan-release-integration.XXXXXXXX")" + +trap 'rm -rf "$WORK_ROOT"; rm -f "$NPM_USERCONFIG"' EXIT + +if [[ ! -x "$PYTHON" ]]; then + PYTHON=python +fi +if [[ ! -x "$NPM" ]]; then + NPM=npm + NODE_BIN="$(dirname "$(command -v "$NPM")")" +fi + +cd "$ROOT" + +RELEASE_VERSION="$("$PYTHON" - <<'PY' +from pathlib import Path +import tomllib + +with (Path.cwd() / "pyproject.toml").open("rb") as handle: + print(tomllib.load(handle)["project"]["version"]) +PY +)" +RELEASE_TAG="${GOVOPLAN_RELEASE_TAG:-v$RELEASE_VERSION}" + +export RELEASE_TAG RELEASE_VERSION +export PATH="$WEBUI_BIN:$NODE_BIN:$PATH" +export NPM_CONFIG_USERCONFIG="$NPM_USERCONFIG" +unset npm_config_tmp NPM_CONFIG_TMP + +run_step() { + echo + echo "==> $*" +} + +run_step "Validate release refs and installed package metadata" +"$PYTHON" - <<'PY' +from __future__ import annotations + +import importlib.metadata as metadata +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +root = Path.cwd() +release_requirements = root / "requirements-release.txt" +release_webui = root / "webui" / "package.release.json" +expected_version = os.environ["RELEASE_VERSION"] +expected_tag = os.environ["RELEASE_TAG"] +errors: list[str] = [] + +python_requirements: list[tuple[str, str, str]] = [] +for line in release_requirements.read_text(encoding="utf-8").splitlines(): + match = re.match(r"^(govoplan-[a-z0-9-]+)\s+@\s+git\+ssh://(.+?\.git)@(v[0-9]+\.[0-9]+\.[0-9]+)$", line.strip()) + if match: + python_requirements.append((match.group(1), f"ssh://{match.group(2)}", match.group(3))) + +if not python_requirements: + errors.append("No GovOPlaN git+ssh release requirements found.") + +for package_name, repo_url, tag in python_requirements: + if tag != expected_tag: + errors.append(f"{package_name} release requirement uses {tag!r}, expected {expected_tag!r}.") + version = metadata.version(package_name) + if version != tag.removeprefix("v"): + errors.append(f"{package_name} installed version {version!r} does not match {tag!r}.") + result = subprocess.run( + ["git", "ls-remote", "--tags", repo_url, f"refs/tags/{tag}", f"refs/tags/{tag}^{{}}"], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=45, + check=False, + ) + if result.returncode != 0 or f"refs/tags/{tag}" not in result.stdout: + errors.append(f"{package_name} tag {tag} is not readable from {repo_url}: {result.stderr.strip()}") + +release_package = json.loads(release_webui.read_text(encoding="utf-8")) +if release_package.get("version") != expected_version: + errors.append(f"Core release WebUI version is {release_package.get('version')!r}, expected {expected_version!r}.") + +webui_dependencies = release_package.get("dependencies", {}) +for package_name, spec in sorted(webui_dependencies.items()): + if not package_name.startswith("@govoplan/"): + continue + tag = spec.rsplit("#", 1)[-1] if "#" in spec else "" + if tag != expected_tag: + errors.append(f"{package_name} release dependency uses {tag!r}, expected {expected_tag}.") + package_json = root / "webui" / "node_modules" / package_name / "package.json" + if not package_json.exists(): + errors.append(f"{package_name} is missing from release node_modules.") + continue + installed = json.loads(package_json.read_text(encoding="utf-8")) + if installed.get("version") != expected_version: + errors.append(f"{package_name} installed version {installed.get('version')!r}, expected {expected_version!r}.") + +shared_peers = ("lucide-react", "react", "react-dom", "react-router-dom") +root_peers = release_package.get("peerDependencies", {}) +for package_json in sorted((root / "webui" / "node_modules" / "@govoplan").glob("*/package.json")): + package = json.loads(package_json.read_text(encoding="utf-8")) + peers = package.get("peerDependencies", {}) + for name in shared_peers: + if name in peers and name in root_peers and peers[name] != root_peers[name]: + errors.append(f"{package.get('name')} peer {name}={peers[name]!r}, expected {root_peers[name]!r}.") + +if errors: + print("\n".join(errors), file=sys.stderr) + raise SystemExit(1) + +print(f"Release refs and metadata passed for {len(python_requirements)} Python packages and {len(webui_dependencies)} WebUI packages.") +PY + +run_step "Validate installed module manifests and registry" +"$PYTHON" - <<'PY' +from __future__ import annotations + +import sys +from govoplan_core.server.registry import available_module_manifests, build_platform_registry + +expected = { + "access", + "admin", + "audit", + "calendar", + "campaigns", + "dashboard", + "docs", + "files", + "identity", + "idm", + "mail", + "ops", + "organizations", + "policy", + "tenancy", +} + +manifests = available_module_manifests() +missing = sorted(expected - set(manifests)) +if missing: + print(f"Missing installed module manifests: {', '.join(missing)}", file=sys.stderr) + raise SystemExit(1) + +registry = build_platform_registry(tuple(sorted(expected))) +snapshot = registry.validate() +print("Registry modules:", ", ".join(manifest.id for manifest in snapshot.manifests)) +PY + +run_step "Run SQLite migration smoke for release modules" +"$PYTHON" - <<'PY' +from __future__ import annotations + +import tempfile +from pathlib import Path + +from govoplan_core.db.migrations import migrate_database + +enabled_modules = ( + "tenancy", + "organizations", + "identity", + "access", + "admin", + "dashboard", + "policy", + "audit", + "campaigns", + "files", + "mail", + "calendar", + "docs", + "ops", +) + +with tempfile.TemporaryDirectory(prefix="govoplan-release-migration-") as tmp: + database_url = f"sqlite:///{Path(tmp) / 'release-integration.sqlite3'}" + result = migrate_database(database_url=database_url, enabled_modules=enabled_modules) + if not result.current_revision: + raise SystemExit("Migration smoke did not produce an Alembic revision.") + print(f"SQLite release migration smoke reached {result.current_revision}.") +PY + +run_step "Clone release module test sources" +for repo in govoplan-mail govoplan-calendar govoplan-campaign; do + git clone --depth 1 --branch "$RELEASE_TAG" "git@git.add-ideas.de:add-ideas/${repo}.git" "$WORK_ROOT/$repo" +done + +run_step "Run core backend release tests" +"$PYTHON" -m unittest \ + tests.test_module_system \ + tests.test_access_contracts \ + tests.test_identity_organization_contracts \ + tests.test_core_events \ + tests.test_policy_contracts +"$PYTHON" scripts/check_dependency_boundaries.py + +run_step "Run cloned module backend tests" +"$PYTHON" -m unittest discover -s "$WORK_ROOT/govoplan-mail/tests" +"$PYTHON" -m unittest discover -s "$WORK_ROOT/govoplan-calendar/tests" +"$PYTHON" -m unittest discover -s "$WORK_ROOT/govoplan-campaign/tests" + +run_step "Run core WebUI release tests and build" +cd "$ROOT/webui" +"$NPM" run test:mail-components +"$NPM" run test:module-capabilities +"$NPM" run test:module-permutations +"$NPM" run build + +run_step "Run cloned module WebUI tests" +cd "$WORK_ROOT/govoplan-mail/webui" +"$NPM" run test:mail-ui + +cd "$WORK_ROOT/govoplan-campaign/webui" +"$NPM" run test:policy-ui +"$NPM" run test:template-preview +"$NPM" run test:import-utils + +echo +echo "Release integration check passed."