Initialize GovOPlaN meta repository
This commit is contained in:
83
tools/release/generate-catalog-keypair.py
Normal file
83
tools/release/generate-catalog-keypair.py
Normal 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())
|
||||
408
tools/release/generate-release-catalog.py
Normal file
408
tools/release/generate-release-catalog.py
Normal 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())
|
||||
116
tools/release/generate-release-lock.sh
Normal file
116
tools/release/generate-release-lock.sh
Normal 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"
|
||||
76
tools/release/install-webui-release-dependencies.sh
Normal file
76
tools/release/install-webui-release-dependencies.sh
Normal 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
|
||||
260
tools/release/publish-release-catalog.sh
Normal file
260
tools/release/publish-release-catalog.sh
Normal 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"
|
||||
864
tools/release/push-release-tag.sh
Normal file
864
tools/release/push-release-tag.sh
Normal 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
|
||||
1165
tools/release/release-doctor.py
Normal file
1165
tools/release/release-doctor.py
Normal file
File diff suppressed because it is too large
Load Diff
459
tools/release/release-migration-audit.py
Normal file
459
tools/release/release-migration-audit.py
Normal 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())
|
||||
Reference in New Issue
Block a user