Initialize GovOPlaN meta repository

This commit is contained in:
2026-07-11 17:16:58 +02:00
commit 70109afc78
70 changed files with 11641 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Generate an Ed25519 keypair for signing GovOPlaN release catalogs."""
from __future__ import annotations
import argparse
import base64
from datetime import UTC, datetime
import json
from pathlib import Path
import stat
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--key-id", required=True, help="Public key id recorded in catalog signatures.")
parser.add_argument("--private-key", type=Path, required=True, help="Private PEM output path. Keep outside git.")
parser.add_argument("--public-key", type=Path, help="Optional base64 public key output path.")
parser.add_argument("--keyring", type=Path, help="Optional public keyring JSON output path.")
parser.add_argument("--status", default="active", choices=("active", "next", "retired", "revoked", "disabled"))
parser.add_argument("--force", action="store_true", help="Overwrite existing key files.")
args = parser.parse_args()
private_path = args.private_key.expanduser()
public_path = args.public_key.expanduser() if args.public_key else None
keyring_path = args.keyring.expanduser() if args.keyring else None
if private_path.exists() and not args.force:
raise SystemExit(f"Private key already exists: {private_path}")
if public_path is not None and public_path.exists() and not args.force:
raise SystemExit(f"Public key already exists: {public_path}")
private_key = Ed25519PrivateKey.generate()
private_bytes = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
public_bytes = private_key.public_key().public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
public_base64 = base64.b64encode(public_bytes).decode("ascii")
private_path.parent.mkdir(parents=True, exist_ok=True)
private_path.write_bytes(private_bytes)
private_path.chmod(stat.S_IRUSR | stat.S_IWUSR)
if public_path is not None:
public_path.parent.mkdir(parents=True, exist_ok=True)
public_path.write_text(public_base64 + "\n", encoding="utf-8")
if keyring_path is not None:
keyring_path.parent.mkdir(parents=True, exist_ok=True)
keyring = {
"keyring_version": "1",
"purpose": "govoplan module package catalog signatures",
"generated_at": datetime.now(tz=UTC).isoformat().replace("+00:00", "Z"),
"keys": [
{
"key_id": args.key_id,
"status": args.status,
"public_key": public_base64,
"not_before": datetime.now(tz=UTC).date().isoformat() + "T00:00:00Z",
}
],
}
keyring_path.write_text(json.dumps(keyring, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"private_key={private_path}")
if public_path is not None:
print(f"public_key={public_path}")
if keyring_path is not None:
print(f"keyring={keyring_path}")
print(f"key_id={args.key_id}")
print(f"public_key_base64={public_base64}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,408 @@
#!/usr/bin/env python3
"""Generate and sign a GovOPlaN module package release catalog."""
from __future__ import annotations
import argparse
import base64
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
import json
import os
from pathlib import Path
import sys
from typing import Any
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
META_ROOT = Path(__file__).resolve().parents[2]
CORE_ROOT = Path(os.environ.get("GOVOPLAN_CORE_ROOT", META_ROOT.parent / "govoplan-core")).resolve()
sys.path.insert(0, str(CORE_ROOT / "src"))
from govoplan_core.core.modules import ModuleManifest # noqa: E402
from govoplan_core.server.registry import available_module_manifests # noqa: E402
GITEA_BASE = "git+ssh://git@git.add-ideas.de/add-ideas"
@dataclass(frozen=True, slots=True)
class CatalogModule:
module_id: str
repo: str
python_package: str
name: str
description: str
tags: tuple[str, ...]
webui_package: str | None = None
provides_interfaces: tuple[dict[str, object], ...] = ()
requires_interfaces: tuple[dict[str, object], ...] = ()
CATALOG_MODULES = (
CatalogModule(
module_id="tenancy",
repo="govoplan-tenancy",
python_package="govoplan-tenancy",
name="Tenancy",
description="Tenant registry, tenant settings, and tenant resolution platform module.",
tags=("official", "platform-module"),
),
CatalogModule(
module_id="organizations",
repo="govoplan-organizations",
python_package="govoplan-organizations",
name="Organizations",
description="Organization units, functions, and account-held function assignments.",
tags=("official", "platform-module"),
),
CatalogModule(
module_id="identity",
repo="govoplan-identity",
python_package="govoplan-identity",
name="Identity",
description="Canonical identities and links between identities and platform accounts.",
tags=("official", "platform-module"),
),
CatalogModule(
module_id="access",
repo="govoplan-access",
python_package="govoplan-access",
name="Access",
description="Authentication, accounts, users, groups, roles, API keys, and access capabilities.",
tags=("official", "platform-module"),
webui_package="@govoplan/access-webui",
),
CatalogModule(
module_id="admin",
repo="govoplan-admin",
python_package="govoplan-admin",
name="Admin",
description="System settings, governance templates, module management, and admin shell contributions.",
tags=("official", "platform-module"),
webui_package="@govoplan/admin-webui",
),
CatalogModule(
module_id="policy",
repo="govoplan-policy",
python_package="govoplan-policy",
name="Policy",
description="Policy and governance capability module.",
tags=("official", "platform-module"),
webui_package="@govoplan/policy-webui",
),
CatalogModule(
module_id="audit",
repo="govoplan-audit",
python_package="govoplan-audit",
name="Audit",
description="Audit-log storage and audit administration routes.",
tags=("official", "platform-module"),
webui_package="@govoplan/audit-webui",
),
CatalogModule(
module_id="dashboard",
repo="govoplan-dashboard",
python_package="govoplan-dashboard",
name="Dashboard",
description="Configurable user home assembled from module-provided dashboard widgets.",
tags=("official", "platform-module"),
webui_package="@govoplan/dashboard-webui",
),
CatalogModule(
module_id="files",
repo="govoplan-files",
python_package="govoplan-files",
name="Files",
description="Managed file spaces and campaign attachment integration.",
tags=("official", "service-module"),
webui_package="@govoplan/files-webui",
),
CatalogModule(
module_id="mail",
repo="govoplan-mail",
python_package="govoplan-mail",
name="Mail",
description="SMTP/IMAP profile management, credential policy, and read-only mailbox access.",
tags=("official", "service-module"),
webui_package="@govoplan/mail-webui",
),
CatalogModule(
module_id="campaigns",
repo="govoplan-campaign",
python_package="govoplan-campaign",
name="Campaigns",
description="Campaign authoring, validation, queueing, delivery control, and reports.",
tags=("official", "business-module"),
webui_package="@govoplan/campaign-webui",
),
CatalogModule(
module_id="calendar",
repo="govoplan-calendar",
python_package="govoplan-calendar",
name="Calendar",
description="Calendar collections, events, CalDAV sources, and calendar WebUI routes.",
tags=("official", "service-module"),
webui_package="@govoplan/calendar-webui",
),
CatalogModule(
module_id="docs",
repo="govoplan-docs",
python_package="govoplan-docs",
name="Docs",
description="Configured-system documentation and evidence-aware help surfaces.",
tags=("official", "platform-module"),
webui_package="@govoplan/docs-webui",
),
CatalogModule(
module_id="ops",
repo="govoplan-ops",
python_package="govoplan-ops",
name="Ops",
description="Runtime health, deployment profile, worker split, and sizing visibility.",
tags=("official", "platform-module"),
webui_package="@govoplan/ops-webui",
),
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", required=True, help="GovOPlaN release version, without leading v.")
parser.add_argument("--channel", default="stable")
parser.add_argument("--sequence", type=int, help="Monotonic channel sequence. Defaults to UTC timestamp.")
parser.add_argument("--expires-days", type=int, default=90)
parser.add_argument("--catalog-output", type=Path, required=True)
parser.add_argument("--keyring-output", type=Path)
parser.add_argument(
"--catalog-signing-key",
action="append",
default=[],
metavar="KEY_ID=PRIVATE_KEY",
help="Ed25519 private key used to sign the catalog; may be repeated for rotation.",
)
parser.add_argument("--public-base-url", default="https://govoplan.add-ideas.de")
parser.add_argument("--repository-base", default=GITEA_BASE)
args = parser.parse_args()
version = args.version.removeprefix("v")
tag = f"v{version}"
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"))
expires_at = generated_at + timedelta(days=args.expires_days)
signing_keys = [_parse_signing_key(value) for value in args.catalog_signing_key]
catalog = _catalog_payload(
version=version,
tag=tag,
channel=args.channel,
sequence=sequence,
generated_at=generated_at,
expires_at=expires_at,
repository_base=args.repository_base.rstrip("/"),
public_base_url=args.public_base_url.rstrip("/"),
)
if signing_keys:
catalog["signatures"] = [_signature(catalog, key_id=key_id, private_key=private_key) for key_id, private_key in signing_keys]
output = args.catalog_output.expanduser()
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(catalog, indent=2, sort_keys=True) + "\n", encoding="utf-8")
if args.keyring_output is not None:
keyring_output = args.keyring_output.expanduser()
keyring_output.parent.mkdir(parents=True, exist_ok=True)
keyring_output.write_text(
json.dumps(_keyring(signing_keys=signing_keys, generated_at=generated_at), indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
print(f"catalog={output}")
if args.keyring_output is not None:
print(f"keyring={args.keyring_output.expanduser()}")
print(f"channel={args.channel}")
print(f"sequence={sequence}")
print(f"version={version}")
return 0
def _catalog_payload(
*,
version: str,
tag: str,
channel: str,
sequence: int,
generated_at: datetime,
expires_at: datetime,
repository_base: str,
public_base_url: str,
) -> dict[str, Any]:
manifests = _discovered_catalog_manifests()
modules: list[dict[str, Any]] = []
for module in CATALOG_MODULES:
manifest = manifests.get(module.module_id)
module_version = manifest.version if manifest is not None else version
module_tag = f"v{module_version.removeprefix('v')}"
entry: dict[str, Any] = {
"module_id": module.module_id,
"name": module.name,
"description": module.description,
"version": module_version,
"action": "install",
"python_package": module.python_package,
"python_ref": f"{module.python_package} @ {repository_base}/{module.repo}.git@{module_tag}",
"license_features": [f"module.{module.module_id}"],
"tags": list(module.tags),
}
if module.webui_package:
entry["webui_package"] = module.webui_package
entry["webui_ref"] = f"{repository_base}/{module.repo}.git#{module_tag}"
manifest_metadata = _manifest_catalog_metadata(manifest)
entry.update(manifest_metadata)
if module.provides_interfaces:
entry["provides_interfaces"] = [dict(item) for item in module.provides_interfaces]
if module.requires_interfaces:
entry["requires_interfaces"] = [dict(item) for item in module.requires_interfaces]
modules.append(entry)
return {
"catalog_version": "1",
"channel": channel,
"sequence": sequence,
"generated_at": _json_datetime(generated_at),
"expires_at": _json_datetime(expires_at),
"release": {
"version": version,
"tag": tag,
"catalog_url": f"{public_base_url}/catalogs/v1/channels/{channel}.json",
"keyring_url": f"{public_base_url}/catalogs/v1/keyring.json",
},
"core_release": {
"name": "GovOPlaN Core",
"version": version,
"python_package": "govoplan-core",
"python_ref": f"govoplan-core[server] @ {repository_base}/govoplan-core.git@{tag}",
"webui_package": "@govoplan/core-webui",
"webui_ref": f"{repository_base}/govoplan-core.git#{tag}",
},
"modules": modules,
}
def _discovered_catalog_manifests() -> dict[str, ModuleManifest]:
try:
return available_module_manifests(ignore_load_errors=True)
except Exception:
return {}
def _manifest_catalog_metadata(manifest: ModuleManifest | None) -> dict[str, object]:
if manifest is None:
return {}
payload: dict[str, object] = {}
if manifest.dependencies:
payload["dependencies"] = list(manifest.dependencies)
if manifest.optional_dependencies:
payload["optional_dependencies"] = list(manifest.optional_dependencies)
if manifest.migration_spec is not None:
payload["migration_safety"] = "requires_review"
payload["migration_notes"] = "Module owns database migrations; review release notes and migration output before activation."
if manifest.migration_spec.migration_after:
payload["migration_after"] = list(manifest.migration_spec.migration_after)
if manifest.migration_spec.migration_before:
payload["migration_before"] = list(manifest.migration_spec.migration_before)
if manifest.migration_spec.migration_tasks:
tasks: list[dict[str, object]] = []
for task in manifest.migration_spec.migration_tasks:
task_payload: dict[str, object] = {
"task_id": task.task_id,
"phase": task.phase,
"summary": task.summary,
"task_version": task.task_version,
"safety": task.safety,
"idempotent": task.idempotent,
}
if task.timeout_seconds is not None:
task_payload["timeout_seconds"] = task.timeout_seconds
tasks.append(task_payload)
payload["migration_tasks"] = tasks
if manifest.provides_interfaces:
payload["provides_interfaces"] = [
{"name": item.name, "version": item.version}
for item in manifest.provides_interfaces
]
if manifest.requires_interfaces:
requirements: list[dict[str, object]] = []
for item in manifest.requires_interfaces:
requirement: dict[str, object] = {
"name": item.name,
"optional": item.optional,
}
if item.version_min is not None:
requirement["version_min"] = item.version_min
if item.version_max_exclusive is not None:
requirement["version_max_exclusive"] = item.version_max_exclusive
requirements.append(requirement)
payload["requires_interfaces"] = requirements
return payload
def _parse_signing_key(value: str) -> tuple[str, Ed25519PrivateKey]:
key_id, separator, path_text = value.partition("=")
if not separator or not key_id.strip() or not path_text.strip():
raise SystemExit("--catalog-signing-key must use KEY_ID=/path/to/private.pem")
path = Path(path_text).expanduser()
private_key = serialization.load_pem_private_key(path.read_bytes(), password=None)
if not isinstance(private_key, Ed25519PrivateKey):
raise SystemExit(f"Catalog signing key must be an Ed25519 private key: {path}")
return key_id.strip(), private_key
def _signature(payload: dict[str, Any], *, key_id: str, private_key: Ed25519PrivateKey) -> dict[str, str]:
signature_payload = dict(payload)
signature_payload.pop("signature", None)
signature_payload.pop("signatures", None)
signature = private_key.sign(_canonical_bytes(signature_payload))
return {
"algorithm": "ed25519",
"key_id": key_id,
"value": base64.b64encode(signature).decode("ascii"),
}
def _keyring(*, signing_keys: list[tuple[str, Ed25519PrivateKey]], generated_at: datetime) -> dict[str, Any]:
return {
"keyring_version": "1",
"purpose": "govoplan module package catalog signatures",
"generated_at": _json_datetime(generated_at),
"keys": [
{
"key_id": key_id,
"status": "active",
"public_key": _public_key_base64(private_key),
"not_before": generated_at.date().isoformat() + "T00:00:00Z",
}
for key_id, private_key in signing_keys
],
}
def _public_key_base64(private_key: Ed25519PrivateKey) -> str:
public_bytes = private_key.public_key().public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
return base64.b64encode(public_bytes).decode("ascii")
def _canonical_bytes(payload: object) -> bytes:
return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
def _json_datetime(value: datetime) -> str:
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,116 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage:
tools/release/generate-release-lock.sh [options]
Generates webui/package-lock.release.json from webui/package.release.json in a
temporary workspace. The normal development package.json and package-lock.json
are left untouched.
Run this after the module git tags referenced by package.release.json exist and
are reachable, and before tagging the core release commit that should contain
the regenerated release lockfile.
Options:
--npm <path> npm executable to use.
--core-root <path> govoplan-core checkout. Defaults to ../govoplan-core.
-h, --help Show this help.
USAGE
}
fail() {
echo "error: $*" >&2
exit 1
}
META_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
CORE_ROOT="${GOVOPLAN_CORE_ROOT:-$META_ROOT/../govoplan-core}"
CORE_ROOT="$(cd "$CORE_ROOT" && pwd)"
WEBUI="$CORE_ROOT/webui"
NPM_BIN="${NPM:-}"
NODE_BIN="${NODE:-}"
if [[ -z "$NPM_BIN" ]]; then
if [[ -x "/home/zemion/.nvm/versions/node/v22.22.3/bin/npm" ]]; then
NPM_BIN="/home/zemion/.nvm/versions/node/v22.22.3/bin/npm"
else
NPM_BIN="npm"
fi
fi
if [[ -z "$NODE_BIN" ]]; then
if [[ -x "$(dirname "$NPM_BIN")/node" ]]; then
NODE_BIN="$(dirname "$NPM_BIN")/node"
else
NODE_BIN="node"
fi
fi
while [[ $# -gt 0 ]]; do
case "$1" in
--npm)
[[ $# -ge 2 ]] || fail "missing value for $1"
NPM_BIN="$2"
shift 2
;;
--core-root)
[[ $# -ge 2 ]] || fail "missing value for $1"
CORE_ROOT="$(cd "$2" && pwd)"
WEBUI="$CORE_ROOT/webui"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
[[ -f "$WEBUI/package.release.json" ]] || fail "missing $WEBUI/package.release.json"
command -v "$NPM_BIN" >/dev/null 2>&1 || fail "npm executable not found: $NPM_BIN"
command -v "$NODE_BIN" >/dev/null 2>&1 || fail "node executable not found: $NODE_BIN"
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/govoplan-release-lock.XXXXXXXX")"
cleanup() {
rm -rf "$TMP_DIR"
}
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"
(
cd "$TMP_DIR"
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");
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
for (const group of ["dependencies", "devDependencies", "optionalDependencies"]) {
for (const [name, spec] of Object.entries(pkg[group] || {})) {
if (typeof spec === "string" && spec.startsWith("git+")) {
console.log(name);
}
}
}
NODE
)
if [[ "${#GIT_PACKAGES[@]}" -gt 0 ]]; then
echo "Refreshing git package lock entries: ${GIT_PACKAGES[*]}"
PATH="$(dirname "$NPM_BIN"):$PATH" "$NPM_BIN" update --package-lock-only --ignore-scripts "${GIT_PACKAGES[@]}"
fi
)
cp "$TMP_DIR/package-lock.json" "$WEBUI/package-lock.release.json"
echo "Updated $WEBUI/package-lock.release.json"

View File

@@ -0,0 +1,76 @@
#!/usr/bin/env bash
set -euo pipefail
META_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
CORE_ROOT="${GOVOPLAN_CORE_ROOT:-$META_ROOT/../govoplan-core}"
CORE_ROOT="$(cd "$CORE_ROOT" && pwd)"
WEBUI_DIR="${1:-$CORE_ROOT/webui}"
WORK_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/govoplan-webui-release-deps.XXXXXXXX")"
GOVOPLAN_DEPS="$WORK_ROOT/govoplan-webui-deps.tsv"
export GOVOPLAN_DEPS
trap 'rm -rf "$WORK_ROOT"' EXIT
retry() {
local attempt status
for attempt in 1 2 3; do
if "$@"; then
return 0
fi
status=$?
if [[ "$attempt" == 3 ]]; then
return "$status"
fi
sleep $((attempt * 10))
done
}
cd "$WEBUI_DIR"
node <<'JS'
const fs = require("fs");
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
const rel = JSON.parse(fs.readFileSync("package.release.json", "utf8"));
const govoplanDeps = [];
const dependencies = {...(rel.dependencies || {})};
for (const [name, spec] of Object.entries(dependencies)) {
if (name.startsWith("@govoplan/")) {
govoplanDeps.push([name, spec]);
delete dependencies[name];
}
}
for (const key of ["devDependencies", "peerDependencies", "optionalDependencies", "overrides"]) {
if (rel[key]) pkg[key] = rel[key];
}
pkg.dependencies = dependencies;
fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2) + "\n");
fs.writeFileSync(process.env.GOVOPLAN_DEPS, govoplanDeps.map((item) => item.join("\t")).join("\n") + "\n");
JS
rm -f package-lock.json
npm cache clean --force
retry npm install --prefer-online
module_paths=()
while IFS=$'\t' read -r package_name spec; do
[[ -n "${package_name:-}" ]] || continue
git_url="${spec%%#*}"
git_ref="${spec#*#}"
if [[ "$git_url" == "$spec" || -z "$git_ref" ]]; then
echo "Unsupported GovOPlaN WebUI git spec for $package_name: $spec" >&2
exit 1
fi
clone_url="${git_url#git+}"
clone_dir="$WORK_ROOT/${package_name#@govoplan/}"
echo "Installing $package_name from $clone_url#$git_ref"
retry git clone --depth 1 --branch "$git_ref" "$clone_url" "$clone_dir"
module_paths+=("file:$clone_dir")
done < "$GOVOPLAN_DEPS"
if [[ "${#module_paths[@]}" -gt 0 ]]; then
retry npm install --prefer-online --no-save --install-links "${module_paths[@]}"
fi

View File

@@ -0,0 +1,260 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage:
tools/release/publish-release-catalog.sh --version <x.y.z> --catalog-signing-key <key-id=/path/private.pem> [options]
Generates a signed module package catalog for a GovOPlaN release, writes it into
addideas-govoplan-website/public/catalogs/v1, validates it, optionally builds the website,
and optionally commits/tags/pushes addideas-govoplan-website.
Options:
--version <x.y.z> Release version without leading v.
--channel <name> Catalog channel. Defaults to stable.
--sequence <number> Monotonic channel sequence. Defaults to UTC timestamp.
--expires-days <days> Catalog expiry window. Defaults to 90.
--catalog-signing-key <key-id=/path/private.pem>
Ed25519 private key. May be repeated for rotation.
--core-root <path> govoplan-core checkout. Defaults to ../govoplan-core.
--web-root <path> addideas-govoplan-website checkout. Defaults to ../addideas-govoplan-website.
--public-base-url <url> Public website URL. Defaults to https://govoplan.add-ideas.de.
--npm <path> npm executable used for optional web build.
--build-web Run npm run build in addideas-govoplan-website after generating files.
--commit Commit generated catalog files in addideas-govoplan-website.
--tag Tag addideas-govoplan-website with catalog-v<x.y.z>. Implies --commit.
--push Push addideas-govoplan-website branch and tag. Implies --commit.
--remote <name> Git remote for push. Defaults to origin.
--branch <name> Branch to push. Defaults to current addideas-govoplan-website branch.
-n, --dry-run Print commands and validate inputs without writing git changes.
-h, --help Show this help.
USAGE
}
fail() {
echo "error: $*" >&2
exit 1
}
META_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
PARENT="$(dirname "$META_ROOT")"
CORE_ROOT="${GOVOPLAN_CORE_ROOT:-$PARENT/govoplan-core}"
CORE_ROOT="$(cd "$CORE_ROOT" && pwd)"
WEB_ROOT="$PARENT/addideas-govoplan-website"
VERSION=""
CHANNEL="stable"
SEQUENCE=""
EXPIRES_DAYS="90"
PUBLIC_BASE_URL="https://govoplan.add-ideas.de"
REMOTE="origin"
BRANCH=""
BUILD_WEB=0
COMMIT=0
TAG_WEB=0
PUSH=0
DRY_RUN=0
SIGNING_KEYS=()
NPM_BIN="${NPM:-}"
if [[ -z "${PYTHON:-}" ]]; then
if [[ -x "$META_ROOT/.venv/bin/python" ]]; then
PYTHON="$META_ROOT/.venv/bin/python"
else
PYTHON="python3"
fi
fi
if [[ -z "$NPM_BIN" ]]; then
if [[ -x "/home/zemion/.nvm/versions/node/v22.22.3/bin/npm" ]]; then
NPM_BIN="/home/zemion/.nvm/versions/node/v22.22.3/bin/npm"
else
NPM_BIN="npm"
fi
fi
while [[ $# -gt 0 ]]; do
case "$1" in
--version)
[[ $# -ge 2 ]] || fail "missing value for $1"
VERSION="${2#v}"
shift 2
;;
--channel)
[[ $# -ge 2 ]] || fail "missing value for $1"
CHANNEL="$2"
shift 2
;;
--sequence)
[[ $# -ge 2 ]] || fail "missing value for $1"
SEQUENCE="$2"
shift 2
;;
--expires-days)
[[ $# -ge 2 ]] || fail "missing value for $1"
EXPIRES_DAYS="$2"
shift 2
;;
--catalog-signing-key)
[[ $# -ge 2 ]] || fail "missing value for $1"
SIGNING_KEYS+=("$2")
shift 2
;;
--core-root)
[[ $# -ge 2 ]] || fail "missing value for $1"
CORE_ROOT="$(cd "$2" && pwd)"
shift 2
;;
--web-root)
[[ $# -ge 2 ]] || fail "missing value for $1"
WEB_ROOT="$(cd "$2" && pwd)"
shift 2
;;
--public-base-url)
[[ $# -ge 2 ]] || fail "missing value for $1"
PUBLIC_BASE_URL="$2"
shift 2
;;
--npm)
[[ $# -ge 2 ]] || fail "missing value for $1"
NPM_BIN="$2"
shift 2
;;
--build-web)
BUILD_WEB=1
shift
;;
--commit)
COMMIT=1
shift
;;
--tag)
TAG_WEB=1
COMMIT=1
shift
;;
--push)
PUSH=1
COMMIT=1
shift
;;
--remote)
[[ $# -ge 2 ]] || fail "missing value for $1"
REMOTE="$2"
shift 2
;;
--branch)
[[ $# -ge 2 ]] || fail "missing value for $1"
BRANCH="$2"
shift 2
;;
-n|--dry-run)
DRY_RUN=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
[[ -n "$VERSION" ]] || fail "--version is required"
[[ "$VERSION" =~ ^[0-9]+[.][0-9]+[.][0-9]+$ ]] || fail "version must be x.y.z: $VERSION"
[[ ${#SIGNING_KEYS[@]} -gt 0 ]] || fail "at least one --catalog-signing-key is required"
[[ -d "$CORE_ROOT/.git" ]] || fail "not a govoplan-core git repo: $CORE_ROOT"
[[ -d "$WEB_ROOT/.git" ]] || fail "not an addideas-govoplan-website git repo: $WEB_ROOT"
command -v "$PYTHON" >/dev/null 2>&1 || fail "Python not found: $PYTHON"
if [[ "$BUILD_WEB" -eq 1 ]]; then
command -v "$NPM_BIN" >/dev/null 2>&1 || fail "npm not found: $NPM_BIN"
fi
if [[ -z "$BRANCH" ]]; then
BRANCH="$(git -C "$WEB_ROOT" symbolic-ref --quiet --short HEAD || true)"
fi
[[ -n "$BRANCH" ]] || fail "cannot infer addideas-govoplan-website branch; pass --branch"
CATALOG_PATH="$WEB_ROOT/public/catalogs/v1/channels/$CHANNEL.json"
KEYRING_PATH="$WEB_ROOT/public/catalogs/v1/keyring.json"
TAG_NAME="catalog-v$VERSION"
run() {
printf '+'
printf ' %q' "$@"
printf '\n'
if [[ "$DRY_RUN" -eq 0 ]]; then
"$@"
fi
}
GEN_ARGS=(
"$PYTHON" "$META_ROOT/tools/release/generate-release-catalog.py"
--version "$VERSION"
--channel "$CHANNEL"
--expires-days "$EXPIRES_DAYS"
--catalog-output "$CATALOG_PATH"
--keyring-output "$KEYRING_PATH"
--public-base-url "$PUBLIC_BASE_URL"
)
if [[ -n "$SEQUENCE" ]]; then
GEN_ARGS+=(--sequence "$SEQUENCE")
fi
for signing_key in "${SIGNING_KEYS[@]}"; do
GEN_ARGS+=(--catalog-signing-key "$signing_key")
done
run "${GEN_ARGS[@]}"
if [[ "$DRY_RUN" -eq 0 ]]; then
GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE="$KEYRING_PATH" \
"$PYTHON" -m govoplan_core.commands.module_installer \
--validate-package-catalog "$CATALOG_PATH" \
--require-signed-catalog \
--approved-catalog-channel "$CHANNEL" \
--format json >/dev/null
else
echo "+ GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE=$KEYRING_PATH $PYTHON -m govoplan_core.commands.module_installer --validate-package-catalog $CATALOG_PATH --require-signed-catalog --approved-catalog-channel $CHANNEL --format json"
fi
if [[ "$BUILD_WEB" -eq 1 ]]; then
run env PATH="$(dirname "$NPM_BIN"):$PATH" "$NPM_BIN" --prefix "$WEB_ROOT" run build
fi
if [[ "$COMMIT" -eq 1 ]]; then
run git -C "$WEB_ROOT" add "$CATALOG_PATH" "$KEYRING_PATH"
if [[ "$DRY_RUN" -eq 0 ]]; then
if git -C "$WEB_ROOT" diff --cached --quiet; then
echo "No addideas-govoplan-website catalog changes to commit."
else
run git -C "$WEB_ROOT" commit -m "Publish GovOPlaN v$VERSION $CHANNEL catalog"
fi
else
run git -C "$WEB_ROOT" commit -m "Publish GovOPlaN v$VERSION $CHANNEL catalog"
fi
fi
if [[ "$TAG_WEB" -eq 1 ]]; then
if git -C "$WEB_ROOT" rev-parse --quiet --verify "refs/tags/$TAG_NAME" >/dev/null; then
fail "addideas-govoplan-website tag already exists: $TAG_NAME"
fi
run git -C "$WEB_ROOT" tag -a "$TAG_NAME" -m "Publish GovOPlaN v$VERSION $CHANNEL catalog"
fi
if [[ "$PUSH" -eq 1 ]]; then
if [[ "$TAG_WEB" -eq 1 ]]; then
run git -C "$WEB_ROOT" push --atomic "$REMOTE" "HEAD:refs/heads/$BRANCH" "refs/tags/$TAG_NAME"
else
run git -C "$WEB_ROOT" push "$REMOTE" "HEAD:refs/heads/$BRANCH"
fi
fi
echo "Catalog ready:"
echo " $CATALOG_PATH"
echo " $KEYRING_PATH"
echo " URL: $PUBLIC_BASE_URL/catalogs/v1/channels/$CHANNEL.json"

View File

@@ -0,0 +1,864 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage:
tools/release/push-release-tag.sh [options]
Bumps installable GovOPlaN package versions, commits pending changes in the
GovOPlaN release/module repositories, creates an annotated release tag in each
repo, and pushes branch+tag.
By default the script asks which version part to bump:
major X.Y.Z -> (X+1).0.0
minor X.Y.Z -> X.(Y+1).0
subversion X.Y.Z -> X.Y.(Z+1)
Options:
--bump <kind> Version bump: major, minor, subversion, or patch.
--version <x.y.z> Explicit target version instead of a bump. May equal
the current repo versions when they were bumped already.
-r, --remote <name> Remote to push to. Defaults to origin.
-b, --branch <name> Branch to update in every repo. Defaults to each current branch.
-m, --message <text> Commit message. Defaults to "Release v<x.y.z>".
--tag-message <text> Annotated tag message. Defaults to the commit message.
--skip-release-lock Do not regenerate core webui/package-lock.release.json.
--warn-migration-audit Run the migration release audit without baseline strictness.
--strict-migration-audit
Require current Alembic heads to be recorded in the
latest migration release baseline.
--skip-migration-audit Do not run the release migration audit.
--publish-web-catalog After core/modules are pushed, publish a signed release
catalog into addideas-govoplan-website.
--web-root <path> addideas-govoplan-website checkout for --publish-web-catalog.
Defaults to ../addideas-govoplan-website.
--catalog-signing-key <key-id=/path/private.pem>
Catalog signing key for --publish-web-catalog. May be
repeated during key rotation.
--catalog-channel <name>
Catalog channel. Defaults to stable.
--catalog-sequence <n> Monotonic catalog sequence. Defaults to UTC timestamp.
--catalog-expires-days <days>
Catalog expiry window. Defaults to 90.
--catalog-public-url <url>
Public website URL. Defaults to
https://govoplan.add-ideas.de.
--build-web-catalog Run npm run build in addideas-govoplan-website before committing the
catalog update.
--npm <path> npm executable for lockfile generation.
-y, --yes Do not prompt before committing, tagging, and pushing.
-n, --dry-run Print intended actions without changing files or git state.
-h, --help Show this help.
Repos:
Installable release packages:
govoplan-access
govoplan-admin
govoplan-tenancy
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:
tools/release/push-release-tag.sh
tools/release/push-release-tag.sh --bump subversion
tools/release/push-release-tag.sh --version 0.2.0 --message "Release v0.2.0"
USAGE
}
fail() {
echo "error: $*" >&2
exit 1
}
META_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
PARENT="$(dirname "$META_ROOT")"
ROOT="${GOVOPLAN_CORE_ROOT:-$PARENT/govoplan-core}"
ROOT="$(cd "$ROOT" && pwd)"
PACKAGE_MODULE_REPOS=(
"$PARENT/govoplan-access"
"$PARENT/govoplan-admin"
"$PARENT/govoplan-approvals"
"$PARENT/govoplan-assets"
"$PARENT/govoplan-audit"
"$PARENT/govoplan-booking"
"$PARENT/govoplan-calendar"
"$PARENT/govoplan-campaign"
"$PARENT/govoplan-certificates"
"$PARENT/govoplan-committee"
"$PARENT/govoplan-consultation"
"$PARENT/govoplan-contracts"
"$PARENT/govoplan-dashboard"
"$PARENT/govoplan-docs"
"$PARENT/govoplan-facilities"
"$PARENT/govoplan-files"
"$PARENT/govoplan-forms-runtime"
"$PARENT/govoplan-grants"
"$PARENT/govoplan-helpdesk"
"$PARENT/govoplan-identity"
"$PARENT/govoplan-idm"
"$PARENT/govoplan-inspections"
"$PARENT/govoplan-issue-reporting"
"$PARENT/govoplan-learning"
"$PARENT/govoplan-mail"
"$PARENT/govoplan-ops"
"$PARENT/govoplan-organizations"
"$PARENT/govoplan-permits"
"$PARENT/govoplan-policy"
"$PARENT/govoplan-procurement"
"$PARENT/govoplan-records"
"$PARENT/govoplan-resources"
"$PARENT/govoplan-risk-compliance"
"$PARENT/govoplan-tenancy"
"$PARENT/govoplan-transparency"
)
TAG_ONLY_MODULE_REPOS=(
"$PARENT/govoplan-addresses"
"$PARENT/govoplan-appointments"
"$PARENT/govoplan-cases"
"$PARENT/govoplan-connectors"
"$PARENT/govoplan-dms"
"$PARENT/govoplan-erp"
"$PARENT/govoplan-fit-connect"
"$PARENT/govoplan-forms"
"$PARENT/govoplan-identity-trust"
"$PARENT/govoplan-ledger"
"$PARENT/govoplan-notifications"
"$PARENT/govoplan-payments"
"$PARENT/govoplan-portal"
"$PARENT/govoplan-postbox"
"$PARENT/govoplan-reporting"
"$PARENT/govoplan-scheduling"
"$PARENT/govoplan-search"
"$PARENT/govoplan-tasks"
"$PARENT/govoplan-templates"
"$PARENT/govoplan-workflow"
"$PARENT/govoplan-xoev"
"$PARENT/govoplan-xrechnung"
"$PARENT/govoplan-xta-osci"
)
MODULE_REPOS=("${PACKAGE_MODULE_REPOS[@]}" "${TAG_ONLY_MODULE_REPOS[@]}")
PACKAGE_REPOS=("${PACKAGE_MODULE_REPOS[@]}" "$ROOT")
REPOS=(
"${MODULE_REPOS[@]}"
"$ROOT"
)
REMOTE="origin"
BRANCH=""
BUMP=""
TARGET_VERSION=""
COMMIT_MESSAGE=""
TAG_MESSAGE=""
DRY_RUN=0
YES=0
GENERATE_RELEASE_LOCK=1
MIGRATION_AUDIT_MODE="auto"
NPM_BIN="${NPM:-}"
PUBLISH_WEB_CATALOG=0
WEB_ROOT="$PARENT/addideas-govoplan-website"
CATALOG_CHANNEL="stable"
CATALOG_SEQUENCE=""
CATALOG_EXPIRES_DAYS="90"
CATALOG_PUBLIC_URL="https://govoplan.add-ideas.de"
BUILD_WEB_CATALOG=0
CATALOG_SIGNING_KEYS=()
if [[ -z "${PYTHON:-}" ]]; then
if [[ -x "$META_ROOT/.venv/bin/python" ]]; then
PYTHON="$META_ROOT/.venv/bin/python"
else
PYTHON="python3"
fi
fi
if [[ -z "$NPM_BIN" ]]; then
if [[ -x "/home/zemion/.nvm/versions/node/v22.22.3/bin/npm" ]]; then
NPM_BIN="/home/zemion/.nvm/versions/node/v22.22.3/bin/npm"
else
NPM_BIN="npm"
fi
fi
export GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -o BatchMode=yes -o ConnectTimeout=15}"
GIT_REMOTE_TIMEOUT="${GIT_REMOTE_TIMEOUT:-30}"
normalize_bump() {
local value="${1,,}"
case "$value" in
major|minor|subversion)
printf '%s\n' "$value"
;;
patch|sub|sub-version)
printf '%s\n' "subversion"
;;
*)
return 1
;;
esac
}
validate_version() {
[[ "$1" =~ ^[0-9]+[.][0-9]+[.][0-9]+$ ]]
}
version_gt() {
local new_major new_minor new_patch old_major old_minor old_patch
IFS=. read -r new_major new_minor new_patch <<< "$1"
IFS=. read -r old_major old_minor old_patch <<< "$2"
(( new_major > old_major )) && return 0
(( new_major < old_major )) && return 1
(( new_minor > old_minor )) && return 0
(( new_minor < old_minor )) && return 1
(( new_patch > old_patch )) && return 0
return 1
}
bump_version() {
local version="$1"
local bump="$2"
local major minor patch
IFS=. read -r major minor patch <<< "$version"
case "$bump" in
major)
printf '%s.0.0\n' "$((major + 1))"
;;
minor)
printf '%s.%s.0\n' "$major" "$((minor + 1))"
;;
subversion)
printf '%s.%s.%s\n' "$major" "$minor" "$((patch + 1))"
;;
*)
fail "unknown bump kind: $bump"
;;
esac
}
get_project_name() {
"$PYTHON" -c 'import pathlib, sys, tomllib; print(tomllib.loads(pathlib.Path(sys.argv[1]).read_text())["project"]["name"])' "$1/pyproject.toml"
}
get_project_version() {
"$PYTHON" -c 'import pathlib, sys, tomllib; print(tomllib.loads(pathlib.Path(sys.argv[1]).read_text())["project"]["version"])' "$1/pyproject.toml"
}
update_pyproject() {
local repo="$1"
local version="$2"
"$PYTHON" - "$repo/pyproject.toml" "$version" <<'PYCODE'
from __future__ import annotations
import pathlib
import re
import sys
import tomllib
path = pathlib.Path(sys.argv[1])
new_version = sys.argv[2]
text = path.read_text()
data = tomllib.loads(text)
project = data["project"]
name = project["name"]
old_version = project["version"]
text, version_count = re.subn(
r'(?m)^version\s*=\s*["\'][^"\']+["\']\s*$',
f'version = "{new_version}"',
text,
count=1,
)
if version_count != 1:
raise SystemExit(f"could not update project.version in {path}")
if name != "govoplan-core":
text, dependency_count = re.subn(
r'(govoplan-[A-Za-z0-9-]+>=)\d+\.\d+\.\d+',
rf'\g<1>{new_version}',
text,
)
if dependency_count < 1:
raise SystemExit(f"could not update govoplan-core dependency in {path}")
path.write_text(text)
print(f"{name}: {old_version} -> {new_version}")
PYCODE
}
update_manifest_version() {
local repo="$1"
local project_name="$2"
local version="$3"
local manifest_paths=()
if [[ "$project_name" == "govoplan-core" ]]; then
return 0
fi
while IFS= read -r -d '' manifest_path; do
manifest_paths+=("$manifest_path")
done < <(find "$repo/src" -path "*/backend/manifest.py" -print0 2>/dev/null)
[[ "${#manifest_paths[@]}" -gt 0 ]] || fail "missing module manifest under $repo/src"
for manifest_path in "${manifest_paths[@]}"; do
"$PYTHON" - "$manifest_path" "$version" <<'PYCODE'
from __future__ import annotations
import pathlib
import re
import sys
path = pathlib.Path(sys.argv[1])
new_version = sys.argv[2]
text = path.read_text()
text, count = re.subn(
r'(?m)^(\s*version=)["\'][^"\']+["\'](,?\s*)$',
rf'\1"{new_version}"\2',
text,
count=1,
)
if count == 0:
text, count = re.subn(
r'(?m)^(MODULE_VERSION\s*=\s*)["\'][^"\']+["\'](\s*)$',
rf'\1"{new_version}"\2',
text,
count=1,
)
if count != 1:
raise SystemExit(f"could not update ModuleManifest.version in {path}")
path.write_text(text)
PYCODE
done
}
update_webui_package() {
local repo="$1"
local project_name="$2"
local version="$3"
local package_path=""
local release_package_path="$repo/webui/package.release.json"
for package_path in "$repo/package.json" "$repo/webui/package.json"; do
[[ -f "$package_path" ]] || continue
"$PYTHON" - "$package_path" "$project_name" "$version" <<'PYCODE'
from __future__ import annotations
import json
import pathlib
import sys
path = pathlib.Path(sys.argv[1])
project_name = sys.argv[2]
new_version = sys.argv[3]
data = json.loads(path.read_text())
data["version"] = new_version
if project_name != "govoplan-core":
peers = data.setdefault("peerDependencies", {})
if "@govoplan/core-webui" in peers:
peers["@govoplan/core-webui"] = f"^{new_version}"
path.write_text(json.dumps(data, indent=2) + "\n")
PYCODE
done
if [[ "$project_name" == "govoplan-core" && -f "$release_package_path" ]]; then
"$PYTHON" - "$release_package_path" "$version" <<'PYCODE'
from __future__ import annotations
import json
import pathlib
import re
import sys
path = pathlib.Path(sys.argv[1])
new_version = sys.argv[2]
data = json.loads(path.read_text())
data["version"] = new_version
dependencies = data.setdefault("dependencies", {})
for name, spec in list(dependencies.items()):
if name.startswith("@govoplan/") and isinstance(spec, str) and spec.startswith("git+"):
dependencies[name] = re.sub(r"#v\d+\.\d+\.\d+$", f"#v{new_version}", spec)
path.write_text(json.dumps(data, indent=2) + "\n")
PYCODE
fi
}
update_version_files() {
local repo="$1"
local version="$2"
local project_name="${PROJECT_NAMES[$repo]}"
update_pyproject "$repo" "$version"
update_manifest_version "$repo" "$project_name" "$version"
update_webui_package "$repo" "$project_name" "$version"
}
refresh_development_webui_lock() {
[[ -f "$ROOT/webui/package.json" ]] || return 0
[[ -f "$ROOT/webui/package-lock.json" ]] || return 0
run env PATH="$(dirname "$NPM_BIN"):$PATH" "$NPM_BIN" --prefix "$ROOT/webui" install --package-lock-only --ignore-scripts
}
generate_release_lock() {
if [[ "$GENERATE_RELEASE_LOCK" -eq 0 ]]; then
return
fi
[[ -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"
}
run_migration_release_audit() {
local audit_script="$META_ROOT/tools/release/release-migration-audit.py"
local command=("$PYTHON" "$audit_script" "--track" "release")
case "$MIGRATION_AUDIT_MODE" in
skip)
echo "Migration release audit: skipped"
return
;;
strict)
command+=("--strict")
;;
auto)
command+=("--strict-if-baseline")
;;
warn)
;;
*)
fail "unknown migration audit mode: $MIGRATION_AUDIT_MODE"
;;
esac
[[ -f "$audit_script" ]] || fail "missing migration audit helper: $audit_script"
run "${command[@]}"
}
print_command() {
printf '+'
printf ' %q' "$@"
printf '\n'
}
run() {
print_command "$@"
if [[ "$DRY_RUN" -eq 0 ]]; then
"$@"
fi
}
prompt_for_bump() {
local answer=""
if [[ -n "$BUMP" || -n "$TARGET_VERSION" ]]; then
return
fi
if [[ ! -t 0 ]]; then
fail "pass --bump major|minor|subversion or --version x.y.z for non-interactive use"
fi
echo "Select version increase:"
echo " 1) major X.Y.Z -> (X+1).0.0"
echo " 2) minor X.Y.Z -> X.(Y+1).0"
echo " 3) subversion X.Y.Z -> X.Y.(Z+1)"
printf 'Choice [subversion]: '
read -r answer
case "${answer,,}" in
""|3|subversion|patch|sub|sub-version)
BUMP="subversion"
;;
1|major)
BUMP="major"
;;
2|minor)
BUMP="minor"
;;
*)
fail "unknown version bump: $answer"
;;
esac
}
confirm_release() {
local answer=""
if [[ "$DRY_RUN" -eq 1 || "$YES" -eq 1 ]]; then
return
fi
if [[ ! -t 0 ]]; then
fail "refusing to commit, tag, and push without confirmation; pass --yes for non-interactive use"
fi
printf 'Commit all changes, tag all repos, and push to %s? [y/N] ' "$REMOTE"
read -r answer
case "${answer,,}" in
y|yes)
;;
*)
echo "Aborted."
exit 1
;;
esac
}
while [[ $# -gt 0 ]]; do
case "$1" in
--bump)
[[ $# -ge 2 ]] || fail "missing value for $1"
BUMP="$(normalize_bump "$2")" || fail "unknown bump kind: $2"
shift 2
;;
--version)
[[ $# -ge 2 ]] || fail "missing value for $1"
TARGET_VERSION="$2"
shift 2
;;
-r|--remote)
[[ $# -ge 2 ]] || fail "missing value for $1"
REMOTE="$2"
shift 2
;;
-b|--branch)
[[ $# -ge 2 ]] || fail "missing value for $1"
BRANCH="$2"
shift 2
;;
-m|--message)
[[ $# -ge 2 ]] || fail "missing value for $1"
COMMIT_MESSAGE="$2"
shift 2
;;
--tag-message)
[[ $# -ge 2 ]] || fail "missing value for $1"
TAG_MESSAGE="$2"
shift 2
;;
--skip-release-lock)
GENERATE_RELEASE_LOCK=0
shift
;;
--warn-migration-audit)
MIGRATION_AUDIT_MODE="warn"
shift
;;
--strict-migration-audit)
MIGRATION_AUDIT_MODE="strict"
shift
;;
--skip-migration-audit)
MIGRATION_AUDIT_MODE="skip"
shift
;;
--publish-web-catalog)
PUBLISH_WEB_CATALOG=1
shift
;;
--web-root)
[[ $# -ge 2 ]] || fail "missing value for $1"
WEB_ROOT="$2"
shift 2
;;
--catalog-signing-key)
[[ $# -ge 2 ]] || fail "missing value for $1"
CATALOG_SIGNING_KEYS+=("$2")
shift 2
;;
--catalog-channel)
[[ $# -ge 2 ]] || fail "missing value for $1"
CATALOG_CHANNEL="$2"
shift 2
;;
--catalog-sequence)
[[ $# -ge 2 ]] || fail "missing value for $1"
CATALOG_SEQUENCE="$2"
shift 2
;;
--catalog-expires-days)
[[ $# -ge 2 ]] || fail "missing value for $1"
CATALOG_EXPIRES_DAYS="$2"
shift 2
;;
--catalog-public-url)
[[ $# -ge 2 ]] || fail "missing value for $1"
CATALOG_PUBLIC_URL="$2"
shift 2
;;
--build-web-catalog)
BUILD_WEB_CATALOG=1
shift
;;
--npm)
[[ $# -ge 2 ]] || fail "missing value for $1"
NPM_BIN="$2"
shift 2
;;
-y|--yes)
YES=1
shift
;;
-n|--dry-run)
DRY_RUN=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
if [[ -n "$BUMP" && -n "$TARGET_VERSION" ]]; then
fail "use either --bump or --version, not both"
fi
if [[ "$PUBLISH_WEB_CATALOG" -eq 1 && ${#CATALOG_SIGNING_KEYS[@]} -eq 0 ]]; then
fail "--publish-web-catalog requires at least one --catalog-signing-key"
fi
prompt_for_bump
if ! command -v "$PYTHON" >/dev/null 2>&1; then
fail "Python interpreter not found: $PYTHON"
fi
if ! command -v "$NPM_BIN" >/dev/null 2>&1; then
fail "npm executable not found: $NPM_BIN"
fi
if [[ "$PUBLISH_WEB_CATALOG" -eq 1 ]]; then
[[ -d "$WEB_ROOT/.git" ]] || fail "addideas-govoplan-website repo not found: $WEB_ROOT"
[[ -x "$META_ROOT/tools/release/publish-release-catalog.sh" ]] || fail "missing executable catalog publisher: $META_ROOT/tools/release/publish-release-catalog.sh"
fi
declare -A PROJECT_NAMES
declare -A OLD_VERSIONS
declare -A BRANCHES
declare -A REPO_KINDS
for repo in "${REPOS[@]}"; do
[[ -d "$repo/.git" ]] || fail "not a git repo: $repo"
if [[ -f "$repo/pyproject.toml" ]]; then
REPO_KINDS["$repo"]="package"
PROJECT_NAMES["$repo"]="$(get_project_name "$repo")"
OLD_VERSIONS["$repo"]="$(get_project_version "$repo")"
validate_version "${OLD_VERSIONS[$repo]}" || fail "unsupported version ${OLD_VERSIONS[$repo]} in $repo"
else
REPO_KINDS["$repo"]="tag-only"
PROJECT_NAMES["$repo"]="$(basename "$repo")"
OLD_VERSIONS["$repo"]="tag-only"
fi
if [[ -n "$BRANCH" ]]; then
BRANCHES["$repo"]="$BRANCH"
else
BRANCHES["$repo"]="$(git -C "$repo" symbolic-ref --quiet --short HEAD || true)"
fi
[[ -n "${BRANCHES[$repo]}" ]] || fail "cannot infer branch for $repo; pass --branch <name>"
git -C "$repo" check-ref-format "refs/heads/${BRANCHES[$repo]}" || fail "invalid branch name ${BRANCHES[$repo]} for $repo"
git -C "$repo" remote get-url "$REMOTE" >/dev/null || fail "remote $REMOTE not found in $repo"
upstream="$(git -C "$repo" rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null || true)"
if [[ -n "$upstream" && "$upstream" != "@{u}" ]]; then
behind_count="$(git -C "$repo" rev-list --count "HEAD..$upstream")"
[[ "$behind_count" == "0" ]] || fail "$repo is behind $upstream by $behind_count commit(s); pull/rebase first"
fi
done
if [[ -z "$TARGET_VERSION" ]]; then
BASE_VERSION="${OLD_VERSIONS[$ROOT]}"
for repo in "${PACKAGE_REPOS[@]}"; do
if [[ "${OLD_VERSIONS[$repo]}" != "$BASE_VERSION" ]]; then
fail "repo versions differ; pass --version x.y.z to choose an explicit target"
fi
done
TARGET_VERSION="$(bump_version "$BASE_VERSION" "$BUMP")"
fi
validate_version "$TARGET_VERSION" || fail "target version must be x.y.z: $TARGET_VERSION"
for repo in "${PACKAGE_REPOS[@]}"; do
if [[ "$TARGET_VERSION" == "${OLD_VERSIONS[$repo]}" ]]; then
continue
fi
if ! version_gt "$TARGET_VERSION" "${OLD_VERSIONS[$repo]}"; then
fail "target version $TARGET_VERSION must be greater than ${OLD_VERSIONS[$repo]} for ${PROJECT_NAMES[$repo]}"
fi
done
TAG="v$TARGET_VERSION"
COMMIT_MESSAGE="${COMMIT_MESSAGE:-Release $TAG}"
TAG_MESSAGE="${TAG_MESSAGE:-$COMMIT_MESSAGE}"
git -C "$ROOT" check-ref-format "refs/tags/$TAG" || fail "invalid tag name: $TAG"
for repo in "${REPOS[@]}"; do
if git -C "$repo" rev-parse --quiet --verify "refs/tags/$TAG" >/dev/null; then
fail "local tag already exists in ${PROJECT_NAMES[$repo]}: $TAG"
fi
set +e
timeout "$GIT_REMOTE_TIMEOUT" git -C "$repo" ls-remote --exit-code --tags "$REMOTE" "refs/tags/$TAG" >/dev/null
ls_remote_status=$?
set -e
if [[ "$ls_remote_status" -eq 0 ]]; then
fail "remote tag already exists for ${PROJECT_NAMES[$repo]} on $REMOTE: $TAG"
elif [[ "$ls_remote_status" -ne 2 ]]; then
fail "could not check remote tags for ${PROJECT_NAMES[$repo]} on $REMOTE"
fi
done
echo "Release plan:"
echo " version: $TARGET_VERSION"
echo " tag: $TAG"
echo " remote: $REMOTE"
echo " commit message: $COMMIT_MESSAGE"
echo " package repos: ${#PACKAGE_REPOS[@]} versioned"
echo " tag-only module repos: ${#TAG_ONLY_MODULE_REPOS[@]} committed/tagged/pushed without package version files"
if [[ "$GENERATE_RELEASE_LOCK" -eq 1 ]]; then
echo " release lock: regenerate core webui/package-lock.release.json after module tags are pushed"
else
echo " release lock: skipped"
fi
echo " migration audit: $MIGRATION_AUDIT_MODE"
if [[ "$PUBLISH_WEB_CATALOG" -eq 1 ]]; then
echo " web catalog: publish $CATALOG_CHANNEL catalog to $WEB_ROOT after core tag is pushed"
else
echo " web catalog: skipped"
fi
echo
for repo in "${REPOS[@]}"; do
if [[ "${REPO_KINDS[$repo]}" == "package" ]]; then
echo "${PROJECT_NAMES[$repo]}: ${OLD_VERSIONS[$repo]} -> $TARGET_VERSION on ${BRANCHES[$repo]}"
else
echo "${PROJECT_NAMES[$repo]}: tag-only $TAG on ${BRANCHES[$repo]}"
fi
git -C "$repo" status --short
echo
done
run_migration_release_audit
confirm_release
for repo in "${PACKAGE_REPOS[@]}"; do
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "Would update version files in $repo to $TARGET_VERSION"
else
update_version_files "$repo" "$TARGET_VERSION"
fi
done
refresh_development_webui_lock
for repo in "${MODULE_REPOS[@]}"; do
run git -C "$repo" add -A
if [[ "$DRY_RUN" -eq 0 ]]; then
if git -C "$repo" diff --cached --quiet; then
if [[ "${REPO_KINDS[$repo]}" == "package" ]]; then
if [[ "$TARGET_VERSION" == "${OLD_VERSIONS[$repo]}" ]]; then
echo "No staged changes for ${PROJECT_NAMES[$repo]}; version is already $TARGET_VERSION, tagging current HEAD."
else
fail "no staged changes for ${PROJECT_NAMES[$repo]} after version bump"
fi
else
echo "No staged changes for ${PROJECT_NAMES[$repo]}; tagging current HEAD."
fi
else
run git -C "$repo" commit -m "$COMMIT_MESSAGE"
fi
else
run git -C "$repo" commit -m "$COMMIT_MESSAGE"
fi
run git -C "$repo" tag -a "$TAG" -m "$TAG_MESSAGE"
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
run git -C "$ROOT" add -A
if [[ "$DRY_RUN" -eq 0 ]]; then
if git -C "$ROOT" diff --cached --quiet; then
fail "no staged changes for ${PROJECT_NAMES[$ROOT]} after version bump"
fi
fi
run git -C "$ROOT" commit -m "$COMMIT_MESSAGE"
run git -C "$ROOT" tag -a "$TAG" -m "$TAG_MESSAGE"
run git -C "$ROOT" push --atomic "$REMOTE" "HEAD:refs/heads/${BRANCHES[$ROOT]}" "refs/tags/$TAG"
if [[ "$PUBLISH_WEB_CATALOG" -eq 1 ]]; then
CATALOG_ARGS=(
"$META_ROOT/tools/release/publish-release-catalog.sh"
--version "$TARGET_VERSION"
--channel "$CATALOG_CHANNEL"
--expires-days "$CATALOG_EXPIRES_DAYS"
--core-root "$ROOT"
--web-root "$WEB_ROOT"
--public-base-url "$CATALOG_PUBLIC_URL"
--npm "$NPM_BIN"
--commit
--tag
--push
--remote "$REMOTE"
)
if [[ -n "$BRANCH" ]]; then
CATALOG_ARGS+=(--branch "$BRANCH")
fi
if [[ -n "$CATALOG_SEQUENCE" ]]; then
CATALOG_ARGS+=(--sequence "$CATALOG_SEQUENCE")
fi
if [[ "$BUILD_WEB_CATALOG" -eq 1 ]]; then
CATALOG_ARGS+=(--build-web)
fi
for signing_key in "${CATALOG_SIGNING_KEYS[@]}"; do
CATALOG_ARGS+=(--catalog-signing-key "$signing_key")
done
if [[ "$DRY_RUN" -eq 1 ]]; then
CATALOG_ARGS+=(--dry-run)
fi
run "${CATALOG_ARGS[@]}"
fi
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "Dry run complete for $TAG across all GovOPlaN repos."
else
echo "Released $TAG across all GovOPlaN repos."
fi

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,459 @@
#!/usr/bin/env python
from __future__ import annotations
import argparse
import ast
from dataclasses import dataclass
from datetime import datetime, timezone
import json
import os
from pathlib import Path
from typing import Any
META_ROOT = Path(__file__).resolve().parents[2]
ROOT = Path(os.environ.get("GOVOPLAN_CORE_ROOT", META_ROOT.parent / "govoplan-core")).resolve()
DEFAULT_BASELINE_FILE = ROOT / "docs" / "migration-release-baselines.json"
MIGRATION_TRACK_RELEASE = "release"
MIGRATION_TRACK_DEV = "dev"
MIGRATION_TRACKS = (MIGRATION_TRACK_RELEASE, MIGRATION_TRACK_DEV)
@dataclass(frozen=True, slots=True)
class Migration:
owner: str
path: Path
revision: str
down_revisions: tuple[str, ...]
depends_on: tuple[str, ...]
branch_labels: tuple[str, ...]
def main() -> int:
parser = argparse.ArgumentParser(
description="Audit GovOPlaN Alembic migrations against the release baseline policy.",
)
parser.add_argument(
"--workspace-root",
type=Path,
default=ROOT.parent,
help="Directory containing govoplan-* checkouts. Defaults to the parent of govoplan-core.",
)
parser.add_argument(
"--baseline-file",
type=Path,
default=DEFAULT_BASELINE_FILE,
help="JSON file recording released migration heads.",
)
parser.add_argument(
"--track",
choices=MIGRATION_TRACKS,
default=MIGRATION_TRACK_RELEASE,
help="Migration track to audit. The release track is compared to release baselines; the dev track only validates the detailed graph.",
)
parser.add_argument(
"--strict",
action="store_true",
help="Fail when current heads are not recorded in the latest release baseline.",
)
parser.add_argument(
"--strict-if-baseline",
action="store_true",
help="Run in strict mode only after at least one release baseline is recorded.",
)
parser.add_argument(
"--record-release",
metavar="VERSION",
help="Record the current reviewed migration heads as a release baseline.",
)
parser.add_argument(
"--replace-release",
action="store_true",
help="Replace an existing release entry when used with --record-release.",
)
parser.add_argument(
"--squash-plan",
action="store_true",
help="Print the reviewed/manual squash checklist for the current graph.",
)
parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.")
args = parser.parse_args()
migrations = discover_migrations(args.workspace_root, track=args.track)
baseline = load_baseline_file(args.baseline_file)
report = build_report(
migrations,
baseline=baseline,
baseline_file=args.baseline_file,
workspace_root=args.workspace_root,
track=args.track,
)
if args.record_release:
if args.track != MIGRATION_TRACK_RELEASE:
raise SystemExit("--record-release is only valid for --track release")
if report["graph_errors"]:
print_text_report(report)
return 1
baseline = record_release_baseline(
baseline,
report,
release=args.record_release,
replace=args.replace_release,
)
write_baseline_file(args.baseline_file, baseline)
report = build_report(
migrations,
baseline=baseline,
baseline_file=args.baseline_file,
workspace_root=args.workspace_root,
track=args.track,
)
if args.squash_plan:
print_squash_plan(report)
elif args.json:
print(json.dumps(report, indent=2, sort_keys=True))
else:
print_text_report(report)
has_graph_errors = bool(report["graph_errors"])
has_strict_errors = bool(report["strict_errors"])
strict_required = args.strict or (args.strict_if_baseline and report["release_count"] > 0)
if has_graph_errors or (strict_required and has_strict_errors):
return 1
return 0
def discover_migrations(workspace_root: Path, *, track: str = MIGRATION_TRACK_RELEASE) -> list[Migration]:
migrations: list[Migration] = []
for versions_dir in _migration_version_dirs(workspace_root, track=track):
owner = owner_for_versions_dir(versions_dir)
for path in sorted(versions_dir.glob("*.py")):
if path.name == "__init__.py":
continue
migration = parse_migration_file(owner, path)
if migration is not None:
migrations.append(migration)
return migrations
def _migration_version_dirs(workspace_root: Path, *, track: str = MIGRATION_TRACK_RELEASE) -> list[Path]:
version_dir_name = "dev_versions" if track == MIGRATION_TRACK_DEV else "versions"
candidates = [ROOT / "alembic" / version_dir_name]
for repo in sorted(workspace_root.glob("govoplan-*")):
candidates.extend(sorted(repo.glob(f"src/govoplan_*/backend/migrations/{version_dir_name}")))
seen: set[Path] = set()
result: list[Path] = []
for candidate in candidates:
resolved = candidate.resolve()
if resolved in seen or not candidate.is_dir():
continue
seen.add(resolved)
result.append(candidate)
return result
def owner_for_versions_dir(versions_dir: Path) -> str:
core_locations = {
(ROOT / "alembic" / "versions").resolve(),
(ROOT / "alembic" / "dev_versions").resolve(),
}
if versions_dir.resolve() in core_locations:
return "govoplan-core"
for parent in versions_dir.parents:
if parent.name.startswith("govoplan-"):
return parent.name
return versions_dir.parent.name
def parse_migration_file(owner: str, path: Path) -> Migration | None:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
values: dict[str, Any] = {}
for statement in tree.body:
if isinstance(statement, ast.Assign):
for target in statement.targets:
if isinstance(target, ast.Name) and target.id in {"revision", "down_revision", "depends_on", "branch_labels"}:
values[target.id] = ast.literal_eval(statement.value)
elif (
isinstance(statement, ast.AnnAssign)
and isinstance(statement.target, ast.Name)
and statement.target.id in {"revision", "down_revision", "depends_on", "branch_labels"}
and statement.value is not None
):
values[statement.target.id] = ast.literal_eval(statement.value)
revision = values.get("revision")
if not isinstance(revision, str):
return None
return Migration(
owner=owner,
path=path,
revision=revision,
down_revisions=_normalize_revision_tuple(values.get("down_revision")),
depends_on=_normalize_revision_tuple(values.get("depends_on")),
branch_labels=_normalize_string_tuple(values.get("branch_labels")),
)
def _normalize_revision_tuple(value: Any) -> tuple[str, ...]:
if value is None:
return ()
if isinstance(value, str):
return (value,)
if isinstance(value, (list, tuple, set)):
return tuple(str(item) for item in value if item is not None)
return (str(value),)
def _normalize_string_tuple(value: Any) -> tuple[str, ...]:
if value is None:
return ()
if isinstance(value, str):
return (value,)
if isinstance(value, (list, tuple, set)):
return tuple(str(item) for item in value)
return (str(value),)
def load_baseline_file(path: Path) -> dict[str, Any]:
if not path.exists():
return {"version": 1, "releases": [], "_missing": True}
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise SystemExit(f"{path} must contain a JSON object")
releases = payload.get("releases")
if not isinstance(releases, list):
raise SystemExit(f"{path} must contain a releases list")
return payload
def build_report(
migrations: list[Migration],
*,
baseline: dict[str, Any],
baseline_file: Path,
workspace_root: Path,
track: str = MIGRATION_TRACK_RELEASE,
) -> dict[str, Any]:
revisions: dict[str, Migration] = {}
graph_errors: list[str] = []
for migration in migrations:
existing = revisions.get(migration.revision)
if existing is not None:
graph_errors.append(
f"duplicate revision {migration.revision}: {existing.path} and {migration.path}"
)
revisions[migration.revision] = migration
referenced = {
revision
for migration in migrations
for revision in (*migration.down_revisions, *migration.depends_on)
}
for migration in migrations:
for down_revision in migration.down_revisions:
if down_revision not in revisions:
graph_errors.append(
f"{migration.revision} references missing down_revision {down_revision} in {migration.path}"
)
for dependency in migration.depends_on:
if dependency not in revisions:
graph_errors.append(
f"{migration.revision} references missing depends_on {dependency} in {migration.path}"
)
current_heads = sorted(revision for revision in revisions if revision not in referenced)
current_head_entries = [
{
"owner": revisions[revision].owner,
"revision": revision,
}
for revision in current_heads
]
owner_rows = []
for owner in sorted({migration.owner for migration in migrations}):
owner_migrations = [migration for migration in migrations if migration.owner == owner]
owner_revisions = {migration.revision for migration in owner_migrations}
owner_referenced = {
revision
for migration in owner_migrations
for revision in (*migration.down_revisions, *migration.depends_on)
if revision in owner_revisions
}
owner_heads = sorted(owner_revisions - owner_referenced)
owner_rows.append(
{
"owner": owner,
"migrations": len(owner_migrations),
"heads": owner_heads,
}
)
releases = baseline.get("releases") or []
latest_release = releases[-1] if releases else None
latest_heads = _latest_release_heads(latest_release)
unrecorded_heads = sorted(set(current_heads) - latest_heads) if latest_release else current_heads
strict_errors: list[str] = []
if track == MIGRATION_TRACK_RELEASE:
if baseline.get("_missing"):
strict_errors.append(f"baseline file is missing: {baseline_file}")
if not releases:
strict_errors.append("no released migration baseline has been recorded yet")
if unrecorded_heads:
strict_errors.append(
"current migration heads are not recorded in the latest release baseline: "
+ ", ".join(unrecorded_heads)
)
return {
"workspace_root": str(workspace_root),
"track": track,
"baseline_file": str(baseline_file),
"baseline_file_missing": bool(baseline.get("_missing")),
"release_count": len(releases),
"latest_release": latest_release,
"owners": owner_rows,
"current_heads": current_heads,
"current_head_entries": current_head_entries,
"graph_errors": graph_errors,
"strict_errors": strict_errors,
}
def _latest_release_heads(latest_release: Any) -> set[str]:
if not isinstance(latest_release, dict):
return set()
heads = latest_release.get("heads") or []
graph_heads = latest_release.get("graph_heads") or []
result: set[str] = set()
for head_list in (heads, graph_heads):
if not isinstance(head_list, list):
continue
for entry in head_list:
if isinstance(entry, str):
result.add(entry)
elif isinstance(entry, dict) and isinstance(entry.get("revision"), str):
result.add(entry["revision"])
owner_heads = latest_release.get("owner_heads") or []
if isinstance(owner_heads, list):
for entry in owner_heads:
if not isinstance(entry, dict):
continue
revisions = entry.get("revisions") or []
if isinstance(revisions, str):
result.add(revisions)
elif isinstance(revisions, list):
result.update(revision for revision in revisions if isinstance(revision, str))
return result
def record_release_baseline(
baseline: dict[str, Any],
report: dict[str, Any],
*,
release: str,
replace: bool,
) -> dict[str, Any]:
payload = {
key: value
for key, value in baseline.items()
if not key.startswith("_")
}
payload.setdefault("version", 1)
releases = list(payload.get("releases") or [])
existing_index = next(
(index for index, item in enumerate(releases) if isinstance(item, dict) and item.get("release") == release),
None,
)
if existing_index is not None and not replace:
raise SystemExit(f"release {release} already exists in baseline file; pass --replace-release to update it")
entry = {
"release": release,
"recorded_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
"track": MIGRATION_TRACK_RELEASE,
"squash_policy": "reviewed-manual",
"heads": report["current_head_entries"],
"owner_heads": [
{
"owner": owner["owner"],
"revisions": owner["heads"],
}
for owner in report["owners"]
],
}
if existing_index is None:
releases.append(entry)
else:
releases[existing_index] = entry
payload["releases"] = releases
return payload
def write_baseline_file(path: Path, baseline: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(baseline, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def print_text_report(report: dict[str, Any]) -> None:
print("Migration release audit")
print(f" workspace: {report['workspace_root']}")
print(f" track: {report['track']}")
print(f" baseline file: {report['baseline_file']}")
print(f" recorded releases: {report['release_count']}")
print()
print("Owners:")
for owner in report["owners"]:
heads = ", ".join(owner["heads"]) if owner["heads"] else "-"
print(f" {owner['owner']}: {owner['migrations']} migration(s), head(s): {heads}")
print()
print("Current heads:")
for entry in report["current_head_entries"]:
print(f" {entry['owner']}: {entry['revision']}")
if not report["current_head_entries"]:
print(" -")
if report["graph_errors"]:
print()
print("Graph errors:")
for error in report["graph_errors"]:
print(f" - {error}")
if report["strict_errors"]:
print()
print("Release baseline warnings:")
for error in report["strict_errors"]:
print(f" - {error}")
def print_squash_plan(report: dict[str, Any]) -> None:
print("Manual migration squash plan")
print()
print("Policy:")
print(" - Do not rewrite revision IDs that have shipped to real installations.")
print(" - Keep detailed development migrations on the dev track.")
print(" - Add reviewed release-track baselines or release-to-release step-up migrations.")
print(" - Review generated release shortcuts like normal schema code.")
print(" - Run PostgreSQL migration smoke checks after any squash.")
print()
print("Current owner heads:")
for owner in report["owners"]:
heads = ", ".join(owner["heads"]) if owner["heads"] else "-"
print(f" - {owner['owner']}: {heads}")
print()
print("Current Alembic graph heads:")
for entry in report["current_head_entries"]:
print(f" - {entry['owner']}: {entry['revision']}")
if not report["current_head_entries"]:
print(" -")
print()
print("Release steps:")
print(" 1. Decide which dev-track revisions are unreleased and should be folded into the next release shortcut.")
print(" 2. Add reviewed release-track baseline or step-up migrations without deleting the dev-track chain.")
print(" 3. Run tools/release/release-migration-audit.py, tools/release/release-migration-audit.py --track dev, and PostgreSQL release checks.")
print(" 4. Record the reviewed heads with tools/release/release-migration-audit.py --record-release <x.y.z>.")
print(" 5. Cut the release with tools/release/push-release-tag.sh; audit is strict after the first baseline.")
if __name__ == "__main__":
raise SystemExit(main())