Release v0.1.2

This commit is contained in:
2026-06-25 19:58:20 +02:00
parent 15794e920e
commit 02564047e9
73 changed files with 4073 additions and 478 deletions

60
scripts/check-focused.sh Normal file
View File

@@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="/mnt/DATA/git/govoplan-core"
NODE="/home/zemion/.nvm/versions/node/v22.22.3/bin"
NPM="$NODE/npm"
WEBUI_BIN="$ROOT/webui/node_modules/.bin"
export PATH="$WEBUI_BIN:$NODE:$PATH"
cd "$ROOT"
./.venv/bin/python - <<'PY'
import ast
import pathlib
import sys
roots = [
pathlib.Path("/mnt/DATA/git/govoplan-core/src"),
pathlib.Path("/mnt/DATA/git/govoplan-mail/src"),
pathlib.Path("/mnt/DATA/git/govoplan-files/src"),
pathlib.Path("/mnt/DATA/git/govoplan-campaign/src"),
pathlib.Path("/mnt/DATA/git/govoplan-mail/tests"),
]
errors = []
count = 0
for root in roots:
if not root.exists():
continue
for path in root.rglob("*.py"):
count += 1
try:
ast.parse(path.read_text(), filename=str(path))
except SyntaxError as exc:
errors.append(f"{path}:{exc.lineno}:{exc.offset}: {exc.msg}")
if errors:
print("\n".join(errors))
sys.exit(1)
print(f"AST syntax check passed for {count} Python files")
PY
./.venv/bin/python -c 'import govoplan_core.db.bootstrap; import govoplan_core.admin.service; import govoplan_files.backend.router; import govoplan_mail.backend.sending.imap; print("targeted backend imports passed")'
./.venv/bin/python -m unittest tests.test_module_system
./.venv/bin/python -m unittest discover -s /mnt/DATA/git/govoplan-mail/tests
./.venv/bin/python -m unittest tests.test_api_smoke.ApiSmokeTests.test_mailbox_message_listing_reports_total_count
cd "$ROOT/webui"
"$NPM" run test:mail-components
"$NPM" run test:module-capabilities
"$NPM" run test:module-permutations
cd /mnt/DATA/git/govoplan-mail/webui
"$NPM" run test:mail-ui
cd /mnt/DATA/git/govoplan-campaign/webui
"$NPM" run test:policy-ui
"$NPM" run test:template-preview

424
scripts/push-release-tag.sh Normal file
View File

@@ -0,0 +1,424 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage:
scripts/push-release-tag.sh [options]
Bumps all GovOPlaN package versions, commits all changes in all four repos,
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.
-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.
-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:
govoplan-core
govoplan-files
govoplan-mail
govoplan-campaign
Examples:
scripts/push-release-tag.sh
scripts/push-release-tag.sh --bump subversion
scripts/push-release-tag.sh --version 0.2.0 --message "Release v0.2.0"
USAGE
}
fail() {
echo "error: $*" >&2
exit 1
}
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PARENT="$(dirname "$ROOT")"
REPOS=(
"$ROOT"
"$PARENT/govoplan-files"
"$PARENT/govoplan-mail"
"$PARENT/govoplan-campaign"
)
REMOTE="origin"
BRANCH=""
BUMP=""
TARGET_VERSION=""
COMMIT_MESSAGE=""
TAG_MESSAGE=""
DRY_RUN=0
YES=0
if [[ -z "${PYTHON:-}" ]]; then
if [[ -x "$ROOT/.venv/bin/python" ]]; then
PYTHON="$ROOT/.venv/bin/python"
else
PYTHON="python3"
fi
fi
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-core>=\d+\.\d+\.\d+',
f'govoplan-core>={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
}
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
;;
-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
prompt_for_bump
if ! command -v "$PYTHON" >/dev/null 2>&1; then
fail "Python interpreter not found: $PYTHON"
fi
declare -A PROJECT_NAMES
declare -A OLD_VERSIONS
declare -A BRANCHES
for repo in "${REPOS[@]}"; do
[[ -d "$repo/.git" ]] || fail "not a git repo: $repo"
[[ -f "$repo/pyproject.toml" ]] || fail "missing pyproject.toml: $repo"
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"
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" ]]; 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 "${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 "${REPOS[@]}"; do
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
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
for repo in "${REPOS[@]}"; do
echo "${PROJECT_NAMES[$repo]}: ${OLD_VERSIONS[$repo]} -> $TARGET_VERSION on ${BRANCHES[$repo]}"
git -C "$repo" status --short
echo
done
confirm_release
for repo in "${REPOS[@]}"; do
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "Would update $repo/pyproject.toml to $TARGET_VERSION"
else
update_pyproject "$repo" "$TARGET_VERSION"
fi
done
for repo in "${REPOS[@]}"; do
run git -C "$repo" add -A
if [[ "$DRY_RUN" -eq 0 ]]; then
if git -C "$repo" diff --cached --quiet; then
fail "no staged changes for ${PROJECT_NAMES[$repo]} after version bump"
fi
fi
run git -C "$repo" commit -m "$COMMIT_MESSAGE"
run git -C "$repo" tag -a "$TAG" -m "$TAG_MESSAGE"
done
for repo in "${REPOS[@]}"; do
run git -C "$repo" push --atomic "$REMOTE" "HEAD:refs/heads/${BRANCHES[$repo]}" "refs/tags/$TAG"
done
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