intermediate commit
This commit is contained in:
@@ -42,8 +42,7 @@ jobs:
|
|||||||
working-directory: govoplan
|
working-directory: govoplan
|
||||||
run: |
|
run: |
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
.venv/bin/python -m pip install --upgrade pip
|
.venv/bin/python tools/repo/sync-python-environment.py --requirements requirements-release.txt --python .venv/bin/python --upgrade-pip
|
||||||
.venv/bin/python -m pip install -r requirements-release.txt
|
|
||||||
.venv/bin/python -m pip install 'pip-audit>=2.9,<3'
|
.venv/bin/python -m pip install 'pip-audit>=2.9,<3'
|
||||||
- name: Install WebUI release dependencies
|
- name: Install WebUI release dependencies
|
||||||
working-directory: govoplan
|
working-directory: govoplan
|
||||||
|
|||||||
@@ -38,8 +38,7 @@ jobs:
|
|||||||
working-directory: govoplan
|
working-directory: govoplan
|
||||||
run: |
|
run: |
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
.venv/bin/python -m pip install --upgrade pip
|
.venv/bin/python tools/repo/sync-python-environment.py --requirements requirements-release.txt --python .venv/bin/python --upgrade-pip
|
||||||
.venv/bin/python -m pip install -r requirements-release.txt
|
|
||||||
.venv/bin/python -m pip install '../govoplan-core[dev]'
|
.venv/bin/python -m pip install '../govoplan-core[dev]'
|
||||||
- name: Install WebUI release dependencies with test scripts
|
- name: Install WebUI release dependencies with test scripts
|
||||||
working-directory: govoplan
|
working-directory: govoplan
|
||||||
|
|||||||
@@ -37,8 +37,7 @@ jobs:
|
|||||||
working-directory: govoplan
|
working-directory: govoplan
|
||||||
run: |
|
run: |
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
.venv/bin/python -m pip install --upgrade pip
|
.venv/bin/python tools/repo/sync-python-environment.py --requirements requirements-release.txt --python .venv/bin/python --upgrade-pip
|
||||||
.venv/bin/python -m pip install -r requirements-release.txt
|
|
||||||
.venv/bin/python -m pip install '../govoplan-core[dev]'
|
.venv/bin/python -m pip install '../govoplan-core[dev]'
|
||||||
- name: Install WebUI release dependencies
|
- name: Install WebUI release dependencies
|
||||||
working-directory: govoplan
|
working-directory: govoplan
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ title = "GovOPlaN secret scanning"
|
|||||||
[allowlist]
|
[allowlist]
|
||||||
description = "Repository examples and documented development placeholders"
|
description = "Repository examples and documented development placeholders"
|
||||||
regexes = [
|
regexes = [
|
||||||
'''dev-multimailer-api-key''',
|
|
||||||
'''example(\.invalid|\.org|\.com)''',
|
'''example(\.invalid|\.org|\.com)''',
|
||||||
'''postgresql(\+psycopg)?://[^:@/\s]+:\*\*\*@''',
|
'''postgresql(\+psycopg)?://[^:@/\s]+:\*\*\*@''',
|
||||||
'''<redacted>''',
|
'''<redacted>''',
|
||||||
|
|||||||
35
README.md
35
README.md
@@ -17,8 +17,7 @@ Create the whole-product development virtualenv in this meta repository:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
./.venv/bin/python -m pip install --upgrade pip
|
./.venv/bin/python tools/repo/sync-python-environment.py --requirements requirements-dev.txt --python ./.venv/bin/python --upgrade-pip
|
||||||
./.venv/bin/python -m pip install -r requirements-dev.txt
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The meta venv is the default Python environment for launch, check, release, and
|
The meta venv is the default Python environment for launch, check, release, and
|
||||||
@@ -55,6 +54,24 @@ Update generated repository type notes in all READMEs:
|
|||||||
./tools/repo/update-repository-type-notes.py
|
./tools/repo/update-repository-type-notes.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Regenerate the human-readable repository link index:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./tools/repo/generate-repository-index.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Synchronize the Python environment after package metadata changes:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./.venv/bin/python tools/repo/sync-python-environment.py --requirements requirements-dev.txt --python ./.venv/bin/python
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the static cross-repository module contract check:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./tools/checks/check-contracts.sh
|
||||||
|
```
|
||||||
|
|
||||||
Run the consolidated focused verification suite:
|
Run the consolidated focused verification suite:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -76,6 +93,12 @@ Run installer rollback drills:
|
|||||||
Release, catalog, Gitea, security-audit, and cross-repository maintenance
|
Release, catalog, Gitea, security-audit, and cross-repository maintenance
|
||||||
commands should also be called from this repository through `tools/`.
|
commands should also be called from this repository through `tools/`.
|
||||||
|
|
||||||
|
Start the local release console:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./.venv/bin/python tools/release/release-console.py
|
||||||
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
The repository root `.env.example` is the self-hosted operator template for a
|
The repository root `.env.example` is the self-hosted operator template for a
|
||||||
@@ -89,7 +112,13 @@ such as `~/.config/gitea/gitea.env` and be passed with `--env-file`.
|
|||||||
|
|
||||||
The repository categories are documented in
|
The repository categories are documented in
|
||||||
`docs/REPOSITORY_STRUCTURE.md`. The machine-readable list lives in
|
`docs/REPOSITORY_STRUCTURE.md`. The machine-readable list lives in
|
||||||
`repositories.json`.
|
`repositories.json`; the clickable human-readable index is
|
||||||
|
`docs/REPOSITORY_INDEX.md`.
|
||||||
|
|
||||||
|
Meta ownership and module install/contract boundaries are documented in
|
||||||
|
`docs/META_REPO_SCAN.md` and `docs/MODULE_CONTRACTS_AND_INSTALLS.md`.
|
||||||
|
Frontend layout principles for module pages are documented in
|
||||||
|
`docs/FRONTEND_LAYOUT_PRINCIPLES.md`.
|
||||||
|
|
||||||
# GovOPlaN Docker
|
# GovOPlaN Docker
|
||||||
|
|
||||||
|
|||||||
57
docs/FRONTEND_LAYOUT_PRINCIPLES.md
Normal file
57
docs/FRONTEND_LAYOUT_PRINCIPLES.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# GovOPlaN Frontend Layout Principles
|
||||||
|
|
||||||
|
GovOPlaN modules should choose their page layout by the kind of work the user is
|
||||||
|
doing, not by the repository that owns the feature.
|
||||||
|
|
||||||
|
## Structured Data Directories
|
||||||
|
|
||||||
|
Use a full-available-space workspace for structured data directories: files,
|
||||||
|
addresses, calendars, records, mailboxes, document stores, and similar domains
|
||||||
|
where the primary task is browsing, selecting, filtering, inspecting, and acting
|
||||||
|
on related objects.
|
||||||
|
|
||||||
|
Principles:
|
||||||
|
|
||||||
|
- The module route should use the full available content area.
|
||||||
|
- Do not add a separate page heading row above the main workspace.
|
||||||
|
- Prefer persistent navigation panes, such as tree panels, source panels, folder
|
||||||
|
panels, calendar list panels, or mailbox folder panels.
|
||||||
|
- Keep collection navigation and collection-level actions close to the relevant
|
||||||
|
pane header.
|
||||||
|
- Use bounded widths for navigation/list panes and let the main detail/content
|
||||||
|
pane take the remaining space.
|
||||||
|
- Keep filtering controls inside the pane they affect.
|
||||||
|
- Use overlays, toasts, or floating alerts for transient messages so the
|
||||||
|
workspace height does not change.
|
||||||
|
|
||||||
|
This pattern is appropriate when the user is working inside one coherent data
|
||||||
|
domain and needs spatial continuity.
|
||||||
|
|
||||||
|
## Workflow And Configuration Surfaces
|
||||||
|
|
||||||
|
Use the standard heading/menu/card visual language for workflow structures,
|
||||||
|
settings, administration, dashboards, and pages that collect essentially
|
||||||
|
unrelated areas.
|
||||||
|
|
||||||
|
Principles:
|
||||||
|
|
||||||
|
- A page heading and subnavigation are appropriate when the page explains a
|
||||||
|
task, workflow stage, or administrative area.
|
||||||
|
- Cards are appropriate for repeated independent panels, settings groups,
|
||||||
|
summaries, and dashboard widgets.
|
||||||
|
- Collapsible panels and segmented controls are appropriate when a dense
|
||||||
|
configuration area needs controlled disclosure.
|
||||||
|
- Avoid forcing workflow/configuration pages into a file-explorer style unless
|
||||||
|
the primary interaction is genuinely directory browsing.
|
||||||
|
|
||||||
|
This pattern is appropriate when the user is comparing or configuring separate
|
||||||
|
concerns rather than navigating one structured object space.
|
||||||
|
|
||||||
|
## Shared Components
|
||||||
|
|
||||||
|
Reusable layout components belong in `govoplan-core` WebUI. Modules may consume
|
||||||
|
shared components from core, but must not import another module's private UI
|
||||||
|
components directly.
|
||||||
|
|
||||||
|
When a module-specific component becomes generally useful, promote it to core
|
||||||
|
with a parameterized API before reusing it elsewhere.
|
||||||
83
docs/META_REPO_SCAN.md
Normal file
83
docs/META_REPO_SCAN.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Meta Repository Scan
|
||||||
|
|
||||||
|
Scan date: 2026-07-13.
|
||||||
|
|
||||||
|
This scan checked local repositories under `/mnt/DATA/git` listed in
|
||||||
|
`repositories.json`.
|
||||||
|
|
||||||
|
## Repository Inventory
|
||||||
|
|
||||||
|
`repositories.json` already lists every checked-out GovOPlaN repository.
|
||||||
|
|
||||||
|
Checked-out repositories not listed in `repositories.json`: none.
|
||||||
|
|
||||||
|
Repositories listed in `repositories.json` but not checked out locally: none.
|
||||||
|
|
||||||
|
The human-readable link index is `docs/REPOSITORY_INDEX.md`; the JSON file
|
||||||
|
remains the machine-readable source of truth.
|
||||||
|
|
||||||
|
## Meta-Owned Content
|
||||||
|
|
||||||
|
These items are correctly owned by the meta repository:
|
||||||
|
|
||||||
|
- `.gitea/workflows`: cross-repository CI and audit workflows.
|
||||||
|
- `.gitleaks.toml`: whole-workspace secret scan policy.
|
||||||
|
- `audit-reports`: whole-workspace audit output.
|
||||||
|
- `dev/postgres`: shared development PostgreSQL service.
|
||||||
|
- `dev/production-like`: production-like product validation composition.
|
||||||
|
- `tools/checks`: whole-product checks and audit launchers.
|
||||||
|
- `tools/gitea`: issue/wiki/label/backlog tooling.
|
||||||
|
- `tools/launch`: product launch entry points.
|
||||||
|
- `tools/release`: release catalog, lock, push, and release console tooling.
|
||||||
|
- `tools/repo`: repository bootstrap, status, and metadata tooling.
|
||||||
|
|
||||||
|
## Module-Local Content That Should Stay Local
|
||||||
|
|
||||||
|
These paths look operational, but they are tied to one module's protocol or
|
||||||
|
transport behavior and should stay in the owning repository for now:
|
||||||
|
|
||||||
|
- `govoplan-campaign/dev/mail-testbed`: mail transport testbed for campaign.
|
||||||
|
- `govoplan-files/dev/connectors`: WebDAV, Nextcloud, and SMB connector smoke
|
||||||
|
environment for files.
|
||||||
|
|
||||||
|
The meta repo can later wrap these with aggregate commands, but the test-bed
|
||||||
|
definitions should remain close to the module code unless they become a shared
|
||||||
|
product deployment profile.
|
||||||
|
|
||||||
|
## Generated Or Local-Only Content
|
||||||
|
|
||||||
|
These should not move to the meta repo; they should be ignored or cleaned:
|
||||||
|
|
||||||
|
- `__pycache__` directories under `tools/gitea` and module test beds.
|
||||||
|
- `.venv` and `.ruff_cache` directories.
|
||||||
|
- Populated `.env` files such as `govoplan-campaign/dev/mail-testbed/.env`.
|
||||||
|
|
||||||
|
## Repo-Local Workflow Files
|
||||||
|
|
||||||
|
Most module repositories have `.gitea/ISSUE_TEMPLATE` installed. These are
|
||||||
|
repo-local generated copies of the shared workflow templates and are fine to
|
||||||
|
keep in each repository.
|
||||||
|
|
||||||
|
Repositories currently missing a `.gitea` directory in the local checkout:
|
||||||
|
|
||||||
|
- `govoplan-dashboard`
|
||||||
|
- `govoplan-evaluation`
|
||||||
|
- `govoplan-poll`
|
||||||
|
- `govoplan-rest`
|
||||||
|
- `govoplan-soap`
|
||||||
|
|
||||||
|
If those repositories should use the shared issue templates, run the meta repo
|
||||||
|
installer from `govoplan/tools/gitea/gitea-install-workflow.py`.
|
||||||
|
|
||||||
|
## Do Not Move
|
||||||
|
|
||||||
|
The following stay in each module repository:
|
||||||
|
|
||||||
|
- `pyproject.toml` and package dependency metadata.
|
||||||
|
- Backend manifests and migrations.
|
||||||
|
- Module-owned tests.
|
||||||
|
- Module READMEs and module-specific docs.
|
||||||
|
- WebUI package manifests and module frontend source.
|
||||||
|
|
||||||
|
Those files describe or implement the module itself. The meta repo should
|
||||||
|
catalog and orchestrate them, not become the owner of module code.
|
||||||
120
docs/MODULE_CONTRACTS_AND_INSTALLS.md
Normal file
120
docs/MODULE_CONTRACTS_AND_INSTALLS.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# Module Contracts and Install Boundaries
|
||||||
|
|
||||||
|
## Why Some Changes Require `pip install`
|
||||||
|
|
||||||
|
GovOPlaN discovers runtime modules through Python package entry points in the
|
||||||
|
`govoplan.modules` group. Core reads those entry points with
|
||||||
|
`importlib.metadata.entry_points()`, imports the configured manifest factory, and
|
||||||
|
then builds the module registry from the returned `ModuleManifest`.
|
||||||
|
|
||||||
|
That means the Python environment must know that a package exists before core can
|
||||||
|
discover it.
|
||||||
|
|
||||||
|
In development, `requirements-dev.txt` installs modules with `-e
|
||||||
|
../govoplan-module`. With editable installs:
|
||||||
|
|
||||||
|
- normal Python source changes are picked up after the process reloads;
|
||||||
|
- manifest code changes are picked up after the process reloads;
|
||||||
|
- new imports inside an already installed package are picked up after reload.
|
||||||
|
|
||||||
|
`pip install` is still needed when package metadata changes:
|
||||||
|
|
||||||
|
- a repository was not installed in the environment before;
|
||||||
|
- a `pyproject.toml` entry point is added, renamed, or removed;
|
||||||
|
- package dependencies change;
|
||||||
|
- optional extras change;
|
||||||
|
- package names or import roots change;
|
||||||
|
- console scripts or other installed metadata change;
|
||||||
|
- release installs need a different tag, wheel, or source ref.
|
||||||
|
|
||||||
|
The same principle applies to WebUI packages: source changes are local during
|
||||||
|
development, but `package.json` dependency or export changes require an install
|
||||||
|
step so the consuming app sees the correct package metadata.
|
||||||
|
|
||||||
|
## Current Contract Mechanism
|
||||||
|
|
||||||
|
Modules already announce contracts through `ModuleManifest`:
|
||||||
|
|
||||||
|
- `dependencies`
|
||||||
|
- `optional_dependencies`
|
||||||
|
- `required_capabilities`
|
||||||
|
- `optional_capabilities`
|
||||||
|
- `provides_interfaces`
|
||||||
|
- `requires_interfaces`
|
||||||
|
- `capability_factories`
|
||||||
|
- permissions and role templates
|
||||||
|
- route factories, migrations, docs, lifecycle hooks, and frontend metadata
|
||||||
|
|
||||||
|
Core validates the active manifest graph when it builds the registry. Release
|
||||||
|
tooling can inspect those manifests to calculate compatibility and migration
|
||||||
|
impact.
|
||||||
|
|
||||||
|
## What Core Can and Cannot Pick Up Automatically
|
||||||
|
|
||||||
|
Core can pick up contract changes automatically after reload when the changed
|
||||||
|
module package is already installed and importable.
|
||||||
|
|
||||||
|
Core cannot discover a new module or new entry point that has not been installed
|
||||||
|
into the environment, because there is no distribution metadata to enumerate.
|
||||||
|
|
||||||
|
Core also cannot notify every module about a contract change at edit time by
|
||||||
|
itself. The runtime registry is built from installed/importable packages. The
|
||||||
|
right place for cross-module announcement is the meta repo tooling and CI:
|
||||||
|
|
||||||
|
- scan manifests across all repositories;
|
||||||
|
- build an impact graph from provided/required interfaces and capabilities;
|
||||||
|
- run affected module tests;
|
||||||
|
- post Gitea issue/release notes for affected modules;
|
||||||
|
- block releases when a required interface is missing or incompatible.
|
||||||
|
|
||||||
|
## Mitigation Strategy
|
||||||
|
|
||||||
|
Use three layers:
|
||||||
|
|
||||||
|
1. Editable development environment.
|
||||||
|
Keep `requirements-dev.txt` in the meta repo as the one workspace installer.
|
||||||
|
Source edits then need process reloads, not repeated full installs.
|
||||||
|
|
||||||
|
2. Versioned runtime contracts.
|
||||||
|
Keep adding and tightening `provides_interfaces`, `requires_interfaces`, and
|
||||||
|
capability protocols. Treat interface names and versions as public module
|
||||||
|
contracts.
|
||||||
|
|
||||||
|
3. Meta-level contract audit.
|
||||||
|
The meta repo statically reads `src/**/backend/manifest.py` files across all
|
||||||
|
repositories and validates provided/required interface ranges without
|
||||||
|
importing the packages. CI blocks missing or incompatible required
|
||||||
|
interfaces before release installs are attempted.
|
||||||
|
|
||||||
|
Run the static graph check with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./tools/checks/check-contracts.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `--json` when the release console or another automation needs structured
|
||||||
|
provider/consumer impact data.
|
||||||
|
|
||||||
|
## Practical Rule
|
||||||
|
|
||||||
|
Do not run `pip install` for every code edit. Run it when package metadata or the
|
||||||
|
set of installed packages changes.
|
||||||
|
|
||||||
|
For normal development:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./.venv/bin/python tools/repo/sync-python-environment.py --requirements requirements-dev.txt --python ./.venv/bin/python
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart the server when Python code or manifests change. The development
|
||||||
|
launcher runs this helper automatically by default; set
|
||||||
|
`GOVOPLAN_AUTO_SYNC_PYTHON=0` to disable that preflight.
|
||||||
|
|
||||||
|
For release validation:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./.venv/bin/python tools/repo/sync-python-environment.py --requirements requirements-release.txt --python ./.venv/bin/python
|
||||||
|
```
|
||||||
|
|
||||||
|
That install is necessary because the release environment intentionally resolves
|
||||||
|
tagged package refs, not local editable source trees.
|
||||||
194
docs/RELEASE_CONSOLE.md
Normal file
194
docs/RELEASE_CONSOLE.md
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
# GovOPlaN Release Console
|
||||||
|
|
||||||
|
The release console is a local operator tool for planning and executing
|
||||||
|
GovOPlaN releases. It belongs to the `govoplan` meta repository because it works
|
||||||
|
across all local checkouts, release scripts, module manifests, migration audits,
|
||||||
|
catalog files, Git state, and signing keys.
|
||||||
|
|
||||||
|
The current implementation has a read-only dashboard plus guarded local
|
||||||
|
candidate/publish actions:
|
||||||
|
|
||||||
|
- inspect repositories from `repositories.json`
|
||||||
|
- show dirty, ahead, behind, missing, no-HEAD, and tag state
|
||||||
|
- show local package catalog and keyring state
|
||||||
|
- optionally run release/dev migration audits
|
||||||
|
- propose next actions without executing them
|
||||||
|
- compare local catalog/keyring JSON with the published channel and public
|
||||||
|
keyring when online checks are enabled
|
||||||
|
- configure target versions per release unit in the web UI
|
||||||
|
- select the repositories that should advance through checkboxes
|
||||||
|
- generate dry-run selective release plans for independently versioned packages
|
||||||
|
- generate signed catalog candidates for the selected release units
|
||||||
|
- preview/apply/push reviewed catalog candidates behind explicit confirmations
|
||||||
|
|
||||||
|
Start it from the meta repository:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./.venv/bin/python tools/release/release-console.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The server binds to `127.0.0.1` by default and prints a URL containing a local
|
||||||
|
API token. Open that URL in a browser on the same machine.
|
||||||
|
|
||||||
|
The web UI starts with a repository table. Each repository can be checked
|
||||||
|
independently and assigned its own target version. Repositories without version
|
||||||
|
metadata remain visible so they can be planned as initial releases. `Build Plan`
|
||||||
|
shows the dry-run commands for the selected rows, and `Generate Candidate`
|
||||||
|
creates a signed catalog candidate that advances only selected repositories that
|
||||||
|
already have a catalog entry.
|
||||||
|
|
||||||
|
The release-control area above the repository table is read-only and is meant
|
||||||
|
to become the central release cockpit. It shows:
|
||||||
|
|
||||||
|
- local and published channel health
|
||||||
|
- catalog/keyring drift
|
||||||
|
- signature and trusted-key status
|
||||||
|
- published module versions and refs
|
||||||
|
- local checkout version drift against the catalog
|
||||||
|
- catalog-declared interface compatibility
|
||||||
|
|
||||||
|
The target-version control can generate the next major, minor, or subversion
|
||||||
|
from the current base version. Manual target input accepts explicit versions
|
||||||
|
such as `0.2.0` or `0.2.0-alpha1`, but requires the first three version numbers
|
||||||
|
to move forward.
|
||||||
|
|
||||||
|
Plain repository pushes are separate from catalog publication. `Preview Push`
|
||||||
|
shows the selected repository push commands. `Push Selected` requires `PUSH` in
|
||||||
|
the repository push confirmation field.
|
||||||
|
|
||||||
|
The catalog workflow panel can also operate on the same selected rows:
|
||||||
|
|
||||||
|
- `Generate` creates a signed candidate below `runtime/release-candidates/`.
|
||||||
|
- `Preview` validates a candidate and shows what would be copied into the
|
||||||
|
website repository.
|
||||||
|
- `Apply + Tag` requires `APPLY` in the confirmation field.
|
||||||
|
- `Push` requires `PUSH` in the confirmation field.
|
||||||
|
|
||||||
|
The default signing key is
|
||||||
|
`$HOME/.config/govoplan/release-keys/release-key-1.pem` when no signing key is
|
||||||
|
entered in the UI.
|
||||||
|
|
||||||
|
Generate a selective release plan from the terminal:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./.venv/bin/python tools/release/release-plan.py \
|
||||||
|
--repo govoplan-files \
|
||||||
|
--target-version 0.1.9 \
|
||||||
|
--channel stable \
|
||||||
|
--online
|
||||||
|
```
|
||||||
|
|
||||||
|
`--online` compares the local catalog/keyring with the published channel and
|
||||||
|
published keyring. Use `--remote-tags` only when the plan also needs to check
|
||||||
|
Git remotes for tag existence; that can be slower across the full repository
|
||||||
|
set.
|
||||||
|
|
||||||
|
Build a signed selective catalog candidate:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
KEY_DIR="$HOME/.config/govoplan/release-keys"
|
||||||
|
|
||||||
|
./.venv/bin/python tools/release/release-catalog.py selective \
|
||||||
|
--repo-version govoplan-files=0.1.9 \
|
||||||
|
--channel stable \
|
||||||
|
--catalog-signing-key "release-key-1=$KEY_DIR/release-key-1.pem"
|
||||||
|
```
|
||||||
|
|
||||||
|
This writes candidate catalog/keyring files below `runtime/release-candidates/`,
|
||||||
|
generates the browsable module directory below `modules/`, validates the signed
|
||||||
|
candidate with the module installer validator, and reports whether the candidate
|
||||||
|
catalog/keyring match the currently published channel. It does not publish to
|
||||||
|
the website repository.
|
||||||
|
|
||||||
|
Regenerate the browsable module directory from an existing catalog/keyring:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./.venv/bin/python tools/release/release-catalog.py module-directory \
|
||||||
|
--catalog ../addideas-govoplan-website/public/catalogs/v1/channels/stable.json \
|
||||||
|
--keyring ../addideas-govoplan-website/public/catalogs/v1/keyring.json \
|
||||||
|
--output-dir runtime/module-directory-preview \
|
||||||
|
--channel stable
|
||||||
|
```
|
||||||
|
|
||||||
|
Preview publication of a reviewed candidate:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./.venv/bin/python tools/release/release-catalog.py publish-candidate \
|
||||||
|
--candidate-dir runtime/release-candidates/stable-YYYYMMDD-HHMMSS \
|
||||||
|
--channel stable
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply the reviewed candidate into the website repository without pushing:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./.venv/bin/python tools/release/release-catalog.py publish-candidate \
|
||||||
|
--candidate-dir runtime/release-candidates/stable-YYYYMMDD-HHMMSS \
|
||||||
|
--channel stable \
|
||||||
|
--apply \
|
||||||
|
--commit \
|
||||||
|
--tag
|
||||||
|
```
|
||||||
|
|
||||||
|
Push is a separate explicit flag:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./.venv/bin/python tools/release/release-catalog.py publish-candidate \
|
||||||
|
--candidate-dir runtime/release-candidates/stable-YYYYMMDD-HHMMSS \
|
||||||
|
--channel stable \
|
||||||
|
--apply \
|
||||||
|
--commit \
|
||||||
|
--tag \
|
||||||
|
--push
|
||||||
|
```
|
||||||
|
|
||||||
|
Published channels are expected below the public catalog base URL:
|
||||||
|
|
||||||
|
- `https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json`
|
||||||
|
- `https://govoplan.add-ideas.de/catalogs/v1/keyring.json`
|
||||||
|
|
||||||
|
The console treats channels as release artifacts. A package can advance without
|
||||||
|
forcing every repository to the same tag, but channel publication must preserve
|
||||||
|
the unchanged package versions, validate interface compatibility, sign the
|
||||||
|
updated catalog, and keep the published keyring healthy.
|
||||||
|
|
||||||
|
## Published Module Directory
|
||||||
|
|
||||||
|
The target release repository is an online, browsable module directory. The
|
||||||
|
catalog remains the machine-readable channel entry point, but the published
|
||||||
|
space should also expose a tree that operators and installations can inspect:
|
||||||
|
|
||||||
|
- `/catalogs/v1/channels/<channel>.json` describes the active channel state.
|
||||||
|
- `/catalogs/v1/keyring.json` publishes trusted release signing keys.
|
||||||
|
- `/catalogs/v1/modules/<module>/<version>/manifest.json` describes one
|
||||||
|
published module version, including repository refs, package refs, contracts,
|
||||||
|
compatibility windows, signatures, and available artifacts.
|
||||||
|
- `/catalogs/v1/modules/<module>/index.json` lists available versions for
|
||||||
|
one module.
|
||||||
|
- `/catalogs/v1/modules/index.json` lists all published modules.
|
||||||
|
|
||||||
|
Gitea tags/releases remain the source release anchors. The public GovOPlaN
|
||||||
|
catalog directory becomes the installation-facing release repository that points
|
||||||
|
to those anchors and carries structured compatibility information.
|
||||||
|
|
||||||
|
Selective catalog candidates now include this directory tree. Publishing a
|
||||||
|
candidate copies the channel catalog, keyring, and `modules/` tree into the
|
||||||
|
website repository together.
|
||||||
|
|
||||||
|
Current limitation: selective catalog generation updates existing catalog
|
||||||
|
entries. Initial catalog entry synthesis for brand-new modules is still a
|
||||||
|
separate backend slice.
|
||||||
|
|
||||||
|
## Direction
|
||||||
|
|
||||||
|
The console should grow in slices:
|
||||||
|
|
||||||
|
1. Read-only dashboard and next-action suggestions.
|
||||||
|
2. Release plan builder that writes explicit JSON plans.
|
||||||
|
3. Dry-run executor that shows exact commands and expected file changes.
|
||||||
|
4. Apply executor for commit, tag, push, artifact build, signing, and catalog
|
||||||
|
publication.
|
||||||
|
5. Compatibility planner for manifest contracts and version ranges.
|
||||||
|
6. Install/test workflow that validates a release from tags or catalog entries.
|
||||||
|
|
||||||
|
All mutation must stay explicit: plan first, dry run second, apply only after a
|
||||||
|
clear confirmation.
|
||||||
89
docs/REPOSITORY_INDEX.md
Normal file
89
docs/REPOSITORY_INDEX.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# GovOPlaN Repository Index
|
||||||
|
|
||||||
|
Generated from `repositories.json`. Use that JSON file as the machine-readable source of truth; this page is the human-readable link index.
|
||||||
|
|
||||||
|
## System
|
||||||
|
|
||||||
|
| Repository | Subtype | Local path | Gitea |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `govoplan` | `meta` | `../govoplan` | [govoplan](https://git.add-ideas.de/add-ideas/govoplan) |
|
||||||
|
| `govoplan-core` | `kernel` | `../govoplan-core` | [govoplan-core](https://git.add-ideas.de/add-ideas/govoplan-core) |
|
||||||
|
|
||||||
|
## Module
|
||||||
|
|
||||||
|
| Repository | Subtype | Local path | Gitea |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `govoplan-access` | `platform` | `../govoplan-access` | [govoplan-access](https://git.add-ideas.de/add-ideas/govoplan-access) |
|
||||||
|
| `govoplan-addresses` | `domain` | `../govoplan-addresses` | [govoplan-addresses](https://git.add-ideas.de/add-ideas/govoplan-addresses) |
|
||||||
|
| `govoplan-admin` | `platform` | `../govoplan-admin` | [govoplan-admin](https://git.add-ideas.de/add-ideas/govoplan-admin) |
|
||||||
|
| `govoplan-appointments` | `domain` | `../govoplan-appointments` | [govoplan-appointments](https://git.add-ideas.de/add-ideas/govoplan-appointments) |
|
||||||
|
| `govoplan-approvals` | `domain` | `../govoplan-approvals` | [govoplan-approvals](https://git.add-ideas.de/add-ideas/govoplan-approvals) |
|
||||||
|
| `govoplan-assets` | `domain` | `../govoplan-assets` | [govoplan-assets](https://git.add-ideas.de/add-ideas/govoplan-assets) |
|
||||||
|
| `govoplan-audit` | `platform` | `../govoplan-audit` | [govoplan-audit](https://git.add-ideas.de/add-ideas/govoplan-audit) |
|
||||||
|
| `govoplan-booking` | `domain` | `../govoplan-booking` | [govoplan-booking](https://git.add-ideas.de/add-ideas/govoplan-booking) |
|
||||||
|
| `govoplan-calendar` | `domain` | `../govoplan-calendar` | [govoplan-calendar](https://git.add-ideas.de/add-ideas/govoplan-calendar) |
|
||||||
|
| `govoplan-campaign` | `domain` | `../govoplan-campaign` | [govoplan-campaign](https://git.add-ideas.de/add-ideas/govoplan-campaign) |
|
||||||
|
| `govoplan-cases` | `domain` | `../govoplan-cases` | [govoplan-cases](https://git.add-ideas.de/add-ideas/govoplan-cases) |
|
||||||
|
| `govoplan-certificates` | `domain` | `../govoplan-certificates` | [govoplan-certificates](https://git.add-ideas.de/add-ideas/govoplan-certificates) |
|
||||||
|
| `govoplan-committee` | `domain` | `../govoplan-committee` | [govoplan-committee](https://git.add-ideas.de/add-ideas/govoplan-committee) |
|
||||||
|
| `govoplan-consultation` | `domain` | `../govoplan-consultation` | [govoplan-consultation](https://git.add-ideas.de/add-ideas/govoplan-consultation) |
|
||||||
|
| `govoplan-contracts` | `domain` | `../govoplan-contracts` | [govoplan-contracts](https://git.add-ideas.de/add-ideas/govoplan-contracts) |
|
||||||
|
| `govoplan-dashboard` | `platform` | `../govoplan-dashboard` | [govoplan-dashboard](https://git.add-ideas.de/add-ideas/govoplan-dashboard) |
|
||||||
|
| `govoplan-dms` | `domain` | `../govoplan-dms` | [govoplan-dms](https://git.add-ideas.de/add-ideas/govoplan-dms) |
|
||||||
|
| `govoplan-dist-lists` | `domain` | `../govoplan-dist-lists` | [govoplan-dist-lists](https://git.add-ideas.de/add-ideas/govoplan-dist-lists) |
|
||||||
|
| `govoplan-docs` | `platform` | `../govoplan-docs` | [govoplan-docs](https://git.add-ideas.de/add-ideas/govoplan-docs) |
|
||||||
|
| `govoplan-erp` | `domain` | `../govoplan-erp` | [govoplan-erp](https://git.add-ideas.de/add-ideas/govoplan-erp) |
|
||||||
|
| `govoplan-evaluation` | `domain` | `../govoplan-evaluation` | [govoplan-evaluation](https://git.add-ideas.de/add-ideas/govoplan-evaluation) |
|
||||||
|
| `govoplan-facilities` | `domain` | `../govoplan-facilities` | [govoplan-facilities](https://git.add-ideas.de/add-ideas/govoplan-facilities) |
|
||||||
|
| `govoplan-files` | `domain` | `../govoplan-files` | [govoplan-files](https://git.add-ideas.de/add-ideas/govoplan-files) |
|
||||||
|
| `govoplan-forms` | `domain` | `../govoplan-forms` | [govoplan-forms](https://git.add-ideas.de/add-ideas/govoplan-forms) |
|
||||||
|
| `govoplan-forms-runtime` | `platform` | `../govoplan-forms-runtime` | [govoplan-forms-runtime](https://git.add-ideas.de/add-ideas/govoplan-forms-runtime) |
|
||||||
|
| `govoplan-grants` | `domain` | `../govoplan-grants` | [govoplan-grants](https://git.add-ideas.de/add-ideas/govoplan-grants) |
|
||||||
|
| `govoplan-helpdesk` | `domain` | `../govoplan-helpdesk` | [govoplan-helpdesk](https://git.add-ideas.de/add-ideas/govoplan-helpdesk) |
|
||||||
|
| `govoplan-identity` | `platform` | `../govoplan-identity` | [govoplan-identity](https://git.add-ideas.de/add-ideas/govoplan-identity) |
|
||||||
|
| `govoplan-identity-trust` | `platform` | `../govoplan-identity-trust` | [govoplan-identity-trust](https://git.add-ideas.de/add-ideas/govoplan-identity-trust) |
|
||||||
|
| `govoplan-idm` | `platform` | `../govoplan-idm` | [govoplan-idm](https://git.add-ideas.de/add-ideas/govoplan-idm) |
|
||||||
|
| `govoplan-inspections` | `domain` | `../govoplan-inspections` | [govoplan-inspections](https://git.add-ideas.de/add-ideas/govoplan-inspections) |
|
||||||
|
| `govoplan-issue-reporting` | `domain` | `../govoplan-issue-reporting` | [govoplan-issue-reporting](https://git.add-ideas.de/add-ideas/govoplan-issue-reporting) |
|
||||||
|
| `govoplan-learning` | `domain` | `../govoplan-learning` | [govoplan-learning](https://git.add-ideas.de/add-ideas/govoplan-learning) |
|
||||||
|
| `govoplan-ledger` | `domain` | `../govoplan-ledger` | [govoplan-ledger](https://git.add-ideas.de/add-ideas/govoplan-ledger) |
|
||||||
|
| `govoplan-mail` | `domain` | `../govoplan-mail` | [govoplan-mail](https://git.add-ideas.de/add-ideas/govoplan-mail) |
|
||||||
|
| `govoplan-notifications` | `platform` | `../govoplan-notifications` | [govoplan-notifications](https://git.add-ideas.de/add-ideas/govoplan-notifications) |
|
||||||
|
| `govoplan-ops` | `platform` | `../govoplan-ops` | [govoplan-ops](https://git.add-ideas.de/add-ideas/govoplan-ops) |
|
||||||
|
| `govoplan-organizations` | `platform` | `../govoplan-organizations` | [govoplan-organizations](https://git.add-ideas.de/add-ideas/govoplan-organizations) |
|
||||||
|
| `govoplan-payments` | `domain` | `../govoplan-payments` | [govoplan-payments](https://git.add-ideas.de/add-ideas/govoplan-payments) |
|
||||||
|
| `govoplan-permits` | `domain` | `../govoplan-permits` | [govoplan-permits](https://git.add-ideas.de/add-ideas/govoplan-permits) |
|
||||||
|
| `govoplan-policy` | `platform` | `../govoplan-policy` | [govoplan-policy](https://git.add-ideas.de/add-ideas/govoplan-policy) |
|
||||||
|
| `govoplan-poll` | `domain` | `../govoplan-poll` | [govoplan-poll](https://git.add-ideas.de/add-ideas/govoplan-poll) |
|
||||||
|
| `govoplan-portal` | `domain` | `../govoplan-portal` | [govoplan-portal](https://git.add-ideas.de/add-ideas/govoplan-portal) |
|
||||||
|
| `govoplan-postbox` | `domain` | `../govoplan-postbox` | [govoplan-postbox](https://git.add-ideas.de/add-ideas/govoplan-postbox) |
|
||||||
|
| `govoplan-procurement` | `domain` | `../govoplan-procurement` | [govoplan-procurement](https://git.add-ideas.de/add-ideas/govoplan-procurement) |
|
||||||
|
| `govoplan-records` | `domain` | `../govoplan-records` | [govoplan-records](https://git.add-ideas.de/add-ideas/govoplan-records) |
|
||||||
|
| `govoplan-reporting` | `domain` | `../govoplan-reporting` | [govoplan-reporting](https://git.add-ideas.de/add-ideas/govoplan-reporting) |
|
||||||
|
| `govoplan-resources` | `domain` | `../govoplan-resources` | [govoplan-resources](https://git.add-ideas.de/add-ideas/govoplan-resources) |
|
||||||
|
| `govoplan-risk-compliance` | `domain` | `../govoplan-risk-compliance` | [govoplan-risk-compliance](https://git.add-ideas.de/add-ideas/govoplan-risk-compliance) |
|
||||||
|
| `govoplan-scheduling` | `domain` | `../govoplan-scheduling` | [govoplan-scheduling](https://git.add-ideas.de/add-ideas/govoplan-scheduling) |
|
||||||
|
| `govoplan-search` | `platform` | `../govoplan-search` | [govoplan-search](https://git.add-ideas.de/add-ideas/govoplan-search) |
|
||||||
|
| `govoplan-tasks` | `domain` | `../govoplan-tasks` | [govoplan-tasks](https://git.add-ideas.de/add-ideas/govoplan-tasks) |
|
||||||
|
| `govoplan-templates` | `domain` | `../govoplan-templates` | [govoplan-templates](https://git.add-ideas.de/add-ideas/govoplan-templates) |
|
||||||
|
| `govoplan-tenancy` | `platform` | `../govoplan-tenancy` | [govoplan-tenancy](https://git.add-ideas.de/add-ideas/govoplan-tenancy) |
|
||||||
|
| `govoplan-transparency` | `domain` | `../govoplan-transparency` | [govoplan-transparency](https://git.add-ideas.de/add-ideas/govoplan-transparency) |
|
||||||
|
| `govoplan-workflow` | `platform` | `../govoplan-workflow` | [govoplan-workflow](https://git.add-ideas.de/add-ideas/govoplan-workflow) |
|
||||||
|
|
||||||
|
## Connector
|
||||||
|
|
||||||
|
| Repository | Subtype | Local path | Gitea |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `govoplan-connectors` | `connector-hub` | `../govoplan-connectors` | [govoplan-connectors](https://git.add-ideas.de/add-ideas/govoplan-connectors) |
|
||||||
|
| `govoplan-fit-connect` | `standard` | `../govoplan-fit-connect` | [govoplan-fit-connect](https://git.add-ideas.de/add-ideas/govoplan-fit-connect) |
|
||||||
|
| `govoplan-rest` | `protocol` | `../govoplan-rest` | [govoplan-rest](https://git.add-ideas.de/add-ideas/govoplan-rest) |
|
||||||
|
| `govoplan-soap` | `protocol` | `../govoplan-soap` | [govoplan-soap](https://git.add-ideas.de/add-ideas/govoplan-soap) |
|
||||||
|
| `govoplan-xoev` | `standard` | `../govoplan-xoev` | [govoplan-xoev](https://git.add-ideas.de/add-ideas/govoplan-xoev) |
|
||||||
|
| `govoplan-xrechnung` | `standard` | `../govoplan-xrechnung` | [govoplan-xrechnung](https://git.add-ideas.de/add-ideas/govoplan-xrechnung) |
|
||||||
|
| `govoplan-xta-osci` | `standard` | `../govoplan-xta-osci` | [govoplan-xta-osci](https://git.add-ideas.de/add-ideas/govoplan-xta-osci) |
|
||||||
|
|
||||||
|
## Website
|
||||||
|
|
||||||
|
| Repository | Subtype | Local path | Gitea |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `addideas-govoplan-website` | `public-site` | `../addideas-govoplan-website` | [addideas-govoplan-website](https://git.add-ideas.de/add-ideas/addideas-govoplan-website) |
|
||||||
@@ -40,6 +40,11 @@ The wrapper tags the toolbox image by a fingerprint of the Dockerfile and
|
|||||||
the existing local image instead of reinstalling all tools. The stable alias is
|
the existing local image instead of reinstalling all tools. The stable alias is
|
||||||
`govoplan/security-audit:local` unless `SECURITY_AUDIT_IMAGE` is set.
|
`govoplan/security-audit:local` unless `SECURITY_AUDIT_IMAGE` is set.
|
||||||
|
|
||||||
|
Semgrep is installed separately in the toolbox image because current Semgrep
|
||||||
|
packages pin Click to an affected `8.1.x` line. The image upgrades Click after
|
||||||
|
Semgrep installation and fails during build if Semgrep no longer starts with the
|
||||||
|
patched Click version.
|
||||||
|
|
||||||
Force a cached rebuild:
|
Force a cached rebuild:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -65,6 +70,13 @@ tools/checks/security-audit/run.sh --mode quick --scope current --update --build
|
|||||||
- `ci`: quick plus Semgrep public registry rulesets, Trivy, pip-audit, npm audit.
|
- `ci`: quick plus Semgrep public registry rulesets, Trivy, pip-audit, npm audit.
|
||||||
- `full`: ci plus OSV-Scanner, jscpd, Radon, and Xenon.
|
- `full`: ci plus OSV-Scanner, jscpd, Radon, and Xenon.
|
||||||
|
|
||||||
|
Bandit and Ruff security reports are split by source kind. Production code under
|
||||||
|
`src/` is written to `bandit.json` and `ruff-security.json` and controls strict
|
||||||
|
mode. Test code under `tests/` is still scanned for visibility, but its findings
|
||||||
|
are written separately to `bandit-tests.json` and `ruff-security-tests.json` so
|
||||||
|
fixture passwords, assertions, and temporary paths do not hide the production
|
||||||
|
baseline.
|
||||||
|
|
||||||
## Gating
|
## Gating
|
||||||
|
|
||||||
The initial Gitea workflow runs in report-only mode:
|
The initial Gitea workflow runs in report-only mode:
|
||||||
@@ -115,6 +127,13 @@ Keep active implementation status in issues instead of committing generated
|
|||||||
audit reports. `audit-reports/` is ignored; quote the report directory and the
|
audit reports. `audit-reports/` is ignored; quote the report directory and the
|
||||||
important scanner counts in the tracker issue.
|
important scanner counts in the tracker issue.
|
||||||
|
|
||||||
|
The jscpd step is intentionally scoped to application and test source. It
|
||||||
|
excludes documentation snippets, package manifests, generated translations,
|
||||||
|
public SVG assets, workflow YAML, and declarative backend schema JSON because
|
||||||
|
those reports produce metadata or asset repetition rather than actionable source
|
||||||
|
duplication. Keep exclusions narrow and create child issues for source-code
|
||||||
|
clusters that cross module ownership or make behavior harder to change safely.
|
||||||
|
|
||||||
## Image Freshness
|
## Image Freshness
|
||||||
|
|
||||||
The regular `Security Audit` workflow reuses the fingerprinted toolbox image
|
The regular `Security Audit` workflow reuses the fingerprinted toolbox image
|
||||||
@@ -133,6 +152,8 @@ tools first:
|
|||||||
cd /mnt/DATA/git/govoplan
|
cd /mnt/DATA/git/govoplan
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
./.venv/bin/python -m pip install -r requirements-audit.txt
|
./.venv/bin/python -m pip install -r requirements-audit.txt
|
||||||
|
./.venv/bin/python -m pip install 'semgrep>=1.140,<2'
|
||||||
|
./.venv/bin/python -m pip install --upgrade --no-deps 'click>=8.3.3'
|
||||||
```
|
```
|
||||||
|
|
||||||
Then install the non-Python tools (`gitleaks`, `trivy`, `osv-scanner`, `jscpd`)
|
Then install the non-Python tools (`gitleaks`, `trivy`, `osv-scanner`, `jscpd`)
|
||||||
|
|||||||
@@ -149,6 +149,12 @@
|
|||||||
"description": "GovOPlaN Dms module behavior or integration.",
|
"description": "GovOPlaN Dms module behavior or integration.",
|
||||||
"exclusive": false
|
"exclusive": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "module/dist-lists",
|
||||||
|
"color": "0e8a16",
|
||||||
|
"description": "GovOPlaN Distribution Lists module behavior or integration.",
|
||||||
|
"exclusive": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "module/erp",
|
"name": "module/erp",
|
||||||
"color": "fef2c0",
|
"color": "fef2c0",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
{"name": "govoplan-contracts", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-contracts.git", "path": "govoplan-contracts"},
|
{"name": "govoplan-contracts", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-contracts.git", "path": "govoplan-contracts"},
|
||||||
{"name": "govoplan-dashboard", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-dashboard.git", "path": "govoplan-dashboard"},
|
{"name": "govoplan-dashboard", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-dashboard.git", "path": "govoplan-dashboard"},
|
||||||
{"name": "govoplan-dms", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-dms.git", "path": "govoplan-dms"},
|
{"name": "govoplan-dms", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-dms.git", "path": "govoplan-dms"},
|
||||||
|
{"name": "govoplan-dist-lists", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-dist-lists.git", "path": "govoplan-dist-lists"},
|
||||||
{"name": "govoplan-docs", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-docs.git", "path": "govoplan-docs"},
|
{"name": "govoplan-docs", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-docs.git", "path": "govoplan-docs"},
|
||||||
{"name": "govoplan-erp", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-erp.git", "path": "govoplan-erp"},
|
{"name": "govoplan-erp", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-erp.git", "path": "govoplan-erp"},
|
||||||
{"name": "govoplan-evaluation", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-evaluation.git", "path": "govoplan-evaluation"},
|
{"name": "govoplan-evaluation", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-evaluation.git", "path": "govoplan-evaluation"},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
bandit>=1.8,<2
|
bandit>=1.8,<2
|
||||||
|
click>=8.3.3
|
||||||
filelock>=3.20.3
|
filelock>=3.20.3
|
||||||
idna>=3.15
|
idna>=3.15
|
||||||
pip>=26.1.2
|
pip>=26.1.2
|
||||||
@@ -6,5 +7,4 @@ pip-audit>=2.9,<3
|
|||||||
python-multipart>=0.0.31
|
python-multipart>=0.0.31
|
||||||
radon>=6,<7
|
radon>=6,<7
|
||||||
ruff>=0.14,<1
|
ruff>=0.14,<1
|
||||||
semgrep>=1.140,<2
|
|
||||||
xenon>=0.9,<1
|
xenon>=0.9,<1
|
||||||
|
|||||||
@@ -12,10 +12,16 @@
|
|||||||
-e ../govoplan-policy
|
-e ../govoplan-policy
|
||||||
-e ../govoplan-audit
|
-e ../govoplan-audit
|
||||||
-e ../govoplan-dashboard
|
-e ../govoplan-dashboard
|
||||||
|
-e ../govoplan-addresses
|
||||||
|
-e ../govoplan-dist-lists
|
||||||
-e ../govoplan-files
|
-e ../govoplan-files
|
||||||
-e ../govoplan-mail
|
-e ../govoplan-mail
|
||||||
-e ../govoplan-campaign
|
-e ../govoplan-campaign
|
||||||
-e ../govoplan-calendar
|
-e ../govoplan-calendar
|
||||||
|
-e ../govoplan-poll
|
||||||
|
-e ../govoplan-scheduling
|
||||||
|
-e ../govoplan-notifications
|
||||||
|
-e ../govoplan-evaluation
|
||||||
-e ../govoplan-docs
|
-e ../govoplan-docs
|
||||||
-e ../govoplan-ops
|
-e ../govoplan-ops
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
@@ -25,3 +31,4 @@ idna>=3.15
|
|||||||
pip>=26.1.2
|
pip>=26.1.2
|
||||||
pip-audit>=2.9,<3
|
pip-audit>=2.9,<3
|
||||||
python-multipart>=0.0.31
|
python-multipart>=0.0.31
|
||||||
|
ruff>=0.14,<1
|
||||||
|
|||||||
@@ -11,9 +11,14 @@ govoplan-admin @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git@v0.
|
|||||||
govoplan-policy @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git@v0.1.8
|
govoplan-policy @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git@v0.1.8
|
||||||
govoplan-audit @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git@v0.1.8
|
govoplan-audit @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git@v0.1.8
|
||||||
govoplan-dashboard @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git@v0.1.8
|
govoplan-dashboard @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git@v0.1.8
|
||||||
|
govoplan-addresses @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-addresses.git@v0.1.8
|
||||||
govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.8
|
govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.8
|
||||||
govoplan-mail @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git@v0.1.8
|
govoplan-mail @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git@v0.1.8
|
||||||
govoplan-campaign @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git@v0.1.8
|
govoplan-campaign @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git@v0.1.8
|
||||||
govoplan-calendar @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git@v0.1.8
|
govoplan-calendar @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git@v0.1.8
|
||||||
|
govoplan-poll @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-poll.git@v0.1.8
|
||||||
|
govoplan-scheduling @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-scheduling.git@v0.1.8
|
||||||
|
govoplan-notifications @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-notifications.git@v0.1.8
|
||||||
|
govoplan-evaluation @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-evaluation.git@v0.1.8
|
||||||
govoplan-docs @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git@v0.1.8
|
govoplan-docs @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git@v0.1.8
|
||||||
govoplan-ops @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git@v0.1.8
|
govoplan-ops @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git@v0.1.8
|
||||||
|
|||||||
149
tools/checks/check-contracts.py
Normal file
149
tools/checks/check-contracts.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Static cross-repository module contract check."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(META_ROOT / "tools" / "release"))
|
||||||
|
|
||||||
|
from govoplan_release.contracts import ( # noqa: E402
|
||||||
|
collect_contracts,
|
||||||
|
format_version_range,
|
||||||
|
interface_impact_map,
|
||||||
|
provided_interface_map,
|
||||||
|
validate_contracts,
|
||||||
|
)
|
||||||
|
from govoplan_release.model import RepositorySnapshot, to_jsonable # noqa: E402
|
||||||
|
from govoplan_release.workspace import load_repository_specs, resolve_repo_path, resolve_workspace_root # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--workspace-root",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
help="Workspace root containing the repositories. Defaults to repositories.json default_parent.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--include-website", action="store_true", help="Also scan website repositories.")
|
||||||
|
parser.add_argument("--json", action="store_true", help="Print machine-readable JSON.")
|
||||||
|
parser.add_argument("--no-impact", action="store_true", help="Omit human-readable provider impact output.")
|
||||||
|
parser.add_argument("--strict-warnings", action="store_true", help="Fail when warnings are present.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
workspace_root = resolve_workspace_root(args.workspace_root)
|
||||||
|
repositories = repository_snapshots(workspace_root=workspace_root, include_website=args.include_website)
|
||||||
|
contracts = collect_contracts(repositories)
|
||||||
|
issues = validate_contracts(contracts)
|
||||||
|
providers = provided_interface_map(contracts)
|
||||||
|
consumers = interface_impact_map(contracts)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"workspace_root": str(workspace_root),
|
||||||
|
"repository_count": len(repositories),
|
||||||
|
"contract_count": len(contracts),
|
||||||
|
"provider_count": sum(len(contract.provides_interfaces) for contract in contracts),
|
||||||
|
"requirement_count": sum(len(contract.requires_interfaces) for contract in contracts),
|
||||||
|
"providers": provider_payload(providers, consumers),
|
||||||
|
"issues": to_jsonable(issues),
|
||||||
|
}
|
||||||
|
|
||||||
|
blockers = [issue for issue in issues if issue.severity == "blocker"]
|
||||||
|
warnings = [issue for issue in issues if issue.severity == "warning"]
|
||||||
|
exit_code = 1 if blockers or (args.strict_warnings and warnings) else 0
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(payload, indent=2, sort_keys=True))
|
||||||
|
return exit_code
|
||||||
|
|
||||||
|
print("Static module contract scan")
|
||||||
|
print(f"Workspace: {workspace_root}")
|
||||||
|
print(
|
||||||
|
f"Contracts: {payload['contract_count']} modules, "
|
||||||
|
f"{payload['provider_count']} providers, {payload['requirement_count']} requirements"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not args.no_impact:
|
||||||
|
print()
|
||||||
|
print("Provider impact:")
|
||||||
|
if providers:
|
||||||
|
for interface_name, provider_items in providers.items():
|
||||||
|
provider_text = ", ".join(
|
||||||
|
f"{contract.module_id}@{provider.version} ({contract.repo})"
|
||||||
|
for contract, provider in provider_items
|
||||||
|
)
|
||||||
|
consumer_text = ", ".join(
|
||||||
|
f"{contract.module_id} ({format_version_range(version_min=req.version_min, version_max_exclusive=req.version_max_exclusive)})"
|
||||||
|
for contract, req in consumers.get(interface_name, ())
|
||||||
|
)
|
||||||
|
if not consumer_text:
|
||||||
|
consumer_text = "no consumers"
|
||||||
|
print(f"- {interface_name}: {provider_text}; consumers: {consumer_text}")
|
||||||
|
else:
|
||||||
|
print("- no provided interfaces found")
|
||||||
|
|
||||||
|
if issues:
|
||||||
|
print()
|
||||||
|
print("Contract issues:")
|
||||||
|
for issue in issues:
|
||||||
|
location = issue.repo or "workspace"
|
||||||
|
if issue.module_id:
|
||||||
|
location += f"/{issue.module_id}"
|
||||||
|
print(f"- [{issue.severity}] {issue.code}: {location}: {issue.message}")
|
||||||
|
else:
|
||||||
|
print()
|
||||||
|
print("Contract check passed.")
|
||||||
|
|
||||||
|
return exit_code
|
||||||
|
|
||||||
|
|
||||||
|
def repository_snapshots(*, workspace_root: Path, include_website: bool) -> tuple[RepositorySnapshot, ...]:
|
||||||
|
snapshots: list[RepositorySnapshot] = []
|
||||||
|
for spec in load_repository_specs(include_website=include_website):
|
||||||
|
path = resolve_repo_path(spec, workspace_root)
|
||||||
|
snapshots.append(
|
||||||
|
RepositorySnapshot(
|
||||||
|
spec=spec,
|
||||||
|
absolute_path=str(path),
|
||||||
|
exists=path.exists(),
|
||||||
|
is_git=(path / ".git").exists(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(snapshots)
|
||||||
|
|
||||||
|
|
||||||
|
def provider_payload(providers, consumers):
|
||||||
|
payload: dict[str, dict[str, list[dict[str, str | bool | None]]]] = {}
|
||||||
|
for interface_name, provider_items in providers.items():
|
||||||
|
payload[interface_name] = {
|
||||||
|
"providers": [
|
||||||
|
{
|
||||||
|
"repo": contract.repo,
|
||||||
|
"module_id": contract.module_id,
|
||||||
|
"version": provider.version,
|
||||||
|
"manifest_path": contract.manifest_path,
|
||||||
|
}
|
||||||
|
for contract, provider in provider_items
|
||||||
|
],
|
||||||
|
"consumers": [
|
||||||
|
{
|
||||||
|
"repo": contract.repo,
|
||||||
|
"module_id": contract.module_id,
|
||||||
|
"version_min": requirement.version_min,
|
||||||
|
"version_max_exclusive": requirement.version_max_exclusive,
|
||||||
|
"optional": requirement.optional,
|
||||||
|
"manifest_path": contract.manifest_path,
|
||||||
|
}
|
||||||
|
for contract, requirement in consumers.get(interface_name, ())
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
12
tools/checks/check-contracts.sh
Normal file
12
tools/checks/check-contracts.sh
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
META_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
VENV_ROOT="${GOVOPLAN_VENV_ROOT:-$META_ROOT/.venv}"
|
||||||
|
PYTHON="${PYTHON:-$VENV_ROOT/bin/python}"
|
||||||
|
|
||||||
|
if [[ ! -x "$PYTHON" ]]; then
|
||||||
|
PYTHON=python3
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$PYTHON" "$META_ROOT/tools/checks/check-contracts.py" "$@"
|
||||||
@@ -33,39 +33,6 @@ PY
|
|||||||
"$PYTHON" - <<'PY'
|
"$PYTHON" - <<'PY'
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib.metadata
|
|
||||||
import pathlib
|
|
||||||
import sys
|
|
||||||
|
|
||||||
prefix = pathlib.Path(sys.prefix)
|
|
||||||
legacy_patterns = (
|
|
||||||
"__editable__.govoplan_module_multimailer-*.pth",
|
|
||||||
"__editable___govoplan_module_multimailer_*_finder.py",
|
|
||||||
"govoplan_module_multimailer-*.dist-info",
|
|
||||||
)
|
|
||||||
problems: list[pathlib.Path] = []
|
|
||||||
|
|
||||||
for site_packages in (prefix / "lib").glob("python*/site-packages"):
|
|
||||||
for pattern in legacy_patterns:
|
|
||||||
problems.extend(site_packages.glob(pattern))
|
|
||||||
|
|
||||||
for dist in importlib.metadata.distributions():
|
|
||||||
if dist.metadata.get("Name", "").lower() == "govoplan-module-multimailer":
|
|
||||||
problems.append(pathlib.Path(str(dist.locate_file(""))))
|
|
||||||
|
|
||||||
if problems:
|
|
||||||
print("Dependency hygiene failed: stale legacy multimailer install metadata found.", file=sys.stderr)
|
|
||||||
for path in sorted(set(problems)):
|
|
||||||
print(f" {path}", file=sys.stderr)
|
|
||||||
print("Remove the stale files or recreate the venv. This package pins obsolete dependencies.", file=sys.stderr)
|
|
||||||
raise SystemExit(1)
|
|
||||||
|
|
||||||
print("No stale legacy multimailer package metadata found.")
|
|
||||||
PY
|
|
||||||
|
|
||||||
"$PYTHON" - <<'PY'
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pathlib
|
import pathlib
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -75,6 +42,7 @@ scan_roots = [
|
|||||||
root / "tests",
|
root / "tests",
|
||||||
root.parent / "govoplan-access" / "src",
|
root.parent / "govoplan-access" / "src",
|
||||||
root.parent / "govoplan-admin" / "src",
|
root.parent / "govoplan-admin" / "src",
|
||||||
|
root.parent / "govoplan-addresses" / "src",
|
||||||
root.parent / "govoplan-audit" / "src",
|
root.parent / "govoplan-audit" / "src",
|
||||||
root.parent / "govoplan-calendar" / "src",
|
root.parent / "govoplan-calendar" / "src",
|
||||||
root.parent / "govoplan-campaign" / "src",
|
root.parent / "govoplan-campaign" / "src",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ unset npm_config_tmp NPM_CONFIG_TMP
|
|||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
GOVOPLAN_CORE_ROOT="$ROOT" PYTHON="$PYTHON" CHECK_TESTCLIENT_DEPRECATIONS=1 bash "$META_ROOT/tools/checks/check-dependency-hygiene.sh"
|
GOVOPLAN_CORE_ROOT="$ROOT" PYTHON="$PYTHON" CHECK_TESTCLIENT_DEPRECATIONS=1 bash "$META_ROOT/tools/checks/check-dependency-hygiene.sh"
|
||||||
|
"$PYTHON" "$META_ROOT/tools/checks/check-contracts.py" --no-impact
|
||||||
|
|
||||||
"$PYTHON" - <<'PY'
|
"$PYTHON" - <<'PY'
|
||||||
import ast
|
import ast
|
||||||
@@ -37,6 +38,7 @@ roots = [
|
|||||||
pathlib.Path("/mnt/DATA/git/govoplan-core/src"),
|
pathlib.Path("/mnt/DATA/git/govoplan-core/src"),
|
||||||
pathlib.Path("/mnt/DATA/git/govoplan-access/src"),
|
pathlib.Path("/mnt/DATA/git/govoplan-access/src"),
|
||||||
pathlib.Path("/mnt/DATA/git/govoplan-admin/src"),
|
pathlib.Path("/mnt/DATA/git/govoplan-admin/src"),
|
||||||
|
pathlib.Path("/mnt/DATA/git/govoplan-addresses/src"),
|
||||||
pathlib.Path("/mnt/DATA/git/govoplan-tenancy/src"),
|
pathlib.Path("/mnt/DATA/git/govoplan-tenancy/src"),
|
||||||
pathlib.Path("/mnt/DATA/git/govoplan-organizations/src"),
|
pathlib.Path("/mnt/DATA/git/govoplan-organizations/src"),
|
||||||
pathlib.Path("/mnt/DATA/git/govoplan-identity/src"),
|
pathlib.Path("/mnt/DATA/git/govoplan-identity/src"),
|
||||||
@@ -67,7 +69,7 @@ if errors:
|
|||||||
print(f"AST syntax check passed for {count} Python files")
|
print(f"AST syntax check passed for {count} Python files")
|
||||||
PY
|
PY
|
||||||
|
|
||||||
"$PYTHON" -c 'import govoplan_core.db.bootstrap; import govoplan_access.backend.admin.service; import govoplan_files.backend.router; import govoplan_mail.backend.sending.imap; print("targeted backend imports passed")'
|
"$PYTHON" -c 'import govoplan_core.db.bootstrap; import govoplan_access.backend.admin.service; import govoplan_addresses.backend.manifest; import govoplan_files.backend.router; import govoplan_mail.backend.sending.imap; print("targeted backend imports passed")'
|
||||||
"$META_ROOT/tools/checks/check_dependency_boundaries.py"
|
"$META_ROOT/tools/checks/check_dependency_boundaries.py"
|
||||||
"$PYTHON" -m unittest tests.test_module_system
|
"$PYTHON" -m unittest tests.test_module_system
|
||||||
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-mail/tests
|
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-mail/tests
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ if [[ ! -x "$NPM" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
"$PYTHON" "$META_ROOT/tools/checks/check-contracts.py" --no-impact
|
||||||
"$PYTHON" -m unittest tests.test_module_system tests.test_access_contracts
|
"$PYTHON" -m unittest tests.test_module_system tests.test_access_contracts
|
||||||
"$PYTHON" "$META_ROOT/tools/checks/check_dependency_boundaries.py"
|
"$PYTHON" "$META_ROOT/tools/checks/check_dependency_boundaries.py"
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ install_cloned_webui_dependencies() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
run_step "Validate release refs and installed package metadata"
|
run_step "Validate release refs and installed package metadata"
|
||||||
|
"$PYTHON" "$META_ROOT/tools/checks/check-contracts.py" --no-impact
|
||||||
"$PYTHON" - <<'PY'
|
"$PYTHON" - <<'PY'
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -104,9 +104,17 @@ if [[ "${#REPOS[@]}" -le 12 ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
declare -a PY_ROOTS=()
|
declare -a PY_ROOTS=()
|
||||||
|
declare -a PY_PROD_ROOTS=()
|
||||||
|
declare -a PY_TEST_ROOTS=()
|
||||||
for repo in "${REPOS[@]}"; do
|
for repo in "${REPOS[@]}"; do
|
||||||
[[ -d "$repo/src" ]] && PY_ROOTS+=("$repo/src")
|
if [[ -d "$repo/src" ]]; then
|
||||||
[[ -d "$repo/tests" ]] && PY_ROOTS+=("$repo/tests")
|
PY_ROOTS+=("$repo/src")
|
||||||
|
PY_PROD_ROOTS+=("$repo/src")
|
||||||
|
fi
|
||||||
|
if [[ -d "$repo/tests" ]]; then
|
||||||
|
PY_ROOTS+=("$repo/tests")
|
||||||
|
PY_TEST_ROOTS+=("$repo/tests")
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
safe_name() {
|
safe_name() {
|
||||||
@@ -184,13 +192,25 @@ run_semgrep() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
run_bandit() {
|
run_bandit() {
|
||||||
[[ "${#PY_ROOTS[@]}" -gt 0 ]] || return 0
|
local status=0
|
||||||
bandit -r "${PY_ROOTS[@]}" -f json -o "$REPORTS_DIR/bandit.json"
|
if [[ "${#PY_PROD_ROOTS[@]}" -gt 0 ]]; then
|
||||||
|
bandit -r "${PY_PROD_ROOTS[@]}" -f json -o "$REPORTS_DIR/bandit.json" || status=1
|
||||||
|
fi
|
||||||
|
if [[ "${#PY_TEST_ROOTS[@]}" -gt 0 ]]; then
|
||||||
|
bandit -r "${PY_TEST_ROOTS[@]}" -f json -o "$REPORTS_DIR/bandit-tests.json" || true
|
||||||
|
fi
|
||||||
|
return "$status"
|
||||||
}
|
}
|
||||||
|
|
||||||
run_ruff_security() {
|
run_ruff_security() {
|
||||||
[[ "${#PY_ROOTS[@]}" -gt 0 ]] || return 0
|
local status=0
|
||||||
ruff check --select S --output-format json "${PY_ROOTS[@]}" > "$REPORTS_DIR/ruff-security.json"
|
if [[ "${#PY_PROD_ROOTS[@]}" -gt 0 ]]; then
|
||||||
|
ruff check --select S --output-format json "${PY_PROD_ROOTS[@]}" > "$REPORTS_DIR/ruff-security.json" || status=1
|
||||||
|
fi
|
||||||
|
if [[ "${#PY_TEST_ROOTS[@]}" -gt 0 ]]; then
|
||||||
|
ruff check --select S --output-format json "${PY_TEST_ROOTS[@]}" > "$REPORTS_DIR/ruff-security-tests.json" || true
|
||||||
|
fi
|
||||||
|
return "$status"
|
||||||
}
|
}
|
||||||
|
|
||||||
run_gitleaks() {
|
run_gitleaks() {
|
||||||
@@ -272,12 +292,39 @@ run_osv_scanner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
run_jscpd() {
|
run_jscpd() {
|
||||||
|
local ignored_paths
|
||||||
|
ignored_paths=(
|
||||||
|
"**/.git/**"
|
||||||
|
"**/node_modules/**"
|
||||||
|
"**/.venv/**"
|
||||||
|
"**/dist/**"
|
||||||
|
"**/build/**"
|
||||||
|
"**/audit-reports/**"
|
||||||
|
"**/package-lock.json"
|
||||||
|
"**/package-lock.release.json"
|
||||||
|
"**/package.json"
|
||||||
|
"package.json"
|
||||||
|
"**/generatedTranslations.ts"
|
||||||
|
"**/docs/gitea-labels.json"
|
||||||
|
"**/*.md"
|
||||||
|
"**/*.md:*"
|
||||||
|
"*.md"
|
||||||
|
"*.md:*"
|
||||||
|
"**/*.svg"
|
||||||
|
"*.svg"
|
||||||
|
".gitea/workflows/*.yml"
|
||||||
|
"**/.gitea/workflows/*.yml"
|
||||||
|
"**/backend/schema/*.schema.json"
|
||||||
|
)
|
||||||
|
local ignored_path_pattern
|
||||||
|
ignored_path_pattern="$(printf '%s,' "${ignored_paths[@]}")"
|
||||||
|
ignored_path_pattern="${ignored_path_pattern%,}"
|
||||||
jscpd \
|
jscpd \
|
||||||
--silent \
|
--silent \
|
||||||
--min-tokens 80 \
|
--min-tokens 80 \
|
||||||
--reporters json \
|
--reporters json \
|
||||||
--output "$REPORTS_DIR/jscpd" \
|
--output "$REPORTS_DIR/jscpd" \
|
||||||
--ignore "**/.git/**,**/node_modules/**,**/.venv/**,**/dist/**,**/build/**,**/audit-reports/**,**/package-lock.json,**/package-lock.release.json,**/generatedTranslations.ts,**/docs/gitea-labels.json" \
|
--ignore "$ignored_path_pattern" \
|
||||||
"${REPOS[@]}"
|
"${REPOS[@]}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ REPOS = {
|
|||||||
"core": CORE_ROOT / "src" / "govoplan_core",
|
"core": CORE_ROOT / "src" / "govoplan_core",
|
||||||
"access": REPOS_ROOT / "govoplan-access" / "src" / "govoplan_access",
|
"access": REPOS_ROOT / "govoplan-access" / "src" / "govoplan_access",
|
||||||
"admin": REPOS_ROOT / "govoplan-admin" / "src" / "govoplan_admin",
|
"admin": REPOS_ROOT / "govoplan-admin" / "src" / "govoplan_admin",
|
||||||
|
"addresses": REPOS_ROOT / "govoplan-addresses" / "src" / "govoplan_addresses",
|
||||||
"tenancy": REPOS_ROOT / "govoplan-tenancy" / "src" / "govoplan_tenancy",
|
"tenancy": REPOS_ROOT / "govoplan-tenancy" / "src" / "govoplan_tenancy",
|
||||||
"organizations": REPOS_ROOT / "govoplan-organizations" / "src" / "govoplan_organizations",
|
"organizations": REPOS_ROOT / "govoplan-organizations" / "src" / "govoplan_organizations",
|
||||||
"identity": REPOS_ROOT / "govoplan-identity" / "src" / "govoplan_identity",
|
"identity": REPOS_ROOT / "govoplan-identity" / "src" / "govoplan_identity",
|
||||||
@@ -29,6 +30,7 @@ PREFIXES = {
|
|||||||
"core": "govoplan_core",
|
"core": "govoplan_core",
|
||||||
"access": "govoplan_access",
|
"access": "govoplan_access",
|
||||||
"admin": "govoplan_admin",
|
"admin": "govoplan_admin",
|
||||||
|
"addresses": "govoplan_addresses",
|
||||||
"tenancy": "govoplan_tenancy",
|
"tenancy": "govoplan_tenancy",
|
||||||
"organizations": "govoplan_organizations",
|
"organizations": "govoplan_organizations",
|
||||||
"identity": "govoplan_identity",
|
"identity": "govoplan_identity",
|
||||||
@@ -39,16 +41,18 @@ PREFIXES = {
|
|||||||
"mail": "govoplan_mail",
|
"mail": "govoplan_mail",
|
||||||
"campaign": "govoplan_campaign",
|
"campaign": "govoplan_campaign",
|
||||||
}
|
}
|
||||||
FEATURE_OWNERS = ("files", "mail", "campaign")
|
FEATURE_OWNERS = ("addresses", "files", "mail", "campaign")
|
||||||
PLATFORM_OWNERS = ("admin", "tenancy", "organizations", "identity", "policy", "audit", "dashboard")
|
PLATFORM_OWNERS = ("admin", "tenancy", "organizations", "identity", "policy", "audit", "dashboard")
|
||||||
WEBUI_REPOS = {
|
WEBUI_REPOS = {
|
||||||
"core": CORE_ROOT / "webui",
|
"core": CORE_ROOT / "webui",
|
||||||
|
"addresses": REPOS_ROOT / "govoplan-addresses" / "webui",
|
||||||
"files": REPOS_ROOT / "govoplan-files" / "webui",
|
"files": REPOS_ROOT / "govoplan-files" / "webui",
|
||||||
"mail": REPOS_ROOT / "govoplan-mail" / "webui",
|
"mail": REPOS_ROOT / "govoplan-mail" / "webui",
|
||||||
"campaign": REPOS_ROOT / "govoplan-campaign" / "webui",
|
"campaign": REPOS_ROOT / "govoplan-campaign" / "webui",
|
||||||
}
|
}
|
||||||
WEBUI_PACKAGES = {
|
WEBUI_PACKAGES = {
|
||||||
"core": "@govoplan/core-webui",
|
"core": "@govoplan/core-webui",
|
||||||
|
"addresses": "@govoplan/addresses-webui",
|
||||||
"files": "@govoplan/files-webui",
|
"files": "@govoplan/files-webui",
|
||||||
"mail": "@govoplan/mail-webui",
|
"mail": "@govoplan/mail-webui",
|
||||||
"campaign": "@govoplan/campaign-webui",
|
"campaign": "@govoplan/campaign-webui",
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ COPY requirements-audit.txt /tmp/requirements-audit.txt
|
|||||||
|
|
||||||
RUN python -m pip install --upgrade pip \
|
RUN python -m pip install --upgrade pip \
|
||||||
&& python -m pip install --no-cache-dir -r /tmp/requirements-audit.txt \
|
&& python -m pip install --no-cache-dir -r /tmp/requirements-audit.txt \
|
||||||
|
&& python -m pip install --no-cache-dir "semgrep>=1.140,<2" \
|
||||||
|
&& python -m pip install --no-cache-dir --upgrade --no-deps "click>=8.3.3" \
|
||||||
&& npm install -g jscpd \
|
&& npm install -g jscpd \
|
||||||
&& semgrep --version \
|
&& semgrep --version \
|
||||||
&& bandit --version \
|
&& bandit --version \
|
||||||
|
|||||||
9
tools/generate-release-catalog.py
Normal file
9
tools/generate-release-catalog.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Compatibility wrapper for the release catalog generator."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import runpy
|
||||||
|
|
||||||
|
runpy.run_path(str(Path(__file__).resolve().parent / "release" / "generate-release-catalog.py"), run_name="__main__")
|
||||||
@@ -159,7 +159,7 @@ class GiteaClient:
|
|||||||
if not host:
|
if not host:
|
||||||
raise GiteaError("Gitea API URL is missing a hostname.")
|
raise GiteaError("Gitea API URL is missing a hostname.")
|
||||||
if self._api_url.scheme == "https":
|
if self._api_url.scheme == "https":
|
||||||
self._connection = http.client.HTTPSConnection(host, self._api_url.port, timeout=self.timeout)
|
self._connection = http.client.HTTPSConnection(host, self._api_url.port, timeout=self.timeout) # nosemgrep: python.lang.security.audit.httpsconnection-detected.httpsconnection-detected
|
||||||
elif self._api_url.scheme == "http":
|
elif self._api_url.scheme == "http":
|
||||||
self._connection = http.client.HTTPConnection(host, self._api_url.port, timeout=self.timeout)
|
self._connection = http.client.HTTPConnection(host, self._api_url.port, timeout=self.timeout)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ FRONTEND_FORCE_RELOAD="${GOVOPLAN_FRONTEND_FORCE_RELOAD:-1}"
|
|||||||
FRONTEND_CLEAR_VITE_CACHE="${GOVOPLAN_FRONTEND_CLEAR_VITE_CACHE:-1}"
|
FRONTEND_CLEAR_VITE_CACHE="${GOVOPLAN_FRONTEND_CLEAR_VITE_CACHE:-1}"
|
||||||
FRONTEND_USE_POLLING="${GOVOPLAN_FRONTEND_USE_POLLING:-1}"
|
FRONTEND_USE_POLLING="${GOVOPLAN_FRONTEND_USE_POLLING:-1}"
|
||||||
FRONTEND_POLLING_INTERVAL="${GOVOPLAN_FRONTEND_POLLING_INTERVAL:-500}"
|
FRONTEND_POLLING_INTERVAL="${GOVOPLAN_FRONTEND_POLLING_INTERVAL:-500}"
|
||||||
|
AUTO_SYNC_PYTHON="${GOVOPLAN_AUTO_SYNC_PYTHON:-1}"
|
||||||
DEV_DATABASE_BACKEND="${GOVOPLAN_DEV_DATABASE_BACKEND:-postgres}"
|
DEV_DATABASE_BACKEND="${GOVOPLAN_DEV_DATABASE_BACKEND:-postgres}"
|
||||||
|
|
||||||
LOG_DIR="${GOVOPLAN_DEV_LOG_DIR:-$META_ROOT/runtime/dev-launcher}"
|
LOG_DIR="${GOVOPLAN_DEV_LOG_DIR:-$META_ROOT/runtime/dev-launcher}"
|
||||||
@@ -137,6 +138,14 @@ trap cleanup EXIT INT TERM
|
|||||||
[ -f "$WEBUI_ROOT/package.json" ] || fail "WebUI package.json not found at $WEBUI_ROOT/package.json"
|
[ -f "$WEBUI_ROOT/package.json" ] || fail "WebUI package.json not found at $WEBUI_ROOT/package.json"
|
||||||
[ -d "$WEBUI_ROOT/node_modules" ] || fail "WebUI node_modules missing. Run: cd $WEBUI_ROOT && PATH=$NODE_BIN:\$PATH $NPM install"
|
[ -d "$WEBUI_ROOT/node_modules" ] || fail "WebUI node_modules missing. Run: cd $WEBUI_ROOT && PATH=$NODE_BIN:\$PATH $NPM install"
|
||||||
|
|
||||||
|
if [ "$AUTO_SYNC_PYTHON" = "1" ]; then
|
||||||
|
"$PYTHON" "$META_ROOT/tools/repo/sync-python-environment.py" \
|
||||||
|
--requirements "$META_ROOT/requirements-dev.txt" \
|
||||||
|
--python "$PYTHON" \
|
||||||
|
--stamp "$VENV_ROOT/.govoplan-sync-requirements-dev.json" \
|
||||||
|
|| fail "Python environment synchronization failed"
|
||||||
|
fi
|
||||||
|
|
||||||
(
|
(
|
||||||
cd "$META_ROOT"
|
cd "$META_ROOT"
|
||||||
"$PYTHON" - <<'PY'
|
"$PYTHON" - <<'PY'
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ set +a
|
|||||||
|
|
||||||
export APP_ENV="${APP_ENV:-staging}"
|
export APP_ENV="${APP_ENV:-staging}"
|
||||||
export GOVOPLAN_INSTALL_PROFILE="${GOVOPLAN_INSTALL_PROFILE:-production-like}"
|
export GOVOPLAN_INSTALL_PROFILE="${GOVOPLAN_INSTALL_PROFILE:-production-like}"
|
||||||
export ENABLED_MODULES="${ENABLED_MODULES:-tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops}"
|
export ENABLED_MODULES="${ENABLED_MODULES:-tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,notifications,docs,ops}"
|
||||||
export DATABASE_URL="${DATABASE_URL:-${GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL:-postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan}}"
|
export DATABASE_URL="${DATABASE_URL:-${GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL:-postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan}}"
|
||||||
export GOVOPLAN_DATABASE_URL_PGTOOLS="${GOVOPLAN_DATABASE_URL_PGTOOLS:-${GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL_PGTOOLS:-postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan}}"
|
export GOVOPLAN_DATABASE_URL_PGTOOLS="${GOVOPLAN_DATABASE_URL_PGTOOLS:-${GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL_PGTOOLS:-postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan}}"
|
||||||
export REDIS_URL="${REDIS_URL:-${GOVOPLAN_PRODUCTION_LIKE_REDIS_URL:-redis://127.0.0.1:56379/0}}"
|
export REDIS_URL="${REDIS_URL:-${GOVOPLAN_PRODUCTION_LIKE_REDIS_URL:-redis://127.0.0.1:56379/0}}"
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export DATABASE_URL="${DATABASE_URL:-${GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL:-po
|
|||||||
export GOVOPLAN_DATABASE_URL_PGTOOLS="${GOVOPLAN_DATABASE_URL_PGTOOLS:-${GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL_PGTOOLS:-postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan}}"
|
export GOVOPLAN_DATABASE_URL_PGTOOLS="${GOVOPLAN_DATABASE_URL_PGTOOLS:-${GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL_PGTOOLS:-postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan}}"
|
||||||
export REDIS_URL="${REDIS_URL:-${GOVOPLAN_PRODUCTION_LIKE_REDIS_URL:-redis://127.0.0.1:56379/0}}"
|
export REDIS_URL="${REDIS_URL:-${GOVOPLAN_PRODUCTION_LIKE_REDIS_URL:-redis://127.0.0.1:56379/0}}"
|
||||||
export CELERY_ENABLED="${CELERY_ENABLED:-true}"
|
export CELERY_ENABLED="${CELERY_ENABLED:-true}"
|
||||||
export ENABLED_MODULES="${ENABLED_MODULES:-tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops}"
|
export ENABLED_MODULES="${ENABLED_MODULES:-tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,notifications,docs,ops}"
|
||||||
export FILE_STORAGE_BACKEND="${FILE_STORAGE_BACKEND:-local}"
|
export FILE_STORAGE_BACKEND="${FILE_STORAGE_BACKEND:-local}"
|
||||||
export FILE_STORAGE_LOCAL_ROOT="${FILE_STORAGE_LOCAL_ROOT:-$META_ROOT/runtime/production-like/files}"
|
export FILE_STORAGE_LOCAL_ROOT="${FILE_STORAGE_LOCAL_ROOT:-$META_ROOT/runtime/production-like/files}"
|
||||||
export DEV_AUTO_MIGRATE_ENABLED="${DEV_AUTO_MIGRATE_ENABLED:-false}"
|
export DEV_AUTO_MIGRATE_ENABLED="${DEV_AUTO_MIGRATE_ENABLED:-false}"
|
||||||
|
|||||||
@@ -110,6 +110,15 @@ CATALOG_MODULES = (
|
|||||||
tags=("official", "platform-module"),
|
tags=("official", "platform-module"),
|
||||||
webui_package="@govoplan/dashboard-webui",
|
webui_package="@govoplan/dashboard-webui",
|
||||||
),
|
),
|
||||||
|
CatalogModule(
|
||||||
|
module_id="addresses",
|
||||||
|
repo="govoplan-addresses",
|
||||||
|
python_package="govoplan-addresses",
|
||||||
|
name="Addresses",
|
||||||
|
description="Reusable address directories, recipient sources, consent metadata, and address quality workflows.",
|
||||||
|
tags=("official", "business-module"),
|
||||||
|
webui_package="@govoplan/addresses-webui",
|
||||||
|
),
|
||||||
CatalogModule(
|
CatalogModule(
|
||||||
module_id="files",
|
module_id="files",
|
||||||
repo="govoplan-files",
|
repo="govoplan-files",
|
||||||
|
|||||||
67
tools/release/govoplan_release/__init__.py
Normal file
67
tools/release/govoplan_release/__init__.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Shared release tooling for the local GovOPlaN release console."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .model import ReleaseDashboard, ReleasePlan, RepositorySnapshot, RepositorySpec
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ReleaseDashboard",
|
||||||
|
"ReleasePlan",
|
||||||
|
"RepositorySnapshot",
|
||||||
|
"RepositorySpec",
|
||||||
|
"build_dashboard",
|
||||||
|
"build_release_intelligence",
|
||||||
|
"build_release_plan",
|
||||||
|
"build_selective_catalog_candidate",
|
||||||
|
"build_selective_release_plan",
|
||||||
|
"publish_catalog_candidate",
|
||||||
|
"prepare_repositories",
|
||||||
|
"push_repositories",
|
||||||
|
"sync_repositories",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str):
|
||||||
|
value = _load_lazy_export(name)
|
||||||
|
globals()[name] = value
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _load_lazy_export(name: str):
|
||||||
|
if name == "build_dashboard":
|
||||||
|
from .dashboard import build_dashboard
|
||||||
|
|
||||||
|
return build_dashboard
|
||||||
|
if name == "build_release_intelligence":
|
||||||
|
from .release_intelligence import build_release_intelligence
|
||||||
|
|
||||||
|
return build_release_intelligence
|
||||||
|
if name == "build_release_plan":
|
||||||
|
from .planner import build_release_plan
|
||||||
|
|
||||||
|
return build_release_plan
|
||||||
|
if name == "build_selective_catalog_candidate":
|
||||||
|
from .selective_catalog import build_selective_catalog_candidate
|
||||||
|
|
||||||
|
return build_selective_catalog_candidate
|
||||||
|
if name == "build_selective_release_plan":
|
||||||
|
from .selective_planner import build_selective_release_plan
|
||||||
|
|
||||||
|
return build_selective_release_plan
|
||||||
|
if name == "publish_catalog_candidate":
|
||||||
|
from .publisher import publish_catalog_candidate
|
||||||
|
|
||||||
|
return publish_catalog_candidate
|
||||||
|
if name == "prepare_repositories":
|
||||||
|
from .repository_prepare import prepare_repositories
|
||||||
|
|
||||||
|
return prepare_repositories
|
||||||
|
if name == "push_repositories":
|
||||||
|
from .repository_push import push_repositories
|
||||||
|
|
||||||
|
return push_repositories
|
||||||
|
if name == "sync_repositories":
|
||||||
|
from .repository_sync import sync_repositories
|
||||||
|
|
||||||
|
return sync_repositories
|
||||||
|
raise AttributeError(name)
|
||||||
185
tools/release/govoplan_release/catalog.py
Normal file
185
tools/release/govoplan_release/catalog.py
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
"""Release catalog inspection."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
|
||||||
|
from .http_fetch import fetch_http
|
||||||
|
from .model import CatalogSnapshot
|
||||||
|
from .workspace import website_root
|
||||||
|
|
||||||
|
DEFAULT_PUBLIC_BASE_URL = "https://govoplan.add-ideas.de"
|
||||||
|
|
||||||
|
|
||||||
|
def collect_catalog_snapshot(
|
||||||
|
*,
|
||||||
|
workspace_root: Path,
|
||||||
|
channel: str = "stable",
|
||||||
|
public_base_url: str = DEFAULT_PUBLIC_BASE_URL,
|
||||||
|
check_public: bool = False,
|
||||||
|
) -> CatalogSnapshot:
|
||||||
|
web_root = website_root(workspace_root)
|
||||||
|
catalog_path = web_root / "public" / "catalogs" / "v1" / "channels" / f"{channel}.json"
|
||||||
|
keyring_path = web_root / "public" / "catalogs" / "v1" / "keyring.json"
|
||||||
|
public_catalog_url = f"{public_base_url.rstrip('/')}/catalogs/v1/channels/{channel}.json"
|
||||||
|
public_keyring_url = f"{public_base_url.rstrip('/')}/catalogs/v1/keyring.json"
|
||||||
|
|
||||||
|
catalog_version: str | None = None
|
||||||
|
core_release_version: str | None = None
|
||||||
|
module_count = 0
|
||||||
|
signature_count = 0
|
||||||
|
generated_at: str | None = None
|
||||||
|
expires_at: str | None = None
|
||||||
|
catalog_error: str | None = None
|
||||||
|
local_catalog_hash: str | None = None
|
||||||
|
|
||||||
|
if catalog_path.exists():
|
||||||
|
try:
|
||||||
|
payload = json.loads(catalog_path.read_text(encoding="utf-8"))
|
||||||
|
local_catalog_hash = canonical_hash(payload)
|
||||||
|
parsed = parse_catalog_payload(payload)
|
||||||
|
catalog_version = parsed["catalog_version"]
|
||||||
|
core_release_version = parsed["core_release_version"]
|
||||||
|
module_count = parsed["module_count"] or 0
|
||||||
|
signature_count = parsed["signature_count"] or 0
|
||||||
|
generated_at = parsed["generated_at"]
|
||||||
|
expires_at = parsed["expires_at"]
|
||||||
|
except Exception as exc: # noqa: BLE001 - shown in local operator UI.
|
||||||
|
catalog_error = str(exc)
|
||||||
|
|
||||||
|
key_count = 0
|
||||||
|
keyring_error: str | None = None
|
||||||
|
local_keyring_hash: str | None = None
|
||||||
|
if keyring_path.exists():
|
||||||
|
try:
|
||||||
|
payload = json.loads(keyring_path.read_text(encoding="utf-8"))
|
||||||
|
local_keyring_hash = canonical_hash(payload)
|
||||||
|
keys = payload.get("keys") if isinstance(payload, dict) else None
|
||||||
|
key_count = len(keys) if isinstance(keys, list) else 0
|
||||||
|
except Exception as exc: # noqa: BLE001 - shown in local operator UI.
|
||||||
|
keyring_error = str(exc)
|
||||||
|
|
||||||
|
public_catalog_exists: bool | None = None
|
||||||
|
public_keyring_exists: bool | None = None
|
||||||
|
public_core_release_version: str | None = None
|
||||||
|
public_module_count: int | None = None
|
||||||
|
public_signature_count: int | None = None
|
||||||
|
public_key_count: int | None = None
|
||||||
|
public_catalog_hash: str | None = None
|
||||||
|
public_keyring_hash: str | None = None
|
||||||
|
public_catalog_error: str | None = None
|
||||||
|
public_keyring_error: str | None = None
|
||||||
|
|
||||||
|
if check_public:
|
||||||
|
public_catalog = fetch_json(public_catalog_url)
|
||||||
|
if public_catalog["ok"]:
|
||||||
|
public_catalog_exists = True
|
||||||
|
payload = public_catalog["payload"]
|
||||||
|
public_catalog_hash = canonical_hash(payload)
|
||||||
|
parsed = parse_catalog_payload(payload)
|
||||||
|
public_core_release_version = parsed["core_release_version"]
|
||||||
|
public_module_count = parsed["module_count"]
|
||||||
|
public_signature_count = parsed["signature_count"]
|
||||||
|
else:
|
||||||
|
public_catalog_exists = False
|
||||||
|
public_catalog_error = str(public_catalog["error"])
|
||||||
|
|
||||||
|
public_keyring = fetch_json(public_keyring_url)
|
||||||
|
if public_keyring["ok"]:
|
||||||
|
public_keyring_exists = True
|
||||||
|
payload = public_keyring["payload"]
|
||||||
|
public_keyring_hash = canonical_hash(payload)
|
||||||
|
keys = payload.get("keys") if isinstance(payload, dict) else None
|
||||||
|
public_key_count = len(keys) if isinstance(keys, list) else 0
|
||||||
|
else:
|
||||||
|
public_keyring_exists = False
|
||||||
|
public_keyring_error = str(public_keyring["error"])
|
||||||
|
|
||||||
|
return CatalogSnapshot(
|
||||||
|
channel=channel,
|
||||||
|
website_path=str(web_root),
|
||||||
|
catalog_path=str(catalog_path),
|
||||||
|
catalog_exists=catalog_path.exists(),
|
||||||
|
keyring_path=str(keyring_path),
|
||||||
|
keyring_exists=keyring_path.exists(),
|
||||||
|
catalog_version=catalog_version,
|
||||||
|
core_release_version=core_release_version,
|
||||||
|
module_count=module_count,
|
||||||
|
signature_count=signature_count,
|
||||||
|
key_count=key_count,
|
||||||
|
generated_at=generated_at,
|
||||||
|
expires_at=expires_at,
|
||||||
|
catalog_error=catalog_error,
|
||||||
|
keyring_error=keyring_error,
|
||||||
|
local_catalog_hash=local_catalog_hash,
|
||||||
|
local_keyring_hash=local_keyring_hash,
|
||||||
|
public_catalog_url=public_catalog_url,
|
||||||
|
public_keyring_url=public_keyring_url,
|
||||||
|
public_checked=check_public,
|
||||||
|
public_catalog_exists=public_catalog_exists,
|
||||||
|
public_keyring_exists=public_keyring_exists,
|
||||||
|
public_core_release_version=public_core_release_version,
|
||||||
|
public_module_count=public_module_count,
|
||||||
|
public_signature_count=public_signature_count,
|
||||||
|
public_key_count=public_key_count,
|
||||||
|
public_catalog_hash=public_catalog_hash,
|
||||||
|
public_keyring_hash=public_keyring_hash,
|
||||||
|
public_catalog_error=public_catalog_error,
|
||||||
|
public_keyring_error=public_keyring_error,
|
||||||
|
catalog_matches_public=compare_hashes(local_catalog_hash, public_catalog_hash) if check_public else None,
|
||||||
|
keyring_matches_public=compare_hashes(local_keyring_hash, public_keyring_hash) if check_public else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_catalog_payload(payload: object) -> dict[str, str | int | None]:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return {
|
||||||
|
"catalog_version": None,
|
||||||
|
"core_release_version": None,
|
||||||
|
"module_count": 0,
|
||||||
|
"signature_count": 0,
|
||||||
|
"generated_at": None,
|
||||||
|
"expires_at": None,
|
||||||
|
}
|
||||||
|
core_release = payload.get("core_release")
|
||||||
|
modules = payload.get("modules")
|
||||||
|
signatures = payload.get("signatures")
|
||||||
|
return {
|
||||||
|
"catalog_version": string_or_none(payload.get("catalog_version")),
|
||||||
|
"core_release_version": string_or_none(core_release.get("version")) if isinstance(core_release, dict) else None,
|
||||||
|
"module_count": len(modules) if isinstance(modules, list) else 0,
|
||||||
|
"signature_count": len(signatures) if isinstance(signatures, list) else 0,
|
||||||
|
"generated_at": string_or_none(payload.get("generated_at")),
|
||||||
|
"expires_at": string_or_none(payload.get("expires_at")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_json(url: str) -> dict[str, object]:
|
||||||
|
try:
|
||||||
|
response = fetch_http(
|
||||||
|
url,
|
||||||
|
timeout=10,
|
||||||
|
label="Public release catalog URL",
|
||||||
|
headers={"User-Agent": "GovOPlaN release-console"},
|
||||||
|
)
|
||||||
|
return {"ok": True, "payload": json.loads(response.text())}
|
||||||
|
except (HTTPError, URLError, TimeoutError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
return {"ok": False, "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_hash(payload: object) -> str:
|
||||||
|
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
|
||||||
|
return hashlib.sha256(canonical).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def compare_hashes(local_hash: str | None, public_hash: str | None) -> bool | None:
|
||||||
|
if local_hash is None or public_hash is None:
|
||||||
|
return None
|
||||||
|
return local_hash == public_hash
|
||||||
|
|
||||||
|
|
||||||
|
def string_or_none(value: object) -> str | None:
|
||||||
|
return value if isinstance(value, str) else None
|
||||||
445
tools/release/govoplan_release/contracts.py
Normal file
445
tools/release/govoplan_release/contracts.py
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
"""Manifest contract extraction and validation for release planning.
|
||||||
|
|
||||||
|
The static scanner intentionally parses manifest source files with ``ast``
|
||||||
|
instead of importing module packages. That keeps contract checks usable before
|
||||||
|
``pip install`` has been rerun for a changed ``pyproject.toml`` or entry point.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .model import (
|
||||||
|
CompatibilityIssue,
|
||||||
|
InterfaceProviderSnapshot,
|
||||||
|
InterfaceRequirementSnapshot,
|
||||||
|
ModuleContractSnapshot,
|
||||||
|
RepositorySnapshot,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_contracts(repositories: tuple[RepositorySnapshot, ...]) -> tuple[ModuleContractSnapshot, ...]:
|
||||||
|
contracts: list[ModuleContractSnapshot] = []
|
||||||
|
for repo in repositories:
|
||||||
|
if not repo.exists or not repo.is_git:
|
||||||
|
continue
|
||||||
|
root = Path(repo.absolute_path)
|
||||||
|
for manifest in sorted(root.glob("src/**/backend/manifest.py")):
|
||||||
|
contract = parse_manifest_contract(manifest, repo_name=repo.spec.name)
|
||||||
|
if contract is not None:
|
||||||
|
contracts.append(contract)
|
||||||
|
return tuple(contracts)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_contracts(contracts: tuple[ModuleContractSnapshot, ...]) -> tuple[CompatibilityIssue, ...]:
|
||||||
|
"""Validate the statically collected module interface graph.
|
||||||
|
|
||||||
|
This mirrors the runtime interface closure validation in
|
||||||
|
``govoplan_core.core.registry`` but reports all issues at once for CI and
|
||||||
|
release planning.
|
||||||
|
"""
|
||||||
|
|
||||||
|
issues: list[CompatibilityIssue] = []
|
||||||
|
providers: dict[str, list[tuple[ModuleContractSnapshot, InterfaceProviderSnapshot]]] = {}
|
||||||
|
module_ids: dict[str, list[ModuleContractSnapshot]] = {}
|
||||||
|
|
||||||
|
for contract in contracts:
|
||||||
|
module_ids.setdefault(contract.module_id, []).append(contract)
|
||||||
|
local_provider_names: set[str] = set()
|
||||||
|
local_requirement_names: set[str] = set()
|
||||||
|
|
||||||
|
for provider in contract.provides_interfaces:
|
||||||
|
if provider.name in local_provider_names:
|
||||||
|
issues.append(
|
||||||
|
CompatibilityIssue(
|
||||||
|
severity="blocker",
|
||||||
|
code="duplicate_provided_interface",
|
||||||
|
message=f"{contract.module_id} provides interface {provider.name!r} more than once.",
|
||||||
|
repo=contract.repo,
|
||||||
|
module_id=contract.module_id,
|
||||||
|
interface=provider.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
local_provider_names.add(provider.name)
|
||||||
|
if not valid_interface_name(provider.name):
|
||||||
|
issues.append(
|
||||||
|
CompatibilityIssue(
|
||||||
|
severity="blocker",
|
||||||
|
code="invalid_provided_interface_name",
|
||||||
|
message=f"{contract.module_id} provides invalid interface name {provider.name!r}.",
|
||||||
|
repo=contract.repo,
|
||||||
|
module_id=contract.module_id,
|
||||||
|
interface=provider.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not provider.version.strip():
|
||||||
|
issues.append(
|
||||||
|
CompatibilityIssue(
|
||||||
|
severity="blocker",
|
||||||
|
code="missing_provided_interface_version",
|
||||||
|
message=f"{contract.module_id} provides interface {provider.name!r} without a version.",
|
||||||
|
repo=contract.repo,
|
||||||
|
module_id=contract.module_id,
|
||||||
|
interface=provider.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
providers.setdefault(provider.name, []).append((contract, provider))
|
||||||
|
|
||||||
|
for requirement in contract.requires_interfaces:
|
||||||
|
if requirement.name in local_requirement_names:
|
||||||
|
issues.append(
|
||||||
|
CompatibilityIssue(
|
||||||
|
severity="blocker",
|
||||||
|
code="duplicate_required_interface",
|
||||||
|
message=f"{contract.module_id} requires interface {requirement.name!r} more than once.",
|
||||||
|
repo=contract.repo,
|
||||||
|
module_id=contract.module_id,
|
||||||
|
interface=requirement.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
local_requirement_names.add(requirement.name)
|
||||||
|
if not valid_interface_name(requirement.name):
|
||||||
|
issues.append(
|
||||||
|
CompatibilityIssue(
|
||||||
|
severity="blocker",
|
||||||
|
code="invalid_required_interface_name",
|
||||||
|
message=f"{contract.module_id} requires invalid interface name {requirement.name!r}.",
|
||||||
|
repo=contract.repo,
|
||||||
|
module_id=contract.module_id,
|
||||||
|
interface=requirement.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if requirement.version_min is not None and not requirement.version_min.strip():
|
||||||
|
issues.append(
|
||||||
|
CompatibilityIssue(
|
||||||
|
severity="blocker",
|
||||||
|
code="blank_required_interface_minimum",
|
||||||
|
message=f"{contract.module_id} has a blank minimum version for {requirement.name!r}.",
|
||||||
|
repo=contract.repo,
|
||||||
|
module_id=contract.module_id,
|
||||||
|
interface=requirement.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if requirement.version_max_exclusive is not None and not requirement.version_max_exclusive.strip():
|
||||||
|
issues.append(
|
||||||
|
CompatibilityIssue(
|
||||||
|
severity="blocker",
|
||||||
|
code="blank_required_interface_maximum",
|
||||||
|
message=f"{contract.module_id} has a blank maximum version for {requirement.name!r}.",
|
||||||
|
repo=contract.repo,
|
||||||
|
module_id=contract.module_id,
|
||||||
|
interface=requirement.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not version_range_is_valid(
|
||||||
|
version_min=requirement.version_min,
|
||||||
|
version_max_exclusive=requirement.version_max_exclusive,
|
||||||
|
):
|
||||||
|
issues.append(
|
||||||
|
CompatibilityIssue(
|
||||||
|
severity="blocker",
|
||||||
|
code="invalid_required_interface_range",
|
||||||
|
message=(
|
||||||
|
f"{contract.module_id} requires {requirement.name!r} with invalid range "
|
||||||
|
f"{format_version_range(version_min=requirement.version_min, version_max_exclusive=requirement.version_max_exclusive)}."
|
||||||
|
),
|
||||||
|
repo=contract.repo,
|
||||||
|
module_id=contract.module_id,
|
||||||
|
interface=requirement.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for module_id, matches in sorted(module_ids.items()):
|
||||||
|
if len(matches) > 1:
|
||||||
|
repos = ", ".join(sorted(contract.repo for contract in matches))
|
||||||
|
for contract in matches:
|
||||||
|
issues.append(
|
||||||
|
CompatibilityIssue(
|
||||||
|
severity="blocker",
|
||||||
|
code="duplicate_module_id",
|
||||||
|
message=f"Module id {module_id!r} is declared by multiple repositories: {repos}.",
|
||||||
|
repo=contract.repo,
|
||||||
|
module_id=module_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for contract in contracts:
|
||||||
|
for requirement in contract.requires_interfaces:
|
||||||
|
available = providers.get(requirement.name, [])
|
||||||
|
matching = [
|
||||||
|
(provider_contract, provider)
|
||||||
|
for provider_contract, provider in available
|
||||||
|
if version_satisfies_range(
|
||||||
|
provider.version,
|
||||||
|
version_min=requirement.version_min,
|
||||||
|
version_max_exclusive=requirement.version_max_exclusive,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if matching:
|
||||||
|
continue
|
||||||
|
version_range = format_version_range(
|
||||||
|
version_min=requirement.version_min,
|
||||||
|
version_max_exclusive=requirement.version_max_exclusive,
|
||||||
|
)
|
||||||
|
if not available:
|
||||||
|
if requirement.optional:
|
||||||
|
continue
|
||||||
|
issues.append(
|
||||||
|
CompatibilityIssue(
|
||||||
|
severity="blocker",
|
||||||
|
code="required_interface_missing",
|
||||||
|
message=f"{contract.module_id} requires unavailable interface {requirement.name!r} ({version_range}).",
|
||||||
|
repo=contract.repo,
|
||||||
|
module_id=contract.module_id,
|
||||||
|
interface=requirement.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
available_versions = ", ".join(
|
||||||
|
f"{provider_contract.module_id}@{provider.version}" for provider_contract, provider in available
|
||||||
|
)
|
||||||
|
issues.append(
|
||||||
|
CompatibilityIssue(
|
||||||
|
severity="warning" if requirement.optional else "blocker",
|
||||||
|
code="interface_version_mismatch",
|
||||||
|
message=(
|
||||||
|
f"{contract.module_id} requires interface {requirement.name!r} ({version_range}) "
|
||||||
|
f"but available providers are {available_versions}."
|
||||||
|
),
|
||||||
|
repo=contract.repo,
|
||||||
|
module_id=contract.module_id,
|
||||||
|
interface=requirement.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return tuple(sorted(issues, key=issue_sort_key))
|
||||||
|
|
||||||
|
|
||||||
|
def interface_impact_map(
|
||||||
|
contracts: tuple[ModuleContractSnapshot, ...],
|
||||||
|
) -> dict[str, tuple[tuple[ModuleContractSnapshot, InterfaceRequirementSnapshot], ...]]:
|
||||||
|
consumers: dict[str, list[tuple[ModuleContractSnapshot, InterfaceRequirementSnapshot]]] = {}
|
||||||
|
for contract in contracts:
|
||||||
|
for requirement in contract.requires_interfaces:
|
||||||
|
consumers.setdefault(requirement.name, []).append((contract, requirement))
|
||||||
|
return {name: tuple(items) for name, items in sorted(consumers.items())}
|
||||||
|
|
||||||
|
|
||||||
|
def provided_interface_map(
|
||||||
|
contracts: tuple[ModuleContractSnapshot, ...],
|
||||||
|
) -> dict[str, tuple[tuple[ModuleContractSnapshot, InterfaceProviderSnapshot], ...]]:
|
||||||
|
providers: dict[str, list[tuple[ModuleContractSnapshot, InterfaceProviderSnapshot]]] = {}
|
||||||
|
for contract in contracts:
|
||||||
|
for provider in contract.provides_interfaces:
|
||||||
|
providers.setdefault(provider.name, []).append((contract, provider))
|
||||||
|
return {name: tuple(items) for name, items in sorted(providers.items())}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_manifest_contract(path: Path, *, repo_name: str) -> ModuleContractSnapshot | None:
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
constants = imported_string_constants(tree, path)
|
||||||
|
constants.update(module_constants(tree, initial=constants))
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, ast.Call):
|
||||||
|
continue
|
||||||
|
if call_name(node.func) != "ModuleManifest":
|
||||||
|
continue
|
||||||
|
keywords = {keyword.arg: keyword.value for keyword in node.keywords if keyword.arg}
|
||||||
|
module_id = value_as_string(keywords.get("id"), constants)
|
||||||
|
if module_id is None:
|
||||||
|
continue
|
||||||
|
return ModuleContractSnapshot(
|
||||||
|
repo=repo_name,
|
||||||
|
module_id=module_id,
|
||||||
|
module_version=value_as_string(keywords.get("version"), constants),
|
||||||
|
manifest_path=str(path),
|
||||||
|
provides_interfaces=parse_providers(keywords.get("provides_interfaces"), constants),
|
||||||
|
requires_interfaces=parse_requirements(keywords.get("requires_interfaces"), constants),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_providers(node: ast.AST | None, constants: dict[str, str]) -> tuple[InterfaceProviderSnapshot, ...]:
|
||||||
|
providers: list[InterfaceProviderSnapshot] = []
|
||||||
|
for call in interface_calls(node, "ModuleInterfaceProvider"):
|
||||||
|
keywords = {keyword.arg: keyword.value for keyword in call.keywords if keyword.arg}
|
||||||
|
name = value_as_string(keywords.get("name"), constants)
|
||||||
|
version = value_as_string(keywords.get("version"), constants)
|
||||||
|
if name and version:
|
||||||
|
providers.append(InterfaceProviderSnapshot(name=name, version=version))
|
||||||
|
return tuple(providers)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_requirements(node: ast.AST | None, constants: dict[str, str]) -> tuple[InterfaceRequirementSnapshot, ...]:
|
||||||
|
requirements: list[InterfaceRequirementSnapshot] = []
|
||||||
|
for call in interface_calls(node, "ModuleInterfaceRequirement"):
|
||||||
|
keywords = {keyword.arg: keyword.value for keyword in call.keywords if keyword.arg}
|
||||||
|
name = value_as_string(keywords.get("name"), constants)
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
requirements.append(
|
||||||
|
InterfaceRequirementSnapshot(
|
||||||
|
name=name,
|
||||||
|
version_min=value_as_string(keywords.get("version_min"), constants),
|
||||||
|
version_max_exclusive=value_as_string(keywords.get("version_max_exclusive"), constants),
|
||||||
|
optional=value_as_bool(keywords.get("optional")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(requirements)
|
||||||
|
|
||||||
|
|
||||||
|
def interface_calls(node: ast.AST | None, expected_name: str) -> tuple[ast.Call, ...]:
|
||||||
|
if node is None:
|
||||||
|
return ()
|
||||||
|
return tuple(
|
||||||
|
child
|
||||||
|
for child in ast.walk(node)
|
||||||
|
if isinstance(child, ast.Call) and call_name(child.func) == expected_name
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def imported_string_constants(tree: ast.Module, path: Path) -> dict[str, str]:
|
||||||
|
constants: dict[str, str] = {}
|
||||||
|
for node in tree.body:
|
||||||
|
if not isinstance(node, ast.ImportFrom):
|
||||||
|
continue
|
||||||
|
source = import_source_path(path, module=node.module, level=node.level)
|
||||||
|
if source is None or not source.exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
imported = module_constants(ast.parse(source.read_text(encoding="utf-8"), filename=str(source)))
|
||||||
|
except SyntaxError:
|
||||||
|
continue
|
||||||
|
for alias in node.names:
|
||||||
|
if alias.name in imported:
|
||||||
|
constants[alias.asname or alias.name] = imported[alias.name]
|
||||||
|
return constants
|
||||||
|
|
||||||
|
|
||||||
|
def import_source_path(path: Path, *, module: str | None, level: int) -> Path | None:
|
||||||
|
if level:
|
||||||
|
base = path.parent
|
||||||
|
for _ in range(level - 1):
|
||||||
|
base = base.parent
|
||||||
|
if module:
|
||||||
|
base = base.joinpath(*module.split("."))
|
||||||
|
candidate = base.with_suffix(".py")
|
||||||
|
else:
|
||||||
|
src_root = next((parent for parent in path.parents if parent.name == "src"), None)
|
||||||
|
if src_root is None or module is None:
|
||||||
|
return None
|
||||||
|
candidate = src_root.joinpath(*module.split(".")).with_suffix(".py")
|
||||||
|
if candidate.exists():
|
||||||
|
return candidate
|
||||||
|
init = candidate.with_suffix("") / "__init__.py"
|
||||||
|
return init if init.exists() else candidate
|
||||||
|
|
||||||
|
|
||||||
|
def module_constants(tree: ast.Module, *, initial: dict[str, str] | None = None) -> dict[str, str]:
|
||||||
|
constants: dict[str, str] = dict(initial or {})
|
||||||
|
for node in tree.body:
|
||||||
|
if isinstance(node, ast.Assign):
|
||||||
|
value = node.value
|
||||||
|
for target in node.targets:
|
||||||
|
if isinstance(target, ast.Name):
|
||||||
|
resolved = value_as_string(value, constants)
|
||||||
|
if resolved is not None:
|
||||||
|
constants[target.id] = resolved
|
||||||
|
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
||||||
|
resolved = value_as_string(node.value, constants)
|
||||||
|
if resolved is not None:
|
||||||
|
constants[node.target.id] = resolved
|
||||||
|
return constants
|
||||||
|
|
||||||
|
|
||||||
|
def value_as_string(node: ast.AST | None, constants: dict[str, str]) -> str | None:
|
||||||
|
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||||
|
return node.value
|
||||||
|
if isinstance(node, ast.Name):
|
||||||
|
return constants.get(node.id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def value_as_bool(node: ast.AST | None) -> bool:
|
||||||
|
return bool(node.value) if isinstance(node, ast.Constant) and isinstance(node.value, bool) else False
|
||||||
|
|
||||||
|
|
||||||
|
def call_name(node: ast.AST) -> str:
|
||||||
|
if isinstance(node, ast.Name):
|
||||||
|
return node.id
|
||||||
|
if isinstance(node, ast.Attribute):
|
||||||
|
return node.attr
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def issue_sort_key(issue: CompatibilityIssue) -> tuple[int, str, str, str, str]:
|
||||||
|
severity_order = {"blocker": 0, "warning": 1, "info": 2}
|
||||||
|
return (
|
||||||
|
severity_order.get(issue.severity, 9),
|
||||||
|
issue.repo or "",
|
||||||
|
issue.module_id or "",
|
||||||
|
issue.interface or "",
|
||||||
|
issue.code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def valid_interface_name(value: str) -> bool:
|
||||||
|
return bool(re.match(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$", value))
|
||||||
|
|
||||||
|
|
||||||
|
def version_satisfies_range(
|
||||||
|
version: str,
|
||||||
|
*,
|
||||||
|
version_min: str | None = None,
|
||||||
|
version_max_exclusive: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
if version_min is not None and compare_versions(version, version_min) < 0:
|
||||||
|
return False
|
||||||
|
if version_max_exclusive is not None and compare_versions(version, version_max_exclusive) >= 0:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def version_range_is_valid(
|
||||||
|
*,
|
||||||
|
version_min: str | None = None,
|
||||||
|
version_max_exclusive: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
if version_min is None or version_max_exclusive is None:
|
||||||
|
return True
|
||||||
|
return compare_versions(version_min, version_max_exclusive) < 0
|
||||||
|
|
||||||
|
|
||||||
|
def format_version_range(
|
||||||
|
*,
|
||||||
|
version_min: str | None = None,
|
||||||
|
version_max_exclusive: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
parts: list[str] = []
|
||||||
|
if version_min is not None:
|
||||||
|
parts.append(f">= {version_min}")
|
||||||
|
if version_max_exclusive is not None:
|
||||||
|
parts.append(f"< {version_max_exclusive}")
|
||||||
|
return ", ".join(parts) if parts else "any version"
|
||||||
|
|
||||||
|
|
||||||
|
def compare_versions(left: str, right: str) -> int:
|
||||||
|
left_parts = version_tuple(left)
|
||||||
|
right_parts = version_tuple(right)
|
||||||
|
max_length = max(len(left_parts), len(right_parts))
|
||||||
|
padded_left = left_parts + (0,) * (max_length - len(left_parts))
|
||||||
|
padded_right = right_parts + (0,) * (max_length - len(right_parts))
|
||||||
|
if padded_left < padded_right:
|
||||||
|
return -1
|
||||||
|
if padded_left > padded_right:
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def version_tuple(value: str) -> tuple[int, ...]:
|
||||||
|
match = re.match(r"^\s*v?(\d+(?:\.\d+)*)", value)
|
||||||
|
if not match:
|
||||||
|
return (0,)
|
||||||
|
return tuple(int(part) for part in match.group(1).split("."))
|
||||||
88
tools/release/govoplan_release/dashboard.py
Normal file
88
tools/release/govoplan_release/dashboard.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""Build read-only release dashboard snapshots."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .catalog import collect_catalog_snapshot
|
||||||
|
from .contracts import collect_contracts
|
||||||
|
from .git_state import collect_repository_snapshot, read_pyproject_version
|
||||||
|
from .migrations import collect_migration_snapshots
|
||||||
|
from .model import DashboardSummary, ReleaseDashboard, RepositorySnapshot
|
||||||
|
from .workspace import META_ROOT, core_root, load_repository_specs, resolve_workspace_root
|
||||||
|
|
||||||
|
|
||||||
|
def build_dashboard(
|
||||||
|
*,
|
||||||
|
workspace_root: Path | str | None = None,
|
||||||
|
target_version: str | None = None,
|
||||||
|
online: bool = False,
|
||||||
|
check_remote_tags: bool = False,
|
||||||
|
check_public_catalog: bool | None = None,
|
||||||
|
include_migrations: bool = False,
|
||||||
|
include_website: bool = False,
|
||||||
|
include_contracts: bool = True,
|
||||||
|
channel: str = "stable",
|
||||||
|
) -> ReleaseDashboard:
|
||||||
|
resolved_workspace = resolve_workspace_root(workspace_root)
|
||||||
|
if check_public_catalog is None:
|
||||||
|
check_public_catalog = online
|
||||||
|
if target_version is None:
|
||||||
|
target_version = read_pyproject_version(core_root(resolved_workspace))
|
||||||
|
target_tag = f"v{target_version}" if target_version else None
|
||||||
|
|
||||||
|
repositories = tuple(
|
||||||
|
collect_repository_snapshot(spec, workspace_root=resolved_workspace, target_tag=target_tag, online=check_remote_tags)
|
||||||
|
for spec in load_repository_specs(include_website=include_website)
|
||||||
|
)
|
||||||
|
catalog = collect_catalog_snapshot(workspace_root=resolved_workspace, channel=channel, check_public=check_public_catalog)
|
||||||
|
migrations = collect_migration_snapshots() if include_migrations else ()
|
||||||
|
contracts = collect_contracts(repositories) if include_contracts else ()
|
||||||
|
return ReleaseDashboard(
|
||||||
|
generated_at=datetime.now(tz=UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
||||||
|
meta_root=str(META_ROOT),
|
||||||
|
workspace_root=str(resolved_workspace),
|
||||||
|
target_version=target_version,
|
||||||
|
target_tag=target_tag,
|
||||||
|
online=online or check_remote_tags or check_public_catalog,
|
||||||
|
include_migrations=include_migrations,
|
||||||
|
summary=summarize(repositories, target_tag=target_tag),
|
||||||
|
repositories=repositories,
|
||||||
|
catalog=catalog,
|
||||||
|
migrations=migrations,
|
||||||
|
contracts=contracts,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def summarize(repositories: tuple[RepositorySnapshot, ...], *, target_tag: str | None) -> DashboardSummary:
|
||||||
|
missing_count = sum(1 for repo in repositories if not repo.exists or not repo.is_git)
|
||||||
|
dirty_count = sum(1 for repo in repositories if repo.dirty)
|
||||||
|
ahead_count = sum(1 for repo in repositories if repo.ahead)
|
||||||
|
behind_count = sum(1 for repo in repositories if repo.behind)
|
||||||
|
no_head_count = sum(1 for repo in repositories if repo.exists and repo.is_git and not repo.has_head)
|
||||||
|
error_count = sum(1 for repo in repositories if repo.errors)
|
||||||
|
safe_directory_count = sum(1 for repo in repositories if repo.safe_directory_required)
|
||||||
|
local_target_tag_missing_count = (
|
||||||
|
sum(1 for repo in repositories if repo.local_target_tag_exists is False and repo.versions.primary == target_tag.removeprefix("v"))
|
||||||
|
if target_tag
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
if missing_count or behind_count or safe_directory_count:
|
||||||
|
status = "blocked"
|
||||||
|
elif dirty_count or ahead_count or no_head_count or error_count or local_target_tag_missing_count:
|
||||||
|
status = "attention"
|
||||||
|
else:
|
||||||
|
status = "ready"
|
||||||
|
return DashboardSummary(
|
||||||
|
repository_count=len(repositories),
|
||||||
|
missing_count=missing_count,
|
||||||
|
dirty_count=dirty_count,
|
||||||
|
ahead_count=ahead_count,
|
||||||
|
behind_count=behind_count,
|
||||||
|
no_head_count=no_head_count,
|
||||||
|
error_count=error_count,
|
||||||
|
safe_directory_count=safe_directory_count,
|
||||||
|
local_target_tag_missing_count=local_target_tag_missing_count,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
178
tools/release/govoplan_release/git_state.py
Normal file
178
tools/release/govoplan_release/git_state.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
"""Read-only Git and version metadata collection."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
from .model import RepositorySnapshot, RepositorySpec, VersionSnapshot
|
||||||
|
from .workspace import resolve_repo_path
|
||||||
|
|
||||||
|
|
||||||
|
def collect_repository_snapshot(
|
||||||
|
spec: RepositorySpec,
|
||||||
|
*,
|
||||||
|
workspace_root: Path,
|
||||||
|
target_tag: str | None,
|
||||||
|
online: bool = False,
|
||||||
|
) -> RepositorySnapshot:
|
||||||
|
path = resolve_repo_path(spec, workspace_root)
|
||||||
|
if not path.exists():
|
||||||
|
return RepositorySnapshot(spec=spec, absolute_path=str(path), exists=False, is_git=False, errors=("repository directory is missing",))
|
||||||
|
if not (path / ".git").exists():
|
||||||
|
return RepositorySnapshot(spec=spec, absolute_path=str(path), exists=True, is_git=False, errors=("not a git repository",))
|
||||||
|
|
||||||
|
errors: list[str] = []
|
||||||
|
status_result = git(path, "status", "--porcelain", timeout=10)
|
||||||
|
if status_result.returncode != 0:
|
||||||
|
message = git_error(status_result)
|
||||||
|
if is_dubious_ownership_error(message):
|
||||||
|
return RepositorySnapshot(
|
||||||
|
spec=spec,
|
||||||
|
absolute_path=str(path),
|
||||||
|
exists=True,
|
||||||
|
is_git=True,
|
||||||
|
safe_directory_required=True,
|
||||||
|
safe_directory_command=safe_directory_command(path),
|
||||||
|
errors=(compact_git_error(message),),
|
||||||
|
)
|
||||||
|
return RepositorySnapshot(
|
||||||
|
spec=spec,
|
||||||
|
absolute_path=str(path),
|
||||||
|
exists=True,
|
||||||
|
is_git=True,
|
||||||
|
errors=(compact_git_error(message),),
|
||||||
|
)
|
||||||
|
|
||||||
|
branch = git_text(path, "branch", "--show-current")
|
||||||
|
has_head = git_success(path, "rev-parse", "--verify", "HEAD")
|
||||||
|
head = git_text(path, "rev-parse", "--short", "HEAD") if has_head else None
|
||||||
|
upstream = git_text(path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}") if has_head else None
|
||||||
|
ahead: int | None = None
|
||||||
|
behind: int | None = None
|
||||||
|
if upstream:
|
||||||
|
counts = git_text(path, "rev-list", "--left-right", "--count", "@{upstream}...HEAD")
|
||||||
|
parts = counts.split()
|
||||||
|
if len(parts) == 2 and all(part.isdigit() for part in parts):
|
||||||
|
behind = int(parts[0])
|
||||||
|
ahead = int(parts[1])
|
||||||
|
|
||||||
|
local_target_tag_exists: bool | None = None
|
||||||
|
remote_target_tag_exists: bool | None = None
|
||||||
|
if target_tag:
|
||||||
|
local_target_tag_exists = git_success(path, "rev-parse", "-q", "--verify", f"refs/tags/{target_tag}")
|
||||||
|
if online:
|
||||||
|
remote_target_tag_exists = git_success(path, "ls-remote", "--exit-code", "--tags", "origin", f"refs/tags/{target_tag}", timeout=8)
|
||||||
|
|
||||||
|
try:
|
||||||
|
versions = collect_versions(path)
|
||||||
|
except Exception as exc: # noqa: BLE001 - surfaced in the dashboard.
|
||||||
|
errors.append(str(exc))
|
||||||
|
versions = VersionSnapshot()
|
||||||
|
|
||||||
|
return RepositorySnapshot(
|
||||||
|
spec=spec,
|
||||||
|
absolute_path=str(path),
|
||||||
|
exists=True,
|
||||||
|
is_git=True,
|
||||||
|
has_head=has_head,
|
||||||
|
head=head,
|
||||||
|
branch=branch or None,
|
||||||
|
upstream=upstream or None,
|
||||||
|
ahead=ahead,
|
||||||
|
behind=behind,
|
||||||
|
dirty_entries=tuple(line for line in status_result.stdout.splitlines() if line.strip()),
|
||||||
|
versions=versions,
|
||||||
|
local_target_tag_exists=local_target_tag_exists,
|
||||||
|
remote_target_tag_exists=remote_target_tag_exists,
|
||||||
|
remote_checked=online,
|
||||||
|
errors=tuple(errors),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_versions(path: Path) -> VersionSnapshot:
|
||||||
|
return VersionSnapshot(
|
||||||
|
pyproject=read_pyproject_version(path),
|
||||||
|
package=read_json_version(path / "package.json"),
|
||||||
|
webui_package=read_json_version(path / "webui" / "package.json"),
|
||||||
|
manifests=read_manifest_versions(path),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def read_pyproject_version(path: Path) -> str | None:
|
||||||
|
pyproject = path / "pyproject.toml"
|
||||||
|
if not pyproject.exists():
|
||||||
|
return None
|
||||||
|
with pyproject.open("rb") as handle:
|
||||||
|
payload = tomllib.load(handle)
|
||||||
|
project = payload.get("project")
|
||||||
|
if isinstance(project, dict) and isinstance(project.get("version"), str):
|
||||||
|
return project["version"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_json_version(path: Path) -> str | None:
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
version = payload.get("version") if isinstance(payload, dict) else None
|
||||||
|
return version if isinstance(version, str) else None
|
||||||
|
|
||||||
|
|
||||||
|
def read_manifest_versions(path: Path) -> tuple[str, ...]:
|
||||||
|
src = path / "src"
|
||||||
|
if not src.exists():
|
||||||
|
return ()
|
||||||
|
versions: list[str] = []
|
||||||
|
for manifest in sorted(src.glob("**/backend/manifest.py")):
|
||||||
|
text = manifest.read_text(encoding="utf-8")
|
||||||
|
match = re.search(r'(?m)^\s*version\s*=\s*["\']([^"\']+)["\']', text)
|
||||||
|
if match is None:
|
||||||
|
match = re.search(r'(?m)^MODULE_VERSION\s*=\s*["\']([^"\']+)["\']', text)
|
||||||
|
if match is not None:
|
||||||
|
versions.append(match.group(1))
|
||||||
|
return tuple(versions)
|
||||||
|
|
||||||
|
|
||||||
|
def git_text(path: Path, *args: str, timeout: int = 10) -> str:
|
||||||
|
result = git(path, *args, timeout=timeout)
|
||||||
|
return result.stdout.strip() if result.returncode == 0 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def git_success(path: Path, *args: str, timeout: int = 10) -> bool:
|
||||||
|
return git(path, *args, timeout=timeout).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def git(path: Path, *args: str, timeout: int = 10) -> subprocess.CompletedProcess[str]:
|
||||||
|
try:
|
||||||
|
return subprocess.run(
|
||||||
|
["git", *args],
|
||||||
|
cwd=path,
|
||||||
|
check=False,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
return subprocess.CompletedProcess(["git", *args], 124, exc.stdout or "", exc.stderr or "git command timed out")
|
||||||
|
|
||||||
|
|
||||||
|
def git_error(result: subprocess.CompletedProcess[str]) -> str:
|
||||||
|
return "\n".join(part for part in (result.stderr.strip(), result.stdout.strip()) if part)
|
||||||
|
|
||||||
|
|
||||||
|
def is_dubious_ownership_error(value: str) -> bool:
|
||||||
|
return "detected dubious ownership in repository" in value or "safe.directory" in value
|
||||||
|
|
||||||
|
|
||||||
|
def compact_git_error(value: str) -> str:
|
||||||
|
lines = [line.strip() for line in value.splitlines() if line.strip()]
|
||||||
|
return lines[0] if lines else "git command failed"
|
||||||
|
|
||||||
|
|
||||||
|
def safe_directory_command(path: Path) -> str:
|
||||||
|
return f"git config --global --add safe.directory {path}"
|
||||||
44
tools/release/govoplan_release/http_fetch.py
Normal file
44
tools/release/govoplan_release/http_fetch.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""Validated HTTP helpers for release tooling."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class HttpFetchResponse:
|
||||||
|
status: int
|
||||||
|
headers: dict[str, str]
|
||||||
|
body: bytes
|
||||||
|
|
||||||
|
def text(self, encoding: str = "utf-8") -> str:
|
||||||
|
return self.body.decode(encoding)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_http_url(value: str, *, label: str = "URL") -> str:
|
||||||
|
parsed = urllib.parse.urlparse(str(value).strip())
|
||||||
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||||
|
raise ValueError(f"{label} must be an absolute HTTP(S) URL.")
|
||||||
|
if parsed.username or parsed.password:
|
||||||
|
raise ValueError(f"{label} must not include embedded credentials.")
|
||||||
|
return urllib.parse.urlunparse(parsed)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_http(
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
timeout: float,
|
||||||
|
label: str = "URL",
|
||||||
|
method: str = "GET",
|
||||||
|
headers: Mapping[str, str] | None = None,
|
||||||
|
) -> HttpFetchResponse:
|
||||||
|
request = urllib.request.Request(validate_http_url(url, label=label), headers=dict(headers or {}), method=method)
|
||||||
|
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - URL is validated by validate_http_url. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
|
||||||
|
return HttpFetchResponse(
|
||||||
|
status=int(getattr(response, "status", 0)),
|
||||||
|
headers=dict(response.headers.items()),
|
||||||
|
body=response.read(),
|
||||||
|
)
|
||||||
53
tools/release/govoplan_release/migrations.py
Normal file
53
tools/release/govoplan_release/migrations.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""Optional migration audit collection for the local release console."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from .model import MigrationTrackSnapshot
|
||||||
|
from .workspace import META_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
def collect_migration_snapshots(*, timeout: int = 30) -> tuple[MigrationTrackSnapshot, ...]:
|
||||||
|
return tuple(collect_migration_snapshot(track, timeout=timeout) for track in ("release", "dev"))
|
||||||
|
|
||||||
|
|
||||||
|
def collect_migration_snapshot(track: str, *, timeout: int) -> MigrationTrackSnapshot:
|
||||||
|
script = META_ROOT / "tools" / "release" / "release-migration-audit.py"
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(script), "--track", track, "--json"],
|
||||||
|
cwd=META_ROOT,
|
||||||
|
check=False,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return MigrationTrackSnapshot(track=track, ok=False, ran=False, error="migration audit timed out")
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
return MigrationTrackSnapshot(
|
||||||
|
track=track,
|
||||||
|
ok=False,
|
||||||
|
ran=True,
|
||||||
|
error=(result.stderr or result.stdout).strip() or f"migration audit exited with {result.returncode}",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(result.stdout)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
return MigrationTrackSnapshot(track=track, ok=False, ran=True, error=f"could not parse audit JSON: {exc}")
|
||||||
|
|
||||||
|
graph_errors = tuple(str(item) for item in payload.get("graph_errors") or ())
|
||||||
|
strict_errors = tuple(str(item) for item in payload.get("strict_errors") or ())
|
||||||
|
return MigrationTrackSnapshot(
|
||||||
|
track=track,
|
||||||
|
ok=not graph_errors and not strict_errors,
|
||||||
|
ran=True,
|
||||||
|
graph_errors=graph_errors,
|
||||||
|
strict_errors=strict_errors,
|
||||||
|
)
|
||||||
314
tools/release/govoplan_release/model.py
Normal file
314
tools/release/govoplan_release/model.py
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
"""Data models for GovOPlaN release tooling.
|
||||||
|
|
||||||
|
The models intentionally stay dataclass-based so both the CLI and the local web
|
||||||
|
console can use them without depending on a web framework or Pydantic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict, dataclass, is_dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RepositorySpec:
|
||||||
|
name: str
|
||||||
|
category: str
|
||||||
|
subtype: str
|
||||||
|
remote: str
|
||||||
|
path: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class VersionSnapshot:
|
||||||
|
pyproject: str | None = None
|
||||||
|
package: str | None = None
|
||||||
|
webui_package: str | None = None
|
||||||
|
manifests: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary(self) -> str | None:
|
||||||
|
return self.pyproject or self.package or self.webui_package or (self.manifests[0] if self.manifests else None)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RepositorySnapshot:
|
||||||
|
spec: RepositorySpec
|
||||||
|
absolute_path: str
|
||||||
|
exists: bool
|
||||||
|
is_git: bool
|
||||||
|
has_head: bool = False
|
||||||
|
head: str | None = None
|
||||||
|
branch: str | None = None
|
||||||
|
upstream: str | None = None
|
||||||
|
ahead: int | None = None
|
||||||
|
behind: int | None = None
|
||||||
|
dirty_entries: tuple[str, ...] = ()
|
||||||
|
versions: VersionSnapshot = VersionSnapshot()
|
||||||
|
local_target_tag_exists: bool | None = None
|
||||||
|
remote_target_tag_exists: bool | None = None
|
||||||
|
remote_checked: bool = False
|
||||||
|
safe_directory_required: bool = False
|
||||||
|
safe_directory_command: str | None = None
|
||||||
|
errors: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dirty(self) -> bool:
|
||||||
|
return bool(self.dirty_entries)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CatalogSnapshot:
|
||||||
|
channel: str
|
||||||
|
website_path: str
|
||||||
|
catalog_path: str
|
||||||
|
catalog_exists: bool
|
||||||
|
keyring_path: str
|
||||||
|
keyring_exists: bool
|
||||||
|
catalog_version: str | None = None
|
||||||
|
core_release_version: str | None = None
|
||||||
|
module_count: int = 0
|
||||||
|
signature_count: int = 0
|
||||||
|
key_count: int = 0
|
||||||
|
generated_at: str | None = None
|
||||||
|
expires_at: str | None = None
|
||||||
|
catalog_error: str | None = None
|
||||||
|
keyring_error: str | None = None
|
||||||
|
local_catalog_hash: str | None = None
|
||||||
|
local_keyring_hash: str | None = None
|
||||||
|
public_catalog_url: str | None = None
|
||||||
|
public_keyring_url: str | None = None
|
||||||
|
public_checked: bool = False
|
||||||
|
public_catalog_exists: bool | None = None
|
||||||
|
public_keyring_exists: bool | None = None
|
||||||
|
public_core_release_version: str | None = None
|
||||||
|
public_module_count: int | None = None
|
||||||
|
public_signature_count: int | None = None
|
||||||
|
public_key_count: int | None = None
|
||||||
|
public_catalog_hash: str | None = None
|
||||||
|
public_keyring_hash: str | None = None
|
||||||
|
public_catalog_error: str | None = None
|
||||||
|
public_keyring_error: str | None = None
|
||||||
|
catalog_matches_public: bool | None = None
|
||||||
|
keyring_matches_public: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class MigrationTrackSnapshot:
|
||||||
|
track: str
|
||||||
|
ok: bool
|
||||||
|
ran: bool
|
||||||
|
graph_errors: tuple[str, ...] = ()
|
||||||
|
strict_errors: tuple[str, ...] = ()
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DashboardSummary:
|
||||||
|
repository_count: int
|
||||||
|
missing_count: int
|
||||||
|
dirty_count: int
|
||||||
|
ahead_count: int
|
||||||
|
behind_count: int
|
||||||
|
no_head_count: int
|
||||||
|
error_count: int
|
||||||
|
safe_directory_count: int
|
||||||
|
local_target_tag_missing_count: int
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReleaseDashboard:
|
||||||
|
generated_at: str
|
||||||
|
meta_root: str
|
||||||
|
workspace_root: str
|
||||||
|
target_version: str | None
|
||||||
|
target_tag: str | None
|
||||||
|
online: bool
|
||||||
|
include_migrations: bool
|
||||||
|
summary: DashboardSummary
|
||||||
|
repositories: tuple[RepositorySnapshot, ...]
|
||||||
|
catalog: CatalogSnapshot
|
||||||
|
migrations: tuple[MigrationTrackSnapshot, ...] = ()
|
||||||
|
contracts: tuple[ModuleContractSnapshot, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PlanAction:
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
detail: str
|
||||||
|
command: str | None = None
|
||||||
|
cwd: str | None = None
|
||||||
|
mutating: bool = False
|
||||||
|
severity: str = "info"
|
||||||
|
repo: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReleasePlan:
|
||||||
|
generated_at: str
|
||||||
|
target_version: str | None
|
||||||
|
target_tag: str | None
|
||||||
|
status: str
|
||||||
|
actions: tuple[PlanAction, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class InterfaceProviderSnapshot:
|
||||||
|
name: str
|
||||||
|
version: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class InterfaceRequirementSnapshot:
|
||||||
|
name: str
|
||||||
|
version_min: str | None = None
|
||||||
|
version_max_exclusive: str | None = None
|
||||||
|
optional: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ModuleContractSnapshot:
|
||||||
|
repo: str
|
||||||
|
module_id: str
|
||||||
|
module_version: str | None
|
||||||
|
manifest_path: str
|
||||||
|
provides_interfaces: tuple[InterfaceProviderSnapshot, ...] = ()
|
||||||
|
requires_interfaces: tuple[InterfaceRequirementSnapshot, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReleasePlanUnit:
|
||||||
|
repo: str
|
||||||
|
category: str
|
||||||
|
subtype: str
|
||||||
|
branch: str | None
|
||||||
|
current_version: str | None
|
||||||
|
target_version: str
|
||||||
|
target_tag: str
|
||||||
|
status: str
|
||||||
|
blockers: tuple[str, ...] = ()
|
||||||
|
warnings: tuple[str, ...] = ()
|
||||||
|
provides_interfaces: tuple[InterfaceProviderSnapshot, ...] = ()
|
||||||
|
requires_interfaces: tuple[InterfaceRequirementSnapshot, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CompatibilityIssue:
|
||||||
|
severity: str
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
repo: str | None = None
|
||||||
|
module_id: str | None = None
|
||||||
|
interface: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReleasePlanStep:
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
detail: str
|
||||||
|
command: str | None = None
|
||||||
|
cwd: str | None = None
|
||||||
|
mutating: bool = False
|
||||||
|
repo: str | None = None
|
||||||
|
status: str = "planned"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SelectiveReleasePlan:
|
||||||
|
generated_at: str
|
||||||
|
target_channel: str
|
||||||
|
status: str
|
||||||
|
units: tuple[ReleasePlanUnit, ...]
|
||||||
|
compatibility: tuple[CompatibilityIssue, ...]
|
||||||
|
dry_run_steps: tuple[ReleasePlanStep, ...]
|
||||||
|
notes: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CatalogEntryChange:
|
||||||
|
repo: str
|
||||||
|
module_id: str | None
|
||||||
|
field: str
|
||||||
|
before: str | None
|
||||||
|
after: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SelectiveCatalogCandidate:
|
||||||
|
generated_at: str
|
||||||
|
channel: str
|
||||||
|
status: str
|
||||||
|
candidate_dir: str
|
||||||
|
catalog_path: str
|
||||||
|
keyring_path: str
|
||||||
|
summary_path: str | None
|
||||||
|
source_catalog: str
|
||||||
|
public_catalog_url: str
|
||||||
|
public_keyring_url: str
|
||||||
|
sequence: int
|
||||||
|
signature_count: int
|
||||||
|
key_count: int
|
||||||
|
candidate_catalog_hash: str
|
||||||
|
candidate_keyring_hash: str
|
||||||
|
published_catalog_hash: str | None
|
||||||
|
published_keyring_hash: str | None
|
||||||
|
candidate_matches_published_catalog: bool | None
|
||||||
|
candidate_matches_published_keyring: bool | None
|
||||||
|
validation_valid: bool
|
||||||
|
validation_error: str | None = None
|
||||||
|
validation_warnings: tuple[str, ...] = ()
|
||||||
|
changes: tuple[CatalogEntryChange, ...] = ()
|
||||||
|
notes: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CatalogPublishStep:
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
detail: str
|
||||||
|
command: str | None = None
|
||||||
|
mutating: bool = False
|
||||||
|
status: str = "planned"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CatalogPublishResult:
|
||||||
|
generated_at: str
|
||||||
|
channel: str
|
||||||
|
status: str
|
||||||
|
applied: bool
|
||||||
|
candidate_dir: str
|
||||||
|
candidate_catalog_path: str
|
||||||
|
candidate_keyring_path: str
|
||||||
|
web_root: str
|
||||||
|
target_catalog_path: str
|
||||||
|
target_keyring_path: str
|
||||||
|
branch: str | None
|
||||||
|
tag_name: str | None
|
||||||
|
validation_valid: bool
|
||||||
|
validation_error: str | None = None
|
||||||
|
validation_warnings: tuple[str, ...] = ()
|
||||||
|
candidate_catalog_hash: str | None = None
|
||||||
|
candidate_keyring_hash: str | None = None
|
||||||
|
target_catalog_hash_before: str | None = None
|
||||||
|
target_keyring_hash_before: str | None = None
|
||||||
|
catalog_changed: bool = False
|
||||||
|
keyring_changed: bool = False
|
||||||
|
steps: tuple[CatalogPublishStep, ...] = ()
|
||||||
|
notes: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
def to_jsonable(value: Any) -> Any:
|
||||||
|
if is_dataclass(value):
|
||||||
|
return {key: to_jsonable(item) for key, item in asdict(value).items()}
|
||||||
|
if isinstance(value, tuple | list):
|
||||||
|
return [to_jsonable(item) for item in value]
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {str(key): to_jsonable(item) for key, item in value.items()}
|
||||||
|
if isinstance(value, Path):
|
||||||
|
return str(value)
|
||||||
|
return value
|
||||||
146
tools/release/govoplan_release/module_directory.py
Normal file
146
tools/release/govoplan_release/module_directory.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"""Generate browsable module-directory artifacts from a release catalog."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .catalog import DEFAULT_PUBLIC_BASE_URL, canonical_hash
|
||||||
|
from .release_intelligence import compatibility_rows, module_rows, signature_rows
|
||||||
|
|
||||||
|
|
||||||
|
def write_module_directory(
|
||||||
|
*,
|
||||||
|
catalog_payload: dict[str, Any],
|
||||||
|
keyring_payload: dict[str, Any],
|
||||||
|
output_root: Path,
|
||||||
|
channel: str,
|
||||||
|
public_base_url: str = DEFAULT_PUBLIC_BASE_URL,
|
||||||
|
) -> tuple[Path, ...]:
|
||||||
|
payloads = module_directory_payloads(
|
||||||
|
catalog_payload=catalog_payload,
|
||||||
|
keyring_payload=keyring_payload,
|
||||||
|
channel=channel,
|
||||||
|
public_base_url=public_base_url,
|
||||||
|
)
|
||||||
|
written: list[Path] = []
|
||||||
|
for relative_path, payload in payloads:
|
||||||
|
path = output_root / relative_path
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
written.append(path)
|
||||||
|
return tuple(written)
|
||||||
|
|
||||||
|
|
||||||
|
def module_directory_payloads(
|
||||||
|
*,
|
||||||
|
catalog_payload: dict[str, Any],
|
||||||
|
keyring_payload: dict[str, Any],
|
||||||
|
channel: str,
|
||||||
|
public_base_url: str = DEFAULT_PUBLIC_BASE_URL,
|
||||||
|
) -> tuple[tuple[Path, dict[str, Any]], ...]:
|
||||||
|
generated_at = json_datetime(datetime.now(tz=UTC))
|
||||||
|
modules = module_rows(catalog_payload)
|
||||||
|
compatibility = compatibility_rows(modules)
|
||||||
|
signatures = signature_rows(catalog_payload, keyring_payload)
|
||||||
|
base_url = public_base_url.rstrip("/")
|
||||||
|
catalog_url = f"{base_url}/catalogs/v1/channels/{channel}.json"
|
||||||
|
keyring_url = f"{base_url}/catalogs/v1/keyring.json"
|
||||||
|
catalog_hash = canonical_hash(catalog_payload)
|
||||||
|
catalog_sequence = catalog_payload.get("sequence")
|
||||||
|
|
||||||
|
files: list[tuple[Path, dict[str, Any]]] = []
|
||||||
|
module_index_entries: list[dict[str, Any]] = []
|
||||||
|
for module in modules:
|
||||||
|
module_id = str(module.get("module_id") or module.get("repo") or "")
|
||||||
|
if not module_id:
|
||||||
|
continue
|
||||||
|
version = str(module.get("version") or "0.0.0")
|
||||||
|
module_slug = safe_path_part(module_id)
|
||||||
|
version_slug = safe_path_part(version)
|
||||||
|
module_base = f"{base_url}/catalogs/v1/modules/{module_slug}"
|
||||||
|
manifest_url = f"{module_base}/{version_slug}/manifest.json"
|
||||||
|
module_index_url = f"{module_base}/index.json"
|
||||||
|
module_compatibility = [row for row in compatibility if row.get("module_id") == module_id]
|
||||||
|
manifest = {
|
||||||
|
"manifest_version": "1",
|
||||||
|
"generated_at": generated_at,
|
||||||
|
"channel": channel,
|
||||||
|
"module": module,
|
||||||
|
"compatibility": module_compatibility,
|
||||||
|
"release": {
|
||||||
|
"catalog_url": catalog_url,
|
||||||
|
"keyring_url": keyring_url,
|
||||||
|
"catalog_hash": catalog_hash,
|
||||||
|
"catalog_sequence": catalog_sequence,
|
||||||
|
"signatures": signatures,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
files.append((Path("modules") / module_slug / version_slug / "manifest.json", manifest))
|
||||||
|
files.append(
|
||||||
|
(
|
||||||
|
Path("modules") / module_slug / "index.json",
|
||||||
|
{
|
||||||
|
"index_version": "1",
|
||||||
|
"generated_at": generated_at,
|
||||||
|
"channel": channel,
|
||||||
|
"module_id": module_id,
|
||||||
|
"name": module.get("name"),
|
||||||
|
"latest_version": version,
|
||||||
|
"latest_manifest_url": manifest_url,
|
||||||
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": version,
|
||||||
|
"manifest_url": manifest_url,
|
||||||
|
"python_tag": module.get("python_tag"),
|
||||||
|
"webui_tag": module.get("webui_tag"),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"release": {
|
||||||
|
"catalog_url": catalog_url,
|
||||||
|
"keyring_url": keyring_url,
|
||||||
|
"catalog_hash": catalog_hash,
|
||||||
|
"catalog_sequence": catalog_sequence,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
module_index_entries.append(
|
||||||
|
{
|
||||||
|
"module_id": module_id,
|
||||||
|
"name": module.get("name"),
|
||||||
|
"latest_version": version,
|
||||||
|
"index_url": module_index_url,
|
||||||
|
"latest_manifest_url": manifest_url,
|
||||||
|
"repo": module.get("repo"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
files.append(
|
||||||
|
(
|
||||||
|
Path("modules") / "index.json",
|
||||||
|
{
|
||||||
|
"index_version": "1",
|
||||||
|
"generated_at": generated_at,
|
||||||
|
"channel": channel,
|
||||||
|
"catalog_url": catalog_url,
|
||||||
|
"keyring_url": keyring_url,
|
||||||
|
"catalog_hash": catalog_hash,
|
||||||
|
"catalog_sequence": catalog_sequence,
|
||||||
|
"module_count": len(module_index_entries),
|
||||||
|
"modules": sorted(module_index_entries, key=lambda item: str(item.get("module_id"))),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(files)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_path_part(value: str) -> str:
|
||||||
|
return re.sub(r"[^A-Za-z0-9_.-]+", "_", value.strip()) or "_"
|
||||||
|
|
||||||
|
|
||||||
|
def json_datetime(value: datetime) -> str:
|
||||||
|
return value.replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||||
176
tools/release/govoplan_release/planner.py
Normal file
176
tools/release/govoplan_release/planner.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
"""Read-only release plan suggestions.
|
||||||
|
|
||||||
|
This is deliberately not an executor yet. The first console slice should make
|
||||||
|
state and next actions explicit before any commit, tag, signing, or publish
|
||||||
|
operation becomes clickable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import shlex
|
||||||
|
|
||||||
|
from .model import PlanAction, ReleaseDashboard, ReleasePlan
|
||||||
|
|
||||||
|
|
||||||
|
def build_release_plan(dashboard: ReleaseDashboard) -> ReleasePlan:
|
||||||
|
actions: list[PlanAction] = []
|
||||||
|
target_version = dashboard.target_version
|
||||||
|
target_tag = dashboard.target_tag
|
||||||
|
|
||||||
|
for repo in dashboard.repositories:
|
||||||
|
if not repo.exists:
|
||||||
|
actions.append(
|
||||||
|
PlanAction(
|
||||||
|
id=f"{repo.spec.name}:missing",
|
||||||
|
title=f"{repo.spec.name} is missing locally",
|
||||||
|
detail="Clone or bootstrap the repository before planning a release.",
|
||||||
|
command=f"tools/repo/bootstrap-repositories.py --repo {shlex.quote(repo.spec.name)}",
|
||||||
|
cwd=dashboard.meta_root,
|
||||||
|
mutating=True,
|
||||||
|
severity="blocker",
|
||||||
|
repo=repo.spec.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if not repo.is_git:
|
||||||
|
actions.append(
|
||||||
|
PlanAction(
|
||||||
|
id=f"{repo.spec.name}:not-git",
|
||||||
|
title=f"{repo.spec.name} is not a Git repository",
|
||||||
|
detail=repo.absolute_path,
|
||||||
|
severity="blocker",
|
||||||
|
repo=repo.spec.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if repo.safe_directory_required:
|
||||||
|
actions.append(
|
||||||
|
PlanAction(
|
||||||
|
id=f"{repo.spec.name}:safe-directory",
|
||||||
|
title=f"{repo.spec.name} requires Git safe.directory approval",
|
||||||
|
detail="Git refused to inspect this checkout until it is trusted for the current user.",
|
||||||
|
command=repo.safe_directory_command,
|
||||||
|
cwd=repo.absolute_path,
|
||||||
|
mutating=True,
|
||||||
|
severity="blocker",
|
||||||
|
repo=repo.spec.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if not repo.has_head:
|
||||||
|
actions.append(
|
||||||
|
PlanAction(
|
||||||
|
id=f"{repo.spec.name}:initial-commit",
|
||||||
|
title=f"{repo.spec.name} needs an initial commit",
|
||||||
|
detail="The repository exists but has no HEAD yet, so it cannot be tagged or released.",
|
||||||
|
command="git add -A && git commit -m \"Initialize repository\" && git push -u origin HEAD",
|
||||||
|
cwd=repo.absolute_path,
|
||||||
|
mutating=True,
|
||||||
|
severity="blocker",
|
||||||
|
repo=repo.spec.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if repo.dirty:
|
||||||
|
actions.append(
|
||||||
|
PlanAction(
|
||||||
|
id=f"{repo.spec.name}:dirty",
|
||||||
|
title=f"{repo.spec.name} has uncommitted changes",
|
||||||
|
detail=f"{len(repo.dirty_entries)} changed path(s) need review before release.",
|
||||||
|
command="git status --short && git diff --stat",
|
||||||
|
cwd=repo.absolute_path,
|
||||||
|
severity="blocker",
|
||||||
|
repo=repo.spec.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if repo.behind:
|
||||||
|
actions.append(
|
||||||
|
PlanAction(
|
||||||
|
id=f"{repo.spec.name}:behind",
|
||||||
|
title=f"{repo.spec.name} is behind {repo.upstream}",
|
||||||
|
detail=f"Behind by {repo.behind} commit(s). Release from an up-to-date branch.",
|
||||||
|
command="git fetch --all --tags --prune",
|
||||||
|
cwd=repo.absolute_path,
|
||||||
|
mutating=True,
|
||||||
|
severity="blocker",
|
||||||
|
repo=repo.spec.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if repo.ahead:
|
||||||
|
actions.append(
|
||||||
|
PlanAction(
|
||||||
|
id=f"{repo.spec.name}:ahead",
|
||||||
|
title=f"{repo.spec.name} has unpushed commits",
|
||||||
|
detail=f"Ahead by {repo.ahead} commit(s). Tags and catalogs should point at pushed commits.",
|
||||||
|
command="git push",
|
||||||
|
cwd=repo.absolute_path,
|
||||||
|
mutating=True,
|
||||||
|
severity="warning",
|
||||||
|
repo=repo.spec.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if target_tag and repo.versions.primary == target_version and repo.local_target_tag_exists is False:
|
||||||
|
actions.append(
|
||||||
|
PlanAction(
|
||||||
|
id=f"{repo.spec.name}:tag-missing",
|
||||||
|
title=f"{repo.spec.name} has no local {target_tag} tag",
|
||||||
|
detail="The version file matches the target release, but the release tag is missing locally.",
|
||||||
|
command=f"git tag -a {shlex.quote(target_tag)} -m \"Release {shlex.quote(target_tag)}\"",
|
||||||
|
cwd=repo.absolute_path,
|
||||||
|
mutating=True,
|
||||||
|
severity="info",
|
||||||
|
repo=repo.spec.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
catalog = dashboard.catalog
|
||||||
|
if target_version and catalog.core_release_version != target_version:
|
||||||
|
actions.append(
|
||||||
|
PlanAction(
|
||||||
|
id="catalog:core-version",
|
||||||
|
title="Stable catalog does not point at the target core release",
|
||||||
|
detail=f"catalog core={catalog.core_release_version or '-'}, target={target_version}",
|
||||||
|
command=(
|
||||||
|
"KEY_DIR=\"$HOME/.config/govoplan/release-keys\" "
|
||||||
|
f"tools/release/publish-release-catalog.sh --version {shlex.quote(target_version)} "
|
||||||
|
"--catalog-signing-key \"release-key-1=$KEY_DIR/release-key-1.pem\" --build-web --dry-run"
|
||||||
|
),
|
||||||
|
cwd=dashboard.meta_root,
|
||||||
|
severity="warning",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if catalog.catalog_exists and catalog.signature_count == 0:
|
||||||
|
actions.append(
|
||||||
|
PlanAction(
|
||||||
|
id="catalog:unsigned",
|
||||||
|
title="Stable catalog is unsigned",
|
||||||
|
detail="Release catalogs must be signed before operators can trust them.",
|
||||||
|
cwd=dashboard.meta_root,
|
||||||
|
severity="blocker",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not catalog.keyring_exists or catalog.key_count == 0:
|
||||||
|
actions.append(
|
||||||
|
PlanAction(
|
||||||
|
id="catalog:keyring",
|
||||||
|
title="Catalog keyring is missing or empty",
|
||||||
|
detail="Generate or publish a public keyring before enforcing signed catalogs.",
|
||||||
|
command=(
|
||||||
|
"tools/release/generate-catalog-keypair.py --key-id release-key-1 "
|
||||||
|
"--private-key \"$HOME/.config/govoplan/release-keys/release-key-1.pem\" "
|
||||||
|
"--keyring \"$HOME/.config/govoplan/release-keys/keyring.json\""
|
||||||
|
),
|
||||||
|
cwd=dashboard.meta_root,
|
||||||
|
mutating=True,
|
||||||
|
severity="warning",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
status = "blocked" if any(action.severity == "blocker" for action in actions) else "attention" if actions else "ready"
|
||||||
|
return ReleasePlan(
|
||||||
|
generated_at=datetime.now(tz=UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
||||||
|
target_version=target_version,
|
||||||
|
target_tag=target_tag,
|
||||||
|
status=status,
|
||||||
|
actions=tuple(actions),
|
||||||
|
)
|
||||||
409
tools/release/govoplan_release/publisher.py
Normal file
409
tools/release/govoplan_release/publisher.py
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
"""Publish reviewed release catalog candidates into the website repository."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from govoplan_core.core.module_package_catalog import validate_module_package_catalog
|
||||||
|
|
||||||
|
from .catalog import canonical_hash
|
||||||
|
from .model import CatalogPublishResult, CatalogPublishStep
|
||||||
|
from .selective_catalog import trusted_keys_from_keyring
|
||||||
|
from .workspace import DEFAULT_WORKSPACE_ROOT, resolve_workspace_root, website_root
|
||||||
|
|
||||||
|
|
||||||
|
def publish_catalog_candidate(
|
||||||
|
*,
|
||||||
|
candidate_dir: Path | str,
|
||||||
|
workspace_root: Path | str | None = None,
|
||||||
|
web_root: Path | str | None = None,
|
||||||
|
channel: str = "stable",
|
||||||
|
apply: bool = False, # noqa: A002 - CLI uses --apply.
|
||||||
|
build_web: bool = False,
|
||||||
|
commit: bool = False,
|
||||||
|
tag: bool = False,
|
||||||
|
push: bool = False,
|
||||||
|
remote: str = "origin",
|
||||||
|
branch: str | None = None,
|
||||||
|
npm: str = "npm",
|
||||||
|
tag_name: str | None = None,
|
||||||
|
allow_dirty_website: bool = False,
|
||||||
|
) -> CatalogPublishResult:
|
||||||
|
workspace = resolve_workspace_root(workspace_root or DEFAULT_WORKSPACE_ROOT)
|
||||||
|
candidate_root = Path(candidate_dir).expanduser()
|
||||||
|
if not candidate_root.is_absolute():
|
||||||
|
candidate_root = Path.cwd() / candidate_root
|
||||||
|
resolved_web_root = Path(web_root).expanduser().resolve() if web_root is not None else website_root(workspace)
|
||||||
|
candidate_catalog = candidate_root / "channels" / f"{channel}.json"
|
||||||
|
candidate_keyring = candidate_root / "keyring.json"
|
||||||
|
candidate_modules = candidate_root / "modules"
|
||||||
|
target_catalog = resolved_web_root / "public" / "catalogs" / "v1" / "channels" / f"{channel}.json"
|
||||||
|
target_keyring = resolved_web_root / "public" / "catalogs" / "v1" / "keyring.json"
|
||||||
|
target_modules = resolved_web_root / "public" / "catalogs" / "v1" / "modules"
|
||||||
|
|
||||||
|
steps: list[CatalogPublishStep] = []
|
||||||
|
notes: list[str] = []
|
||||||
|
blockers: list[str] = []
|
||||||
|
|
||||||
|
if not candidate_catalog.exists():
|
||||||
|
blockers.append(f"candidate catalog is missing: {candidate_catalog}")
|
||||||
|
if not candidate_keyring.exists():
|
||||||
|
blockers.append(f"candidate keyring is missing: {candidate_keyring}")
|
||||||
|
if not resolved_web_root.exists():
|
||||||
|
blockers.append(f"website root is missing: {resolved_web_root}")
|
||||||
|
if push:
|
||||||
|
commit = True
|
||||||
|
if tag:
|
||||||
|
commit = True
|
||||||
|
|
||||||
|
candidate_payload = read_json(candidate_catalog) if candidate_catalog.exists() else None
|
||||||
|
keyring_payload = read_json(candidate_keyring) if candidate_keyring.exists() else None
|
||||||
|
candidate_catalog_hash = canonical_hash(candidate_payload) if candidate_payload is not None else None
|
||||||
|
candidate_keyring_hash = canonical_hash(keyring_payload) if keyring_payload is not None else None
|
||||||
|
target_catalog_hash_before = file_json_hash(target_catalog)
|
||||||
|
target_keyring_hash_before = file_json_hash(target_keyring)
|
||||||
|
validation_valid = False
|
||||||
|
validation_error: str | None = None
|
||||||
|
validation_warnings: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
if candidate_payload is not None and keyring_payload is not None:
|
||||||
|
validation = validate_module_package_catalog(
|
||||||
|
candidate_catalog,
|
||||||
|
require_trusted=True,
|
||||||
|
approved_channels=(channel,),
|
||||||
|
trusted_keys=trusted_keys_from_keyring(keyring_payload if isinstance(keyring_payload, dict) else {}),
|
||||||
|
)
|
||||||
|
validation_valid = validation.get("valid") is True
|
||||||
|
validation_error = str(validation["error"]) if validation.get("error") else None
|
||||||
|
validation_warnings = tuple(str(item) for item in validation.get("warnings") or ())
|
||||||
|
if not validation_valid:
|
||||||
|
blockers.append(validation_error or "candidate catalog validation failed")
|
||||||
|
|
||||||
|
catalog_changed = candidate_catalog_hash != target_catalog_hash_before
|
||||||
|
keyring_changed = candidate_keyring_hash != target_keyring_hash_before
|
||||||
|
if not catalog_changed and not keyring_changed:
|
||||||
|
notes.append("Candidate catalog and keyring already match the website repository.")
|
||||||
|
|
||||||
|
if apply or commit or tag or push:
|
||||||
|
if not (resolved_web_root / ".git").exists():
|
||||||
|
blockers.append(f"website root is not a Git repository: {resolved_web_root}")
|
||||||
|
if not allow_dirty_website and website_dirty(resolved_web_root):
|
||||||
|
blockers.append("website repository has uncommitted changes")
|
||||||
|
|
||||||
|
effective_branch = branch or git_text(resolved_web_root, "branch", "--show-current") or None
|
||||||
|
effective_tag_name = tag_name or default_tag_name(candidate_payload, channel=channel)
|
||||||
|
if (tag or push) and not effective_tag_name:
|
||||||
|
blockers.append("could not infer catalog tag name; pass --tag-name")
|
||||||
|
if (commit or push) and not effective_branch:
|
||||||
|
blockers.append("could not infer website branch; pass --branch")
|
||||||
|
if tag and effective_tag_name and git_success(resolved_web_root, "rev-parse", "--quiet", "--verify", f"refs/tags/{effective_tag_name}"):
|
||||||
|
blockers.append(f"website tag already exists: {effective_tag_name}")
|
||||||
|
|
||||||
|
steps.append(
|
||||||
|
CatalogPublishStep(
|
||||||
|
id="validate",
|
||||||
|
title="Validate candidate catalog",
|
||||||
|
detail="Validate signature, approved channel, freshness, interface closure, and keyring trust.",
|
||||||
|
status="done" if validation_valid else "blocked",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
steps.append(
|
||||||
|
CatalogPublishStep(
|
||||||
|
id="copy",
|
||||||
|
title="Copy candidate catalog, keyring, and module directory",
|
||||||
|
detail=f"{candidate_root} -> {resolved_web_root / 'public' / 'catalogs' / 'v1'}",
|
||||||
|
mutating=True,
|
||||||
|
status="planned" if not apply else "blocked" if blockers else "done",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not candidate_modules.exists():
|
||||||
|
notes.append("Candidate has no module-directory tree; only catalog and keyring will be copied.")
|
||||||
|
if build_web:
|
||||||
|
steps.append(
|
||||||
|
CatalogPublishStep(
|
||||||
|
id="build-web",
|
||||||
|
title="Build website",
|
||||||
|
detail="Run the website build after copying catalog files.",
|
||||||
|
command=shlex.join([npm, "--prefix", str(resolved_web_root), "run", "build"]),
|
||||||
|
mutating=True,
|
||||||
|
status="planned" if not apply else "blocked" if blockers else "pending",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if commit:
|
||||||
|
steps.append(
|
||||||
|
CatalogPublishStep(
|
||||||
|
id="commit",
|
||||||
|
title="Commit website catalog update",
|
||||||
|
detail="Commit generated catalog/keyring files in the website repository.",
|
||||||
|
command=shlex.join(["git", "-C", str(resolved_web_root), "commit", "-m", commit_message(candidate_payload, channel=channel)]),
|
||||||
|
mutating=True,
|
||||||
|
status="planned" if not apply else "blocked" if blockers else "pending",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if tag:
|
||||||
|
steps.append(
|
||||||
|
CatalogPublishStep(
|
||||||
|
id="tag",
|
||||||
|
title=f"Tag website catalog publication {effective_tag_name}",
|
||||||
|
detail="Create an annotated website publication tag.",
|
||||||
|
command=shlex.join(["git", "-C", str(resolved_web_root), "tag", "-a", effective_tag_name, "-m", commit_message(candidate_payload, channel=channel)]),
|
||||||
|
mutating=True,
|
||||||
|
status="planned" if not apply else "blocked" if blockers else "pending",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if push:
|
||||||
|
steps.append(
|
||||||
|
CatalogPublishStep(
|
||||||
|
id="push",
|
||||||
|
title="Push website catalog publication",
|
||||||
|
detail="Push website branch and tag if a tag was requested.",
|
||||||
|
command=push_command(resolved_web_root, remote=remote, branch=effective_branch, tag_name=effective_tag_name if tag else None),
|
||||||
|
mutating=True,
|
||||||
|
status="planned" if not apply else "blocked" if blockers else "pending",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if blockers:
|
||||||
|
return result(
|
||||||
|
status="blocked",
|
||||||
|
applied=False,
|
||||||
|
candidate_root=candidate_root,
|
||||||
|
candidate_catalog=candidate_catalog,
|
||||||
|
candidate_keyring=candidate_keyring,
|
||||||
|
resolved_web_root=resolved_web_root,
|
||||||
|
target_catalog=target_catalog,
|
||||||
|
target_keyring=target_keyring,
|
||||||
|
channel=channel,
|
||||||
|
branch=effective_branch,
|
||||||
|
tag_name=effective_tag_name,
|
||||||
|
validation_valid=validation_valid,
|
||||||
|
validation_error=validation_error,
|
||||||
|
validation_warnings=validation_warnings,
|
||||||
|
candidate_catalog_hash=candidate_catalog_hash,
|
||||||
|
candidate_keyring_hash=candidate_keyring_hash,
|
||||||
|
target_catalog_hash_before=target_catalog_hash_before,
|
||||||
|
target_keyring_hash_before=target_keyring_hash_before,
|
||||||
|
catalog_changed=catalog_changed,
|
||||||
|
keyring_changed=keyring_changed,
|
||||||
|
steps=tuple(steps),
|
||||||
|
notes=tuple([*notes, *blockers]),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not apply:
|
||||||
|
notes.append("Dry run only. Pass --apply to copy files; pass --commit/--tag/--push for Git publication steps.")
|
||||||
|
return result(
|
||||||
|
status="ready",
|
||||||
|
applied=False,
|
||||||
|
candidate_root=candidate_root,
|
||||||
|
candidate_catalog=candidate_catalog,
|
||||||
|
candidate_keyring=candidate_keyring,
|
||||||
|
resolved_web_root=resolved_web_root,
|
||||||
|
target_catalog=target_catalog,
|
||||||
|
target_keyring=target_keyring,
|
||||||
|
channel=channel,
|
||||||
|
branch=effective_branch,
|
||||||
|
tag_name=effective_tag_name,
|
||||||
|
validation_valid=validation_valid,
|
||||||
|
validation_error=validation_error,
|
||||||
|
validation_warnings=validation_warnings,
|
||||||
|
candidate_catalog_hash=candidate_catalog_hash,
|
||||||
|
candidate_keyring_hash=candidate_keyring_hash,
|
||||||
|
target_catalog_hash_before=target_catalog_hash_before,
|
||||||
|
target_keyring_hash_before=target_keyring_hash_before,
|
||||||
|
catalog_changed=catalog_changed,
|
||||||
|
keyring_changed=keyring_changed,
|
||||||
|
steps=tuple(steps),
|
||||||
|
notes=tuple(notes),
|
||||||
|
)
|
||||||
|
|
||||||
|
target_catalog.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(candidate_catalog, target_catalog)
|
||||||
|
shutil.copy2(candidate_keyring, target_keyring)
|
||||||
|
if candidate_modules.exists():
|
||||||
|
if target_modules.exists():
|
||||||
|
shutil.rmtree(target_modules)
|
||||||
|
shutil.copytree(candidate_modules, target_modules)
|
||||||
|
|
||||||
|
completed_steps = [replace_status(step, "done") if step.id == "copy" else step for step in steps]
|
||||||
|
if build_web:
|
||||||
|
run_checked([npm, "--prefix", str(resolved_web_root), "run", "build"], cwd=resolved_web_root)
|
||||||
|
completed_steps = [replace_status(step, "done") if step.id == "build-web" else step for step in completed_steps]
|
||||||
|
if commit:
|
||||||
|
add_paths = ["git", "-C", str(resolved_web_root), "add", str(target_catalog), str(target_keyring)]
|
||||||
|
if target_modules.exists():
|
||||||
|
add_paths.append(str(target_modules))
|
||||||
|
run_checked(add_paths, cwd=resolved_web_root)
|
||||||
|
if git_success(resolved_web_root, "diff", "--cached", "--quiet"):
|
||||||
|
notes.append("No website catalog changes were staged for commit.")
|
||||||
|
completed_steps = [replace_status(step, "skipped") if step.id == "commit" else step for step in completed_steps]
|
||||||
|
else:
|
||||||
|
run_checked(["git", "-C", str(resolved_web_root), "commit", "-m", commit_message(candidate_payload, channel=channel)], cwd=resolved_web_root)
|
||||||
|
completed_steps = [replace_status(step, "done") if step.id == "commit" else step for step in completed_steps]
|
||||||
|
if tag and effective_tag_name:
|
||||||
|
run_checked(["git", "-C", str(resolved_web_root), "tag", "-a", effective_tag_name, "-m", commit_message(candidate_payload, channel=channel)], cwd=resolved_web_root)
|
||||||
|
completed_steps = [replace_status(step, "done") if step.id == "tag" else step for step in completed_steps]
|
||||||
|
if push and effective_branch:
|
||||||
|
push_args = ["git", "-C", str(resolved_web_root), "push"]
|
||||||
|
if tag and effective_tag_name:
|
||||||
|
push_args.extend(["--atomic", remote, f"HEAD:refs/heads/{effective_branch}", f"refs/tags/{effective_tag_name}"])
|
||||||
|
else:
|
||||||
|
push_args.extend([remote, f"HEAD:refs/heads/{effective_branch}"])
|
||||||
|
run_checked(push_args, cwd=resolved_web_root)
|
||||||
|
completed_steps = [replace_status(step, "done") if step.id == "push" else step for step in completed_steps]
|
||||||
|
|
||||||
|
return result(
|
||||||
|
status="published" if push else "applied",
|
||||||
|
applied=True,
|
||||||
|
candidate_root=candidate_root,
|
||||||
|
candidate_catalog=candidate_catalog,
|
||||||
|
candidate_keyring=candidate_keyring,
|
||||||
|
resolved_web_root=resolved_web_root,
|
||||||
|
target_catalog=target_catalog,
|
||||||
|
target_keyring=target_keyring,
|
||||||
|
channel=channel,
|
||||||
|
branch=effective_branch,
|
||||||
|
tag_name=effective_tag_name,
|
||||||
|
validation_valid=validation_valid,
|
||||||
|
validation_error=validation_error,
|
||||||
|
validation_warnings=validation_warnings,
|
||||||
|
candidate_catalog_hash=candidate_catalog_hash,
|
||||||
|
candidate_keyring_hash=candidate_keyring_hash,
|
||||||
|
target_catalog_hash_before=target_catalog_hash_before,
|
||||||
|
target_keyring_hash_before=target_keyring_hash_before,
|
||||||
|
catalog_changed=catalog_changed,
|
||||||
|
keyring_changed=keyring_changed,
|
||||||
|
steps=tuple(completed_steps),
|
||||||
|
notes=tuple(notes),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def result(
|
||||||
|
*,
|
||||||
|
status: str,
|
||||||
|
applied: bool,
|
||||||
|
candidate_root: Path,
|
||||||
|
candidate_catalog: Path,
|
||||||
|
candidate_keyring: Path,
|
||||||
|
resolved_web_root: Path,
|
||||||
|
target_catalog: Path,
|
||||||
|
target_keyring: Path,
|
||||||
|
channel: str,
|
||||||
|
branch: str | None,
|
||||||
|
tag_name: str | None,
|
||||||
|
validation_valid: bool,
|
||||||
|
validation_error: str | None,
|
||||||
|
validation_warnings: tuple[str, ...],
|
||||||
|
candidate_catalog_hash: str | None,
|
||||||
|
candidate_keyring_hash: str | None,
|
||||||
|
target_catalog_hash_before: str | None,
|
||||||
|
target_keyring_hash_before: str | None,
|
||||||
|
catalog_changed: bool,
|
||||||
|
keyring_changed: bool,
|
||||||
|
steps: tuple[CatalogPublishStep, ...],
|
||||||
|
notes: tuple[str, ...],
|
||||||
|
) -> CatalogPublishResult:
|
||||||
|
return CatalogPublishResult(
|
||||||
|
generated_at=datetime.now(tz=UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
||||||
|
channel=channel,
|
||||||
|
status=status,
|
||||||
|
applied=applied,
|
||||||
|
candidate_dir=str(candidate_root),
|
||||||
|
candidate_catalog_path=str(candidate_catalog),
|
||||||
|
candidate_keyring_path=str(candidate_keyring),
|
||||||
|
web_root=str(resolved_web_root),
|
||||||
|
target_catalog_path=str(target_catalog),
|
||||||
|
target_keyring_path=str(target_keyring),
|
||||||
|
branch=branch,
|
||||||
|
tag_name=tag_name,
|
||||||
|
validation_valid=validation_valid,
|
||||||
|
validation_error=validation_error,
|
||||||
|
validation_warnings=validation_warnings,
|
||||||
|
candidate_catalog_hash=candidate_catalog_hash,
|
||||||
|
candidate_keyring_hash=candidate_keyring_hash,
|
||||||
|
target_catalog_hash_before=target_catalog_hash_before,
|
||||||
|
target_keyring_hash_before=target_keyring_hash_before,
|
||||||
|
catalog_changed=catalog_changed,
|
||||||
|
keyring_changed=keyring_changed,
|
||||||
|
steps=steps,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def read_json(path: Path) -> object:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def file_json_hash(path: Path) -> str | None:
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
return canonical_hash(read_json(path))
|
||||||
|
|
||||||
|
|
||||||
|
def website_dirty(path: Path) -> bool:
|
||||||
|
if not (path / ".git").exists():
|
||||||
|
return False
|
||||||
|
return bool(git_text(path, "status", "--porcelain"))
|
||||||
|
|
||||||
|
|
||||||
|
def default_tag_name(payload: object, *, channel: str) -> str | None:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
sequence = payload.get("sequence")
|
||||||
|
release = payload.get("release")
|
||||||
|
selected_units = release.get("selected_units") if isinstance(release, dict) else None
|
||||||
|
if isinstance(selected_units, list) and len(selected_units) == 1 and isinstance(selected_units[0], dict):
|
||||||
|
repo = str(selected_units[0].get("repo") or "catalog")
|
||||||
|
version = str(selected_units[0].get("version") or "").removeprefix("v")
|
||||||
|
if version:
|
||||||
|
return f"catalog-{channel}-{repo}-v{version}"
|
||||||
|
return f"catalog-{channel}-{sequence}" if sequence is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def commit_message(payload: object, *, channel: str) -> str:
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
release = payload.get("release")
|
||||||
|
selected_units = release.get("selected_units") if isinstance(release, dict) else None
|
||||||
|
if isinstance(selected_units, list) and selected_units:
|
||||||
|
units = ", ".join(
|
||||||
|
f"{item.get('repo')} v{str(item.get('version') or '').removeprefix('v')}"
|
||||||
|
for item in selected_units
|
||||||
|
if isinstance(item, dict)
|
||||||
|
)
|
||||||
|
if units:
|
||||||
|
return f"Publish {channel} catalog for {units}"
|
||||||
|
return f"Publish {channel} catalog"
|
||||||
|
|
||||||
|
|
||||||
|
def push_command(web_root: Path, *, remote: str, branch: str | None, tag_name: str | None) -> str:
|
||||||
|
if tag_name:
|
||||||
|
return shlex.join(["git", "-C", str(web_root), "push", "--atomic", remote, f"HEAD:refs/heads/{branch or '<branch>'}", f"refs/tags/{tag_name}"])
|
||||||
|
return shlex.join(["git", "-C", str(web_root), "push", remote, f"HEAD:refs/heads/{branch or '<branch>'}"])
|
||||||
|
|
||||||
|
|
||||||
|
def replace_status(step: CatalogPublishStep, status: str) -> CatalogPublishStep:
|
||||||
|
return CatalogPublishStep(
|
||||||
|
id=step.id,
|
||||||
|
title=step.title,
|
||||||
|
detail=step.detail,
|
||||||
|
command=step.command,
|
||||||
|
mutating=step.mutating,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def git_text(path: Path, *args: str) -> str:
|
||||||
|
result = subprocess.run(["git", *args], cwd=path, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
return result.stdout.strip() if result.returncode == 0 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def git_success(path: Path, *args: str) -> bool:
|
||||||
|
return subprocess.run(["git", *args], cwd=path, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def run_checked(args: list[str], *, cwd: Path) -> None:
|
||||||
|
subprocess.run(args, cwd=cwd, check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
434
tools/release/govoplan_release/release_intelligence.py
Normal file
434
tools/release/govoplan_release/release_intelligence.py
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
"""Release catalog intelligence for the local release cockpit."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
|
||||||
|
from .catalog import DEFAULT_PUBLIC_BASE_URL, canonical_hash, fetch_json
|
||||||
|
from .git_state import collect_repository_snapshot
|
||||||
|
from .workspace import load_repository_specs, resolve_workspace_root, website_root
|
||||||
|
|
||||||
|
|
||||||
|
def build_release_intelligence(
|
||||||
|
*,
|
||||||
|
workspace_root: Path | str | None = None,
|
||||||
|
channel: str = "stable",
|
||||||
|
public_base_url: str = DEFAULT_PUBLIC_BASE_URL,
|
||||||
|
prefer_public: bool = True,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
workspace = resolve_workspace_root(workspace_root)
|
||||||
|
web_root = website_root(workspace)
|
||||||
|
local_catalog_path = web_root / "public" / "catalogs" / "v1" / "channels" / f"{channel}.json"
|
||||||
|
local_keyring_path = web_root / "public" / "catalogs" / "v1" / "keyring.json"
|
||||||
|
public_catalog_url = f"{public_base_url.rstrip('/')}/catalogs/v1/channels/{channel}.json"
|
||||||
|
public_keyring_url = f"{public_base_url.rstrip('/')}/catalogs/v1/keyring.json"
|
||||||
|
|
||||||
|
local_catalog = read_json_file(local_catalog_path)
|
||||||
|
local_keyring = read_json_file(local_keyring_path)
|
||||||
|
public_catalog = fetch_json(public_catalog_url) if prefer_public else {"ok": False, "error": "public check disabled"}
|
||||||
|
public_keyring = fetch_json(public_keyring_url) if prefer_public else {"ok": False, "error": "public check disabled"}
|
||||||
|
|
||||||
|
catalog_source = "public" if prefer_public and public_catalog.get("ok") else "local"
|
||||||
|
keyring_source = "public" if prefer_public and public_keyring.get("ok") else "local"
|
||||||
|
catalog_payload = public_catalog["payload"] if catalog_source == "public" else local_catalog.get("payload")
|
||||||
|
keyring_payload = public_keyring["payload"] if keyring_source == "public" else local_keyring.get("payload")
|
||||||
|
|
||||||
|
modules = module_rows(catalog_payload)
|
||||||
|
compatibility = compatibility_rows(modules)
|
||||||
|
signatures = signature_rows(catalog_payload, keyring_payload)
|
||||||
|
local_versions = repository_versions(workspace)
|
||||||
|
for module in modules:
|
||||||
|
repo = module.get("repo")
|
||||||
|
module["local_version"] = local_versions.get(repo) if isinstance(repo, str) else None
|
||||||
|
module["local_matches_catalog"] = module.get("version") == module.get("local_version") if module.get("local_version") else None
|
||||||
|
|
||||||
|
local_catalog_hash = canonical_hash(local_catalog["payload"]) if local_catalog.get("ok") else None
|
||||||
|
public_catalog_hash = canonical_hash(public_catalog["payload"]) if public_catalog.get("ok") else None
|
||||||
|
local_keyring_hash = canonical_hash(local_keyring["payload"]) if local_keyring.get("ok") else None
|
||||||
|
public_keyring_hash = canonical_hash(public_keyring["payload"]) if public_keyring.get("ok") else None
|
||||||
|
issues = health_issues(
|
||||||
|
catalog_payload=catalog_payload,
|
||||||
|
keyring_payload=keyring_payload,
|
||||||
|
local_catalog_hash=local_catalog_hash,
|
||||||
|
public_catalog_hash=public_catalog_hash,
|
||||||
|
local_keyring_hash=local_keyring_hash,
|
||||||
|
public_keyring_hash=public_keyring_hash,
|
||||||
|
signatures=signatures,
|
||||||
|
compatibility=compatibility,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"generated_at": json_datetime(datetime.now(tz=UTC)),
|
||||||
|
"channel": channel,
|
||||||
|
"catalog_source": catalog_source,
|
||||||
|
"keyring_source": keyring_source,
|
||||||
|
"catalog": catalog_summary(
|
||||||
|
catalog_payload,
|
||||||
|
source=catalog_source,
|
||||||
|
local_path=str(local_catalog_path),
|
||||||
|
public_url=public_catalog_url,
|
||||||
|
local_ok=bool(local_catalog.get("ok")),
|
||||||
|
public_ok=bool(public_catalog.get("ok")),
|
||||||
|
local_hash=local_catalog_hash,
|
||||||
|
public_hash=public_catalog_hash,
|
||||||
|
public_error=str(public_catalog.get("error")) if public_catalog.get("error") else None,
|
||||||
|
),
|
||||||
|
"keyring": keyring_summary(
|
||||||
|
keyring_payload,
|
||||||
|
source=keyring_source,
|
||||||
|
local_path=str(local_keyring_path),
|
||||||
|
public_url=public_keyring_url,
|
||||||
|
local_ok=bool(local_keyring.get("ok")),
|
||||||
|
public_ok=bool(public_keyring.get("ok")),
|
||||||
|
local_hash=local_keyring_hash,
|
||||||
|
public_hash=public_keyring_hash,
|
||||||
|
public_error=str(public_keyring.get("error")) if public_keyring.get("error") else None,
|
||||||
|
),
|
||||||
|
"modules": modules,
|
||||||
|
"signatures": signatures,
|
||||||
|
"compatibility": compatibility,
|
||||||
|
"issues": issues,
|
||||||
|
"summary": {
|
||||||
|
"status": status_from_issues(issues),
|
||||||
|
"published_modules": len(modules),
|
||||||
|
"signed": bool(signatures),
|
||||||
|
"trusted_signatures": sum(1 for item in signatures if item.get("trusted") is True),
|
||||||
|
"compatibility_blockers": sum(1 for item in compatibility if item.get("severity") == "blocker"),
|
||||||
|
"compatibility_warnings": sum(1 for item in compatibility if item.get("severity") == "warning"),
|
||||||
|
"catalog_matches_public": compare_hashes(local_catalog_hash, public_catalog_hash),
|
||||||
|
"keyring_matches_public": compare_hashes(local_keyring_hash, public_keyring_hash),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def read_json_file(path: Path) -> dict[str, object]:
|
||||||
|
if not path.exists():
|
||||||
|
return {"ok": False, "error": "missing"}
|
||||||
|
try:
|
||||||
|
return {"ok": True, "payload": json.loads(path.read_text(encoding="utf-8"))}
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
return {"ok": False, "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_summary(
|
||||||
|
payload: object,
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
local_path: str,
|
||||||
|
public_url: str,
|
||||||
|
local_ok: bool,
|
||||||
|
public_ok: bool,
|
||||||
|
local_hash: str | None,
|
||||||
|
public_hash: str | None,
|
||||||
|
public_error: str | None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
catalog = payload if isinstance(payload, dict) else {}
|
||||||
|
core = catalog.get("core_release") if isinstance(catalog.get("core_release"), dict) else {}
|
||||||
|
modules = catalog.get("modules") if isinstance(catalog.get("modules"), list) else []
|
||||||
|
signatures = catalog.get("signatures") if isinstance(catalog.get("signatures"), list) else []
|
||||||
|
return {
|
||||||
|
"source": source,
|
||||||
|
"local_path": local_path,
|
||||||
|
"public_url": public_url,
|
||||||
|
"local_ok": local_ok,
|
||||||
|
"public_ok": public_ok,
|
||||||
|
"public_error": public_error,
|
||||||
|
"local_hash": local_hash,
|
||||||
|
"public_hash": public_hash,
|
||||||
|
"matches_public": compare_hashes(local_hash, public_hash),
|
||||||
|
"catalog_version": catalog.get("catalog_version"),
|
||||||
|
"sequence": catalog.get("sequence"),
|
||||||
|
"generated_at": catalog.get("generated_at"),
|
||||||
|
"expires_at": catalog.get("expires_at"),
|
||||||
|
"core_version": core.get("version") if isinstance(core, dict) else None,
|
||||||
|
"module_count": len(modules),
|
||||||
|
"signature_count": len(signatures),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def keyring_summary(
|
||||||
|
payload: object,
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
local_path: str,
|
||||||
|
public_url: str,
|
||||||
|
local_ok: bool,
|
||||||
|
public_ok: bool,
|
||||||
|
local_hash: str | None,
|
||||||
|
public_hash: str | None,
|
||||||
|
public_error: str | None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
keyring = payload if isinstance(payload, dict) else {}
|
||||||
|
keys = keyring.get("keys") if isinstance(keyring.get("keys"), list) else []
|
||||||
|
return {
|
||||||
|
"source": source,
|
||||||
|
"local_path": local_path,
|
||||||
|
"public_url": public_url,
|
||||||
|
"local_ok": local_ok,
|
||||||
|
"public_ok": public_ok,
|
||||||
|
"public_error": public_error,
|
||||||
|
"local_hash": local_hash,
|
||||||
|
"public_hash": public_hash,
|
||||||
|
"matches_public": compare_hashes(local_hash, public_hash),
|
||||||
|
"generated_at": keyring.get("generated_at"),
|
||||||
|
"key_count": len(keys),
|
||||||
|
"active_key_count": sum(1 for item in keys if isinstance(item, dict) and item.get("status") == "active"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def module_rows(payload: object) -> list[dict[str, object]]:
|
||||||
|
catalog = payload if isinstance(payload, dict) else {}
|
||||||
|
raw_modules = catalog.get("modules") if isinstance(catalog.get("modules"), list) else []
|
||||||
|
modules: list[dict[str, object]] = []
|
||||||
|
for item in raw_modules:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
repo = module_repo(item)
|
||||||
|
modules.append(
|
||||||
|
{
|
||||||
|
"module_id": string(item.get("module_id")),
|
||||||
|
"name": string(item.get("name")),
|
||||||
|
"version": string(item.get("version")),
|
||||||
|
"repo": repo,
|
||||||
|
"python_ref": string(item.get("python_ref")),
|
||||||
|
"python_tag": ref_tag(string(item.get("python_ref"))),
|
||||||
|
"webui_ref": string(item.get("webui_ref")),
|
||||||
|
"webui_tag": ref_tag(string(item.get("webui_ref"))),
|
||||||
|
"provides_interfaces": interface_list(item.get("provides_interfaces")),
|
||||||
|
"requires_interfaces": requirement_list(item.get("requires_interfaces")),
|
||||||
|
"migration_safety": string(item.get("migration_safety")),
|
||||||
|
"tags": tuple(str(value) for value in item.get("tags", ()) if isinstance(value, str)) if isinstance(item.get("tags"), list) else (),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return sorted(modules, key=lambda row: str(row.get("module_id") or row.get("repo") or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def module_repo(entry: dict[str, object]) -> str | None:
|
||||||
|
for field in ("python_ref", "webui_ref"):
|
||||||
|
value = entry.get(field)
|
||||||
|
if isinstance(value, str):
|
||||||
|
match = re.search(r"/([^/@#]+)[.]git(?:[@#]|$)", value)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
package = entry.get("python_package")
|
||||||
|
if isinstance(package, str) and package.startswith("govoplan-"):
|
||||||
|
return package.split("[", 1)[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def ref_tag(value: str | None) -> str | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
if "#" in value:
|
||||||
|
return value.rsplit("#", 1)[-1]
|
||||||
|
if "@" in value:
|
||||||
|
return value.rsplit("@", 1)[-1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def interface_list(value: object) -> tuple[dict[str, str], ...]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return ()
|
||||||
|
result: list[dict[str, str]] = []
|
||||||
|
for item in value:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
name = string(item.get("name"))
|
||||||
|
version = string(item.get("version"))
|
||||||
|
if name and version:
|
||||||
|
result.append({"name": name, "version": version})
|
||||||
|
return tuple(result)
|
||||||
|
|
||||||
|
|
||||||
|
def requirement_list(value: object) -> tuple[dict[str, object], ...]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return ()
|
||||||
|
result: list[dict[str, object]] = []
|
||||||
|
for item in value:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
name = string(item.get("name"))
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"version_min": string(item.get("version_min")),
|
||||||
|
"version_max_exclusive": string(item.get("version_max_exclusive")),
|
||||||
|
"optional": bool(item.get("optional")),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return tuple(result)
|
||||||
|
|
||||||
|
|
||||||
|
def compatibility_rows(modules: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||||
|
providers: dict[str, list[dict[str, object]]] = {}
|
||||||
|
for module in modules:
|
||||||
|
for provided in module.get("provides_interfaces", ()):
|
||||||
|
if isinstance(provided, dict):
|
||||||
|
providers.setdefault(str(provided.get("name")), []).append(
|
||||||
|
{
|
||||||
|
"module_id": module.get("module_id"),
|
||||||
|
"repo": module.get("repo"),
|
||||||
|
"version": provided.get("version"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
rows: list[dict[str, object]] = []
|
||||||
|
for module in modules:
|
||||||
|
for requirement in module.get("requires_interfaces", ()):
|
||||||
|
if not isinstance(requirement, dict):
|
||||||
|
continue
|
||||||
|
name = string(requirement.get("name"))
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
optional = bool(requirement.get("optional"))
|
||||||
|
available = providers.get(name, [])
|
||||||
|
satisfied = [provider for provider in available if version_satisfies(string(provider.get("version")), requirement)]
|
||||||
|
if satisfied:
|
||||||
|
severity = "ok"
|
||||||
|
status = "satisfied"
|
||||||
|
elif available:
|
||||||
|
severity = "warning" if optional else "blocker"
|
||||||
|
status = "version-mismatch"
|
||||||
|
else:
|
||||||
|
severity = "info" if optional else "blocker"
|
||||||
|
status = "optional-missing" if optional else "missing"
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"module_id": module.get("module_id"),
|
||||||
|
"repo": module.get("repo"),
|
||||||
|
"interface": name,
|
||||||
|
"requirement": format_requirement(requirement),
|
||||||
|
"optional": optional,
|
||||||
|
"status": status,
|
||||||
|
"severity": severity,
|
||||||
|
"providers": available,
|
||||||
|
"satisfied_by": satisfied,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return sorted(rows, key=lambda row: (str(row.get("severity")), str(row.get("module_id")), str(row.get("interface"))))
|
||||||
|
|
||||||
|
|
||||||
|
def signature_rows(catalog_payload: object, keyring_payload: object) -> list[dict[str, object]]:
|
||||||
|
catalog = catalog_payload if isinstance(catalog_payload, dict) else {}
|
||||||
|
keyring = keyring_payload if isinstance(keyring_payload, dict) else {}
|
||||||
|
signatures = catalog.get("signatures") if isinstance(catalog.get("signatures"), list) else []
|
||||||
|
keys = keyring.get("keys") if isinstance(keyring.get("keys"), list) else []
|
||||||
|
trusted = {
|
||||||
|
string(item.get("key_id")): item
|
||||||
|
for item in keys
|
||||||
|
if isinstance(item, dict) and string(item.get("key_id"))
|
||||||
|
}
|
||||||
|
rows: list[dict[str, object]] = []
|
||||||
|
for item in signatures:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
key_id = string(item.get("key_id"))
|
||||||
|
key = trusted.get(key_id)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"key_id": key_id,
|
||||||
|
"algorithm": string(item.get("algorithm")),
|
||||||
|
"trusted": key is not None and key.get("status") == "active",
|
||||||
|
"key_status": string(key.get("status")) if isinstance(key, dict) else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def repository_versions(workspace: Path) -> dict[str | None, str | None]:
|
||||||
|
versions: dict[str | None, str | None] = {}
|
||||||
|
for spec in load_repository_specs(include_website=False):
|
||||||
|
snapshot = collect_repository_snapshot(spec, workspace_root=workspace, target_tag=None, online=False)
|
||||||
|
versions[spec.name] = snapshot.versions.primary
|
||||||
|
return versions
|
||||||
|
|
||||||
|
|
||||||
|
def health_issues(
|
||||||
|
*,
|
||||||
|
catalog_payload: object,
|
||||||
|
keyring_payload: object,
|
||||||
|
local_catalog_hash: str | None,
|
||||||
|
public_catalog_hash: str | None,
|
||||||
|
local_keyring_hash: str | None,
|
||||||
|
public_keyring_hash: str | None,
|
||||||
|
signatures: list[dict[str, object]],
|
||||||
|
compatibility: list[dict[str, object]],
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
issues: list[dict[str, str]] = []
|
||||||
|
if not isinstance(catalog_payload, dict):
|
||||||
|
issues.append({"severity": "blocker", "code": "catalog_missing", "message": "No usable catalog is available."})
|
||||||
|
if not isinstance(keyring_payload, dict):
|
||||||
|
issues.append({"severity": "blocker", "code": "keyring_missing", "message": "No usable keyring is available."})
|
||||||
|
if not signatures:
|
||||||
|
issues.append({"severity": "blocker", "code": "catalog_unsigned", "message": "The selected catalog has no signatures."})
|
||||||
|
elif not any(item.get("trusted") is True for item in signatures):
|
||||||
|
issues.append({"severity": "blocker", "code": "signature_untrusted", "message": "No catalog signature matches an active keyring key."})
|
||||||
|
if compare_hashes(local_catalog_hash, public_catalog_hash) is False:
|
||||||
|
issues.append({"severity": "warning", "code": "catalog_drift", "message": "Local and published catalogs differ."})
|
||||||
|
if compare_hashes(local_keyring_hash, public_keyring_hash) is False:
|
||||||
|
issues.append({"severity": "warning", "code": "keyring_drift", "message": "Local and published keyrings differ."})
|
||||||
|
blockers = [item for item in compatibility if item.get("severity") == "blocker"]
|
||||||
|
warnings = [item for item in compatibility if item.get("severity") == "warning"]
|
||||||
|
if blockers:
|
||||||
|
issues.append({"severity": "blocker", "code": "compatibility_blockers", "message": f"{len(blockers)} compatibility blocker(s) found."})
|
||||||
|
if warnings:
|
||||||
|
issues.append({"severity": "warning", "code": "compatibility_warnings", "message": f"{len(warnings)} compatibility warning(s) found."})
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def version_satisfies(version: str | None, requirement: dict[str, object]) -> bool:
|
||||||
|
parsed = parse_version(version)
|
||||||
|
if parsed is None:
|
||||||
|
return False
|
||||||
|
minimum = parse_version(string(requirement.get("version_min")))
|
||||||
|
maximum = parse_version(string(requirement.get("version_max_exclusive")))
|
||||||
|
if minimum is not None and parsed < minimum:
|
||||||
|
return False
|
||||||
|
if maximum is not None and parsed >= maximum:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def parse_version(value: str | None) -> tuple[int, ...] | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
core = value.removeprefix("v").split("-", 1)[0]
|
||||||
|
parts = core.split(".")
|
||||||
|
if not parts or any(not part.isdigit() for part in parts):
|
||||||
|
return None
|
||||||
|
return tuple(int(part) for part in parts)
|
||||||
|
|
||||||
|
|
||||||
|
def format_requirement(requirement: dict[str, object]) -> str:
|
||||||
|
parts: list[str] = []
|
||||||
|
if requirement.get("version_min"):
|
||||||
|
parts.append(f">={requirement['version_min']}")
|
||||||
|
if requirement.get("version_max_exclusive"):
|
||||||
|
parts.append(f"<{requirement['version_max_exclusive']}")
|
||||||
|
return ", ".join(parts) if parts else "any"
|
||||||
|
|
||||||
|
|
||||||
|
def status_from_issues(issues: list[dict[str, str]]) -> str:
|
||||||
|
if any(item.get("severity") == "blocker" for item in issues):
|
||||||
|
return "blocked"
|
||||||
|
if any(item.get("severity") == "warning" for item in issues):
|
||||||
|
return "attention"
|
||||||
|
return "ready"
|
||||||
|
|
||||||
|
|
||||||
|
def compare_hashes(local_hash: str | None, public_hash: str | None) -> bool | None:
|
||||||
|
if local_hash is None or public_hash is None:
|
||||||
|
return None
|
||||||
|
return local_hash == public_hash
|
||||||
|
|
||||||
|
|
||||||
|
def string(value: object) -> str | None:
|
||||||
|
return value if isinstance(value, str) else None
|
||||||
|
|
||||||
|
|
||||||
|
def json_datetime(value: datetime) -> str:
|
||||||
|
return value.replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||||
137
tools/release/govoplan_release/repository_prepare.py
Normal file
137
tools/release/govoplan_release/repository_prepare.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"""Prepare selected repositories by committing reviewed dirty worktrees."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from .git_state import collect_repository_snapshot
|
||||||
|
from .repository_push import command_text, compact_output
|
||||||
|
from .workspace import load_repository_specs, resolve_repo_path, resolve_workspace_root
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_repositories(
|
||||||
|
*,
|
||||||
|
repos: tuple[str, ...],
|
||||||
|
repo_versions: dict[str, str] | None = None,
|
||||||
|
message: str | None = None,
|
||||||
|
workspace_root: Path | str | None = None,
|
||||||
|
apply: bool = False, # noqa: A002 - mirrors API field.
|
||||||
|
) -> dict[str, object]:
|
||||||
|
workspace = resolve_workspace_root(workspace_root)
|
||||||
|
specs = {spec.name: spec for spec in load_repository_specs(include_website=False)}
|
||||||
|
selected = tuple(dict.fromkeys(repos))
|
||||||
|
versions = repo_versions or {}
|
||||||
|
results: list[dict[str, object]] = []
|
||||||
|
|
||||||
|
for repo in selected:
|
||||||
|
spec = specs.get(repo)
|
||||||
|
if spec is None:
|
||||||
|
results.append({"repo": repo, "status": "blocked", "detail": "repository is not listed in repositories.json"})
|
||||||
|
continue
|
||||||
|
snapshot = collect_repository_snapshot(spec, workspace_root=workspace, target_tag=None, online=False)
|
||||||
|
commit_message = resolve_message(repo=repo, version=versions.get(repo), message=message)
|
||||||
|
command = f"{command_text(('git', 'add', '-A'))} && {command_text(('git', 'commit', '-m', commit_message))}"
|
||||||
|
row: dict[str, object] = {
|
||||||
|
"repo": repo,
|
||||||
|
"cwd": snapshot.absolute_path,
|
||||||
|
"branch": snapshot.branch,
|
||||||
|
"upstream": snapshot.upstream,
|
||||||
|
"head": snapshot.head,
|
||||||
|
"ahead": int(snapshot.ahead or 0),
|
||||||
|
"behind": int(snapshot.behind or 0),
|
||||||
|
"dirty_count": len(snapshot.dirty_entries),
|
||||||
|
"dirty_entries": snapshot.dirty_entries,
|
||||||
|
"message": commit_message,
|
||||||
|
"command": command,
|
||||||
|
}
|
||||||
|
if not snapshot.exists or not snapshot.is_git:
|
||||||
|
results.append({**row, "status": "blocked", "detail": "repository is missing or is not a Git repository"})
|
||||||
|
continue
|
||||||
|
if snapshot.safe_directory_required:
|
||||||
|
results.append({**row, "status": "blocked", "detail": "Git safe.directory approval is required"})
|
||||||
|
continue
|
||||||
|
if not snapshot.has_head:
|
||||||
|
results.append({**row, "status": "blocked", "detail": "repository has no HEAD; initialize it outside the release prepare step"})
|
||||||
|
continue
|
||||||
|
if not snapshot.branch:
|
||||||
|
results.append({**row, "status": "blocked", "detail": "repository is not on a named branch"})
|
||||||
|
continue
|
||||||
|
if snapshot.behind:
|
||||||
|
results.append({**row, "status": "blocked", "detail": f"repository is behind {snapshot.upstream}; sync/update before committing"})
|
||||||
|
continue
|
||||||
|
if not snapshot.dirty_entries:
|
||||||
|
results.append({**row, "status": "noop", "detail": "worktree is clean; no prepare commit needed"})
|
||||||
|
continue
|
||||||
|
if not apply:
|
||||||
|
results.append({**row, "status": "planned", "detail": f"{len(snapshot.dirty_entries)} dirty/untracked path(s) will be committed"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
path = resolve_repo_path(spec, workspace)
|
||||||
|
add_result = run(("git", "add", "-A"), cwd=path)
|
||||||
|
if add_result.returncode != 0:
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
**row,
|
||||||
|
"status": "failed",
|
||||||
|
"returncode": add_result.returncode,
|
||||||
|
"detail": "git add failed",
|
||||||
|
"stdout": compact_output(add_result.stdout),
|
||||||
|
"stderr": compact_output(add_result.stderr),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if run(("git", "diff", "--cached", "--quiet"), cwd=path).returncode == 0:
|
||||||
|
results.append({**row, "status": "noop", "detail": "nothing staged after git add -A"})
|
||||||
|
continue
|
||||||
|
commit_result = run(("git", "commit", "-m", commit_message), cwd=path)
|
||||||
|
if commit_result.returncode != 0:
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
**row,
|
||||||
|
"status": "failed",
|
||||||
|
"returncode": commit_result.returncode,
|
||||||
|
"detail": "git commit failed",
|
||||||
|
"stdout": compact_output(commit_result.stdout),
|
||||||
|
"stderr": compact_output(commit_result.stderr),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
after = collect_repository_snapshot(spec, workspace_root=workspace, target_tag=None, online=False)
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
**row,
|
||||||
|
"status": "committed",
|
||||||
|
"returncode": commit_result.returncode,
|
||||||
|
"detail": f"created commit {after.head or 'HEAD'}",
|
||||||
|
"after_head": after.head,
|
||||||
|
"after_ahead": int(after.ahead or 0),
|
||||||
|
"after_dirty_count": len(after.dirty_entries),
|
||||||
|
"stdout": compact_output(commit_result.stdout),
|
||||||
|
"stderr": compact_output(commit_result.stderr),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if any(item["status"] in {"blocked", "failed"} for item in results):
|
||||||
|
status = "blocked" if not apply else "partial"
|
||||||
|
elif any(item["status"] == "committed" for item in results):
|
||||||
|
status = "committed"
|
||||||
|
elif any(item["status"] == "planned" for item in results):
|
||||||
|
status = "planned"
|
||||||
|
else:
|
||||||
|
status = "noop"
|
||||||
|
return {"status": status, "apply": apply, "repositories": results}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_message(*, repo: str, version: str | None, message: str | None) -> str:
|
||||||
|
if message and message.strip():
|
||||||
|
return message.strip()
|
||||||
|
target = f"v{version.removeprefix('v')}" if version else "release"
|
||||||
|
return f"Release {repo} {target}"
|
||||||
|
|
||||||
|
|
||||||
|
def run(command: tuple[str, ...], *, cwd: Path) -> subprocess.CompletedProcess[str]:
|
||||||
|
try:
|
||||||
|
return subprocess.run(command, cwd=cwd, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
return subprocess.CompletedProcess(command, 124, exc.stdout or "", exc.stderr or "git command timed out")
|
||||||
149
tools/release/govoplan_release/repository_push.py
Normal file
149
tools/release/govoplan_release/repository_push.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
"""Preview and run plain Git pushes for selected repositories."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from .git_state import collect_repository_snapshot
|
||||||
|
from .workspace import load_repository_specs, resolve_repo_path, resolve_workspace_root
|
||||||
|
|
||||||
|
|
||||||
|
def push_repositories(
|
||||||
|
*,
|
||||||
|
repos: tuple[str, ...],
|
||||||
|
workspace_root: Path | str | None = None,
|
||||||
|
remote: str = "origin",
|
||||||
|
apply: bool = False, # noqa: A002 - mirrors API field.
|
||||||
|
) -> dict[str, object]:
|
||||||
|
workspace = resolve_workspace_root(workspace_root)
|
||||||
|
remote = remote.strip() or "origin"
|
||||||
|
specs = {spec.name: spec for spec in load_repository_specs(include_website=False)}
|
||||||
|
selected = tuple(dict.fromkeys(repos))
|
||||||
|
results: list[dict[str, object]] = []
|
||||||
|
|
||||||
|
for repo in selected:
|
||||||
|
spec = specs.get(repo)
|
||||||
|
if spec is None:
|
||||||
|
results.append({"repo": repo, "status": "blocked", "detail": "repository is not listed in repositories.json"})
|
||||||
|
continue
|
||||||
|
snapshot = collect_repository_snapshot(spec, workspace_root=workspace, target_tag=None, online=False)
|
||||||
|
has_upstream = bool(snapshot.upstream)
|
||||||
|
ahead = int(snapshot.ahead or 0)
|
||||||
|
behind = int(snapshot.behind or 0)
|
||||||
|
dirty_count = len(snapshot.dirty_entries)
|
||||||
|
command = push_command(snapshot.branch, remote=remote, has_upstream=has_upstream)
|
||||||
|
row: dict[str, object] = {
|
||||||
|
"repo": repo,
|
||||||
|
"cwd": snapshot.absolute_path,
|
||||||
|
"branch": snapshot.branch,
|
||||||
|
"upstream": snapshot.upstream,
|
||||||
|
"head": snapshot.head,
|
||||||
|
"ahead": ahead,
|
||||||
|
"behind": behind,
|
||||||
|
"dirty_count": dirty_count,
|
||||||
|
"push_target": push_target(snapshot.branch, upstream=snapshot.upstream, remote=remote),
|
||||||
|
"command": command_text(command),
|
||||||
|
}
|
||||||
|
if not snapshot.exists or not snapshot.is_git:
|
||||||
|
results.append({**row, "status": "blocked", "detail": "repository is missing or is not a Git repository"})
|
||||||
|
continue
|
||||||
|
if snapshot.safe_directory_required:
|
||||||
|
results.append({**row, "status": "blocked", "detail": "Git safe.directory approval is required"})
|
||||||
|
continue
|
||||||
|
if not snapshot.has_head:
|
||||||
|
results.append({**row, "status": "blocked", "detail": "repository has no commit to push"})
|
||||||
|
continue
|
||||||
|
if not snapshot.branch:
|
||||||
|
results.append({**row, "status": "blocked", "detail": "repository is not on a named branch"})
|
||||||
|
continue
|
||||||
|
if behind:
|
||||||
|
results.append({**row, "status": "blocked", "detail": f"repository is behind {snapshot.upstream}"})
|
||||||
|
continue
|
||||||
|
if not apply:
|
||||||
|
if has_upstream:
|
||||||
|
status = "planned" if ahead else "noop"
|
||||||
|
detail = (
|
||||||
|
f"{ahead} commit(s) will be pushed to {row['push_target']}"
|
||||||
|
if ahead
|
||||||
|
else "branch has no unpushed commits; nothing would be pushed"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
status = "planned"
|
||||||
|
detail = f"branch has no upstream; HEAD will be pushed to {row['push_target']}"
|
||||||
|
if dirty_count:
|
||||||
|
detail = f"{detail}; {dirty_count} dirty/untracked path(s) stay local"
|
||||||
|
results.append({**row, "status": status, "detail": detail})
|
||||||
|
continue
|
||||||
|
if has_upstream and ahead == 0:
|
||||||
|
detail = "branch has no unpushed commits; nothing was pushed"
|
||||||
|
if dirty_count:
|
||||||
|
detail = f"{detail}; {dirty_count} dirty/untracked path(s) stay local"
|
||||||
|
results.append({**row, "status": "noop", "detail": detail, "command_ran": False})
|
||||||
|
continue
|
||||||
|
|
||||||
|
process = run(command, cwd=resolve_repo_path(spec, workspace))
|
||||||
|
status = "pushed" if process.returncode == 0 else "failed"
|
||||||
|
detail = (
|
||||||
|
f"pushed {ahead} commit(s) to {row['push_target']}"
|
||||||
|
if process.returncode == 0 and has_upstream
|
||||||
|
else f"pushed branch to {row['push_target']}"
|
||||||
|
if process.returncode == 0
|
||||||
|
else "git push failed"
|
||||||
|
)
|
||||||
|
if process.returncode == 0 and dirty_count:
|
||||||
|
detail = f"{detail}; {dirty_count} dirty/untracked path(s) stay local"
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
**row,
|
||||||
|
"status": status,
|
||||||
|
"returncode": process.returncode,
|
||||||
|
"detail": detail,
|
||||||
|
"command_ran": True,
|
||||||
|
"stdout": compact_output(process.stdout),
|
||||||
|
"stderr": compact_output(process.stderr),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if any(item["status"] in {"blocked", "failed"} for item in results):
|
||||||
|
status = "blocked" if not apply else "partial"
|
||||||
|
elif any(item["status"] == "pushed" for item in results):
|
||||||
|
status = "pushed"
|
||||||
|
elif any(item["status"] == "planned" for item in results):
|
||||||
|
status = "planned"
|
||||||
|
else:
|
||||||
|
status = "noop"
|
||||||
|
return {"status": status, "apply": apply, "remote": remote, "repositories": results}
|
||||||
|
|
||||||
|
|
||||||
|
def push_command(branch: str | None, *, remote: str, has_upstream: bool) -> tuple[str, ...]:
|
||||||
|
if has_upstream:
|
||||||
|
return ("git", "push")
|
||||||
|
if branch:
|
||||||
|
return ("git", "push", "-u", remote, "HEAD")
|
||||||
|
return ("git", "push", remote)
|
||||||
|
|
||||||
|
|
||||||
|
def push_target(branch: str | None, *, upstream: str | None, remote: str) -> str:
|
||||||
|
if upstream:
|
||||||
|
return upstream
|
||||||
|
if branch:
|
||||||
|
return f"{remote}/{branch}"
|
||||||
|
return remote
|
||||||
|
|
||||||
|
|
||||||
|
def command_text(command: tuple[str, ...]) -> str:
|
||||||
|
return " ".join(shlex.quote(part) for part in command)
|
||||||
|
|
||||||
|
|
||||||
|
def run(command: tuple[str, ...], *, cwd: Path) -> subprocess.CompletedProcess[str]:
|
||||||
|
try:
|
||||||
|
return subprocess.run(command, cwd=cwd, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
return subprocess.CompletedProcess(command, 124, exc.stdout or "", exc.stderr or "git push timed out")
|
||||||
|
|
||||||
|
|
||||||
|
def compact_output(value: str) -> str:
|
||||||
|
text = value.strip()
|
||||||
|
return text if len(text) <= 4000 else f"{text[:4000]}\n... truncated ..."
|
||||||
99
tools/release/govoplan_release/repository_sync.py
Normal file
99
tools/release/govoplan_release/repository_sync.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""Fetch remote refs for selected repositories without merging."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from .git_state import collect_repository_snapshot
|
||||||
|
from .repository_push import command_text, compact_output
|
||||||
|
from .workspace import load_repository_specs, resolve_repo_path, resolve_workspace_root
|
||||||
|
|
||||||
|
|
||||||
|
def sync_repositories(
|
||||||
|
*,
|
||||||
|
repos: tuple[str, ...],
|
||||||
|
workspace_root: Path | str | None = None,
|
||||||
|
remote: str = "origin",
|
||||||
|
apply: bool = False, # noqa: A002 - mirrors API field.
|
||||||
|
) -> dict[str, object]:
|
||||||
|
workspace = resolve_workspace_root(workspace_root)
|
||||||
|
remote = remote.strip() or "origin"
|
||||||
|
specs = {spec.name: spec for spec in load_repository_specs(include_website=False)}
|
||||||
|
selected = tuple(dict.fromkeys(repos))
|
||||||
|
results: list[dict[str, object]] = []
|
||||||
|
command = ("git", "fetch", "--prune", "--tags", remote)
|
||||||
|
|
||||||
|
for repo in selected:
|
||||||
|
spec = specs.get(repo)
|
||||||
|
if spec is None:
|
||||||
|
results.append({"repo": repo, "status": "blocked", "detail": "repository is not listed in repositories.json"})
|
||||||
|
continue
|
||||||
|
before = collect_repository_snapshot(spec, workspace_root=workspace, target_tag=None, online=False)
|
||||||
|
row: dict[str, object] = {
|
||||||
|
"repo": repo,
|
||||||
|
"cwd": before.absolute_path,
|
||||||
|
"branch": before.branch,
|
||||||
|
"upstream": before.upstream,
|
||||||
|
"head": before.head,
|
||||||
|
"ahead": int(before.ahead or 0),
|
||||||
|
"behind": int(before.behind or 0),
|
||||||
|
"dirty_count": len(before.dirty_entries),
|
||||||
|
"remote": remote,
|
||||||
|
"command": command_text(command),
|
||||||
|
}
|
||||||
|
if not before.exists or not before.is_git:
|
||||||
|
results.append({**row, "status": "blocked", "detail": "repository is missing or is not a Git repository"})
|
||||||
|
continue
|
||||||
|
if before.safe_directory_required:
|
||||||
|
results.append({**row, "status": "blocked", "detail": "Git safe.directory approval is required"})
|
||||||
|
continue
|
||||||
|
if not apply:
|
||||||
|
results.append({**row, "status": "planned", "detail": "fetch remote refs and tags; no merge or rebase will be run"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
process = run(command, cwd=resolve_repo_path(spec, workspace))
|
||||||
|
if process.returncode != 0:
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
**row,
|
||||||
|
"status": "failed",
|
||||||
|
"returncode": process.returncode,
|
||||||
|
"detail": "git fetch failed",
|
||||||
|
"stdout": compact_output(process.stdout),
|
||||||
|
"stderr": compact_output(process.stderr),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
after = collect_repository_snapshot(spec, workspace_root=workspace, target_tag=None, online=False)
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
**row,
|
||||||
|
"status": "synced",
|
||||||
|
"returncode": process.returncode,
|
||||||
|
"detail": "remote refs and tags fetched; local branch was not merged or rebased",
|
||||||
|
"after_ahead": int(after.ahead or 0),
|
||||||
|
"after_behind": int(after.behind or 0),
|
||||||
|
"after_dirty_count": len(after.dirty_entries),
|
||||||
|
"stdout": compact_output(process.stdout),
|
||||||
|
"stderr": compact_output(process.stderr),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if any(item["status"] in {"blocked", "failed"} for item in results):
|
||||||
|
status = "blocked" if not apply else "partial"
|
||||||
|
elif any(item["status"] == "synced" for item in results):
|
||||||
|
status = "synced"
|
||||||
|
elif any(item["status"] == "planned" for item in results):
|
||||||
|
status = "planned"
|
||||||
|
else:
|
||||||
|
status = "noop"
|
||||||
|
return {"status": status, "apply": apply, "remote": remote, "repositories": results}
|
||||||
|
|
||||||
|
|
||||||
|
def run(command: tuple[str, ...], *, cwd: Path) -> subprocess.CompletedProcess[str]:
|
||||||
|
try:
|
||||||
|
return subprocess.run(command, cwd=cwd, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
return subprocess.CompletedProcess(command, 124, exc.stdout or "", exc.stderr or "git fetch timed out")
|
||||||
407
tools/release/govoplan_release/selective_catalog.py
Normal file
407
tools/release/govoplan_release/selective_catalog.py
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
"""Selective release catalog candidate generation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
|
||||||
|
from govoplan_core.core.module_package_catalog import validate_module_package_catalog
|
||||||
|
|
||||||
|
from .catalog import DEFAULT_PUBLIC_BASE_URL, canonical_hash, fetch_json
|
||||||
|
from .contracts import collect_contracts
|
||||||
|
from .git_state import collect_repository_snapshot
|
||||||
|
from .model import CatalogEntryChange, SelectiveCatalogCandidate
|
||||||
|
from .module_directory import write_module_directory
|
||||||
|
from .workspace import load_repository_specs, resolve_workspace_root, website_root
|
||||||
|
|
||||||
|
GITEA_BASE = "git+ssh://git@git.add-ideas.de/add-ideas"
|
||||||
|
|
||||||
|
|
||||||
|
def build_selective_catalog_candidate(
|
||||||
|
*,
|
||||||
|
repo_versions: dict[str, str],
|
||||||
|
channel: str = "stable",
|
||||||
|
workspace_root: Path | str | None = None,
|
||||||
|
output_dir: Path | str | None = None,
|
||||||
|
base_catalog: Path | str | None = None,
|
||||||
|
signing_keys: tuple[str, ...] = (),
|
||||||
|
public_base_url: str = DEFAULT_PUBLIC_BASE_URL,
|
||||||
|
repository_base: str = GITEA_BASE,
|
||||||
|
expires_days: int = 90,
|
||||||
|
sequence: int | None = None,
|
||||||
|
check_public: bool = True,
|
||||||
|
write_summary: bool = True,
|
||||||
|
) -> SelectiveCatalogCandidate:
|
||||||
|
if not repo_versions:
|
||||||
|
raise ValueError("At least one repo version must be provided.")
|
||||||
|
parsed_keys = tuple(parse_signing_key(value) for value in signing_keys)
|
||||||
|
if not parsed_keys:
|
||||||
|
raise ValueError("At least one signing key is required.")
|
||||||
|
|
||||||
|
workspace = resolve_workspace_root(workspace_root)
|
||||||
|
web_root = website_root(workspace)
|
||||||
|
source_catalog = resolve_base_catalog(base_catalog=base_catalog, web_root=web_root, channel=channel)
|
||||||
|
payload = read_catalog(source_catalog)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("Selective catalog updates require object-style catalogs.")
|
||||||
|
|
||||||
|
generated_at = datetime.now(tz=UTC)
|
||||||
|
resolved_sequence = sequence or next_sequence(payload, generated_at=generated_at)
|
||||||
|
candidate = json.loads(json.dumps(payload))
|
||||||
|
candidate["channel"] = channel
|
||||||
|
candidate["sequence"] = resolved_sequence
|
||||||
|
candidate["generated_at"] = json_datetime(generated_at)
|
||||||
|
candidate["expires_at"] = json_datetime(generated_at + timedelta(days=expires_days))
|
||||||
|
release = candidate.get("release")
|
||||||
|
if not isinstance(release, dict):
|
||||||
|
release = {}
|
||||||
|
release["catalog_url"] = f"{public_base_url.rstrip('/')}/catalogs/v1/channels/{channel}.json"
|
||||||
|
release["keyring_url"] = f"{public_base_url.rstrip('/')}/catalogs/v1/keyring.json"
|
||||||
|
release["selected_units"] = [{"repo": repo, "version": version, "tag": f"v{version.removeprefix('v')}"} for repo, version in sorted(repo_versions.items())]
|
||||||
|
candidate["release"] = release
|
||||||
|
|
||||||
|
repo_contracts = contracts_by_repo(workspace)
|
||||||
|
changes = apply_repo_updates(
|
||||||
|
candidate,
|
||||||
|
repo_versions=repo_versions,
|
||||||
|
repo_contracts=repo_contracts,
|
||||||
|
repository_base=repository_base.rstrip("/"),
|
||||||
|
)
|
||||||
|
|
||||||
|
candidate.pop("signature", None)
|
||||||
|
candidate.pop("signatures", None)
|
||||||
|
candidate["signatures"] = [signature(candidate, key_id=key_id, private_key=private_key) for key_id, private_key in parsed_keys]
|
||||||
|
|
||||||
|
output_root = resolve_output_dir(output_dir=output_dir, channel=channel, generated_at=generated_at)
|
||||||
|
catalog_path = output_root / "channels" / f"{channel}.json"
|
||||||
|
keyring_path = output_root / "keyring.json"
|
||||||
|
summary_path = output_root / "summary.json" if write_summary else None
|
||||||
|
catalog_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
catalog_path.write_text(json.dumps(candidate, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
keyring_payload = keyring_payload_for_candidate(
|
||||||
|
existing_keyring=web_root / "public" / "catalogs" / "v1" / "keyring.json",
|
||||||
|
signing_keys=parsed_keys,
|
||||||
|
generated_at=generated_at,
|
||||||
|
)
|
||||||
|
keyring_path.write_text(json.dumps(keyring_payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
module_directory_files = write_module_directory(
|
||||||
|
catalog_payload=candidate,
|
||||||
|
keyring_payload=keyring_payload,
|
||||||
|
output_root=output_root,
|
||||||
|
channel=channel,
|
||||||
|
public_base_url=public_base_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
trusted_keys = trusted_keys_from_keyring(keyring_payload)
|
||||||
|
validation = validate_module_package_catalog(
|
||||||
|
catalog_path,
|
||||||
|
require_trusted=True,
|
||||||
|
approved_channels=(channel,),
|
||||||
|
trusted_keys=trusted_keys,
|
||||||
|
)
|
||||||
|
validation_warnings = tuple(str(item) for item in validation.get("warnings") or ())
|
||||||
|
validation_error = validation.get("error")
|
||||||
|
|
||||||
|
candidate_catalog_hash = canonical_hash(candidate)
|
||||||
|
candidate_keyring_hash = canonical_hash(keyring_payload)
|
||||||
|
public_catalog_url = f"{public_base_url.rstrip('/')}/catalogs/v1/channels/{channel}.json"
|
||||||
|
public_keyring_url = f"{public_base_url.rstrip('/')}/catalogs/v1/keyring.json"
|
||||||
|
published_catalog_hash: str | None = None
|
||||||
|
published_keyring_hash: str | None = None
|
||||||
|
if check_public:
|
||||||
|
published_catalog = fetch_json(public_catalog_url)
|
||||||
|
if published_catalog["ok"]:
|
||||||
|
published_catalog_hash = canonical_hash(published_catalog["payload"])
|
||||||
|
published_keyring = fetch_json(public_keyring_url)
|
||||||
|
if published_keyring["ok"]:
|
||||||
|
published_keyring_hash = canonical_hash(published_keyring["payload"])
|
||||||
|
|
||||||
|
result = SelectiveCatalogCandidate(
|
||||||
|
generated_at=json_datetime(generated_at),
|
||||||
|
channel=channel,
|
||||||
|
status="ready" if validation.get("valid") else "blocked",
|
||||||
|
candidate_dir=str(output_root),
|
||||||
|
catalog_path=str(catalog_path),
|
||||||
|
keyring_path=str(keyring_path),
|
||||||
|
summary_path=str(summary_path) if summary_path is not None else None,
|
||||||
|
source_catalog=str(source_catalog),
|
||||||
|
public_catalog_url=public_catalog_url,
|
||||||
|
public_keyring_url=public_keyring_url,
|
||||||
|
sequence=resolved_sequence,
|
||||||
|
signature_count=len(candidate["signatures"]) if isinstance(candidate.get("signatures"), list) else 0,
|
||||||
|
key_count=len(keyring_payload.get("keys", ())) if isinstance(keyring_payload.get("keys"), list) else 0,
|
||||||
|
candidate_catalog_hash=candidate_catalog_hash,
|
||||||
|
candidate_keyring_hash=candidate_keyring_hash,
|
||||||
|
published_catalog_hash=published_catalog_hash,
|
||||||
|
published_keyring_hash=published_keyring_hash,
|
||||||
|
candidate_matches_published_catalog=compare_hash(candidate_catalog_hash, published_catalog_hash),
|
||||||
|
candidate_matches_published_keyring=compare_hash(candidate_keyring_hash, published_keyring_hash),
|
||||||
|
validation_valid=validation.get("valid") is True,
|
||||||
|
validation_error=str(validation_error) if validation_error else None,
|
||||||
|
validation_warnings=validation_warnings,
|
||||||
|
changes=tuple(changes),
|
||||||
|
notes=(
|
||||||
|
"Candidate catalog preserves unchanged entries from the source catalog.",
|
||||||
|
f"Generated {len(module_directory_files)} module-directory file(s).",
|
||||||
|
"Publishing is intentionally separate from candidate generation.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if summary_path is not None:
|
||||||
|
summary_path.write_text(json.dumps(dataclass_payload(result), indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_base_catalog(*, base_catalog: Path | str | None, web_root: Path, channel: str) -> Path | str:
|
||||||
|
if base_catalog is not None:
|
||||||
|
value = str(base_catalog)
|
||||||
|
return value if value.startswith(("http://", "https://")) else Path(value).expanduser()
|
||||||
|
local = web_root / "public" / "catalogs" / "v1" / "channels" / f"{channel}.json"
|
||||||
|
if local.exists():
|
||||||
|
return local
|
||||||
|
return f"{DEFAULT_PUBLIC_BASE_URL}/catalogs/v1/channels/{channel}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def read_catalog(source: Path | str) -> object:
|
||||||
|
if isinstance(source, str) and source.startswith(("http://", "https://")):
|
||||||
|
payload = fetch_json(source)
|
||||||
|
if not payload["ok"]:
|
||||||
|
raise ValueError(f"Could not fetch catalog {source}: {payload['error']}")
|
||||||
|
return payload["payload"]
|
||||||
|
return json.loads(Path(source).read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def next_sequence(payload: dict[str, Any], *, generated_at: datetime) -> int:
|
||||||
|
timestamp_sequence = int(generated_at.strftime("%Y%m%d%H%M"))
|
||||||
|
try:
|
||||||
|
current = int(payload.get("sequence") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
current = 0
|
||||||
|
return max(current + 1, timestamp_sequence)
|
||||||
|
|
||||||
|
|
||||||
|
def contracts_by_repo(workspace: Path) -> dict[str, Any]:
|
||||||
|
specs = load_repository_specs(include_website=False)
|
||||||
|
snapshots = tuple(
|
||||||
|
collect_repository_snapshot(spec, workspace_root=workspace, target_tag=None, online=False)
|
||||||
|
for spec in specs
|
||||||
|
)
|
||||||
|
return {contract.repo: contract for contract in collect_contracts(snapshots)}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_repo_updates(
|
||||||
|
payload: dict[str, Any],
|
||||||
|
*,
|
||||||
|
repo_versions: dict[str, str],
|
||||||
|
repo_contracts: dict[str, Any],
|
||||||
|
repository_base: str,
|
||||||
|
) -> list[CatalogEntryChange]:
|
||||||
|
changes: list[CatalogEntryChange] = []
|
||||||
|
modules = payload.get("modules")
|
||||||
|
if not isinstance(modules, list):
|
||||||
|
raise ValueError("Catalog payload has no modules list.")
|
||||||
|
|
||||||
|
handled: set[str] = set()
|
||||||
|
if "govoplan-core" in repo_versions:
|
||||||
|
version = repo_versions["govoplan-core"].removeprefix("v")
|
||||||
|
tag = f"v{version}"
|
||||||
|
core_release = payload.get("core_release")
|
||||||
|
if not isinstance(core_release, dict):
|
||||||
|
raise ValueError("Catalog payload has no core_release object.")
|
||||||
|
changes.extend(update_field(core_release, repo="govoplan-core", module_id=None, field="version", value=version))
|
||||||
|
changes.extend(update_field(core_release, repo="govoplan-core", module_id=None, field="python_ref", value=f"govoplan-core[server] @ {repository_base}/govoplan-core.git@{tag}"))
|
||||||
|
changes.extend(update_field(core_release, repo="govoplan-core", module_id=None, field="webui_ref", value=f"{repository_base}/govoplan-core.git#{tag}"))
|
||||||
|
release = payload.get("release")
|
||||||
|
if isinstance(release, dict) and isinstance(release.get("version"), str):
|
||||||
|
changes.extend(update_field(release, repo="govoplan-core", module_id=None, field="version", value=version))
|
||||||
|
changes.extend(update_field(release, repo="govoplan-core", module_id=None, field="tag", value=tag))
|
||||||
|
handled.add("govoplan-core")
|
||||||
|
|
||||||
|
for entry in modules:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
repo = module_entry_repo(entry)
|
||||||
|
if repo not in repo_versions:
|
||||||
|
continue
|
||||||
|
version = repo_versions[repo].removeprefix("v")
|
||||||
|
tag = f"v{version}"
|
||||||
|
module_id = str(entry.get("module_id") or "")
|
||||||
|
package = str(entry.get("python_package") or repo)
|
||||||
|
changes.extend(update_field(entry, repo=repo, module_id=module_id, field="version", value=version))
|
||||||
|
changes.extend(update_field(entry, repo=repo, module_id=module_id, field="python_ref", value=f"{package} @ {repository_base}/{repo}.git@{tag}"))
|
||||||
|
if entry.get("webui_package"):
|
||||||
|
changes.extend(update_field(entry, repo=repo, module_id=module_id, field="webui_ref", value=f"{repository_base}/{repo}.git#{tag}"))
|
||||||
|
contract = repo_contracts.get(repo)
|
||||||
|
if contract is not None:
|
||||||
|
provider_payload = [{"name": item.name, "version": item.version} for item in contract.provides_interfaces]
|
||||||
|
requirement_payload = [
|
||||||
|
{
|
||||||
|
"name": item.name,
|
||||||
|
**({"version_min": item.version_min} if item.version_min else {}),
|
||||||
|
**({"version_max_exclusive": item.version_max_exclusive} if item.version_max_exclusive else {}),
|
||||||
|
"optional": item.optional,
|
||||||
|
}
|
||||||
|
for item in contract.requires_interfaces
|
||||||
|
]
|
||||||
|
if provider_payload:
|
||||||
|
changes.extend(update_json_field(entry, repo=repo, module_id=module_id, field="provides_interfaces", value=provider_payload))
|
||||||
|
if requirement_payload:
|
||||||
|
changes.extend(update_json_field(entry, repo=repo, module_id=module_id, field="requires_interfaces", value=requirement_payload))
|
||||||
|
handled.add(repo)
|
||||||
|
|
||||||
|
missing = sorted(set(repo_versions) - handled)
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"Selected repositories are not represented in the catalog: {', '.join(missing)}")
|
||||||
|
return changes
|
||||||
|
|
||||||
|
|
||||||
|
def module_entry_repo(entry: dict[str, Any]) -> str | None:
|
||||||
|
for field in ("python_ref", "webui_ref"):
|
||||||
|
value = entry.get(field)
|
||||||
|
if isinstance(value, str):
|
||||||
|
match = re.search(r"/([^/@#]+)[.]git(?:[@#]|$)", value)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
package = entry.get("python_package")
|
||||||
|
return str(package).split("[", 1)[0] if isinstance(package, str) and package.startswith("govoplan-") else None
|
||||||
|
|
||||||
|
|
||||||
|
def update_field(target: dict[str, Any], *, repo: str, module_id: str | None, field: str, value: str) -> list[CatalogEntryChange]:
|
||||||
|
before = target.get(field)
|
||||||
|
before_text = before if isinstance(before, str) else None
|
||||||
|
if before_text == value:
|
||||||
|
return []
|
||||||
|
target[field] = value
|
||||||
|
return [CatalogEntryChange(repo=repo, module_id=module_id, field=field, before=before_text, after=value)]
|
||||||
|
|
||||||
|
|
||||||
|
def update_json_field(target: dict[str, Any], *, repo: str, module_id: str | None, field: str, value: list[dict[str, object]]) -> list[CatalogEntryChange]:
|
||||||
|
before = target.get(field)
|
||||||
|
if before == value:
|
||||||
|
return []
|
||||||
|
target[field] = value
|
||||||
|
return [
|
||||||
|
CatalogEntryChange(
|
||||||
|
repo=repo,
|
||||||
|
module_id=module_id,
|
||||||
|
field=field,
|
||||||
|
before=json.dumps(before, sort_keys=True) if before is not None else None,
|
||||||
|
after=json.dumps(value, sort_keys=True),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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 ValueError("--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 ValueError(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)
|
||||||
|
return {
|
||||||
|
"algorithm": "ed25519",
|
||||||
|
"key_id": key_id,
|
||||||
|
"value": base64.b64encode(private_key.sign(canonical_bytes(signature_payload))).decode("ascii"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def keyring_payload_for_candidate(
|
||||||
|
*,
|
||||||
|
existing_keyring: Path,
|
||||||
|
signing_keys: tuple[tuple[str, Ed25519PrivateKey], ...],
|
||||||
|
generated_at: datetime,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
expected = {key_id: public_key_base64(private_key) for key_id, private_key in signing_keys}
|
||||||
|
if existing_keyring.exists():
|
||||||
|
payload = json.loads(existing_keyring.read_text(encoding="utf-8"))
|
||||||
|
keys = payload.get("keys") if isinstance(payload, dict) else None
|
||||||
|
if isinstance(keys, list):
|
||||||
|
existing = {
|
||||||
|
str(item.get("key_id")): str(item.get("public_key") or item.get("public_key_base64"))
|
||||||
|
for item in keys
|
||||||
|
if isinstance(item, dict) and item.get("key_id")
|
||||||
|
}
|
||||||
|
if all(existing.get(key_id) == public_key for key_id, public_key in expected.items()):
|
||||||
|
return payload
|
||||||
|
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 trusted_keys_from_keyring(payload: dict[str, Any]) -> dict[str, str]:
|
||||||
|
keys = payload.get("keys")
|
||||||
|
if not isinstance(keys, list):
|
||||||
|
return {}
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
for item in keys:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
status = str(item.get("status") or "active").lower()
|
||||||
|
if status in {"revoked", "disabled", "retired"}:
|
||||||
|
continue
|
||||||
|
key_id = str(item.get("key_id") or "")
|
||||||
|
public_key = str(item.get("public_key") or item.get("public_key_base64") or "")
|
||||||
|
if key_id and public_key:
|
||||||
|
result[key_id] = public_key
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
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 compare_hash(candidate_hash: str | None, published_hash: str | None) -> bool | None:
|
||||||
|
if candidate_hash is None or published_hash is None:
|
||||||
|
return None
|
||||||
|
return candidate_hash == published_hash
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_output_dir(*, output_dir: Path | str | None, channel: str, generated_at: datetime) -> Path:
|
||||||
|
if output_dir is not None:
|
||||||
|
return Path(output_dir).expanduser()
|
||||||
|
stamp = generated_at.strftime("%Y%m%d-%H%M%S")
|
||||||
|
return Path.cwd() / "runtime" / "release-candidates" / f"{channel}-{stamp}"
|
||||||
|
|
||||||
|
|
||||||
|
def json_datetime(value: datetime) -> str:
|
||||||
|
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def dataclass_payload(value: object) -> dict[str, object]:
|
||||||
|
from dataclasses import asdict
|
||||||
|
|
||||||
|
return asdict(value)
|
||||||
271
tools/release/govoplan_release/selective_planner.py
Normal file
271
tools/release/govoplan_release/selective_planner.py
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
"""Selective release planning for independently versioned packages."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import shlex
|
||||||
|
|
||||||
|
from .contracts import validate_contracts
|
||||||
|
from .model import (
|
||||||
|
CompatibilityIssue,
|
||||||
|
InterfaceProviderSnapshot,
|
||||||
|
InterfaceRequirementSnapshot,
|
||||||
|
ReleaseDashboard,
|
||||||
|
ReleasePlanStep,
|
||||||
|
ReleasePlanUnit,
|
||||||
|
RepositorySnapshot,
|
||||||
|
SelectiveReleasePlan,
|
||||||
|
ModuleContractSnapshot,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_selective_release_plan(
|
||||||
|
dashboard: ReleaseDashboard,
|
||||||
|
*,
|
||||||
|
selected_repos: tuple[str, ...] = (),
|
||||||
|
target_version: str | None = None,
|
||||||
|
repo_versions: dict[str, str] | None = None,
|
||||||
|
channel: str = "stable",
|
||||||
|
) -> SelectiveReleasePlan:
|
||||||
|
repo_versions = repo_versions or {}
|
||||||
|
repositories = selected_repositories(dashboard, selected_repos=selected_repos)
|
||||||
|
contracts_by_repo = {contract.repo: contract for contract in dashboard.contracts}
|
||||||
|
units = tuple(
|
||||||
|
build_unit(repo, target_version=repo_versions.get(repo.spec.name) or target_version, contracts=contracts_by_repo.get(repo.spec.name))
|
||||||
|
for repo in repositories
|
||||||
|
)
|
||||||
|
compatibility = compatibility_issues(dashboard)
|
||||||
|
steps = dry_run_steps(units=units, dashboard=dashboard, channel=channel)
|
||||||
|
notes = release_notes(dashboard)
|
||||||
|
status = plan_status(units=units, compatibility=compatibility)
|
||||||
|
return SelectiveReleasePlan(
|
||||||
|
generated_at=datetime.now(tz=UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
||||||
|
target_channel=channel,
|
||||||
|
status=status,
|
||||||
|
units=units,
|
||||||
|
compatibility=compatibility,
|
||||||
|
dry_run_steps=steps,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def selected_repositories(dashboard: ReleaseDashboard, *, selected_repos: tuple[str, ...]) -> tuple[RepositorySnapshot, ...]:
|
||||||
|
if selected_repos:
|
||||||
|
wanted = set(selected_repos)
|
||||||
|
return tuple(repo for repo in dashboard.repositories if repo.spec.name in wanted)
|
||||||
|
return tuple(repo for repo in dashboard.repositories if release_candidate(repo))
|
||||||
|
|
||||||
|
|
||||||
|
def release_candidate(repo: RepositorySnapshot) -> bool:
|
||||||
|
return repo.dirty or bool(repo.ahead) or (repo.exists and repo.is_git and not repo.has_head)
|
||||||
|
|
||||||
|
|
||||||
|
def build_unit(repo: RepositorySnapshot, *, target_version: str | None, contracts: ModuleContractSnapshot | None) -> ReleasePlanUnit:
|
||||||
|
current_version = repo.versions.primary
|
||||||
|
resolved_target = (target_version or current_version or "0.1.0").removeprefix("v")
|
||||||
|
target_tag = f"v{resolved_target}"
|
||||||
|
blockers: list[str] = []
|
||||||
|
warnings: list[str] = []
|
||||||
|
if not repo.exists:
|
||||||
|
blockers.append("repository is missing locally")
|
||||||
|
elif not repo.is_git:
|
||||||
|
blockers.append("path is not a Git repository")
|
||||||
|
elif repo.safe_directory_required:
|
||||||
|
blockers.append("Git safe.directory approval is required")
|
||||||
|
elif repo.behind:
|
||||||
|
blockers.append(f"repository is behind {repo.upstream}")
|
||||||
|
if repo.exists and repo.is_git and not repo.has_head:
|
||||||
|
blockers.append("repository has no initial commit")
|
||||||
|
if repo.dirty:
|
||||||
|
warnings.append("uncommitted changes will need review before release")
|
||||||
|
if repo.ahead:
|
||||||
|
warnings.append("unpushed commits should be pushed before catalog publication")
|
||||||
|
if current_version is None:
|
||||||
|
warnings.append("no local version metadata found; initial release needs version metadata and catalog entry synthesis")
|
||||||
|
if current_version and current_version == resolved_target and repo.local_target_tag_exists:
|
||||||
|
warnings.append(f"local tag {target_tag} already exists")
|
||||||
|
|
||||||
|
provides: tuple[InterfaceProviderSnapshot, ...] = ()
|
||||||
|
requires: tuple[InterfaceRequirementSnapshot, ...] = ()
|
||||||
|
if contracts is not None:
|
||||||
|
provides = contracts.provides_interfaces
|
||||||
|
requires = contracts.requires_interfaces
|
||||||
|
|
||||||
|
return ReleasePlanUnit(
|
||||||
|
repo=repo.spec.name,
|
||||||
|
category=repo.spec.category,
|
||||||
|
subtype=repo.spec.subtype,
|
||||||
|
branch=repo.branch,
|
||||||
|
current_version=current_version,
|
||||||
|
target_version=resolved_target,
|
||||||
|
target_tag=target_tag,
|
||||||
|
status="blocked" if blockers else "attention" if warnings else "ready",
|
||||||
|
blockers=tuple(blockers),
|
||||||
|
warnings=tuple(warnings),
|
||||||
|
provides_interfaces=provides,
|
||||||
|
requires_interfaces=requires,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def compatibility_issues(dashboard: ReleaseDashboard) -> tuple[CompatibilityIssue, ...]:
|
||||||
|
return validate_contracts(dashboard.contracts)
|
||||||
|
|
||||||
|
|
||||||
|
def dry_run_steps(*, units: tuple[ReleasePlanUnit, ...], dashboard: ReleaseDashboard, channel: str) -> tuple[ReleasePlanStep, ...]:
|
||||||
|
steps: list[ReleasePlanStep] = []
|
||||||
|
for unit in units:
|
||||||
|
steps.append(
|
||||||
|
ReleasePlanStep(
|
||||||
|
id=f"{unit.repo}:preflight",
|
||||||
|
title=f"Inspect {unit.repo}",
|
||||||
|
detail="Verify local branch, dirty state, current version, and target tag before release.",
|
||||||
|
command="git status --short --branch && git tag --list " + shlex.quote(unit.target_tag),
|
||||||
|
cwd=f"{dashboard.workspace_root}/{unit.repo}",
|
||||||
|
repo=unit.repo,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if unit.current_version != unit.target_version:
|
||||||
|
steps.append(
|
||||||
|
ReleasePlanStep(
|
||||||
|
id=f"{unit.repo}:version",
|
||||||
|
title=f"Update {unit.repo} version metadata",
|
||||||
|
detail=f"Prepare version files and manifest metadata for {unit.target_version}.",
|
||||||
|
command=None,
|
||||||
|
cwd=f"{dashboard.workspace_root}/{unit.repo}",
|
||||||
|
mutating=True,
|
||||||
|
repo=unit.repo,
|
||||||
|
status="needs-executor",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
steps.append(
|
||||||
|
ReleasePlanStep(
|
||||||
|
id=f"{unit.repo}:commit",
|
||||||
|
title=f"Commit {unit.repo} release changes",
|
||||||
|
detail="Commit reviewed changes for this release unit only.",
|
||||||
|
command=f"git add -A && git commit -m {shlex.quote(f'Release {unit.repo} {unit.target_tag}')}",
|
||||||
|
cwd=f"{dashboard.workspace_root}/{unit.repo}",
|
||||||
|
mutating=True,
|
||||||
|
repo=unit.repo,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
steps.append(
|
||||||
|
ReleasePlanStep(
|
||||||
|
id=f"{unit.repo}:tag",
|
||||||
|
title=f"Create {unit.repo} tag {unit.target_tag}",
|
||||||
|
detail="Create an annotated tag for this package release.",
|
||||||
|
command=f"git tag -a {shlex.quote(unit.target_tag)} -m {shlex.quote(f'Release {unit.repo} {unit.target_tag}')}",
|
||||||
|
cwd=f"{dashboard.workspace_root}/{unit.repo}",
|
||||||
|
mutating=True,
|
||||||
|
repo=unit.repo,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
steps.append(
|
||||||
|
ReleasePlanStep(
|
||||||
|
id=f"{unit.repo}:push",
|
||||||
|
title=f"Push {unit.repo} release",
|
||||||
|
detail="Push the branch and release tag for this package only.",
|
||||||
|
command=f"git push --atomic origin HEAD:refs/heads/{shlex.quote(unit.branch or 'main')} refs/tags/{shlex.quote(unit.target_tag)}",
|
||||||
|
cwd=f"{dashboard.workspace_root}/{unit.repo}",
|
||||||
|
mutating=True,
|
||||||
|
repo=unit.repo,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if units:
|
||||||
|
initial_units = tuple(unit for unit in units if unit.current_version is None)
|
||||||
|
repo_version_args = " ".join(
|
||||||
|
f"--repo-version {shlex.quote(unit.repo + '=' + unit.target_version)}"
|
||||||
|
for unit in units
|
||||||
|
)
|
||||||
|
if initial_units:
|
||||||
|
steps.append(
|
||||||
|
ReleasePlanStep(
|
||||||
|
id="catalog:initial-entry-synthesis",
|
||||||
|
title=f"Prepare initial {channel} catalog entries",
|
||||||
|
detail=(
|
||||||
|
"Selected initial-release repositories are not guaranteed to exist in the channel catalog yet: "
|
||||||
|
+ ", ".join(unit.repo for unit in initial_units)
|
||||||
|
+ ". The current selective catalog writer updates existing entries only."
|
||||||
|
),
|
||||||
|
cwd=dashboard.meta_root,
|
||||||
|
mutating=True,
|
||||||
|
status="needs-catalog-entry-synthesis",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
steps.append(
|
||||||
|
ReleasePlanStep(
|
||||||
|
id="catalog:selective-generator",
|
||||||
|
title=f"Update {channel} release channel catalog",
|
||||||
|
detail=(
|
||||||
|
"Generate a signed candidate catalog that preserves unchanged package versions "
|
||||||
|
"and updates only selected release units."
|
||||||
|
),
|
||||||
|
command=(
|
||||||
|
"KEY_DIR=\"$HOME/.config/govoplan/release-keys\" "
|
||||||
|
f"tools/release/release-catalog.py selective --channel {shlex.quote(channel)} "
|
||||||
|
f"{repo_version_args} "
|
||||||
|
"--catalog-signing-key \"release-key-1=$KEY_DIR/release-key-1.pem\""
|
||||||
|
),
|
||||||
|
cwd=dashboard.meta_root,
|
||||||
|
mutating=True,
|
||||||
|
status="planned",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
steps.append(
|
||||||
|
ReleasePlanStep(
|
||||||
|
id="catalog:validate-sign-publish",
|
||||||
|
title=f"Validate, sign, and publish {channel}",
|
||||||
|
detail="The candidate writer validates and signs locally. Publishing to the website repo remains a separate apply step.",
|
||||||
|
command="Review the candidate under runtime/release-candidates before publishing it to the website repository.",
|
||||||
|
cwd=dashboard.meta_root,
|
||||||
|
status="needs-publish-executor",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(steps)
|
||||||
|
|
||||||
|
|
||||||
|
def release_notes(dashboard: ReleaseDashboard) -> tuple[str, ...]:
|
||||||
|
notes = [
|
||||||
|
"Release units are independent; selecting one package should not force every repository to a shared tag.",
|
||||||
|
"Channel publication must preserve unchanged package versions and only advance selected release units.",
|
||||||
|
"Compatibility is checked from currently discovered manifest interface providers and requirements.",
|
||||||
|
]
|
||||||
|
if dashboard.catalog.public_checked:
|
||||||
|
notes.append("Local catalog/keyring hashes are compared with the published channel and keyring.")
|
||||||
|
return tuple(notes)
|
||||||
|
|
||||||
|
|
||||||
|
def plan_status(*, units: tuple[ReleasePlanUnit, ...], compatibility: tuple[CompatibilityIssue, ...]) -> str:
|
||||||
|
if any(unit.blockers for unit in units) or any(issue.severity == "blocker" for issue in compatibility):
|
||||||
|
return "blocked"
|
||||||
|
if units or compatibility:
|
||||||
|
return "attention"
|
||||||
|
return "ready"
|
||||||
|
|
||||||
|
|
||||||
|
def version_satisfies(version: str, requirement: InterfaceRequirementSnapshot) -> bool:
|
||||||
|
parsed = parse_version(version)
|
||||||
|
if parsed is None:
|
||||||
|
return False
|
||||||
|
if requirement.version_min and (minimum := parse_version(requirement.version_min)) is not None and parsed < minimum:
|
||||||
|
return False
|
||||||
|
if requirement.version_max_exclusive and (maximum := parse_version(requirement.version_max_exclusive)) is not None and parsed >= maximum:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def parse_version(value: str) -> tuple[int, ...] | None:
|
||||||
|
parts = value.split(".")
|
||||||
|
if not parts or any(not part.isdigit() for part in parts):
|
||||||
|
return None
|
||||||
|
return tuple(int(part) for part in parts)
|
||||||
|
|
||||||
|
|
||||||
|
def format_requirement(requirement: InterfaceRequirementSnapshot) -> str:
|
||||||
|
parts: list[str] = []
|
||||||
|
if requirement.version_min:
|
||||||
|
parts.append(f">={requirement.version_min}")
|
||||||
|
if requirement.version_max_exclusive:
|
||||||
|
parts.append(f"<{requirement.version_max_exclusive}")
|
||||||
|
return ", ".join(parts) if parts else "any version"
|
||||||
59
tools/release/govoplan_release/workspace.py
Normal file
59
tools/release/govoplan_release/workspace.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""Workspace and repository configuration helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .model import RepositorySpec
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
DEFAULT_WORKSPACE_ROOT = META_ROOT.parent
|
||||||
|
REPOSITORIES_FILE = META_ROOT / "repositories.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load_repository_specs(*, include_website: bool = False) -> tuple[RepositorySpec, ...]:
|
||||||
|
payload = json.loads(REPOSITORIES_FILE.read_text(encoding="utf-8"))
|
||||||
|
repositories = payload.get("repositories")
|
||||||
|
if not isinstance(repositories, list):
|
||||||
|
raise ValueError(f"{REPOSITORIES_FILE} does not contain a repositories list")
|
||||||
|
|
||||||
|
specs: list[RepositorySpec] = []
|
||||||
|
for item in repositories:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
category = str(item.get("category", ""))
|
||||||
|
if category == "website" and not include_website:
|
||||||
|
continue
|
||||||
|
specs.append(
|
||||||
|
RepositorySpec(
|
||||||
|
name=str(item["name"]),
|
||||||
|
category=category,
|
||||||
|
subtype=str(item.get("subtype", "")),
|
||||||
|
remote=str(item.get("remote", "")),
|
||||||
|
path=str(item["path"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(specs)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_workspace_root(value: Path | str | None = None) -> Path:
|
||||||
|
if value is None:
|
||||||
|
return DEFAULT_WORKSPACE_ROOT
|
||||||
|
return Path(value).expanduser().resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_repo_path(spec: RepositorySpec, workspace_root: Path) -> Path:
|
||||||
|
path = Path(spec.path)
|
||||||
|
if path.is_absolute():
|
||||||
|
return path
|
||||||
|
return workspace_root / path
|
||||||
|
|
||||||
|
|
||||||
|
def core_root(workspace_root: Path) -> Path:
|
||||||
|
return workspace_root / "govoplan-core"
|
||||||
|
|
||||||
|
|
||||||
|
def website_root(workspace_root: Path) -> Path:
|
||||||
|
return workspace_root / "addideas-govoplan-website"
|
||||||
214
tools/release/release-catalog.py
Normal file
214
tools/release/release-catalog.py
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build GovOPlaN release catalog candidates."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from govoplan_release.module_directory import write_module_directory
|
||||||
|
from govoplan_release.model import to_jsonable
|
||||||
|
from govoplan_release.publisher import publish_catalog_candidate
|
||||||
|
from govoplan_release.selective_catalog import build_selective_catalog_candidate
|
||||||
|
from govoplan_release.workspace import DEFAULT_WORKSPACE_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
selective = subparsers.add_parser("selective", help="Build a signed selective channel catalog candidate.")
|
||||||
|
selective.add_argument("--workspace-root", type=Path, default=DEFAULT_WORKSPACE_ROOT)
|
||||||
|
selective.add_argument("--channel", default="stable")
|
||||||
|
selective.add_argument("--repo-version", action="append", default=[], metavar="REPO=VERSION", required=True)
|
||||||
|
selective.add_argument("--catalog-signing-key", action="append", default=[], metavar="KEY_ID=PRIVATE_KEY", required=True)
|
||||||
|
selective.add_argument("--base-catalog", help="Catalog path or URL to use as the source. Defaults to local channel, then public channel.")
|
||||||
|
selective.add_argument("--output-dir", type=Path, help="Candidate output directory. Defaults to runtime/release-candidates/<channel>-<timestamp>.")
|
||||||
|
selective.add_argument("--public-base-url", default="https://govoplan.add-ideas.de")
|
||||||
|
selective.add_argument("--repository-base", default="git+ssh://git@git.add-ideas.de/add-ideas")
|
||||||
|
selective.add_argument("--expires-days", type=int, default=90)
|
||||||
|
selective.add_argument("--sequence", type=int)
|
||||||
|
selective.add_argument("--skip-public-check", action="store_true")
|
||||||
|
selective.add_argument("--json", action="store_true", help="Print machine-readable summary JSON.")
|
||||||
|
|
||||||
|
publish = subparsers.add_parser("publish-candidate", help="Publish a reviewed catalog candidate into the website repo.")
|
||||||
|
publish.add_argument("--candidate-dir", type=Path, required=True)
|
||||||
|
publish.add_argument("--workspace-root", type=Path, default=DEFAULT_WORKSPACE_ROOT)
|
||||||
|
publish.add_argument("--web-root", type=Path)
|
||||||
|
publish.add_argument("--channel", default="stable")
|
||||||
|
publish.add_argument("--apply", action="store_true", help="Copy candidate files into the website repo. Without this, only preview.")
|
||||||
|
publish.add_argument("--build-web", action="store_true")
|
||||||
|
publish.add_argument("--commit", action="store_true")
|
||||||
|
publish.add_argument("--tag", action="store_true", help="Create a website catalog publication tag. Implies --commit.")
|
||||||
|
publish.add_argument("--push", action="store_true", help="Push the website branch and tag. Implies --commit.")
|
||||||
|
publish.add_argument("--remote", default="origin")
|
||||||
|
publish.add_argument("--branch")
|
||||||
|
publish.add_argument("--tag-name")
|
||||||
|
publish.add_argument("--npm", default="npm")
|
||||||
|
publish.add_argument("--allow-dirty-website", action="store_true")
|
||||||
|
publish.add_argument("--json", action="store_true", help="Print machine-readable summary JSON.")
|
||||||
|
|
||||||
|
directory = subparsers.add_parser("module-directory", help="Generate browsable module-directory files from a catalog and keyring.")
|
||||||
|
directory.add_argument("--catalog", type=Path, required=True)
|
||||||
|
directory.add_argument("--keyring", type=Path, required=True)
|
||||||
|
directory.add_argument("--output-dir", type=Path, required=True)
|
||||||
|
directory.add_argument("--channel", default="stable")
|
||||||
|
directory.add_argument("--public-base-url", default="https://govoplan.add-ideas.de")
|
||||||
|
directory.add_argument("--json", action="store_true", help="Print machine-readable summary JSON.")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.command == "selective":
|
||||||
|
result = build_selective_catalog_candidate(
|
||||||
|
repo_versions=parse_repo_versions(tuple(args.repo_version)),
|
||||||
|
channel=args.channel,
|
||||||
|
workspace_root=args.workspace_root,
|
||||||
|
output_dir=args.output_dir,
|
||||||
|
base_catalog=args.base_catalog,
|
||||||
|
signing_keys=tuple(args.catalog_signing_key),
|
||||||
|
public_base_url=args.public_base_url,
|
||||||
|
repository_base=args.repository_base,
|
||||||
|
expires_days=args.expires_days,
|
||||||
|
sequence=args.sequence,
|
||||||
|
check_public=not args.skip_public_check,
|
||||||
|
)
|
||||||
|
payload = to_jsonable(result)
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(payload, indent=2, sort_keys=True))
|
||||||
|
else:
|
||||||
|
print_text_summary(payload)
|
||||||
|
return 0 if result.status == "ready" else 1
|
||||||
|
if args.command == "publish-candidate":
|
||||||
|
result = publish_catalog_candidate(
|
||||||
|
candidate_dir=args.candidate_dir,
|
||||||
|
workspace_root=args.workspace_root,
|
||||||
|
web_root=args.web_root,
|
||||||
|
channel=args.channel,
|
||||||
|
apply=args.apply,
|
||||||
|
build_web=args.build_web,
|
||||||
|
commit=args.commit,
|
||||||
|
tag=args.tag,
|
||||||
|
push=args.push,
|
||||||
|
remote=args.remote,
|
||||||
|
branch=args.branch,
|
||||||
|
npm=args.npm,
|
||||||
|
tag_name=args.tag_name,
|
||||||
|
allow_dirty_website=args.allow_dirty_website,
|
||||||
|
)
|
||||||
|
payload = to_jsonable(result)
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(payload, indent=2, sort_keys=True))
|
||||||
|
else:
|
||||||
|
print_publish_summary(payload)
|
||||||
|
return 0 if result.status in {"ready", "applied", "published"} else 1
|
||||||
|
if args.command == "module-directory":
|
||||||
|
catalog_payload = json.loads(args.catalog.read_text(encoding="utf-8"))
|
||||||
|
keyring_payload = json.loads(args.keyring.read_text(encoding="utf-8"))
|
||||||
|
files = write_module_directory(
|
||||||
|
catalog_payload=catalog_payload,
|
||||||
|
keyring_payload=keyring_payload,
|
||||||
|
output_root=args.output_dir,
|
||||||
|
channel=args.channel,
|
||||||
|
public_base_url=args.public_base_url,
|
||||||
|
)
|
||||||
|
payload = {"status": "generated", "output_dir": str(args.output_dir), "files": [str(path) for path in files]}
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(payload, indent=2, sort_keys=True))
|
||||||
|
else:
|
||||||
|
print("Module directory")
|
||||||
|
print(f" output_dir: {args.output_dir}")
|
||||||
|
print(f" files: {len(files)}")
|
||||||
|
for path in files[:20]:
|
||||||
|
print(f" {path}")
|
||||||
|
if len(files) > 20:
|
||||||
|
print(f" ... {len(files) - 20} more")
|
||||||
|
return 0
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
def parse_repo_versions(values: tuple[str, ...]) -> dict[str, str]:
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
for value in values:
|
||||||
|
if "=" not in value:
|
||||||
|
raise SystemExit(f"--repo-version must use REPO=VERSION: {value}")
|
||||||
|
repo, version = value.split("=", 1)
|
||||||
|
repo = repo.strip()
|
||||||
|
version = version.strip().removeprefix("v")
|
||||||
|
if not repo or not version:
|
||||||
|
raise SystemExit(f"--repo-version must use REPO=VERSION: {value}")
|
||||||
|
result[repo] = version
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def print_text_summary(payload: dict[str, object]) -> None:
|
||||||
|
print("Selective catalog candidate")
|
||||||
|
for key in (
|
||||||
|
"status",
|
||||||
|
"channel",
|
||||||
|
"sequence",
|
||||||
|
"catalog_path",
|
||||||
|
"keyring_path",
|
||||||
|
"summary_path",
|
||||||
|
"validation_valid",
|
||||||
|
"validation_error",
|
||||||
|
"candidate_matches_published_catalog",
|
||||||
|
"candidate_matches_published_keyring",
|
||||||
|
):
|
||||||
|
print(f" {key}: {payload.get(key)}")
|
||||||
|
changes = payload.get("changes")
|
||||||
|
if isinstance(changes, list):
|
||||||
|
print(f" changes: {len(changes)}")
|
||||||
|
for item in changes[:20]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
print(
|
||||||
|
" "
|
||||||
|
f"{item.get('repo')}:{item.get('module_id') or '-'} "
|
||||||
|
f"{item.get('field')} {item.get('before')!r} -> {item.get('after')!r}"
|
||||||
|
)
|
||||||
|
if len(changes) > 20:
|
||||||
|
print(f" ... {len(changes) - 20} more")
|
||||||
|
warnings = payload.get("validation_warnings")
|
||||||
|
if isinstance(warnings, list) and warnings:
|
||||||
|
print(" validation warnings:")
|
||||||
|
for warning in warnings:
|
||||||
|
print(f" {warning}")
|
||||||
|
|
||||||
|
|
||||||
|
def print_publish_summary(payload: dict[str, object]) -> None:
|
||||||
|
print("Catalog publish candidate")
|
||||||
|
for key in (
|
||||||
|
"status",
|
||||||
|
"applied",
|
||||||
|
"channel",
|
||||||
|
"candidate_dir",
|
||||||
|
"web_root",
|
||||||
|
"target_catalog_path",
|
||||||
|
"target_keyring_path",
|
||||||
|
"branch",
|
||||||
|
"tag_name",
|
||||||
|
"validation_valid",
|
||||||
|
"validation_error",
|
||||||
|
"catalog_changed",
|
||||||
|
"keyring_changed",
|
||||||
|
):
|
||||||
|
print(f" {key}: {payload.get(key)}")
|
||||||
|
steps = payload.get("steps")
|
||||||
|
if isinstance(steps, list):
|
||||||
|
print(" steps:")
|
||||||
|
for item in steps:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
print(f" [{item.get('status')}] {item.get('title')}")
|
||||||
|
command = item.get("command")
|
||||||
|
if command:
|
||||||
|
print(f" {command}")
|
||||||
|
notes = payload.get("notes")
|
||||||
|
if isinstance(notes, list) and notes:
|
||||||
|
print(" notes:")
|
||||||
|
for note in notes:
|
||||||
|
print(f" {note}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
45
tools/release/release-console.py
Normal file
45
tools/release/release-console.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Start the local GovOPlaN release console."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
import secrets
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from govoplan_release.workspace import DEFAULT_WORKSPACE_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--host", default="127.0.0.1", help="Bind host. Defaults to 127.0.0.1.")
|
||||||
|
parser.add_argument("--port", type=int, default=8765, help="Bind port. Defaults to 8765.")
|
||||||
|
parser.add_argument("--workspace-root", type=Path, default=DEFAULT_WORKSPACE_ROOT)
|
||||||
|
parser.add_argument("--no-token", action="store_true", help="Disable the local API token guard.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
import uvicorn
|
||||||
|
except ImportError:
|
||||||
|
print("uvicorn is required. Install the meta development environment with `pip install -r requirements-dev.txt`.", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
from server.app import create_app
|
||||||
|
|
||||||
|
workspace_root = args.workspace_root.expanduser().resolve()
|
||||||
|
token = None if args.no_token else secrets.token_urlsafe(24)
|
||||||
|
app = create_app(workspace_root=workspace_root, token=token)
|
||||||
|
url = f"http://{args.host}:{args.port}/"
|
||||||
|
if token:
|
||||||
|
url = f"{url}?token={token}"
|
||||||
|
print("GovOPlaN release console")
|
||||||
|
print(f" workspace: {workspace_root}")
|
||||||
|
print(f" url: {url}")
|
||||||
|
print(" mode: local release dashboard; mutating actions require explicit confirmation")
|
||||||
|
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -15,10 +15,8 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tomllib
|
import tomllib
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.error import URLError
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
from urllib.request import Request, urlopen
|
|
||||||
|
|
||||||
|
from govoplan_release.http_fetch import fetch_http, validate_http_url
|
||||||
|
|
||||||
META_ROOT = Path(__file__).resolve().parents[2]
|
META_ROOT = Path(__file__).resolve().parents[2]
|
||||||
ROOT = Path(os.environ.get("GOVOPLAN_CORE_ROOT", META_ROOT.parent / "govoplan-core")).resolve()
|
ROOT = Path(os.environ.get("GOVOPLAN_CORE_ROOT", META_ROOT.parent / "govoplan-core")).resolve()
|
||||||
@@ -1208,14 +1206,11 @@ def safe_directory_argv(path: Path) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def check_url(url: str) -> dict[str, Any]:
|
def check_url(url: str) -> dict[str, Any]:
|
||||||
parsed = urlparse(url)
|
|
||||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
||||||
return {"ok": False, "error": "URL must use http:// or https:// with a hostname", "url": url}
|
|
||||||
request = Request(url, method="HEAD")
|
|
||||||
try:
|
try:
|
||||||
with urlopen(request, timeout=8) as response: # noqa: S310 - validated operator-requested release availability check. # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
|
checked_url = validate_http_url(url)
|
||||||
return {"ok": 200 <= response.status < 400, "status": response.status, "url": url}
|
response = fetch_http(checked_url, timeout=8, method="HEAD")
|
||||||
except URLError as exc:
|
return {"ok": 200 <= response.status < 400, "status": response.status, "url": checked_url}
|
||||||
|
except (ValueError, OSError) as exc:
|
||||||
return {"ok": False, "error": str(exc), "url": url}
|
return {"ok": False, "error": str(exc), "url": url}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
84
tools/release/release-plan.py
Normal file
84
tools/release/release-plan.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate a dry-run selective GovOPlaN release plan."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from govoplan_release import build_dashboard, build_selective_release_plan
|
||||||
|
from govoplan_release.model import to_jsonable
|
||||||
|
from govoplan_release.workspace import DEFAULT_WORKSPACE_ROOT, META_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--workspace-root", type=Path, default=DEFAULT_WORKSPACE_ROOT)
|
||||||
|
parser.add_argument("--repo", action="append", default=[], help="Repository to include. May be repeated.")
|
||||||
|
parser.add_argument("--target-version", help="Default target version for selected repositories.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--repo-version",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
metavar="REPO=VERSION",
|
||||||
|
help="Per-repository target version. May be repeated.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--channel", default="stable", help="Release channel to plan for. Defaults to stable.")
|
||||||
|
parser.add_argument("--online", action="store_true", help="Check published catalog/keyring.")
|
||||||
|
parser.add_argument("--remote-tags", action="store_true", help="Also check remote Git tags. This can be slow across many repositories.")
|
||||||
|
parser.add_argument("--include-migrations", action="store_true", help="Run migration audits before planning.")
|
||||||
|
parser.add_argument("--output", type=Path, help="Write JSON plan to this path.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
dashboard = build_dashboard(
|
||||||
|
workspace_root=args.workspace_root,
|
||||||
|
target_version=args.target_version,
|
||||||
|
online=args.online,
|
||||||
|
check_remote_tags=args.remote_tags,
|
||||||
|
check_public_catalog=args.online,
|
||||||
|
include_migrations=args.include_migrations,
|
||||||
|
channel=args.channel,
|
||||||
|
)
|
||||||
|
plan = build_selective_release_plan(
|
||||||
|
dashboard,
|
||||||
|
selected_repos=tuple(args.repo),
|
||||||
|
target_version=args.target_version,
|
||||||
|
repo_versions=parse_repo_versions(tuple(args.repo_version)),
|
||||||
|
channel=args.channel,
|
||||||
|
)
|
||||||
|
payload = json.dumps(to_jsonable(plan), indent=2, sort_keys=True) + "\n"
|
||||||
|
if args.output:
|
||||||
|
output = args.output.expanduser()
|
||||||
|
if not output.is_absolute():
|
||||||
|
output = META_ROOT / output
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output.write_text(payload, encoding="utf-8")
|
||||||
|
print(output)
|
||||||
|
else:
|
||||||
|
print(payload, end="")
|
||||||
|
return 1 if plan.status == "blocked" else 0
|
||||||
|
|
||||||
|
|
||||||
|
def parse_repo_versions(values: tuple[str, ...]) -> dict[str, str]:
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
for value in values:
|
||||||
|
if "=" not in value:
|
||||||
|
raise SystemExit(f"--repo-version must use REPO=VERSION: {value}")
|
||||||
|
repo, version = value.split("=", 1)
|
||||||
|
repo = repo.strip()
|
||||||
|
version = version.strip()
|
||||||
|
if not repo or not version:
|
||||||
|
raise SystemExit(f"--repo-version must use REPO=VERSION: {value}")
|
||||||
|
result[repo] = version
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def default_plan_path(channel: str) -> Path:
|
||||||
|
stamp = datetime.now(tz=UTC).strftime("%Y%m%d-%H%M%S")
|
||||||
|
return META_ROOT / "runtime" / "release-plans" / f"{channel}-{stamp}.json"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
1
tools/release/server/__init__.py
Normal file
1
tools/release/server/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Local release console server package."""
|
||||||
331
tools/release/server/app.py
Normal file
331
tools/release/server/app.py
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
"""FastAPI app for the local GovOPlaN release console."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from govoplan_release import (
|
||||||
|
build_dashboard,
|
||||||
|
build_release_intelligence,
|
||||||
|
build_release_plan,
|
||||||
|
build_selective_catalog_candidate,
|
||||||
|
build_selective_release_plan,
|
||||||
|
publish_catalog_candidate,
|
||||||
|
prepare_repositories,
|
||||||
|
push_repositories,
|
||||||
|
sync_repositories,
|
||||||
|
)
|
||||||
|
from govoplan_release.model import to_jsonable
|
||||||
|
from govoplan_release.workspace import DEFAULT_WORKSPACE_ROOT, META_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogCandidateRequest(BaseModel):
|
||||||
|
repo_versions: dict[str, str] = Field(default_factory=dict)
|
||||||
|
repos: list[str] = Field(default_factory=list)
|
||||||
|
target_version: str | None = None
|
||||||
|
channel: str = "stable"
|
||||||
|
signing_keys: list[str] = Field(default_factory=list)
|
||||||
|
output_dir: str | None = None
|
||||||
|
base_catalog: str | None = None
|
||||||
|
expires_days: int = 90
|
||||||
|
sequence: int | None = None
|
||||||
|
public_base_url: str = "https://govoplan.add-ideas.de"
|
||||||
|
repository_base: str = "git+ssh://git@git.add-ideas.de/add-ideas"
|
||||||
|
check_public: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class PublishCandidateRequest(BaseModel):
|
||||||
|
candidate_dir: str
|
||||||
|
channel: str = "stable"
|
||||||
|
web_root: str | None = None
|
||||||
|
apply: bool = False # noqa: A003 - API field mirrors CLI.
|
||||||
|
build_web: bool = False
|
||||||
|
commit: bool = False
|
||||||
|
tag: bool = False
|
||||||
|
push: bool = False
|
||||||
|
remote: str = "origin"
|
||||||
|
branch: str | None = None
|
||||||
|
tag_name: str | None = None
|
||||||
|
npm: str = "npm"
|
||||||
|
allow_dirty_website: bool = False
|
||||||
|
confirm: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class RepositoryPushRequest(BaseModel):
|
||||||
|
repos: list[str] = Field(default_factory=list)
|
||||||
|
remote: str = "origin"
|
||||||
|
apply: bool = False # noqa: A003 - API field mirrors CLI.
|
||||||
|
confirm: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class RepositorySyncRequest(BaseModel):
|
||||||
|
repos: list[str] = Field(default_factory=list)
|
||||||
|
remote: str = "origin"
|
||||||
|
apply: bool = False # noqa: A003 - API field mirrors CLI.
|
||||||
|
confirm: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class RepositoryPrepareRequest(BaseModel):
|
||||||
|
repos: list[str] = Field(default_factory=list)
|
||||||
|
repo_versions: dict[str, str] = Field(default_factory=dict)
|
||||||
|
message: str | None = None
|
||||||
|
apply: bool = False # noqa: A003 - API field mirrors CLI.
|
||||||
|
confirm: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(*, workspace_root: Path = DEFAULT_WORKSPACE_ROOT, token: str | None = None) -> FastAPI:
|
||||||
|
app = FastAPI(title="GovOPlaN Release Console", version="0.1.0")
|
||||||
|
app.state.workspace_root = workspace_root
|
||||||
|
app.state.token = token
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def require_token(request: Request, call_next): # type: ignore[no-untyped-def]
|
||||||
|
if token and request.url.path.startswith("/api/"):
|
||||||
|
provided = request.headers.get("x-release-console-token") or request.query_params.get("token")
|
||||||
|
if provided != token:
|
||||||
|
return JSONResponse({"detail": "release console token required"}, status_code=401)
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
def index() -> HTMLResponse:
|
||||||
|
index_path = Path(__file__).resolve().parents[1] / "webui" / "index.html"
|
||||||
|
if not index_path.exists():
|
||||||
|
raise HTTPException(status_code=500, detail="release console UI is missing")
|
||||||
|
return HTMLResponse(index_path.read_text(encoding="utf-8"), headers={"Cache-Control": "no-store"})
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health() -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"workspace_root": str(app.state.workspace_root),
|
||||||
|
"token_required": bool(app.state.token),
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/api/dashboard")
|
||||||
|
def dashboard(
|
||||||
|
target_version: str | None = None,
|
||||||
|
online: bool = False,
|
||||||
|
remote_tags: bool = False,
|
||||||
|
public_catalog: bool = True,
|
||||||
|
include_migrations: bool = False,
|
||||||
|
include_website: bool = False,
|
||||||
|
channel: str = "stable",
|
||||||
|
) -> dict[str, object]:
|
||||||
|
snapshot = build_dashboard(
|
||||||
|
workspace_root=app.state.workspace_root,
|
||||||
|
target_version=target_version,
|
||||||
|
online=online,
|
||||||
|
check_remote_tags=online or remote_tags,
|
||||||
|
check_public_catalog=public_catalog,
|
||||||
|
include_migrations=include_migrations,
|
||||||
|
include_website=include_website,
|
||||||
|
channel=channel,
|
||||||
|
)
|
||||||
|
return to_jsonable(snapshot)
|
||||||
|
|
||||||
|
@app.get("/api/plan")
|
||||||
|
def plan(
|
||||||
|
target_version: str | None = None,
|
||||||
|
online: bool = False,
|
||||||
|
remote_tags: bool = False,
|
||||||
|
public_catalog: bool = True,
|
||||||
|
include_migrations: bool = False,
|
||||||
|
channel: str = "stable",
|
||||||
|
) -> dict[str, object]:
|
||||||
|
snapshot = build_dashboard(
|
||||||
|
workspace_root=app.state.workspace_root,
|
||||||
|
target_version=target_version,
|
||||||
|
online=online,
|
||||||
|
check_remote_tags=online or remote_tags,
|
||||||
|
check_public_catalog=public_catalog,
|
||||||
|
include_migrations=include_migrations,
|
||||||
|
channel=channel,
|
||||||
|
)
|
||||||
|
release_plan = build_release_plan(snapshot)
|
||||||
|
return to_jsonable(release_plan)
|
||||||
|
|
||||||
|
@app.get("/api/release-intelligence")
|
||||||
|
def release_intelligence(
|
||||||
|
channel: str = "stable",
|
||||||
|
public_catalog: bool = True,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
intelligence = build_release_intelligence(
|
||||||
|
workspace_root=app.state.workspace_root,
|
||||||
|
channel=channel,
|
||||||
|
prefer_public=public_catalog,
|
||||||
|
)
|
||||||
|
return to_jsonable(intelligence)
|
||||||
|
|
||||||
|
@app.get("/api/selective-plan")
|
||||||
|
def selective_plan(
|
||||||
|
repos: str | None = None,
|
||||||
|
target_version: str | None = None,
|
||||||
|
repo_versions: str | None = None,
|
||||||
|
online: bool = False,
|
||||||
|
remote_tags: bool = False,
|
||||||
|
public_catalog: bool = True,
|
||||||
|
include_migrations: bool = False,
|
||||||
|
channel: str = "stable",
|
||||||
|
) -> dict[str, object]:
|
||||||
|
snapshot = build_dashboard(
|
||||||
|
workspace_root=app.state.workspace_root,
|
||||||
|
target_version=target_version,
|
||||||
|
online=online,
|
||||||
|
check_remote_tags=online or remote_tags,
|
||||||
|
check_public_catalog=public_catalog,
|
||||||
|
include_migrations=include_migrations,
|
||||||
|
channel=channel,
|
||||||
|
)
|
||||||
|
release_plan = build_selective_release_plan(
|
||||||
|
snapshot,
|
||||||
|
selected_repos=parse_csv(repos),
|
||||||
|
target_version=target_version,
|
||||||
|
repo_versions=parse_repo_versions(repo_versions),
|
||||||
|
channel=channel,
|
||||||
|
)
|
||||||
|
return to_jsonable(release_plan)
|
||||||
|
|
||||||
|
@app.get("/api/catalog-candidates")
|
||||||
|
def catalog_candidates() -> dict[str, object]:
|
||||||
|
return {"candidates": list_catalog_candidates()}
|
||||||
|
|
||||||
|
@app.post("/api/catalog-candidates")
|
||||||
|
def catalog_candidate(request: CatalogCandidateRequest) -> dict[str, object]:
|
||||||
|
repo_versions = dict(request.repo_versions)
|
||||||
|
if not repo_versions and request.repos and request.target_version:
|
||||||
|
repo_versions = {repo: request.target_version for repo in request.repos}
|
||||||
|
if not repo_versions:
|
||||||
|
raise HTTPException(status_code=400, detail="repo_versions or repos with target_version is required")
|
||||||
|
signing_keys = tuple(request.signing_keys or default_signing_keys())
|
||||||
|
if not signing_keys:
|
||||||
|
raise HTTPException(status_code=400, detail="No signing key supplied and default release key was not found")
|
||||||
|
candidate = build_selective_catalog_candidate(
|
||||||
|
repo_versions=repo_versions,
|
||||||
|
channel=request.channel,
|
||||||
|
workspace_root=app.state.workspace_root,
|
||||||
|
output_dir=request.output_dir,
|
||||||
|
base_catalog=request.base_catalog,
|
||||||
|
signing_keys=signing_keys,
|
||||||
|
public_base_url=request.public_base_url,
|
||||||
|
repository_base=request.repository_base,
|
||||||
|
expires_days=request.expires_days,
|
||||||
|
sequence=request.sequence,
|
||||||
|
check_public=request.check_public,
|
||||||
|
)
|
||||||
|
return to_jsonable(candidate)
|
||||||
|
|
||||||
|
@app.post("/api/catalog-candidates/publish")
|
||||||
|
def publish_candidate(request: PublishCandidateRequest) -> dict[str, object]:
|
||||||
|
if request.push and request.confirm != "PUSH":
|
||||||
|
raise HTTPException(status_code=400, detail="Push requires confirm=PUSH")
|
||||||
|
if (request.apply or request.commit or request.tag or request.build_web) and not request.push and request.confirm != "APPLY":
|
||||||
|
raise HTTPException(status_code=400, detail="Apply/commit/tag/build requires confirm=APPLY")
|
||||||
|
result = publish_catalog_candidate(
|
||||||
|
candidate_dir=request.candidate_dir,
|
||||||
|
workspace_root=app.state.workspace_root,
|
||||||
|
web_root=request.web_root,
|
||||||
|
channel=request.channel,
|
||||||
|
apply=request.apply,
|
||||||
|
build_web=request.build_web,
|
||||||
|
commit=request.commit,
|
||||||
|
tag=request.tag,
|
||||||
|
push=request.push,
|
||||||
|
remote=request.remote,
|
||||||
|
branch=request.branch,
|
||||||
|
tag_name=request.tag_name,
|
||||||
|
npm=request.npm,
|
||||||
|
allow_dirty_website=request.allow_dirty_website,
|
||||||
|
)
|
||||||
|
return to_jsonable(result)
|
||||||
|
|
||||||
|
@app.post("/api/repositories/push")
|
||||||
|
def repository_push(request: RepositoryPushRequest) -> dict[str, object]:
|
||||||
|
repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip()))
|
||||||
|
if not repos:
|
||||||
|
raise HTTPException(status_code=400, detail="At least one repository must be selected")
|
||||||
|
if request.apply and request.confirm != "PUSH":
|
||||||
|
raise HTTPException(status_code=400, detail="Repository push requires confirm=PUSH")
|
||||||
|
result = push_repositories(repos=repos, workspace_root=app.state.workspace_root, remote=request.remote, apply=request.apply)
|
||||||
|
return to_jsonable(result)
|
||||||
|
|
||||||
|
@app.post("/api/repositories/sync")
|
||||||
|
def repository_sync(request: RepositorySyncRequest) -> dict[str, object]:
|
||||||
|
repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip()))
|
||||||
|
if not repos:
|
||||||
|
raise HTTPException(status_code=400, detail="At least one repository must be selected")
|
||||||
|
if request.apply and request.confirm != "SYNC":
|
||||||
|
raise HTTPException(status_code=400, detail="Repository sync requires confirm=SYNC")
|
||||||
|
result = sync_repositories(repos=repos, workspace_root=app.state.workspace_root, remote=request.remote, apply=request.apply)
|
||||||
|
return to_jsonable(result)
|
||||||
|
|
||||||
|
@app.post("/api/repositories/prepare")
|
||||||
|
def repository_prepare(request: RepositoryPrepareRequest) -> dict[str, object]:
|
||||||
|
repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip()))
|
||||||
|
if not repos:
|
||||||
|
raise HTTPException(status_code=400, detail="At least one repository must be selected")
|
||||||
|
if request.apply and request.confirm != "COMMIT":
|
||||||
|
raise HTTPException(status_code=400, detail="Repository prepare requires confirm=COMMIT")
|
||||||
|
result = prepare_repositories(
|
||||||
|
repos=repos,
|
||||||
|
repo_versions=request.repo_versions,
|
||||||
|
message=request.message,
|
||||||
|
workspace_root=app.state.workspace_root,
|
||||||
|
apply=request.apply,
|
||||||
|
)
|
||||||
|
return to_jsonable(result)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def parse_csv(value: str | None) -> tuple[str, ...]:
|
||||||
|
if not value:
|
||||||
|
return ()
|
||||||
|
return tuple(item.strip() for item in value.split(",") if item.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def parse_repo_versions(value: str | None) -> dict[str, str]:
|
||||||
|
if not value:
|
||||||
|
return {}
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
for item in value.split(","):
|
||||||
|
if ":" not in item:
|
||||||
|
continue
|
||||||
|
repo, version = item.split(":", 1)
|
||||||
|
repo = repo.strip()
|
||||||
|
version = version.strip()
|
||||||
|
if repo and version:
|
||||||
|
result[repo] = version
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def default_signing_keys() -> tuple[str, ...]:
|
||||||
|
key_path = Path.home() / ".config" / "govoplan" / "release-keys" / "release-key-1.pem"
|
||||||
|
if not key_path.exists():
|
||||||
|
return ()
|
||||||
|
return (f"release-key-1={key_path}",)
|
||||||
|
|
||||||
|
|
||||||
|
def list_catalog_candidates() -> list[dict[str, object]]:
|
||||||
|
root = META_ROOT / "runtime" / "release-candidates"
|
||||||
|
if not root.exists():
|
||||||
|
return []
|
||||||
|
candidates: list[dict[str, object]] = []
|
||||||
|
for path in sorted((item for item in root.iterdir() if item.is_dir()), reverse=True):
|
||||||
|
summary = path / "summary.json"
|
||||||
|
if summary.exists():
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
|
||||||
|
payload = json.loads(summary.read_text(encoding="utf-8"))
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
payload.setdefault("candidate_dir", str(path))
|
||||||
|
candidates.append(payload)
|
||||||
|
continue
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
candidates.append({"candidate_dir": str(path), "status": "unknown"})
|
||||||
|
return candidates
|
||||||
1781
tools/release/webui/index.html
Normal file
1781
tools/release/webui/index.html
Normal file
File diff suppressed because it is too large
Load Diff
48
tools/repo/generate-repository-index.py
Normal file
48
tools/repo/generate-repository-index.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
CATEGORIES = ("system", "module", "connector", "website")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
manifest = json.loads((ROOT / "repositories.json").read_text(encoding="utf-8"))
|
||||||
|
lines = [
|
||||||
|
"# GovOPlaN Repository Index",
|
||||||
|
"",
|
||||||
|
"Generated from `repositories.json`. Use that JSON file as the machine-readable source of truth; this page is the human-readable link index.",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for category in CATEGORIES:
|
||||||
|
entries = [entry for entry in manifest["repositories"] if entry["category"] == category]
|
||||||
|
if not entries:
|
||||||
|
continue
|
||||||
|
lines.extend([
|
||||||
|
f"## {category.title()}",
|
||||||
|
"",
|
||||||
|
"| Repository | Subtype | Local path | Gitea |",
|
||||||
|
"| --- | --- | --- | --- |",
|
||||||
|
])
|
||||||
|
for entry in entries:
|
||||||
|
name = entry["name"]
|
||||||
|
subtype = entry.get("subtype", "")
|
||||||
|
path = entry["path"]
|
||||||
|
url = gitea_url(entry["remote"])
|
||||||
|
lines.append(f"| `{name}` | `{subtype}` | `../{path}` | [{name}]({url}) |")
|
||||||
|
lines.append("")
|
||||||
|
(ROOT / "docs" / "REPOSITORY_INDEX.md").write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def gitea_url(remote: str) -> str:
|
||||||
|
if remote.startswith("git@git.add-ideas.de:"):
|
||||||
|
return remote.replace("git@git.add-ideas.de:", "https://git.add-ideas.de/", 1).removesuffix(".git")
|
||||||
|
return remote
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
479
tools/repo/sync-python-environment.py
Normal file
479
tools/repo/sync-python-environment.py
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Synchronize a GovOPlaN Python environment when package metadata changes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
STAMP_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RequirementEntry:
|
||||||
|
line: str
|
||||||
|
install_args: tuple[str, ...]
|
||||||
|
is_local: bool
|
||||||
|
key: str
|
||||||
|
target_path: str | None = None
|
||||||
|
pyproject: str | None = None
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"line": self.line,
|
||||||
|
"install_args": list(self.install_args),
|
||||||
|
"is_local": self.is_local,
|
||||||
|
"key": self.key,
|
||||||
|
}
|
||||||
|
if self.target_path:
|
||||||
|
payload["target_path"] = self.target_path
|
||||||
|
if self.pyproject:
|
||||||
|
payload["pyproject"] = self.pyproject
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class InstallPlan:
|
||||||
|
mode: str
|
||||||
|
reason: str
|
||||||
|
commands: tuple[tuple[str, ...], ...]
|
||||||
|
warnings: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--requirements",
|
||||||
|
type=Path,
|
||||||
|
default=META_ROOT / "requirements-dev.txt",
|
||||||
|
help="Requirements file to install. Defaults to requirements-dev.txt.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--python",
|
||||||
|
default=sys.executable,
|
||||||
|
help="Python executable to use for pip. Defaults to the current interpreter.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--stamp",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
help="Stamp file used to remember the last synchronized metadata digest.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--force", action="store_true", help="Run pip even when the environment stamp is current.")
|
||||||
|
parser.add_argument("--check", action="store_true", help="Exit 1 if synchronization would be required.")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Print what would run without changing the environment.")
|
||||||
|
parser.add_argument("--upgrade-pip", action="store_true", help="Upgrade pip before installing requirements.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
requirements = args.requirements.expanduser().resolve()
|
||||||
|
if not requirements.exists():
|
||||||
|
print(f"Requirements file not found: {requirements}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
python = normalize_python_command(args.python)
|
||||||
|
stamp = (args.stamp or default_stamp_path(python, requirements)).expanduser().resolve()
|
||||||
|
local_requirements = local_requirement_entries(requirements)
|
||||||
|
fingerprint = build_fingerprint(requirements=requirements, python=python, local_requirements=local_requirements)
|
||||||
|
previous = load_stamp(stamp)
|
||||||
|
current = previous.get("digest") == fingerprint["digest"]
|
||||||
|
|
||||||
|
if current and not args.force:
|
||||||
|
payload = stamp_payload(
|
||||||
|
requirements=requirements,
|
||||||
|
python=python,
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
entries=parse_requirement_entries(requirements),
|
||||||
|
)
|
||||||
|
if stamp_needs_refresh(previous, payload):
|
||||||
|
if args.dry_run:
|
||||||
|
print(f"Python environment is current for {requirements.name}. Stamp metadata would be refreshed.")
|
||||||
|
print("No pip command required; the environment stamp would be refreshed.")
|
||||||
|
return 0
|
||||||
|
if args.check:
|
||||||
|
print(f"Python environment is current for {requirements.name}.")
|
||||||
|
return 0
|
||||||
|
write_stamp(stamp, payload)
|
||||||
|
print(f"Python environment is current for {requirements.name}. Stamp metadata refreshed.")
|
||||||
|
return 0
|
||||||
|
print(f"Python environment is current for {requirements.name}.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(f"Python environment metadata is stale for {requirements.name}.")
|
||||||
|
print(f"Tracked inputs: {len(fingerprint['inputs'])}")
|
||||||
|
|
||||||
|
if args.check:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
plan = build_install_plan(
|
||||||
|
previous=previous,
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
requirements=requirements,
|
||||||
|
python=python,
|
||||||
|
local_requirements=local_requirements,
|
||||||
|
force=args.force,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"{plan.mode}: {plan.reason}")
|
||||||
|
for warning in plan.warnings:
|
||||||
|
print(f"warning: {warning}", file=sys.stderr)
|
||||||
|
|
||||||
|
commands: list[tuple[str, ...]] = []
|
||||||
|
if args.upgrade_pip:
|
||||||
|
commands.append((python, "-m", "pip", "install", "--upgrade", "pip"))
|
||||||
|
commands.extend(plan.commands)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
for command in commands:
|
||||||
|
print("+ " + format_command(command))
|
||||||
|
if not commands:
|
||||||
|
print("No pip command required; the environment stamp would be refreshed.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
for command in commands:
|
||||||
|
subprocess.run(command, check=True)
|
||||||
|
|
||||||
|
write_stamp(
|
||||||
|
stamp,
|
||||||
|
stamp_payload(
|
||||||
|
requirements=requirements,
|
||||||
|
python=python,
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
entries=parse_requirement_entries(requirements),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
print(f"Python environment synchronized. Stamp written to {stamp}.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def default_stamp_path(python: str, requirements: Path) -> Path:
|
||||||
|
executable = Path(python)
|
||||||
|
if executable.name.startswith("python") and executable.parent.name == "bin":
|
||||||
|
return executable.parent.parent / f".govoplan-sync-{requirements.stem}.json"
|
||||||
|
return META_ROOT / "runtime" / f".govoplan-sync-{requirements.stem}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_python_command(value: str) -> str:
|
||||||
|
if os.sep not in value:
|
||||||
|
return value
|
||||||
|
path = Path(value).expanduser()
|
||||||
|
if not path.is_absolute():
|
||||||
|
path = Path.cwd() / path
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def stamp_payload(
|
||||||
|
*,
|
||||||
|
requirements: Path,
|
||||||
|
python: str,
|
||||||
|
fingerprint: dict[str, Any],
|
||||||
|
entries: tuple[RequirementEntry, ...],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"version": STAMP_VERSION,
|
||||||
|
"digest": fingerprint["digest"],
|
||||||
|
"requirements": str(requirements),
|
||||||
|
"python": python,
|
||||||
|
"inputs": fingerprint["inputs"],
|
||||||
|
"requirements_entries": [entry.as_dict() for entry in entries],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def stamp_needs_refresh(previous: dict[str, Any], payload: dict[str, object]) -> bool:
|
||||||
|
return any(previous.get(key) != value for key, value in payload.items())
|
||||||
|
|
||||||
|
|
||||||
|
def build_fingerprint(*, requirements: Path, python: str, local_requirements: tuple[RequirementEntry, ...]) -> dict[str, Any]:
|
||||||
|
hasher = hashlib.sha256()
|
||||||
|
inputs: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
add_text(hasher, "stamp-version", str(STAMP_VERSION))
|
||||||
|
add_text(hasher, "python", python)
|
||||||
|
add_file(hasher, inputs, requirements)
|
||||||
|
|
||||||
|
for project in local_pyprojects(local_requirements):
|
||||||
|
add_file(hasher, inputs, project)
|
||||||
|
|
||||||
|
return {"digest": hasher.hexdigest(), "inputs": inputs}
|
||||||
|
|
||||||
|
|
||||||
|
def build_install_plan(
|
||||||
|
*,
|
||||||
|
previous: dict[str, Any],
|
||||||
|
fingerprint: dict[str, Any],
|
||||||
|
requirements: Path,
|
||||||
|
python: str,
|
||||||
|
local_requirements: tuple[RequirementEntry, ...],
|
||||||
|
force: bool,
|
||||||
|
) -> InstallPlan:
|
||||||
|
full_command = (python, "-m", "pip", "install", "-r", str(requirements))
|
||||||
|
if force:
|
||||||
|
return InstallPlan("Full Python environment sync", "--force was requested.", (full_command,))
|
||||||
|
if previous.get("version") != STAMP_VERSION:
|
||||||
|
return InstallPlan("Full Python environment sync", "the environment stamp is missing or uses an older format.", (full_command,))
|
||||||
|
if previous.get("python") != python:
|
||||||
|
return InstallPlan("Full Python environment sync", "the Python executable changed.", (full_command,))
|
||||||
|
|
||||||
|
previous_inputs = inputs_by_path(previous.get("inputs"))
|
||||||
|
current_inputs = inputs_by_path(fingerprint.get("inputs"))
|
||||||
|
if not previous_inputs:
|
||||||
|
return InstallPlan("Full Python environment sync", "there is no previous input fingerprint.", (full_command,))
|
||||||
|
|
||||||
|
requirements_key = str(requirements)
|
||||||
|
changed_paths = {
|
||||||
|
path
|
||||||
|
for path, digest in current_inputs.items()
|
||||||
|
if previous_inputs.get(path) != digest
|
||||||
|
}
|
||||||
|
removed_paths = set(previous_inputs) - set(current_inputs)
|
||||||
|
stale_requirements = requirements_key in changed_paths or requirements_key in removed_paths
|
||||||
|
|
||||||
|
commands: list[tuple[str, ...]] = []
|
||||||
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
if stale_requirements:
|
||||||
|
delta = requirement_delta_plan(previous.get("requirements_entries"), parse_requirement_entries(requirements))
|
||||||
|
if delta is None:
|
||||||
|
return InstallPlan(
|
||||||
|
"Full Python environment sync",
|
||||||
|
"requirements changed in a way that needs the full resolver.",
|
||||||
|
(full_command,),
|
||||||
|
)
|
||||||
|
for install_args in delta.commands:
|
||||||
|
commands.append((python, "-m", "pip", "install", *install_args))
|
||||||
|
warnings.extend(delta.warnings)
|
||||||
|
changed_paths.discard(requirements_key)
|
||||||
|
removed_paths.discard(requirements_key)
|
||||||
|
|
||||||
|
local_by_pyproject = {
|
||||||
|
entry.pyproject: entry
|
||||||
|
for entry in local_requirements
|
||||||
|
if entry.pyproject
|
||||||
|
}
|
||||||
|
for path in sorted(changed_paths):
|
||||||
|
entry = local_by_pyproject.get(path)
|
||||||
|
if entry is None:
|
||||||
|
return InstallPlan(
|
||||||
|
"Full Python environment sync",
|
||||||
|
f"tracked input changed outside a known local project: {path}",
|
||||||
|
(full_command,),
|
||||||
|
)
|
||||||
|
commands.append((python, "-m", "pip", "install", *entry.install_args))
|
||||||
|
|
||||||
|
if removed_paths:
|
||||||
|
return InstallPlan(
|
||||||
|
"Full Python environment sync",
|
||||||
|
"tracked input files disappeared; using the full requirements installer.",
|
||||||
|
(full_command,),
|
||||||
|
)
|
||||||
|
|
||||||
|
deduped_commands = tuple(dict.fromkeys(commands))
|
||||||
|
if deduped_commands:
|
||||||
|
return InstallPlan(
|
||||||
|
"Selective Python environment sync",
|
||||||
|
f"installing {len(deduped_commands)} stale local requirement(s).",
|
||||||
|
deduped_commands,
|
||||||
|
tuple(warnings),
|
||||||
|
)
|
||||||
|
return InstallPlan(
|
||||||
|
"Stamp-only Python environment sync",
|
||||||
|
"requirements metadata changed without a pip-installable requirement change.",
|
||||||
|
(),
|
||||||
|
tuple(warnings),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def requirement_delta_plan(previous_entries: object, current_entries: tuple[RequirementEntry, ...]) -> InstallPlan | None:
|
||||||
|
parsed_previous = previous_requirement_entries(previous_entries)
|
||||||
|
if parsed_previous is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
previous_by_key = {entry.key: entry for entry in parsed_previous}
|
||||||
|
current_by_key = {entry.key: entry for entry in current_entries}
|
||||||
|
changed_current = [
|
||||||
|
entry
|
||||||
|
for key, entry in current_by_key.items()
|
||||||
|
if previous_by_key.get(key) is None or previous_by_key[key].install_args != entry.install_args or previous_by_key[key].line != entry.line
|
||||||
|
]
|
||||||
|
removed_keys = set(previous_by_key) - set(current_by_key)
|
||||||
|
if any(not entry.is_local or not entry.pyproject for entry in changed_current):
|
||||||
|
return None
|
||||||
|
|
||||||
|
warnings = []
|
||||||
|
if removed_keys:
|
||||||
|
warnings.append("Removed requirements are not uninstalled automatically; pip install -r had the same limitation.")
|
||||||
|
commands = tuple((entry.install_args) for entry in changed_current)
|
||||||
|
return InstallPlan(
|
||||||
|
"Requirement delta",
|
||||||
|
"requirements file changed only in local editable requirements.",
|
||||||
|
commands,
|
||||||
|
tuple(warnings),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def previous_requirement_entries(value: object) -> tuple[RequirementEntry, ...] | None:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return None
|
||||||
|
entries: list[RequirementEntry] = []
|
||||||
|
for item in value:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
return None
|
||||||
|
raw_install_args = item.get("install_args")
|
||||||
|
if not isinstance(raw_install_args, list) or not all(isinstance(arg, str) for arg in raw_install_args):
|
||||||
|
return None
|
||||||
|
line = item.get("line")
|
||||||
|
key = item.get("key")
|
||||||
|
if not isinstance(line, str) or not isinstance(key, str):
|
||||||
|
return None
|
||||||
|
entries.append(
|
||||||
|
RequirementEntry(
|
||||||
|
line=line,
|
||||||
|
install_args=tuple(raw_install_args),
|
||||||
|
is_local=bool(item.get("is_local")),
|
||||||
|
key=key,
|
||||||
|
target_path=item.get("target_path") if isinstance(item.get("target_path"), str) else None,
|
||||||
|
pyproject=item.get("pyproject") if isinstance(item.get("pyproject"), str) else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(entries)
|
||||||
|
|
||||||
|
|
||||||
|
def inputs_by_path(value: object) -> dict[str, str]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return {}
|
||||||
|
inputs: dict[str, str] = {}
|
||||||
|
for item in value:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
path = item.get("path")
|
||||||
|
digest = item.get("sha256")
|
||||||
|
if isinstance(path, str) and isinstance(digest, str):
|
||||||
|
inputs[path] = digest
|
||||||
|
return inputs
|
||||||
|
|
||||||
|
|
||||||
|
def local_requirement_entries(requirements: Path) -> tuple[RequirementEntry, ...]:
|
||||||
|
return tuple(entry for entry in parse_requirement_entries(requirements) if entry.is_local and entry.pyproject)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_requirement_entries(requirements: Path) -> tuple[RequirementEntry, ...]:
|
||||||
|
entries: list[RequirementEntry] = []
|
||||||
|
for raw_line in requirements.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith("#") or line.startswith(("-r ", "--requirement")):
|
||||||
|
continue
|
||||||
|
editable = editable_target(line)
|
||||||
|
target = editable or local_path_target(line)
|
||||||
|
if target is None:
|
||||||
|
entries.append(RequirementEntry(line=line, install_args=(line,), is_local=False, key=f"requirement:{line}"))
|
||||||
|
continue
|
||||||
|
path = resolve_requirement_path(requirements.parent, target)
|
||||||
|
if path is None:
|
||||||
|
entries.append(RequirementEntry(line=line, install_args=(line,), is_local=False, key=f"requirement:{line}"))
|
||||||
|
continue
|
||||||
|
pyproject = path / "pyproject.toml"
|
||||||
|
resolved_pyproject = str(pyproject.resolve()) if pyproject.exists() else None
|
||||||
|
install_target = absolute_local_target(requirements.parent, target)
|
||||||
|
install_args = ("-e", install_target) if editable is not None else (install_target,)
|
||||||
|
key = f"local:{resolved_pyproject or path.resolve()}"
|
||||||
|
entries.append(
|
||||||
|
RequirementEntry(
|
||||||
|
line=line,
|
||||||
|
install_args=install_args,
|
||||||
|
is_local=True,
|
||||||
|
key=key,
|
||||||
|
target_path=str(path.resolve()),
|
||||||
|
pyproject=resolved_pyproject,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(entries)
|
||||||
|
|
||||||
|
|
||||||
|
def local_pyprojects(local_requirements: tuple[RequirementEntry, ...]) -> tuple[Path, ...]:
|
||||||
|
projects = {Path(entry.pyproject) for entry in local_requirements if entry.pyproject}
|
||||||
|
return tuple(sorted(projects))
|
||||||
|
|
||||||
|
|
||||||
|
def editable_target(line: str) -> str | None:
|
||||||
|
if line.startswith("-e "):
|
||||||
|
return line[3:].strip()
|
||||||
|
if line.startswith("--editable "):
|
||||||
|
return line.removeprefix("--editable ").strip()
|
||||||
|
if line.startswith("--editable="):
|
||||||
|
return line.removeprefix("--editable=").strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def local_path_target(line: str) -> str | None:
|
||||||
|
if line.startswith(("./", "../", "/")):
|
||||||
|
return line
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_requirement_path(base: Path, target: str) -> Path | None:
|
||||||
|
cleaned, _suffix = split_local_target(target)
|
||||||
|
if "://" in cleaned or cleaned.startswith("git+"):
|
||||||
|
return None
|
||||||
|
if not cleaned:
|
||||||
|
return None
|
||||||
|
return (base / cleaned).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def absolute_local_target(base: Path, target: str) -> str:
|
||||||
|
path_part, suffix = split_local_target(target)
|
||||||
|
return f"{(base / path_part).resolve()}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def split_local_target(target: str) -> tuple[str, str]:
|
||||||
|
cleaned = target.split("#", 1)[0].strip()
|
||||||
|
fragment = target[len(cleaned) :].strip()
|
||||||
|
extras = ""
|
||||||
|
if "[" in cleaned:
|
||||||
|
cleaned, extra_part = cleaned.split("[", 1)
|
||||||
|
extras = f"[{extra_part}"
|
||||||
|
return cleaned.strip(), f"{extras}{fragment}"
|
||||||
|
|
||||||
|
|
||||||
|
def add_file(hasher: hashlib._Hash, inputs: list[dict[str, str]], path: Path) -> None:
|
||||||
|
data = path.read_bytes()
|
||||||
|
digest = hashlib.sha256(data).hexdigest()
|
||||||
|
add_text(hasher, str(path), digest)
|
||||||
|
inputs.append({"path": str(path), "sha256": digest})
|
||||||
|
|
||||||
|
|
||||||
|
def add_text(hasher: hashlib._Hash, key: str, value: str) -> None:
|
||||||
|
hasher.update(key.encode("utf-8"))
|
||||||
|
hasher.update(b"\0")
|
||||||
|
hasher.update(value.encode("utf-8"))
|
||||||
|
hasher.update(b"\0")
|
||||||
|
|
||||||
|
|
||||||
|
def load_stamp(path: Path) -> dict[str, Any]:
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
return payload if isinstance(payload, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def write_stamp(path: Path, payload: dict[str, Any]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def format_command(command: tuple[str, ...]) -> str:
|
||||||
|
return " ".join(shlex.quote(item) for item in command)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user