feat(release): enforce aligned trusted publications

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

View File

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