Compare commits
46 Commits
v0.1.8
...
99534c0251
| Author | SHA1 | Date | |
|---|---|---|---|
| 99534c0251 | |||
| 71e74601cf | |||
| c29f1e9977 | |||
| 5447299289 | |||
| 568ea6059a | |||
| 8b6bcb7a4d | |||
| 2fb0a71bfb | |||
| 8ecf8770ab | |||
| d53db2da06 | |||
| 8906704dc0 | |||
| 30f9ed152a | |||
| eca75cbc93 | |||
| a8abb85676 | |||
| 097f9ca2f4 | |||
| 89771d96fb | |||
| 492fec46b6 | |||
| a863767233 | |||
| 99e255cf81 | |||
| d9819c4aad | |||
| 78b601a2e5 | |||
| a18c7ea040 | |||
| b193d4555f | |||
| a81dc21e90 | |||
| 676030b993 | |||
| a15a74c54c | |||
| c34892f6ea | |||
| 397c87a012 | |||
| f1b07354a0 | |||
| 17f8036bdc | |||
| fcc5885dcf | |||
| 0b1506f860 | |||
| 295b49bb04 | |||
| 65cc099839 | |||
| 1ce3d02239 | |||
| 9805d085fa | |||
| 2e21397868 | |||
| 4b217c7aba | |||
| 8b80afbd60 | |||
| 8255665349 | |||
| 2fc3b66c07 | |||
| 9e7103f613 | |||
| 05ae81d641 | |||
| dd796b4d3c | |||
| 27ea3c82d4 | |||
| 342582645b | |||
| 54ab1945ff |
19
.env.example
19
.env.example
@@ -12,9 +12,26 @@ ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,aud
|
|||||||
|
|
||||||
CELERY_ENABLED=true
|
CELERY_ENABLED=true
|
||||||
REDIS_URL=redis://127.0.0.1:6379/0
|
REDIS_URL=redis://127.0.0.1:6379/0
|
||||||
CELERY_QUEUES=send_email,append_sent,default
|
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
||||||
|
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||||
|
|
||||||
|
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false
|
||||||
|
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
|
||||||
|
GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES=536870912
|
||||||
|
GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=
|
||||||
|
GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=
|
||||||
|
GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES=536870912
|
||||||
|
GOVOPLAN_HTTP_HSTS_SECONDS=31536000
|
||||||
|
|
||||||
|
AUTH_LOGIN_THROTTLE_ENABLED=true
|
||||||
|
AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10
|
||||||
|
AUTH_LOGIN_THROTTLE_CLIENT_LIMIT=100
|
||||||
|
AUTH_LOGIN_THROTTLE_WINDOW_SECONDS=900
|
||||||
|
AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS=30
|
||||||
|
|
||||||
CORS_ORIGINS=https://govoplan.example.org
|
CORS_ORIGINS=https://govoplan.example.org
|
||||||
|
GOVOPLAN_TRUSTED_HOSTS=govoplan.example.org
|
||||||
|
FORWARDED_ALLOW_IPS=127.0.0.1
|
||||||
AUTH_COOKIE_SECURE=true
|
AUTH_COOKIE_SECURE=true
|
||||||
AUTH_COOKIE_SAMESITE=lax
|
AUTH_COOKIE_SAMESITE=lax
|
||||||
AUTH_COOKIE_DOMAIN=
|
AUTH_COOKIE_DOMAIN=
|
||||||
|
|||||||
@@ -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>''',
|
||||||
|
|||||||
73
README.md
73
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
|
||||||
@@ -31,6 +30,12 @@ Start the development stack through the meta repository:
|
|||||||
./tools/launch/launch-dev.sh
|
./tools/launch/launch-dev.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Open the WebUI in a browser after launch only when explicitly requested:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GOVOPLAN_OPEN_BROWSER=1 ./tools/launch/launch-dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
Start the shared development PostgreSQL service:
|
Start the shared development PostgreSQL service:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -55,6 +60,45 @@ 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
|
||||||
|
```
|
||||||
|
|
||||||
|
Require backend, manifest, frontend, lockfile, and release-composition versions
|
||||||
|
to agree before a release:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./.venv/bin/python tools/checks/check-version-alignment.py --release-composition
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate the CycloneDX dependency inventory from a resolved release environment:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./.venv/bin/python tools/release/generate-release-sbom.py --python ./.venv/bin/python
|
||||||
|
```
|
||||||
|
|
||||||
|
For reproducible release artifacts, set `SOURCE_DATE_EPOCH` to the release
|
||||||
|
commit timestamp (or pass an explicit timezone-qualified `--timestamp`):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SOURCE_DATE_EPOCH="$(git -C ../govoplan-core show -s --format=%ct HEAD)" \
|
||||||
|
./.venv/bin/python tools/release/generate-release-sbom.py --python ./.venv/bin/python
|
||||||
|
```
|
||||||
|
|
||||||
Run the consolidated focused verification suite:
|
Run the consolidated focused verification suite:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -76,6 +120,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 +139,24 @@ 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`.
|
||||||
|
The cross-product destination, stakeholder visions, configuration archetypes,
|
||||||
|
connected outcome stories, and capability horizons are documented in
|
||||||
|
the [Connected Governance Platform Roadmap](docs/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md).
|
||||||
|
The selected Campaign-to-Postbox-to-data-to-collaboration implementation path,
|
||||||
|
including stage gates and shared documentation expectations, is in the
|
||||||
|
[Reference Journey Program](docs/REFERENCE_JOURNEY_PROGRAM.md).
|
||||||
|
The administrator journey from Core-only bootstrap through online module
|
||||||
|
installation, scale-out, and reversible environment promotion is defined in
|
||||||
|
[System Administrator Lifecycle User Story](docs/SYSTEM_ADMINISTRATOR_LIFECYCLE_USER_STORY.md).
|
||||||
|
The first Campaign-centric capability and infrastructure fit assessment is in
|
||||||
|
`docs/CAPABILITY_AND_INFRASTRUCTURE_FIT.md`.
|
||||||
|
|
||||||
# GovOPlaN Docker
|
# GovOPlaN Docker
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,12 @@ cd /mnt/DATA/git/govoplan
|
|||||||
tools/launch/launch-dev.sh
|
tools/launch/launch-dev.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The launcher does not open a browser by default. To opt in:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GOVOPLAN_OPEN_BROWSER=1 tools/launch/launch-dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
To force the old SQLite fallback for a disposable local run:
|
To force the old SQLite fallback for a disposable local run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -111,7 +117,12 @@ tools/checks/postgres-integration-check.py \
|
|||||||
```
|
```
|
||||||
|
|
||||||
`--reset-schema` drops and recreates the `public` schema before every module
|
`--reset-schema` drops and recreates the `public` schema before every module
|
||||||
set. Use it only against this disposable database.
|
set. Use it only against this disposable database. Before those permutations,
|
||||||
|
the check runs the Files credential-retirement atomicity proof in random,
|
||||||
|
test-owned schemas. The proof does not modify `public` and fails the release
|
||||||
|
gate if its PostgreSQL or full-stack dependencies are unavailable. The
|
||||||
|
`--skip-retirement-atomicity` option is only for bounded migration/smoke
|
||||||
|
diagnosis; do not use it for release evidence.
|
||||||
|
|
||||||
Stop the testbed:
|
Stop the testbed:
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,27 @@ DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
|
|||||||
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
|
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
|
||||||
REDIS_URL=redis://127.0.0.1:56379/0
|
REDIS_URL=redis://127.0.0.1:56379/0
|
||||||
CELERY_ENABLED=true
|
CELERY_ENABLED=true
|
||||||
CELERY_QUEUES=send_email,append_sent,default
|
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
||||||
|
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||||
|
|
||||||
|
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true
|
||||||
|
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
|
||||||
|
GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES=536870912
|
||||||
|
GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=
|
||||||
|
GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=
|
||||||
|
GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES=536870912
|
||||||
|
GOVOPLAN_HTTP_HSTS_SECONDS=0
|
||||||
|
|
||||||
|
AUTH_LOGIN_THROTTLE_ENABLED=true
|
||||||
|
AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10
|
||||||
|
AUTH_LOGIN_THROTTLE_CLIENT_LIMIT=100
|
||||||
|
AUTH_LOGIN_THROTTLE_WINDOW_SECONDS=900
|
||||||
|
AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS=30
|
||||||
|
|
||||||
ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops
|
ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops
|
||||||
CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
|
CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
|
||||||
|
GOVOPLAN_TRUSTED_HOSTS=127.0.0.1,localhost,testserver
|
||||||
|
FORWARDED_ALLOW_IPS=127.0.0.1
|
||||||
AUTH_COOKIE_SECURE=false
|
AUTH_COOKIE_SECURE=false
|
||||||
FILE_STORAGE_BACKEND=local
|
FILE_STORAGE_BACKEND=local
|
||||||
FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files
|
FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Production-Like Development Profile
|
# Production-Like Development Profile
|
||||||
|
|
||||||
This profile runs the shared services that production depends on while keeping
|
This profile runs the shared services that production depends on while keeping
|
||||||
API, worker, and WebUI code in the editable local repositories.
|
API, worker, scheduler, and WebUI code in the editable local repositories.
|
||||||
|
|
||||||
It provides:
|
It provides:
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ It provides:
|
|||||||
- explicit `ENABLED_MODULES`
|
- explicit `ENABLED_MODULES`
|
||||||
- local durable file storage under `runtime/production-like/files`
|
- local durable file storage under `runtime/production-like/files`
|
||||||
- a Celery worker process using the same queues as the API
|
- a Celery worker process using the same queues as the API
|
||||||
|
- a Celery beat process for durable retry and recovery schedules
|
||||||
|
|
||||||
Start it from the meta repository:
|
Start it from the meta repository:
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ password changes:
|
|||||||
cp dev/production-like/.env.example dev/production-like/.env
|
cp dev/production-like/.env.example dev/production-like/.env
|
||||||
```
|
```
|
||||||
|
|
||||||
The API and worker use:
|
The API, worker, and scheduler use:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
|
DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
|
||||||
@@ -45,7 +46,7 @@ REDIS_URL=redis://127.0.0.1:56379/0
|
|||||||
CELERY_ENABLED=true
|
CELERY_ENABLED=true
|
||||||
```
|
```
|
||||||
|
|
||||||
Stop the launched API/WebUI/worker with `Ctrl+C`. The PostgreSQL and Redis
|
Stop the launched API/WebUI/worker/scheduler with `Ctrl+C`. The PostgreSQL and Redis
|
||||||
containers keep running by default so the next launch is fast. To stop them too:
|
containers keep running by default so the next launch is fast. To stop them too:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
377
docs/CAPABILITY_AND_INFRASTRUCTURE_FIT.md
Normal file
377
docs/CAPABILITY_AND_INFRASTRUCTURE_FIT.md
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
# GovOPlaN Capability and IT-Infrastructure Fit Assessment
|
||||||
|
|
||||||
|
## Assessment record
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| Assessment ID | `campaign-reference-2026-07-22` |
|
||||||
|
| Schema | `govoplan.fit-assessment/0.1.0` |
|
||||||
|
| Assessed on | 2026-07-22 |
|
||||||
|
| Product snapshot | Live Ed25519-signed stable catalog sequence `202607220843`: Core `v0.1.13` and Campaign `v0.1.10` |
|
||||||
|
| Configuration basis | Root `.env.example` and the production-like development profile |
|
||||||
|
| Scope | Campaign-centric internal pilot and small-production candidate |
|
||||||
|
| Explicitly postponed | Workflow and workflow-driven user stories |
|
||||||
|
| Machine-readable companion | [`capability-fit-current.json`](capability-fit-current.json) |
|
||||||
|
| Input schema | [`capability-fit.schema.json`](capability-fit.schema.json) |
|
||||||
|
|
||||||
|
This is a fit assessment, not a production approval or security certification.
|
||||||
|
It deliberately does not infer implementation from a repository, issue, or
|
||||||
|
manifest existing. A conclusion needs code plus a route/contract, test,
|
||||||
|
operational drill, configured example, or explicit evidence that the capability
|
||||||
|
is absent.
|
||||||
|
|
||||||
|
The release baseline below is reproducible at its tagged source and package
|
||||||
|
references. Later main-branch commits do not become release capabilities merely
|
||||||
|
because they are committed, pushed, or code-tested. This assessment therefore
|
||||||
|
distinguishes code-tested, package-integrated, target-tested, and
|
||||||
|
production-approved evidence explicitly.
|
||||||
|
|
||||||
|
## Controlled status vocabulary
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `verified` | The stated capability is implemented and directly exercised by evidence appropriate to the stated scope. Conditions and target-environment proof may still remain. |
|
||||||
|
| `available_unconfigured` | An implementation and supporting evidence exist, but the target endpoint, credentials, policy, or deployment component has not been configured and exercised. |
|
||||||
|
| `partial` | A useful subset exists, but one or more material parts of the stated journey or operational requirement are missing or unproved. |
|
||||||
|
| `scaffold` | Contracts, models, routes, or UI structure exist, but the end-to-end capability is not yet usable. |
|
||||||
|
| `external_system` | GovOPlaN expects the deployment or another system to supply this capability; integration requirements must still be assessed. |
|
||||||
|
| `planned` | The capability is represented only by a concept, backlog item, or design direction. |
|
||||||
|
| `not_fit` | Evidence shows that the current composition cannot meet the stated requirement. |
|
||||||
|
| `not_assessed` | The requirement or target environment is not sufficiently known to reach a conclusion. |
|
||||||
|
|
||||||
|
Status alone is not enough. Every row also states its evidence scope and
|
||||||
|
conditions. For example, a unit-tested SMTP adapter is
|
||||||
|
`available_unconfigured`, not `verified` for an organization's mail system.
|
||||||
|
|
||||||
|
## Executive conclusion
|
||||||
|
|
||||||
|
GovOPlaN is a credible candidate for a controlled, Campaign-centric internal
|
||||||
|
pilot using local accounts, PostgreSQL, durable single-node file storage, and a
|
||||||
|
non-production SMTP/IMAP service. The module contract, Campaign authoring and
|
||||||
|
validation paths, managed attachments, mail-profile boundary, local audit
|
||||||
|
records, and operator status surface all have direct code/test evidence.
|
||||||
|
|
||||||
|
The signed catalog resolves the earlier source/package reproducibility gap, but
|
||||||
|
it does not make the composition production-approved. The production-like
|
||||||
|
profile intentionally runs only PostgreSQL and Redis in containers; API,
|
||||||
|
WebUI, worker, and scheduler processes still run from editable source trees. A
|
||||||
|
target deployment must supply TLS termination, process supervision, secret
|
||||||
|
injection, monitoring, backup storage, and recovery procedures. The open
|
||||||
|
[Core backup/restore issue #29](https://git.add-ideas.de/add-ideas/govoplan-core/issues/29)
|
||||||
|
is a production gate. Identity federation, external audit export, and a tested
|
||||||
|
disaster-recovery plan are also not complete.
|
||||||
|
|
||||||
|
The current recommendation is therefore:
|
||||||
|
|
||||||
|
- **Pilot:** fit with conditions and a bounded proof of concept.
|
||||||
|
- **Small production:** partial fit; do not approve until the proof checks and
|
||||||
|
operational gates below pass against the signed release and target
|
||||||
|
environment.
|
||||||
|
- **Workflow:** planned and postponed; it is not part of either recommended
|
||||||
|
composition in this assessment.
|
||||||
|
|
||||||
|
## Pinned composition
|
||||||
|
|
||||||
|
The configured reference composition comes from the meta repository's
|
||||||
|
`ENABLED_MODULES`. Versions and refs below are pinned by the live stable catalog;
|
||||||
|
commits are the commits peeled from those annotated tags.
|
||||||
|
|
||||||
|
| Module | Tagged source commit | Catalog version/ref | Role in this assessment |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Core runtime | `govoplan-core@d487726f4d2c` | `0.1.13` / `v0.1.13` | API, registry, migration orchestration, shared WebUI, sessions and kernel contracts |
|
||||||
|
| `tenancy` | `govoplan-tenancy@efbec827616b` | `0.1.8` / `v0.1.8` | tenant context and lifecycle |
|
||||||
|
| `organizations` | `govoplan-organizations@39c081c4fb8f` | `0.1.8` / `v0.1.8` | organization model |
|
||||||
|
| `identity` | `govoplan-identity@7a1710af896f` | `0.1.8` / `v0.1.8` | normalized internal identity directory |
|
||||||
|
| `access` | `govoplan-access@f1d64d247e12` | `0.1.11` / `v0.1.11` | local authentication, sessions, API keys and RBAC |
|
||||||
|
| `admin` | `govoplan-admin@11ecf362a36d` | `0.1.8` / `v0.1.8` | administration surfaces |
|
||||||
|
| `dashboard` | `govoplan-dashboard@4b960ad37f0d` | `0.1.8` / `v0.1.8` | module-aware home surface |
|
||||||
|
| `policy` | `govoplan-policy@1063622d311a` | `0.1.9` / `v0.1.9` | policy explanation/configuration boundary |
|
||||||
|
| `audit` | `govoplan-audit@d3d2c60d7dc1` | `0.1.8` / `v0.1.8` | database audit records and retrying audit outbox |
|
||||||
|
| `campaigns` | `govoplan-campaign@735e874bd03c` | `0.1.10` / `v0.1.10` | Campaign authoring, build, delivery control and reporting |
|
||||||
|
| `files` | `govoplan-files@2b34f6e30578` | `0.1.9` / `v0.1.9` | managed files and Campaign attachments |
|
||||||
|
| `mail` | `govoplan-mail@3e2302909022` | `0.1.10` / `v0.1.10` | SMTP/IMAP profiles and transports |
|
||||||
|
| `calendar` | `govoplan-calendar@9bcf41bb1fbb` | `0.1.8` / `v0.1.8` | optional calendar; not needed by the Campaign pilot |
|
||||||
|
| `docs` | `govoplan-docs@be52b716caed` | `0.1.10` / `v0.1.10` | configured-system documentation |
|
||||||
|
| `ops` | `govoplan-ops@341773a4ff8a` | `0.1.8` / `v0.1.8` | readiness and deployment-profile visibility |
|
||||||
|
|
||||||
|
Stable catalog sequence `202607220843` is valid, signed by trusted key
|
||||||
|
`release-key-1`, and pins matching Python and WebUI refs where applicable. This
|
||||||
|
proves release source/package selection and provenance, not behavior in a target
|
||||||
|
environment. No configuration revision/package is pinned yet, so deployment
|
||||||
|
configuration remains a separate reproducibility and promotion gate. The static
|
||||||
|
contract scan found 43 interface contracts, 33 providers, and no contract errors
|
||||||
|
in the current workspace. That proves the scanned manifest graph is internally
|
||||||
|
consistent; it does not prove every provider or production journey.
|
||||||
|
|
||||||
|
`govoplan-addresses@93dddbb8c52a` (`0.1.9` / `v0.1.9`) is an optional catalogued
|
||||||
|
extension for reusable recipient sources. It is not enabled in the pinned root
|
||||||
|
profile and must be added explicitly when the pilot needs address lists or
|
||||||
|
CardDAV.
|
||||||
|
|
||||||
|
## Recommended scenarios
|
||||||
|
|
||||||
|
### Campaign pilot
|
||||||
|
|
||||||
|
Use one internal tenant or office, controlled operators, local GovOPlaN accounts,
|
||||||
|
and non-critical recipient volumes. Enable:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,docs,ops
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `addresses` only when reusable address books/lists or CardDAV are in scope.
|
||||||
|
Leave Calendar and Workflow out unless the pilot has an explicit journey for
|
||||||
|
them. The minimum topology is one supervised API process, one built WebUI, one
|
||||||
|
PostgreSQL database, durable local file storage, and one Redis/Celery worker when
|
||||||
|
asynchronous delivery is enabled. Use a dedicated non-production SMTP/IMAP
|
||||||
|
account and a restricted recipient allow-list.
|
||||||
|
|
||||||
|
### Small-production candidate
|
||||||
|
|
||||||
|
Use a reverse proxy/TLS endpoint, built WebUI assets, separately supervised API
|
||||||
|
and worker processes, PostgreSQL with measured backup/restore, Redis with
|
||||||
|
persistence, and durable shared or object storage. Run one scheduler only when a
|
||||||
|
selected module needs scheduled recovery. Multiple scheduler replicas require
|
||||||
|
leader election or an external lock, which is not established by this report.
|
||||||
|
|
||||||
|
This topology is a recommendation from the [Ops scalability profiles](https://git.add-ideas.de/add-ideas/govoplan-ops/src/branch/main/docs/SCALABILITY_PROFILES.md),
|
||||||
|
not a currently shipped production Compose/Kubernetes/systemd package.
|
||||||
|
|
||||||
|
## Functional capability matrix
|
||||||
|
|
||||||
|
| Capability | Status | Evidence and scope | Conditions, gaps, or exclusions |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Module-aware API/WebUI composition | `verified` | Core registry tests cover installed manifests, route contribution, missing modules, and module permutations; the static contract scan passed; stable catalog sequence `202607220843` has a valid trusted signature and matching package refs. | Package integration is verified; repeat the module-permutation checks on the installed target composition. |
|
||||||
|
| Local tenants, accounts, sessions, API keys and RBAC | `verified` | Access/tenancy manifests, auth dependency tests, cookie/CSRF API smoke tests, and role/permission contracts. | External identity providers are not included in this conclusion. |
|
||||||
|
| Campaign draft authoring, validation, recipient import, build, execution snapshot and mock send | `verified` | Core API smoke tests exercise create/validate/build/mock-send, frozen delivery configuration, policy gates, job deltas and reconciliation; Campaign-focused tests passed; Campaign `v0.1.10` and Core `v0.1.13` are package-integrated in the signed stable catalog. | Code and package integration are verified; usability and target-provider acceptance remain separate. |
|
||||||
|
| Managed Campaign attachments and local file storage | `verified` | Files capability/route tests, Campaign attachment-build tests, and local-storage backend code. | The deployment must provide a durable path and include files in database/storage backup plans. |
|
||||||
|
| SMTP send, IMAP append and mailbox/profile policy | `available_unconfigured` | Mail adapters, encrypted profile model, helper tests, Campaign mail-policy smoke tests, and a GreenMail-oriented runbook/testbed. | No target mail server, TLS chain, throttling, sender policy, bounce/reply process, or real delivery receipt was exercised here. |
|
||||||
|
| Delivery retry, uncertainty and reconciliation | `partial` | Campaign API smoke tests include worker-loss/outcome-unknown reconciliation and frozen job evidence; those paths are present in catalogued Campaign `v0.1.10`. | A target-provider failure drill and operational alerting are still required; package integration does not prove provider behavior. |
|
||||||
|
| Reusable address lists and Campaign recipient-source integration | `available_unconfigured` | Address list/recipient capability and Campaign optional lookup tests passed; manifests expose optional interfaces. | `addresses` is not in the pinned profile. Configure permissions and data governance before use. |
|
||||||
|
| CardDAV address synchronization | `available_unconfigured` | Discovery, preview/conflict, two-way create/update/delete and disconnect tests passed in the current Addresses workspace. | Requires target-server interoperability, credentials, CA/TLS, rate-limit, conflict and restore drills. |
|
||||||
|
| File connectors (WebDAV/Nextcloud/Seafile/SMB/S3 import) | `partial` | Provider descriptors and browse/import helper tests exist; S3 connector tests passed. | Coverage varies by provider and optional dependency. No target content system was exercised; connector egress and provenance policy need review. |
|
||||||
|
| Local audit log and governed-event retry outbox | `verified` | Audit module contract plus enqueue/dispatch/failure-for-retry tests passed. | Central audit/SIEM export, retention enforcement and operational monitoring are not verified. |
|
||||||
|
| Configured-system documentation and Ops status pages | `verified` | Docs/Ops manifests contribute protected routes and WebUI packages; Ops code checks database, Redis, workers, storage and deployment-security settings. | This does not replace external monitoring or a target runbook. |
|
||||||
|
| Calendar/CalDAV integration | `partial` | Calendar `v0.1.8` supplies the catalogued storage/sync foundation. The durable external-write outbox, worker recovery, reconciliation, and retention work is committed, tested, and pushed on Calendar `main` after that tag. | The post-tag outbox work is remote-integrated source, not local-only WIP, but it is not in the signed stable package baseline and has not passed a target CalDAV drill. Bulk synchronized-calendar migration semantics remain separate work. |
|
||||||
|
| External LDAP/AD, OIDC/SAML or SCIM identity integration | `scaffold` | IDM owns normalized assignment APIs and documents connector boundaries. | Provider connectors, login callback flow and target directory reconciliation are not an implemented end-to-end capability. Use local accounts for this pilot. |
|
||||||
|
| Export-control/embargo-list screening | `planned` | Product-level [GovOPlaN #12](https://git.add-ideas.de/add-ideas/govoplan/issues/12) defines the consumer-independent user story. | No screening provider, list provenance, matching policy, review flow or legal evidence exists in this composition. |
|
||||||
|
| Workflow-driven journeys and views | `planned` | Workflow contracts/concepts exist outside this assessment. | Explicitly postponed. Do not include Workflow in pilot or production claims from this report. |
|
||||||
|
|
||||||
|
## Infrastructure matrix
|
||||||
|
|
||||||
|
| Component | Status | Current evidence | Target requirement / gap |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| WebUI and API | `verified` | FastAPI app, module routes, shared WebUI host, health endpoint, dev smoke and module-permutation tests; the catalog pins matching Core/Campaign backend and WebUI refs. | Materialize and supervise immutable artifacts in the target; no production image/service bundle is supplied here. |
|
||||||
|
| Background workers | `available_unconfigured` | Celery app, Campaign queues, Redis configuration and production-like launcher. | Run target broker/worker heartbeat and failure drills; split queues when provider limits or load justify it. |
|
||||||
|
| Scheduler | `partial` | A separately launched Celery beat process exists; Calendar recovery scheduling is committed and pushed after the catalogued `v0.1.8` tag. | The Calendar recovery slice is not stable-package-integrated. Keep single-instance until leader election/locking is proved; add process supervision and missed-schedule alerts. |
|
||||||
|
| PostgreSQL | `verified` | Production target in settings/operator docs, migration tracks and a disposable integration-check harness. | Target HA, patching, connection limits, WAL/archive policy and restore time are external deployment decisions. |
|
||||||
|
| Redis/queues | `available_unconfigured` | Redis-backed Celery configuration, health ping and append-only production-like container. | Target availability, persistence, eviction, authentication/TLS and queue-loss behavior were not assessed. |
|
||||||
|
| Local file storage | `verified` | Local backend, configured root, fallback-root tests and Ops writability check. | Suitable only for a durable single-node/shared path with backup; it blocks independent API scaling when node-local. |
|
||||||
|
| S3-compatible object storage | `partial` | S3 backend and connector code plus mocked browse/import tests. | Exercise the chosen service, credentials, CA, versioning, lifecycle, multipart/size behavior, restore and consistency expectations. |
|
||||||
|
| Reverse proxy and TLS | `external_system` | Core validates explicit CORS and secure-cookie posture; Ops reports unsafe production settings. | Deployment must provide certificates, proxy/header policy, request limits, logs and renewal monitoring. |
|
||||||
|
| Local identity and authorization | `verified` | Password/session/API-key/RBAC capabilities and tests. | Establish account lifecycle, MFA expectation, protected bootstrap and break-glass procedure. |
|
||||||
|
| Federated identity | `scaffold` | IDM boundary and assignment APIs. | Select and implement/test the actual OIDC/SAML/LDAP/SCIM path before requiring federation. |
|
||||||
|
| Application secret encryption | `verified` | Stable `MASTER_KEY_B64` contract and encrypted mail-credential paths. | Rotation and loss-recovery procedure need target validation. |
|
||||||
|
| Secret store and injection | `external_system` | Environment/file references are supported and templates avoid populated secrets. | Choose Vault/KMS/Kubernetes/systemd/environment mechanism, restrict access, rotate credentials and prevent secret exposure in logs/backups. |
|
||||||
|
| SMTP/IMAP and other connectors | `available_unconfigured` | Protocol adapters and development testbeds. | Target endpoints, egress, DNS, CA, throttling, service accounts and data-processing terms remain environment-specific. |
|
||||||
|
| Health/readiness | `verified` | `/health`, protected `/health/details`, and Ops checks for DB, Redis, workers, storage, maintenance and cookie/CORS posture. | Add external probes and distinguish liveness from dependency readiness for the chosen orchestrator. |
|
||||||
|
| Metrics, logs and alerting | `partial` | Correlation IDs, slow-request/query metrics in logs, worker inspection and operator status are implemented. | No bundled metrics exporter, log collector, dashboards, queue-depth alerts, pager route or SLO is verified. |
|
||||||
|
| Audit | `partial` | Local audit tables and retry outbox are verified. | Retention, tamper-evident export, privileged access review and SIEM integration are unproved. |
|
||||||
|
| Backup and restore | `partial` | Operator guide and installer hooks describe `pg_dump`/`pg_restore`; SQLite and simulated installer rollback drills exist. | [Core #29](https://git.add-ideas.de/add-ideas/govoplan-core/issues/29) remains open. No target PostgreSQL + files + secrets restore drill or measured RTO/RPO exists. |
|
||||||
|
| Disaster recovery | `not_assessed` | The Ops guide asks for RPO/RTO and restore drills. | No agreed RPO/RTO, off-site copy, failover topology, dependency recovery order, communications plan or exercise evidence was supplied. |
|
||||||
|
|
||||||
|
The scalability and sizing documentation delivered the documentation portions
|
||||||
|
of [Core #217](https://git.add-ideas.de/add-ideas/govoplan-core/issues/217) and
|
||||||
|
[Core #219](https://git.add-ideas.de/add-ideas/govoplan-core/issues/219).
|
||||||
|
[Core #28](https://git.add-ideas.de/add-ideas/govoplan-core/issues/28) records the
|
||||||
|
operator-documentation slice. These closed tickets are evidence of documented
|
||||||
|
models, not evidence that an organization's production environment has passed
|
||||||
|
them.
|
||||||
|
|
||||||
|
## Trust, data-flow and network boundaries
|
||||||
|
|
||||||
|
| Boundary / flow | Data and trust | Required controls |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Browser -> reverse proxy -> WebUI/API | Session and CSRF cookies, campaign content, recipient personal data and managed files cross the public/client boundary. | HTTPS, exact origins, secure cookies, request/body limits, tenant/RBAC enforcement, security headers and access logs. |
|
||||||
|
| API -> PostgreSQL | Tenants, identities, permissions, campaign drafts/snapshots/jobs, connector metadata and audit evidence enter the primary trusted data store. | Dedicated DB identity, encrypted transport where networked, least privilege, migration control, backup/restore and retention. |
|
||||||
|
| API/worker -> file/object storage | Campaign attachments and generated evidence may contain personal or confidential content. | Private buckets/paths, encryption, scoped credentials, malware/content policy where required, lifecycle and coordinated restore. |
|
||||||
|
| API -> Redis -> worker | Queue messages and job identifiers cross from request processing to an asynchronous trust zone. PostgreSQL remains the durable business-state authority. | Private/authenticated broker, bounded payloads, idempotent claims, queue monitoring and worker isolation. |
|
||||||
|
| Worker -> SMTP/IMAP | Recipient addresses, message bodies and attachments leave GovOPlaN; IMAP append stores a sent copy externally. | Approved service account, TLS/CA policy, recipient/sender policy, rate limits, outcome reconciliation and disclosure/legal basis. |
|
||||||
|
| Connector worker -> CardDAV/WebDAV/Seafile/SMB/S3/CalDAV | Address data, files or calendars cross an organizational/system boundary in both directions depending on connector mode. | Explicit direction, scoped credentials, endpoint allow-list, provenance, conflict policy, retry/reconciliation and target interoperability test. |
|
||||||
|
| Operator -> installer/catalog/migration plane | A highly privileged process can mutate packages, schemas and desired module state. | Separate operator identity, signed/pinned catalogs, maintenance mode, immutable run records, backups, rollback checks and restricted network access. |
|
||||||
|
| API/audit -> monitoring or SIEM | Operational metadata and potentially personal audit context may leave GovOPlaN. | Data minimization, retention/access policy, authenticated transport, integrity and documented processor/location. No sink is verified today. |
|
||||||
|
|
||||||
|
Typical network requirements are inbound HTTPS to the deployment proxy and
|
||||||
|
private connectivity from API/workers to PostgreSQL and Redis. Outbound access
|
||||||
|
may be required to SMTP submission, IMAP, HTTPS-based connectors/object storage,
|
||||||
|
DNS, NTP, certificate validation, monitoring and the signed module catalog.
|
||||||
|
Ports and destinations must come from the selected infrastructure; common
|
||||||
|
defaults such as PostgreSQL 5432, Redis 6379, SMTP 465/587 and IMAP 993 are not
|
||||||
|
an allow-list.
|
||||||
|
|
||||||
|
## Known assumptions, gaps and risks
|
||||||
|
|
||||||
|
Facts:
|
||||||
|
|
||||||
|
- The production-like profile is a development validation composition, not a
|
||||||
|
production deployment artifact.
|
||||||
|
- The assessed package baseline is the trusted, signed stable catalog sequence
|
||||||
|
`202607220843`; it pins Core `v0.1.13` and Campaign `v0.1.10`.
|
||||||
|
- Workflow is postponed.
|
||||||
|
- The default composition does not enable Addresses.
|
||||||
|
- Health/readiness checks exist; an external monitoring stack does not.
|
||||||
|
|
||||||
|
Assumptions that require confirmation:
|
||||||
|
|
||||||
|
- The pilot can use local accounts and one internal tenant/office.
|
||||||
|
- A controlled non-production SMTP/IMAP account and safe recipients are
|
||||||
|
available.
|
||||||
|
- Campaign volume is below the measured limits of a single worker and database.
|
||||||
|
- Local durable storage is acceptable for the pilot and all recipient data has
|
||||||
|
an approved legal basis and retention policy.
|
||||||
|
|
||||||
|
Highest risks:
|
||||||
|
|
||||||
|
1. A signed, reproducible package selection can still be installed or configured
|
||||||
|
incorrectly and has not been accepted in the target environment.
|
||||||
|
2. Real SMTP/IMAP behavior, throttling and ambiguous outcomes have not been
|
||||||
|
proven against the target provider.
|
||||||
|
3. Backup/restore and disaster recovery are not demonstrated across database,
|
||||||
|
files, keys and deployment configuration.
|
||||||
|
4. External identity, monitoring, secret-store and reverse-proxy controls are
|
||||||
|
deployment gaps rather than GovOPlaN-delivered components.
|
||||||
|
5. Post-tag Calendar work is committed and pushed but remains outside the signed
|
||||||
|
stable package baseline; branch integration must not be mistaken for a
|
||||||
|
release.
|
||||||
|
6. Data classification, retention, recipient consent/legal basis and external
|
||||||
|
disclosure rules are not assessed for a concrete organization.
|
||||||
|
|
||||||
|
## Proof checks before promotion
|
||||||
|
|
||||||
|
1. Materialize the signed catalog into an isolated installation and rerun the
|
||||||
|
contract, migration, module-permutation, and focused acceptance gates against
|
||||||
|
the installed artifacts.
|
||||||
|
2. Run a target-like Campaign from import through validation, attachment build,
|
||||||
|
queue, SMTP acceptance, IMAP append, reporting and audit using safe data.
|
||||||
|
3. Drill transient SMTP failure, accepted-but-local-commit-lost uncertainty,
|
||||||
|
worker restart, Redis interruption and manual reconciliation without a
|
||||||
|
duplicate send.
|
||||||
|
4. Restore PostgreSQL, managed files, configuration and encrypted credentials
|
||||||
|
into an isolated environment; measure RPO and RTO.
|
||||||
|
5. Validate proxy/TLS, CORS, cookies, headers, body limits, account bootstrap,
|
||||||
|
maintenance access and secret redaction.
|
||||||
|
6. Measure representative recipient/file volume, queue age, database growth,
|
||||||
|
send rate and provider throttling; set capacity and alert thresholds.
|
||||||
|
7. Confirm audit/retention/privacy/legal requirements and any SIEM or archive
|
||||||
|
export before using production personal data.
|
||||||
|
|
||||||
|
## Reusable assessment questionnaire
|
||||||
|
|
||||||
|
Answers should contain no secrets and no unnecessary personal data. Each answer
|
||||||
|
must be marked `answered`, `assumed`, `not_applicable`, or `not_assessed` and may
|
||||||
|
link evidence.
|
||||||
|
|
||||||
|
### Scope and outcomes
|
||||||
|
|
||||||
|
- What outcome and reference journey must GovOPlaN support?
|
||||||
|
- Which users, roles, tenants, organization units and delegated functions take
|
||||||
|
part?
|
||||||
|
- Which journey steps are mandatory, optional, manual, external, or explicitly
|
||||||
|
postponed?
|
||||||
|
- What constitutes pilot success and production acceptance?
|
||||||
|
|
||||||
|
### Data, privacy, records and policy
|
||||||
|
|
||||||
|
- Which data classes enter database, files, messages, calendars, addresses,
|
||||||
|
logs and audit evidence?
|
||||||
|
- What are the legal basis, purpose, minimization, access, residency, retention,
|
||||||
|
deletion, archive and legal-hold requirements?
|
||||||
|
- Which external systems/processors receive data, and in which jurisdictions?
|
||||||
|
- Which decisions require four-eyes approval, explainability or immutable
|
||||||
|
evidence?
|
||||||
|
|
||||||
|
### Identity and integrations
|
||||||
|
|
||||||
|
- Are local accounts acceptable, or are OIDC, SAML, LDAP/AD or SCIM mandatory?
|
||||||
|
- What are the MFA, joiner/mover/leaver, service-account and break-glass rules?
|
||||||
|
- Which SMTP/IMAP, file/DMS, CardDAV/CalDAV, ERP, API or event endpoints are in
|
||||||
|
scope? Record protocol/version, direction, auth, CA, rate limit and owner.
|
||||||
|
- Which integrations may be unavailable, and what degraded/manual behavior is
|
||||||
|
acceptable?
|
||||||
|
|
||||||
|
### Workload and growth
|
||||||
|
|
||||||
|
- Tenants, named/active/concurrent users and peak requests?
|
||||||
|
- Campaigns per period, recipients per Campaign, send window, attachment sizes,
|
||||||
|
import size and retry peak?
|
||||||
|
- Managed files, database and audit volume now and over the retention window?
|
||||||
|
- Connector batches, queue depth/age, scheduled jobs and external rate limits?
|
||||||
|
|
||||||
|
### Availability, hosting and operations
|
||||||
|
|
||||||
|
- Required service hours, planned maintenance, availability, RPO and RTO?
|
||||||
|
- Hosting, network zones, egress/proxy/DNS/NTP/CA, data-residency and
|
||||||
|
air-gap constraints?
|
||||||
|
- Who operates PostgreSQL, Redis, storage, TLS, identity, secrets, monitoring,
|
||||||
|
backups and incident response?
|
||||||
|
- What deployment, patch, migration, rollback, restore and DR drills must pass?
|
||||||
|
|
||||||
|
### Procurement and decisions
|
||||||
|
|
||||||
|
- Required open-source/license, support, accessibility, security, certification,
|
||||||
|
interoperability and procurement conditions?
|
||||||
|
- Which requirements are blockers, accepted risks, residual risks or later
|
||||||
|
roadmap work?
|
||||||
|
- Who owns each decision, evidence item and next review date?
|
||||||
|
|
||||||
|
For every capability and infrastructure conclusion, record the requirement,
|
||||||
|
status from the controlled vocabulary, evidence scope, evidence links,
|
||||||
|
assumptions, gaps, risks, recommendation and proof check. Reassessment must pin a
|
||||||
|
new composition and review any row whose code, evidence, configuration or target
|
||||||
|
requirement changed.
|
||||||
|
|
||||||
|
## Evidence used in this slice
|
||||||
|
|
||||||
|
- [Production-like profile](../dev/production-like/README.md) and
|
||||||
|
[Compose dependencies](../dev/production-like/docker-compose.yml)
|
||||||
|
- [Module contracts and install boundaries](MODULE_CONTRACTS_AND_INSTALLS.md)
|
||||||
|
- [Core deployment operator guide](https://git.add-ideas.de/add-ideas/govoplan-core/src/branch/main/docs/DEPLOYMENT_OPERATOR_GUIDE.md)
|
||||||
|
- [Ops scalability profiles](https://git.add-ideas.de/add-ideas/govoplan-ops/src/branch/main/docs/SCALABILITY_PROFILES.md)
|
||||||
|
- Actual module manifests in the pinned repositories and the static contract
|
||||||
|
checker in this meta repository
|
||||||
|
- Core module-system/API smoke/auth/install-config tests, plus focused Campaign,
|
||||||
|
Files, Mail, Audit and Addresses tests
|
||||||
|
- [Campaign delivery runbook](https://git.add-ideas.de/add-ideas/govoplan-campaign/src/branch/main/docs/CAMPAIGN_DELIVERY_RUNBOOK.md)
|
||||||
|
- [Live signed stable catalog](https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json)
|
||||||
|
and [trusted keyring](https://govoplan.add-ideas.de/catalogs/v1/keyring.json)
|
||||||
|
- Annotated source tags `govoplan-core/v0.1.13` and
|
||||||
|
`govoplan-campaign/v0.1.10`, including their catalogued Python and WebUI refs
|
||||||
|
|
||||||
|
Checks retained from the first assessment slice against its then-current
|
||||||
|
workspace:
|
||||||
|
|
||||||
|
- static manifest/interface contract scan: 43 contracts, 29 providers, no issues
|
||||||
|
- selected Core module-composition, auth/CSRF, health, Campaign journey,
|
||||||
|
reconciliation and install-configuration tests: 14 passed
|
||||||
|
- Campaign tests: 14 passed
|
||||||
|
- Files tests: 14 passed
|
||||||
|
- Mail tests: 22 passed
|
||||||
|
- Audit tests: 5 passed
|
||||||
|
- Addresses tests: 14 passed (one non-failing SQLite resource warning)
|
||||||
|
- JSON Schema validation of the machine-readable assessment: passed
|
||||||
|
|
||||||
|
Refresh checks on 2026-07-22:
|
||||||
|
|
||||||
|
- live stable catalog sequence `202607220843`: valid Ed25519 signature trusted
|
||||||
|
through `release-key-1`, no validator warnings
|
||||||
|
- current static contract scan: 43 modules, 33 providers, 19 requirements, no
|
||||||
|
contract errors
|
||||||
|
- catalog-selected commits: Core `d487726f4d2c` at `v0.1.13` and Campaign
|
||||||
|
`735e874bd03c` at `v0.1.10`; local and remote release refs agree
|
||||||
|
- Campaign's immutable `v0.1.10` tag object and peeled commit exactly match the
|
||||||
|
remote tag and catalog record; Calendar durable outbox work is committed and
|
||||||
|
pushed after catalogued `v0.1.8`
|
||||||
|
- JSON Schema validation of the refreshed machine-readable assessment: passed
|
||||||
|
|
||||||
|
These checks are evidence for the rows above; they are not a substitute for the
|
||||||
|
installed-release and target-environment proof checks.
|
||||||
1416
docs/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md
Normal file
1416
docs/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md
Normal file
File diff suppressed because it is too large
Load Diff
77
docs/FRONTEND_LAYOUT_PRINCIPLES.md
Normal file
77
docs/FRONTEND_LAYOUT_PRINCIPLES.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
These concise layout choices are one canonical input to the broader
|
||||||
|
[`INTERFACE_PATTERN_LANGUAGE.md`](INTERFACE_PATTERN_LANGUAGE.md). The current
|
||||||
|
route and rollout evidence lives in
|
||||||
|
[`INTERFACE_SURFACE_INVENTORY.md`](INTERFACE_SURFACE_INVENTORY.md).
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- In a list-detail workspace such as Scheduling, keep related lists stacked in
|
||||||
|
the left pane and use the remaining main pane for view/create/edit. A single
|
||||||
|
Add action stays in the relevant list-pane header and opens the common main
|
||||||
|
editor; it does not create an additional menu or launcher.
|
||||||
|
- 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.
|
||||||
|
- A collapsible card whose sole content is a table gives that table the full
|
||||||
|
available card body; avoid nested cards, duplicate padding, inner max-widths,
|
||||||
|
and nested scrolling.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
Non-self-explanatory fields use Core `FieldLabel`; documented omissions must
|
||||||
|
name their accessible-label source. Explicit Discard and dirty navigation use
|
||||||
|
the same Core unsaved-changes dialog. Table action sets retain unavailable row
|
||||||
|
actions as disabled controls and reserve empty-state slots so Add remains
|
||||||
|
aligned. Use central feedback/dialog components; `window.alert` is not an
|
||||||
|
authorized product surface unless a product-owner-approved exception is first
|
||||||
|
recorded in the Core decision ledger.
|
||||||
@@ -140,6 +140,7 @@ Use one `type/*` label:
|
|||||||
|
|
||||||
- `type/bug`
|
- `type/bug`
|
||||||
- `type/feature`
|
- `type/feature`
|
||||||
|
- `type/user-story`
|
||||||
- `type/task`
|
- `type/task`
|
||||||
- `type/debt`
|
- `type/debt`
|
||||||
- `type/docs`
|
- `type/docs`
|
||||||
|
|||||||
479
docs/INTERFACE_PATTERN_LANGUAGE.md
Normal file
479
docs/INTERFACE_PATTERN_LANGUAGE.md
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
# GovOPlaN Interface Pattern Language
|
||||||
|
|
||||||
|
This document is the cross-repository pattern language for GovOPlaN user
|
||||||
|
interfaces. It turns the existing ethical doctrine, binding UI/UX decisions,
|
||||||
|
layout principles, and module boundary into a common composition and review
|
||||||
|
grammar. It does not replace those sources.
|
||||||
|
|
||||||
|
The companion [interface surface inventory](INTERFACE_SURFACE_INVENTORY.md)
|
||||||
|
records which surfaces the current code contributes and where each surface
|
||||||
|
enters the rollout.
|
||||||
|
|
||||||
|
## Source Of Truth And Precedence
|
||||||
|
|
||||||
|
Use the narrowest owning document when changing a rule:
|
||||||
|
|
||||||
|
1. `govoplan-core/docs/INTERFACE_ETHICS_AND_DESIGN_DOCTRINE.md` owns why a
|
||||||
|
consequential interface must preserve context, decision, consequence,
|
||||||
|
responsibility, contestability, and traceability.
|
||||||
|
2. `govoplan-core/docs/UI_UX_DECISION_LEDGER.md` owns accepted product decisions
|
||||||
|
such as progressive disclosure, adaptive forms, blocker language, guided
|
||||||
|
operations, and the platform theme contract.
|
||||||
|
3. `docs/FRONTEND_LAYOUT_PRINCIPLES.md` owns the high-level choice between a
|
||||||
|
full-space structured-data workspace and a heading/menu/card workflow or
|
||||||
|
configuration surface.
|
||||||
|
4. `govoplan-core/docs/MODULE_ARCHITECTURE.md` owns the shell, route, navigation,
|
||||||
|
UI-capability, and shared-component boundaries.
|
||||||
|
5. This document owns the common pattern names, placement grammar, wording and
|
||||||
|
state conventions, focused-view composition, and definition of done across
|
||||||
|
those sources.
|
||||||
|
|
||||||
|
If two rules appear to conflict, do not create a third local convention. Record
|
||||||
|
the conflict in the owning decision ledger, resolve it there, and update the
|
||||||
|
affected patterns and surfaces together.
|
||||||
|
|
||||||
|
## Product Contract
|
||||||
|
|
||||||
|
GovOPlaN should feel calm because it shows what is relevant to the task, not
|
||||||
|
because it hides authority, risk, or evidence. Every surface follows these
|
||||||
|
rules:
|
||||||
|
|
||||||
|
- Start with the user's current object and task.
|
||||||
|
- Show common actions before advanced controls.
|
||||||
|
- Keep context, status, problems, and the next action spatially connected.
|
||||||
|
- Make consequential effects explicit before execution and observed effects
|
||||||
|
inspectable afterwards.
|
||||||
|
- Treat permissions, policy, privacy, and module availability as behavior, not
|
||||||
|
decoration.
|
||||||
|
- Keep optional modules optional. Compose through core route and UI-capability
|
||||||
|
contracts, never sibling-private components.
|
||||||
|
- Use the centrally exported core components wherever a matching contract
|
||||||
|
exists. A module-local replacement is not an implementation choice: it is a
|
||||||
|
product exception that requires explicit product-owner authorization.
|
||||||
|
- Preserve a stable way back to the containing object and the broader system.
|
||||||
|
- Do not let navigation, selection, or a view switch imply consent.
|
||||||
|
|
||||||
|
## Surface Archetypes
|
||||||
|
|
||||||
|
Choose an archetype from the task, then specialize it for the domain. A route
|
||||||
|
may contain more than one bounded archetype, but it should have one dominant
|
||||||
|
one.
|
||||||
|
|
||||||
|
| Archetype | Use when | Standard anatomy | Do not use when |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Directory or explorer | Users browse hierarchical collections such as files, mailboxes, calendars, addresses, or records. | Collection/source pane, collection actions, filter in the pane it affects, main list/content pane, optional detail pane. | The content is a set of unrelated settings or workflow stages. |
|
||||||
|
| List-detail workspace | Users repeatedly find objects, inspect one, and act without losing list context. | Search/filter/list, persistent selected-object context, detail/actions, stable selection and URL. | A single guided operation is the primary task. |
|
||||||
|
| Focused task view | Only a bounded composition is needed to complete one task. | Task identity and reason, selected object, necessary module regions/actions, progress or status, obvious exit to the full system. | Hiding a surface would obscure a consequence, blocker, or required evidence. |
|
||||||
|
| Create or edit | Users change one coherent object state. | Adaptive typed form, field-level validation, advanced section, save/cancel, unsaved-change guard. | Discovery-heavy setup or a broad consequential change needs staged review. |
|
||||||
|
| Guided setup or import | The user must discover, upload, map, test, or preflight before a safe result exists. | Named steps, current progress, preserved inputs, validation/problem list, review, resumable completion where work is durable. | An ordinary edit can be understood as one coherent form. |
|
||||||
|
| Review or decision | A person must inspect evidence and deliberately approve, reject, send, publish, or otherwise commit. | Decision context, evidence/problems, consequence and reversibility, authority/provenance, explicit action, resulting record. | The interaction is passive inspection. |
|
||||||
|
| Monitoring, progress, or report | Users observe asynchronous or aggregate state and intervene when needed. | Summary, filters, durable job/item state, last update, retry/reconcile/intervention, detail and evidence. | A toast is sufficient for a short, non-durable local action. |
|
||||||
|
| Administration or configuration | Users compare and configure separate concerns. | Heading and scope, grouped subnavigation, overview/list plus selected details, adaptive editor or guided risky operation, effective policy and source. | The primary task is browsing one structured object space. |
|
||||||
|
| Dashboard | Users need a task-oriented starting point across modules. | Prioritized actionable widgets, scoped status, clear destination per widget, explicit refresh/staleness. | It merely duplicates every module navigation item or metric. |
|
||||||
|
| Public service or entry | An unauthenticated or external participant starts or resumes a service. | Service identity, eligibility/context, privacy and evidence expectations, accessible form/task, save/resume or handoff. | The actor is performing internal administration. |
|
||||||
|
|
||||||
|
## Placement Grammar
|
||||||
|
|
||||||
|
### Shell And Navigation
|
||||||
|
|
||||||
|
- The global title bar and rail belong to core. A module contributes routes,
|
||||||
|
navigation metadata, and explicit UI capabilities; it does not reproduce the
|
||||||
|
shell.
|
||||||
|
- Global navigation answers "which service area?" Local subnavigation answers
|
||||||
|
"which stable facet of this object or area?" A progress indicator answers
|
||||||
|
"where am I in this operation?" Do not use those three controls
|
||||||
|
interchangeably.
|
||||||
|
- Keep the current tenant, actor, object, and selected version or scope stable
|
||||||
|
across local navigation. Guard unsaved work before navigation.
|
||||||
|
- Permission and capability filtering happens before view composition. A
|
||||||
|
missing menu item is not evidence that an actor lacks backend access, and a
|
||||||
|
visible item is never authorization by itself.
|
||||||
|
- Route, selection, panel, and view changes must not execute consequential
|
||||||
|
actions.
|
||||||
|
|
||||||
|
### Page And Workspace
|
||||||
|
|
||||||
|
- Structured directories use the full available content space and persistent
|
||||||
|
panes. They do not add a decorative heading row that reduces working height.
|
||||||
|
- Workflow, configuration, dashboard, and explanatory pages may use a heading.
|
||||||
|
The heading names the task or scoped object and contains only route-level
|
||||||
|
actions.
|
||||||
|
- Put a collection-wide create action in the heading of the collection it
|
||||||
|
affects. Use a short, specific label such as `Add` when the heading already
|
||||||
|
names the object. Do not duplicate that action in a permanently visible side
|
||||||
|
panel. A side panel used as the creation surface appears for creation and is
|
||||||
|
otherwise absent or returns to its documented non-creation purpose.
|
||||||
|
- Put filters beside the list or pane they affect. Put bulk actions immediately
|
||||||
|
above or beside the current selection. Put object actions with the object
|
||||||
|
detail, not in the global title bar.
|
||||||
|
- Full-page create and edit surfaces put their persistent action cluster in the
|
||||||
|
upper-right of the page heading. `Discard` comes before `Save …`, with the
|
||||||
|
primary save action at the far right. Keep both controls in the same place
|
||||||
|
across validation, loading, and saved states; guard unsaved work when the
|
||||||
|
user discards or navigates away. Explicit Discard and dirty in-application
|
||||||
|
navigation use the same central unsaved-changes dialog and registered
|
||||||
|
save/discard callbacks. A browser-controlled tab/window unload warning is the
|
||||||
|
only unavoidable different surface.
|
||||||
|
- A page or panel has one visually primary action for its current state. Put
|
||||||
|
secondary actions beside it. Separate destructive actions and name their real
|
||||||
|
effect.
|
||||||
|
- Dialog actions use a stable footer: cancel/back first, then the primary action
|
||||||
|
at the end. Header and footer stay fixed while long bodies scroll.
|
||||||
|
- Use cards for independent groups, summaries, and settings blocks. Do not wrap
|
||||||
|
every region in a card or nest cards merely to create spacing.
|
||||||
|
- When a collapsible card contains one table and no other content, the table
|
||||||
|
uses the card's full available width and body height. The card/table region
|
||||||
|
owns overflow; do not add an inner max-width, decorative wrapper, duplicate
|
||||||
|
padding, or nested scroll container that reduces the working area.
|
||||||
|
- Row actions in tables are icon-only controls in a stable rightmost action
|
||||||
|
column. Order them by intent: inspect/open, edit, copy/duplicate,
|
||||||
|
transfer/share/download, retry/restore, then remove/delete last. Omit actions
|
||||||
|
only when they are structurally irrelevant to the entire table. An action
|
||||||
|
that belongs to the table but is unavailable for one row remains in its
|
||||||
|
normal position and is disabled; when the reason is not obvious from row
|
||||||
|
state, provide it through the central focusable disabled-action explanation.
|
||||||
|
It does not disappear. Every icon has a translated accessible name and matching tooltip.
|
||||||
|
Separate destructive actions visually, and use a named confirmation/review
|
||||||
|
surface when the consequence cannot be understood from the icon and row
|
||||||
|
context. In an empty editable table, place Add in the same left-most action
|
||||||
|
slot it occupies in a populated row and reserve the remaining slots so the
|
||||||
|
column geometry does not move.
|
||||||
|
- Transient feedback should not shift the workspace. Durable failures, partial
|
||||||
|
results, and blockers remain attached to the affected item or operation.
|
||||||
|
|
||||||
|
### Detail And Explanation
|
||||||
|
|
||||||
|
- Keep the selected object's identity and material status visible while its
|
||||||
|
detail changes.
|
||||||
|
- Every non-self-explanatory field uses the central `FieldLabel`; short field
|
||||||
|
help sits with that label. A field without `FieldLabel` is an explicit
|
||||||
|
documented omission whose register names the field, rationale, and accessible
|
||||||
|
label source. Users may hide inline help markers with their persisted
|
||||||
|
interface preference; the visible/accessibility label and validation remain.
|
||||||
|
Longer "Why?", policy source,
|
||||||
|
diagnostics, or provenance belongs in an expandable area, detail panel, or
|
||||||
|
review step.
|
||||||
|
- Empty space is not an error. An empty state states what is empty, why that can
|
||||||
|
happen, and the permitted next action. Do not show creation actions to actors
|
||||||
|
who cannot create.
|
||||||
|
|
||||||
|
## Visual Grammar
|
||||||
|
|
||||||
|
- Core owns the appearance contract and shared CSS tokens. Modules use core
|
||||||
|
colors, spacing, radii, shadows, focus treatment, status colors, and disabled
|
||||||
|
treatment; module CSS may specialize layout only.
|
||||||
|
- Establish hierarchy through spacing, typography, grouping, and placement
|
||||||
|
before adding borders or color.
|
||||||
|
- Color never carries status or required action alone. Pair it with text and,
|
||||||
|
where useful, an icon.
|
||||||
|
- Icons support recognition but do not replace accessible names. Use the core
|
||||||
|
icon-name mapping for navigation.
|
||||||
|
- Keep list columns, tree indentation, headers, dialog dimensions, and action
|
||||||
|
positions stable as content changes.
|
||||||
|
- Density is a user preference, not a license to remove labels, focus targets,
|
||||||
|
explanations, or consequences.
|
||||||
|
- Respect system/light/dark themes and reduced-motion preferences through the
|
||||||
|
core contract. Do not build module-local theme systems.
|
||||||
|
|
||||||
|
## Wording Grammar
|
||||||
|
|
||||||
|
Use the same noun for the same domain object in navigation, headings, fields,
|
||||||
|
actions, states, API-facing explanations, and documentation. Prefer the most
|
||||||
|
specific user-facing noun: "Recipients" rather than "Data", "Delivery job"
|
||||||
|
rather than "Process", and "Mail profile" rather than "Configuration" when
|
||||||
|
that is what the user is acting on.
|
||||||
|
|
||||||
|
Actions use a verb plus the object or consequence:
|
||||||
|
|
||||||
|
- Prefer `Save campaign`, `Review messages`, `Queue delivery`, `Retry failed
|
||||||
|
deliveries`, or `Delete calendar`.
|
||||||
|
- Avoid `Submit`, `OK`, `Continue`, or `Execute` when a more precise action is
|
||||||
|
available.
|
||||||
|
- Use `Continue` only when it advances a reversible guided flow without
|
||||||
|
committing the final effect.
|
||||||
|
- Do not say `Undo` when the system can only cancel future work, create a
|
||||||
|
correction, supersede a record, or request retraction.
|
||||||
|
|
||||||
|
State text describes observed state, not optimism. Use stable shared terms where
|
||||||
|
they fit: `Draft`, `Ready for review`, `Blocked`, `Queued`, `Running`,
|
||||||
|
`Retry scheduled`, `Partially completed`, `Completed`, `Failed`, and
|
||||||
|
`Cancelled`. Domain-specific states may refine these terms but should not give a
|
||||||
|
shared term a contradictory meaning.
|
||||||
|
|
||||||
|
Blocked and failed actions use the structured language from DUE-005:
|
||||||
|
|
||||||
|
- what is unavailable or failed
|
||||||
|
- why, in plain language
|
||||||
|
- what must happen next
|
||||||
|
- who can do it
|
||||||
|
- where to go
|
||||||
|
- optional technical details behind deliberate disclosure
|
||||||
|
|
||||||
|
Errors should identify the affected object and whether saved state or external
|
||||||
|
effects may already exist. Never expose raw exception text as the only user
|
||||||
|
message.
|
||||||
|
|
||||||
|
Use the central dialog, confirmation, alert, and attached-error components for
|
||||||
|
feedback. `window.alert` and the global `alert` function are prohibited. A
|
||||||
|
genuinely unavoidable exception requires explicit product-owner authorization
|
||||||
|
and an entry in the Core alert exception register before implementation.
|
||||||
|
|
||||||
|
## State Contract
|
||||||
|
|
||||||
|
Every surface implements the states it can reach; it does not render a blank
|
||||||
|
region while waiting or collapse distinct outcomes into a generic error.
|
||||||
|
|
||||||
|
| State | Required treatment |
|
||||||
|
| --- | --- |
|
||||||
|
| Loading | Keep the stable shell and context visible. Name what is loading; preserve usable prior data when safe. |
|
||||||
|
| Empty | State the scope and reason, then show only permitted next actions. |
|
||||||
|
| Validation problem | Attach the problem to the field/item and provide a navigable summary when problems span regions or steps. |
|
||||||
|
| Permission denied | Name the unavailable action or object, the required actor/role where safe, and a valid exit. Do not leak protected data. |
|
||||||
|
| Capability unavailable | Distinguish not installed, disabled, not configured, unhealthy, and not permitted when the actor may know. Give the responsible actor and target. |
|
||||||
|
| Offline or unreachable | Preserve local context and unsaved input, show last-known/stale state, and offer a safe retry. Do not present network absence as an authentication failure. |
|
||||||
|
| Stale or conflicting | Show which data changed, preserve both values where feasible, and offer reload, merge, or explicit overwrite according to policy. |
|
||||||
|
| Partial result | Show completed and incomplete effects separately. Never label a partial operation successful without qualification. |
|
||||||
|
| Asynchronous work | Show durable job identity, queued/running/retry/block/final state, last update, progress if meaningful, and leave/return behavior. |
|
||||||
|
| Success | State the resulting object/effect and provide its evidence or destination. Use a transient toast only when the result is already visible and durable elsewhere. |
|
||||||
|
| Destructive or corrective action | Preview scope, downstream effects, reversibility limit, evidence, and required confirmation or approval. |
|
||||||
|
|
||||||
|
Long-running or external work must expose retry and reconciliation as observable
|
||||||
|
states. The UI must not imply that a request and its external effect were one
|
||||||
|
atomic success when an outbox, worker, or remote system sits between them.
|
||||||
|
|
||||||
|
## Consequence And Provenance
|
||||||
|
|
||||||
|
Before an action affects records, rights, policy, retention, communications,
|
||||||
|
money, external systems, or workflow state, its action surface must answer the
|
||||||
|
decision-surface questions in the interface doctrine. At minimum show:
|
||||||
|
|
||||||
|
- affected object and scope
|
||||||
|
- acting identity or system actor and relevant authority
|
||||||
|
- immediate and possible downstream effects
|
||||||
|
- whether the operation is reversible, cancellable, corrective, or final
|
||||||
|
- blockers and their resolution path
|
||||||
|
- audit/evidence that will be created
|
||||||
|
- effective policy or configuration source when it changes the decision
|
||||||
|
|
||||||
|
After execution, users must be able to reach the command/job, observed effects,
|
||||||
|
policy result, failures, retries, reconciliation outcome, actor, time, and source
|
||||||
|
data that explain the result. Provenance may be quiet by default, but it must not
|
||||||
|
be absent.
|
||||||
|
|
||||||
|
Privacy follows the same rule: lists, previews, logs, notifications, and
|
||||||
|
diagnostics show only the personal or secret data needed for the actor's task.
|
||||||
|
Redaction must be explicit enough that users do not mistake a redacted value for
|
||||||
|
missing source data.
|
||||||
|
|
||||||
|
## Focused Views
|
||||||
|
|
||||||
|
A focused view is a declarative UI composition for a task. It selects the
|
||||||
|
routes, local regions, navigation entries, and actions relevant to that task
|
||||||
|
after installed-module, capability, permission, and policy filtering. It does
|
||||||
|
not change backend authorization or domain state.
|
||||||
|
|
||||||
|
A view definition must be able to explain:
|
||||||
|
|
||||||
|
- its stable identifier, label, and task purpose
|
||||||
|
- the current object/scope and default destination
|
||||||
|
- included navigation and contributed regions/actions, with deterministic order
|
||||||
|
- the visible escape to the containing module and full system
|
||||||
|
- why the view is active and how the user may switch when switching is allowed
|
||||||
|
- what happens to unsaved work when entering, leaving, or switching views
|
||||||
|
|
||||||
|
Focused views follow these invariants:
|
||||||
|
|
||||||
|
- Do not hide a blocker, material consequence, provenance, or required review
|
||||||
|
merely to make the screen quieter.
|
||||||
|
- Preserve the global tenant/actor context and provide an obvious exit.
|
||||||
|
- Filter unavailable contributions without leaving broken separators, empty
|
||||||
|
groups, or dead destinations.
|
||||||
|
- A manual or automatic switch is navigation, not consent. Guard unsaved work
|
||||||
|
and never execute a domain action as a side effect.
|
||||||
|
- Display why a non-manual default was selected, for example a role or task
|
||||||
|
default, without exposing protected policy details.
|
||||||
|
- Treat an unknown or invalid view as a recoverable fallback to the normal
|
||||||
|
module surface.
|
||||||
|
|
||||||
|
When more than one source proposes a focused view, use this precedence:
|
||||||
|
|
||||||
|
1. a manual view pinned for the current user session
|
||||||
|
2. a current-task suggestion, including a future workflow-step suggestion
|
||||||
|
3. the user's saved default
|
||||||
|
4. the role or tenant default
|
||||||
|
5. the normal full interface
|
||||||
|
|
||||||
|
Show the active source and an escape to the full interface. A task or workflow
|
||||||
|
suggestion is never an authorization change and never locks the user into the
|
||||||
|
composition; tenant policy may constrain which views are selectable, but it
|
||||||
|
must not hide required evidence or remove that escape. Workflow implementation
|
||||||
|
is explicitly postponed and is not a prerequisite for defining, manually
|
||||||
|
selecting, testing, or piloting focused views. A future workflow module may
|
||||||
|
request a view through a core contract; it must not own the view composition
|
||||||
|
implementation.
|
||||||
|
|
||||||
|
## Accessibility And Responsive Contract
|
||||||
|
|
||||||
|
- Every action is reachable and operable by keyboard in a logical order.
|
||||||
|
- Use semantic headings, landmarks, labels, tables/lists, and native controls
|
||||||
|
before adding ARIA. Icon-only controls need stable accessible names.
|
||||||
|
- Focus is visible. Dialogs trap focus, announce their name, and return focus to
|
||||||
|
the control that opened them. Validation moves or links focus to the first
|
||||||
|
relevant problem without losing the problem summary.
|
||||||
|
- Loading, saved, failed, queued, progress, and externally updated states are
|
||||||
|
announced without repeatedly interrupting the user.
|
||||||
|
- Disabled primary actions need a focusable explanation; a pointer-only tooltip
|
||||||
|
is insufficient.
|
||||||
|
- Do not rely on color, hover, drag-and-drop, pointer precision, or animation as
|
||||||
|
the only interaction. Provide keyboard and explicit-control equivalents.
|
||||||
|
- At narrow widths and high zoom, preserve task order and action access. Collapse
|
||||||
|
secondary panes into an explicit drawer/step and never move a destructive
|
||||||
|
action into the primary position.
|
||||||
|
- Honor reduced motion. Avoid motion that implies progress when the operation is
|
||||||
|
merely waiting.
|
||||||
|
- Truncation has an accessible full-value path. Personal or secret values remain
|
||||||
|
redacted according to permission and policy in that path.
|
||||||
|
|
||||||
|
## Component Ownership
|
||||||
|
|
||||||
|
Core already exports shell, navigation, access-boundary, form, dialog, loading,
|
||||||
|
status, policy/provenance, blocker, review, table, tree, message-display, and
|
||||||
|
unsaved-change primitives. These centrally exported components are mandatory
|
||||||
|
across GovOPlaN wherever their contract covers the interaction. In particular,
|
||||||
|
use the core `Card` for logical sections, `DataGrid` for tabular collections and
|
||||||
|
row actions, and `ToggleSwitch` (the standard Toggle control) for boolean
|
||||||
|
settings. Styling a native element or a module-local component to imitate one
|
||||||
|
of these controls is duplication, not reuse.
|
||||||
|
|
||||||
|
A route or domain composition assembled from central primitives is not a custom
|
||||||
|
control. Any new reusable UI control, presentation primitive, or module-local
|
||||||
|
substitute is a custom component and requires explicit product-owner
|
||||||
|
authorization before implementation. Record the authorization in the owning
|
||||||
|
decision or issue together with:
|
||||||
|
|
||||||
|
- the narrowly defined purpose and consumers
|
||||||
|
- why no central component or composition satisfies the need
|
||||||
|
- the exact scope in which the exception may be used
|
||||||
|
- its accessibility, state, theme, and test contract
|
||||||
|
- whether it should remain domain-specific or later become a core component
|
||||||
|
|
||||||
|
An authorized custom component serves only that specific purpose. It must not
|
||||||
|
duplicate, fork, restyle into a substitute for, or silently broaden beyond a
|
||||||
|
central component. Code review convenience, an existing local implementation,
|
||||||
|
or a small visual difference is not authorization. When core gains the required
|
||||||
|
contract, migrate the exception unless the product owner explicitly retains it.
|
||||||
|
|
||||||
|
Do not promote a component only because two screens look similar. Promote it to
|
||||||
|
`@govoplan/core-webui` after a second consumer or a clear platform contract has
|
||||||
|
proved shared behavior, accessibility, state, and extension needs. Modules own
|
||||||
|
domain composition, wording, and policy semantics; core owns generic contracts
|
||||||
|
and appearance.
|
||||||
|
|
||||||
|
### Scheduling Request Composition Reference
|
||||||
|
|
||||||
|
The Scheduling request surface is the first explicit reference composition for
|
||||||
|
these rules:
|
||||||
|
|
||||||
|
- The persistent left panel contains `My scheduling requests` and `Scheduling
|
||||||
|
requests for me` as two stacked lists. It preserves list context like mailbox
|
||||||
|
folders but does not invent folders.
|
||||||
|
- The left pane's `Scheduling requests` heading owns one short `Add` action. It
|
||||||
|
opens a new request in the right pane without an extra menu or duplicate
|
||||||
|
launcher.
|
||||||
|
- The right main pane is the stable view/create/edit surface. Selecting a list
|
||||||
|
item opens its details; Add opens the same editor composition used for edit.
|
||||||
|
- `Basic information`, `Calendar integration`, `Candidate slots`, and
|
||||||
|
`Participants` are logical sections rendered with the central `Card`.
|
||||||
|
- Privacy and participation behavior is a separate settings `Card`; dependent
|
||||||
|
number/password fields are disclosed by their central `ToggleSwitch`.
|
||||||
|
- Candidate slots and participants are row collections rendered with the
|
||||||
|
central `DataGrid`, including its stable action column.
|
||||||
|
- Calendar integration is a boolean choice rendered with the central
|
||||||
|
`ToggleSwitch`; dependent calendar controls are disclosed only when enabled.
|
||||||
|
- Each participant is one structured row containing name, email address, and
|
||||||
|
ordered row actions. An address-parsing text area is not the ordinary editor;
|
||||||
|
parsing pasted address lists belongs only in an explicitly designed bulk
|
||||||
|
import flow.
|
||||||
|
- View mode shows participation statistics and only state-valid quick actions.
|
||||||
|
Scheduling owns those current domain actions; a future Workflow module may
|
||||||
|
coordinate them through stable action contracts but is not a runtime
|
||||||
|
dependency of the surface.
|
||||||
|
|
||||||
|
Apply the underlying placement and component rules to equivalent collection and
|
||||||
|
create/edit surfaces throughout the system; the Scheduling domain names are an
|
||||||
|
example, not a module-local convention.
|
||||||
|
|
||||||
|
## Test Expectations
|
||||||
|
|
||||||
|
For every changed surface, select tests from each applicable layer:
|
||||||
|
|
||||||
|
- route and permission tests: module enabled/disabled permutations, route guard,
|
||||||
|
contribution filtering, fallback, and direct-link behavior
|
||||||
|
- behavior tests: primary task, validation, unsaved-change guard, confirmation,
|
||||||
|
retry/reconcile, partial result, and leave/return behavior
|
||||||
|
- accessibility tests: semantic names/roles, keyboard order, focus entry/return,
|
||||||
|
live-state announcements, and non-color status meaning
|
||||||
|
- state tests: loading, empty, denied, capability-missing, stale/conflict,
|
||||||
|
offline, partial, success, and destructive/corrective outcomes as applicable
|
||||||
|
- composition tests: absent optional module, duplicate/unknown contribution,
|
||||||
|
deterministic ordering, and focused-view fallback
|
||||||
|
- presentation checks: supported widths/zoom, light/dark/system theme, reduced
|
||||||
|
motion, comfortable/compact density, and long translated text
|
||||||
|
- i18n checks: user-facing strings owned by the rendering package and structural
|
||||||
|
audits passing
|
||||||
|
- visual regression: useful for geometry and hierarchy after behavior and
|
||||||
|
accessibility assertions exist; never the only evidence
|
||||||
|
|
||||||
|
If the current harness cannot automate a required check, record the gap and the
|
||||||
|
manual evidence in the owning issue. "Not tested" is an inventory state, not a
|
||||||
|
reason to infer that a pattern is satisfied.
|
||||||
|
|
||||||
|
## Definition Of Done For A Surface
|
||||||
|
|
||||||
|
- The dominant task and archetype are recorded in the inventory.
|
||||||
|
- Placement, action hierarchy, wording, and every reachable state follow this
|
||||||
|
pattern language or an explicit ledger exception.
|
||||||
|
- Permission, capability, privacy, and redaction behavior are verified.
|
||||||
|
- Consequence, reversibility, authority, evidence, and provenance are present
|
||||||
|
where applicable.
|
||||||
|
- Keyboard, focus, announcement, responsive, theme, density, motion, and i18n
|
||||||
|
behavior are covered in proportion to the surface.
|
||||||
|
- Optional modules remain optional and no sibling-private UI import was added.
|
||||||
|
- Every matching central component is reused. Any custom-component exception
|
||||||
|
has recorded product-owner authorization, narrow scope, rationale, and tests,
|
||||||
|
and does not duplicate a central component.
|
||||||
|
- Behavioral/accessibility evidence is linked from the rollout matrix and issue.
|
||||||
|
- Configured-system help can reach the applicable pattern or reference topic
|
||||||
|
when [Docs #15](https://git.add-ideas.de/add-ideas/govoplan-docs/issues/15)
|
||||||
|
supplies that experience.
|
||||||
|
|
||||||
|
## First Pilot: Campaign
|
||||||
|
|
||||||
|
[Campaign #74](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/74)
|
||||||
|
is the first full-domain audit and migration. It should prove patterns before
|
||||||
|
generic extraction:
|
||||||
|
|
||||||
|
- [#59](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/59) and
|
||||||
|
[#73](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/73): stable,
|
||||||
|
accessible preview and attachment-detail overlays
|
||||||
|
- [#63](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/63): review
|
||||||
|
stages, outcomes, blockers, and intervention vocabulary
|
||||||
|
- [#62](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/62): explicit
|
||||||
|
synchronous/asynchronous send mode and durable delivery progress
|
||||||
|
- [#65](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/65): one
|
||||||
|
coherent report filtering and count-affordance model
|
||||||
|
- [#35](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/35): guided
|
||||||
|
first-campaign entry
|
||||||
|
|
||||||
|
These slices do not depend on the Workflow runtime. Campaign's current
|
||||||
|
domain-owned review/send state is enough to prove layout, wording, focused-view,
|
||||||
|
progress, intervention, and evidence patterns.
|
||||||
|
|
||||||
|
## Revision Procedure
|
||||||
|
|
||||||
|
1. Record a changed product decision in the core UI/UX decision ledger.
|
||||||
|
2. Update the relevant pattern here without copying the full owning doctrine.
|
||||||
|
3. Update the surface inventory and rollout owner/issues.
|
||||||
|
4. Change shared components only where the proven contract belongs to core.
|
||||||
|
5. Migrate and test affected module surfaces.
|
||||||
|
6. Publish configured-system pattern/reference help through the Docs module.
|
||||||
236
docs/INTERFACE_SURFACE_INVENTORY.md
Normal file
236
docs/INTERFACE_SURFACE_INVENTORY.md
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
# GovOPlaN Interface Surface Inventory And Rollout
|
||||||
|
|
||||||
|
This is the initial evidence inventory for the product-wide interface pattern
|
||||||
|
language. It records code contributions, not an assertion that every listed
|
||||||
|
surface is complete, enabled in a deployment, usable, or compliant.
|
||||||
|
|
||||||
|
The applicable design contract is
|
||||||
|
[`INTERFACE_PATTERN_LANGUAGE.md`](INTERFACE_PATTERN_LANGUAGE.md).
|
||||||
|
|
||||||
|
## Snapshot And Method
|
||||||
|
|
||||||
|
Snapshot refreshed: 2026-07-22.
|
||||||
|
|
||||||
|
Evidence was read from tracked Git `HEAD` in the local GovOPlaN checkouts:
|
||||||
|
|
||||||
|
- core routes and fallback behavior in `govoplan-core/webui/src/App.tsx`
|
||||||
|
- every present `govoplan-*/webui/src/module.ts`
|
||||||
|
- the matching backend `src/*/backend/manifest.py`
|
||||||
|
- named UI capabilities and contribution identifiers in each `module.ts`
|
||||||
|
- Campaign nested routes in its tracked `CampaignWorkspace.tsx` and
|
||||||
|
`SectionSidebar.tsx`
|
||||||
|
|
||||||
|
Tracked commits are used as the integrated baseline. Dirty worktree changes
|
||||||
|
are not treated as delivered behavior. The former Campaign recipient-editor
|
||||||
|
and preview WIP is integrated in tracked commits; completed work is no longer
|
||||||
|
described as a local exception.
|
||||||
|
|
||||||
|
Runtime visibility remains conditional on the module being packaged and
|
||||||
|
enabled, server metadata, installed optional capabilities, the authenticated
|
||||||
|
actor, tenant context, route permission guards, and inner-surface permission
|
||||||
|
checks. A route in this inventory therefore means "the code contributes this
|
||||||
|
route when its module is active", not "every user sees it".
|
||||||
|
|
||||||
|
Inventory states:
|
||||||
|
|
||||||
|
- **Contributed**: a route or named UI capability exists in tracked source.
|
||||||
|
- **Metadata gap**: frontend runtime source contributes a route but the backend
|
||||||
|
manifest does not describe the same route/navigation surface.
|
||||||
|
- **Unreviewed**: the surface has not yet completed the pattern, state,
|
||||||
|
accessibility, privacy, and consequence audit. This is the default unless an
|
||||||
|
issue supplies verification evidence.
|
||||||
|
- **Pilot**: the surface is in the Campaign-first rollout.
|
||||||
|
|
||||||
|
## Core Shell Surfaces
|
||||||
|
|
||||||
|
| Surface | Owner and code evidence | Audience/access evidence | Primary task and target archetype | Audit / rollout |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| Public landing and login | `govoplan-core` `PublicLandingPage`; rendered while no authenticated principal exists | Unauthenticated; maintenance and backend-reachability context are shell inputs | Understand the service and authenticate; public entry | Unreviewed; later public-entry audit |
|
||||||
|
| Session/bootstrap state | `govoplan-core` `App.tsx` and `AppShell` | All browser sessions during bootstrap | Understand that session/platform state is loading; state contract | Unreviewed; core shell |
|
||||||
|
| `/` authenticated redirect | `govoplan-core` chooses the first visible navigation destination | Authenticated; result depends on visible nav contributions | Enter the actor's first accessible service area; navigation behavior, not a content page | Unreviewed; focused-view/default-route work must preserve this fallback |
|
||||||
|
| `/dashboard` fallback | `govoplan-core` `DashboardPage` only when the Dashboard module is absent | Authenticated; no route-specific scope in core | Cross-module starting point; dashboard | Unreviewed; compare with module dashboard before shared changes |
|
||||||
|
| `/settings` | `govoplan-core` `SettingsPage` | Authenticated; contributed sections and integrations filter internally | Profile, UI/workspace preference, local connection, and user-scoped integration settings; configuration | Unreviewed; Core #225 program |
|
||||||
|
| Shell chrome | `AppShell`, `Titlebar`, `IconRail`, `BreadcrumbBar`, `HelpMenu`, language menu, unsaved-change provider | Public/authenticated variants; nav filtered later | Tenant/actor context, global navigation, help, language, session and maintenance state | Unreviewed; platform-owned prerequisite for focused views |
|
||||||
|
|
||||||
|
## Direct Module Route Contributions
|
||||||
|
|
||||||
|
The access guard column reports only the route-level declaration in
|
||||||
|
`module.ts`. Inner APIs and controls may impose additional checks.
|
||||||
|
|
||||||
|
| Route | Owner / render evidence | Route-level access evidence | Primary task | Target archetype | Status / priority |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| `/admin` | `govoplan-access` `AdminPage` | Any core `adminReadScopes` | Administer system and tenant concerns assembled from module sections | Administration/configuration | Contributed; unreviewed; P1 under [Core #225](https://git.add-ideas.de/add-ideas/govoplan-core/issues/225) |
|
||||||
|
| `/address-book` | `govoplan-addresses` `AddressBookPage` | `addresses:contact:read` | Browse and manage contacts, address books, and lists | Directory/list-detail | Contributed; unreviewed; P2 after Campaign |
|
||||||
|
| `/calendar` | `govoplan-calendar` `CalendarPage` | `calendar:event:read` | Browse calendars/events and act on calendar data | Directory/list-detail | Contributed; metadata gap; unreviewed; P2 after Campaign |
|
||||||
|
| `/campaigns` | `govoplan-campaign` `CampaignListPage` | `campaigns:campaign:read` | Find, compare, create, and open campaigns | List-detail entry | Pilot; P1 [Campaign #74](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/74) |
|
||||||
|
| `/campaigns/:campaignId/*` | `govoplan-campaign` `CampaignResourceRoute` and `CampaignWorkspace` | `campaigns:campaign:read`, plus resource probe | Configure, review, send, and inspect one campaign/version | List-detail workspace containing edit, review, monitoring, and evidence surfaces | Pilot; P1 Campaign #74 |
|
||||||
|
| `/operator` | `govoplan-campaign` `OperatorQueuePage` | `campaigns:campaign:read` and any of queue, control, retry, or reconcile | Monitor and intervene in campaign jobs through authority-specific controls | Monitoring/work queue | Pilot; durable queue controls delivered in [Campaign #78](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/78); #74 audit remains |
|
||||||
|
| `/reports` | `govoplan-campaign` `AggregateReportsPage` | `campaigns:report:read` | Compare privacy-protected cross-campaign outcome totals without recipient detail, diagnostics, export, or drill-down | Aggregate reporting | Pilot; aggregate-reader surface delivered in [Campaign #80](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/80); #74 audit remains |
|
||||||
|
| `/templates` | `govoplan-campaign` `TemplatesPage` | No route guard declared in `module.ts` | Browse/manage campaign templates | Directory/list-detail | Pilot audit; permission intent must be verified; P2 |
|
||||||
|
| `/dashboard` | `govoplan-dashboard` `DashboardPage` | No route-specific scope | Assemble module-provided actionable widgets | Dashboard | Contributed; unreviewed; P2 |
|
||||||
|
| `/docs` | `govoplan-docs` `DocsPage` | Docs read or system/tenant settings read scopes | Read configured, available, and evidence-aware documentation | Documentation directory/reference | Contributed; unreviewed; P1 [Docs #15](https://git.add-ideas.de/add-ideas/govoplan-docs/issues/15) after initial pattern content |
|
||||||
|
| `/files` | `govoplan-files` `FilesPage` | `files:file:read` | Browse folders/files and perform managed-file work | Directory/explorer | Contributed; metadata gap; unreviewed; P2 after Campaign |
|
||||||
|
| `/idm` | `govoplan-idm` `IdmPage` | Any IDM assignment/write or organization function-assign scope | Inspect and govern identity/function assignments | List-detail/configuration | Contributed; unreviewed; P2 |
|
||||||
|
| `/mail` | `govoplan-mail` `MailboxPage` | `mail:mailbox:read` | Browse mailboxes and messages | Directory/list-detail | Contributed; metadata gap; unreviewed; P2 after Campaign |
|
||||||
|
| `/notifications` | `govoplan-notifications` `NotificationCenterPage` | `notifications:notification:read` | Inspect and acknowledge notification state | List-detail/inbox | Contributed without a nav item or backend frontend metadata; navigation intent unknown; P2 discovery |
|
||||||
|
| `/ops` | `govoplan-ops` `OpsPage` | Ops read or system/tenant settings read scopes | Inspect runtime health and readiness | Monitoring | Contributed; unreviewed; P2 |
|
||||||
|
| `/organizations` | `govoplan-organizations` `OrganizationsPage` | Organization model/unit/function or admin settings read scopes | Model and inspect organizational structures/functions | Directory/list-detail | Contributed; unreviewed; P2 |
|
||||||
|
| `/scheduling` | `govoplan-scheduling` `SchedulingPage` | `scheduling:schedule:read` | Plan and decide scheduling requests and availability | List-detail/guided decision | Contributed; metadata gap; unreviewed; P2 |
|
||||||
|
|
||||||
|
## Manifest And Runtime Route Alignment
|
||||||
|
|
||||||
|
Backend route metadata lets operators, Docs, release tooling, and remote bundle
|
||||||
|
loading reason about the configured interface without executing module UI code.
|
||||||
|
`module.ts` remains the executable local route/render source. Missing metadata
|
||||||
|
is recorded here as an evidence gap; this inventory does not infer whether each
|
||||||
|
gap is intentional.
|
||||||
|
|
||||||
|
| Module | `module.ts` routes | Backend manifest frontend routes | Backend nav alignment | Result |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| Access | `/admin` | `/admin` | Aligned | Described |
|
||||||
|
| Addresses | `/address-book` | `/address-book` | Aligned | Described |
|
||||||
|
| Admin | No direct route; `admin.sections` | None | Not applicable | Composed surface |
|
||||||
|
| Audit | No direct route; `admin.sections` | None | Not applicable | Composed surface |
|
||||||
|
| Calendar | `/calendar` | None | `/calendar` nav exists | Metadata gap |
|
||||||
|
| Campaign | Five routes | None | Four top-level nav items exist | Metadata gap; wildcard resource route is also undescribed |
|
||||||
|
| Dashboard | `/dashboard` | `/dashboard` | Aligned | Described |
|
||||||
|
| Docs | `/docs` | `/docs` | Aligned | Described |
|
||||||
|
| Files | `/files` | None | `/files` nav exists | Metadata gap |
|
||||||
|
| IDM | `/idm` | `/idm` | Aligned | Described |
|
||||||
|
| Mail | `/mail` | None | `/mail` nav exists | Metadata gap |
|
||||||
|
| Notifications | `/notifications` | No frontend metadata | No nav item | Metadata and discovery gap |
|
||||||
|
| Ops | `/ops` | `/ops` | Aligned | Described |
|
||||||
|
| Organizations | `/organizations` | `/organizations` | Aligned | Described |
|
||||||
|
| Policy | No direct route; `admin.sections` | None | Not applicable | Composed surface |
|
||||||
|
| Scheduling | `/scheduling` | None | `/scheduling` nav exists | Metadata gap |
|
||||||
|
|
||||||
|
Before a release claims a complete configured-system route inventory, add a
|
||||||
|
contract check or explicit exceptions so executable routes and manifest
|
||||||
|
metadata cannot silently diverge.
|
||||||
|
|
||||||
|
## Composed Surfaces And Extension Points
|
||||||
|
|
||||||
|
These surfaces are active only when the host and contributing modules are
|
||||||
|
enabled and the actor passes the declared filters.
|
||||||
|
|
||||||
|
| Host surface | Contributor and evidence | Contributed regions/actions | Pattern implication | Audit |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `/admin` | Access host (`AdminPage`) | System tenants/users/roles, tenant users/groups/roles/API keys/settings, function-role mappings, user/group mail and file connector scopes | One stable admin information architecture must contain both host-owned and contributed sections | Unreviewed; P1 Core #225 |
|
||||||
|
| `/admin` | `govoplan-admin` `admin.sections` | Overview; system settings; configuration changes; configuration packages; role/group templates; module management | Configuration, guided operations, review/preflight, consequence | In progress under Core #225; surface-level evidence still needed |
|
||||||
|
| `/admin` | `govoplan-audit` `admin.sections` | System audit; tenant audit | Evidence/provenance and reporting | Unreviewed |
|
||||||
|
| `/admin` | `govoplan-files` `admin.sections` and `files.connectors` | System and tenant file connections plus scoped connector managers used by Access | Adaptive configuration, discovery/test, policy and credentials | First migration family in Core #225; verification incomplete in this inventory |
|
||||||
|
| `/admin` | `govoplan-organizations` `admin.sections` | Tenant organization settings | Configuration/list-detail | Unreviewed |
|
||||||
|
| `/admin` | `govoplan-policy` `admin.sections` | System, tenant, group, and user retention | Effective value, source/provenance, consequential configuration | Unreviewed; Core #225 phase 4 |
|
||||||
|
| `/admin` and `/settings` | `govoplan-mail` `mail.profiles` | System/tenant/group/user mail profile and policy managers | Same server/credential/policy grammar as file connectors | Unreviewed; Core #225 mail migration |
|
||||||
|
| `/settings` | Core host | Profile; interface; workspace; local connection | Personal configuration with adaptive forms and immediate feedback | Unreviewed |
|
||||||
|
| `/settings` | Files and Mail named capabilities | User-scoped file connections and mail profiles/policy | Optional integration regions disappear cleanly when capability absent | Unreviewed |
|
||||||
|
| `/settings` | `govoplan-notifications` `settings.sections` | Notification preferences | Personal configuration | Unreviewed |
|
||||||
|
| `/dashboard` | Dashboard host and `dashboard.widgets` | Installed-modules widget; Ops health widget when Ops contributes it | Widget ordering, staleness, permissions, destination behavior | Unreviewed |
|
||||||
|
| `/organizations` | IDM `organizations.functionActions` | Action leading to assignment view filtered by IDM scopes | Cross-module context action through explicit capability | Unreviewed |
|
||||||
|
| Campaign attachments/import | Files `files.fileExplorer` | Folder tree, managed chooser, file listing/pattern resolution/sharing | Optional domain composition without sibling-private imports | Pilot audit under Campaign #74 |
|
||||||
|
| Campaign review/send | Mail runtime `mail.devMailbox` | Mock-mail verification when backend advertises runtime capability | Optional review stage with unavailable/optional states | Pilot audit under Campaign #63/#62 |
|
||||||
|
|
||||||
|
Other named capability exports (`files.connectors`, `organizations.functionPicker`,
|
||||||
|
and mail profile validation) are contracts consumed inside the composed surfaces
|
||||||
|
above; they are not independent routes.
|
||||||
|
|
||||||
|
## Campaign Pilot Surface Map
|
||||||
|
|
||||||
|
Campaign is detailed first because it exercises almost every archetype. The
|
||||||
|
recipient-data editor is now consolidated into the `recipients` section on
|
||||||
|
remote `main`; [Campaign #67](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/67)
|
||||||
|
records the accepted and verified integration boundary.
|
||||||
|
|
||||||
|
Campaign already consumes core primitives including `ModuleSubnav`, `Card`,
|
||||||
|
`PageTitle`, `Button`, `LoadingFrame`, `DismissibleAlert`, `FormField`,
|
||||||
|
`StatusBadge`, `MetricCard`, `DataGrid`, `TableActionGroup`, `Dialog`,
|
||||||
|
`ConfirmDialog`, `FileDropZone`, `MessageDisplayPanel`, policy components,
|
||||||
|
access/module capabilities, and unsaved-navigation guards. Reuse alone does not
|
||||||
|
prove that the composition or states satisfy the pattern.
|
||||||
|
|
||||||
|
| Surface / code evidence | Primary task | Target pattern | Material consequence/state | Known issue / rollout |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| Campaign list (`CampaignListPage`) | Find, compare, create, open | List-detail entry | Campaign lifecycle/status and creation | Audit in [#74](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/74); guided entry [#35](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/35) |
|
||||||
|
| Overview (`CampaignOverviewPage`) | Understand/edit campaign identity, version, access, lifecycle | Object overview plus adaptive edit | Lock/archive/delete/access changes need real consequence and reversibility wording | #74 remaining audit |
|
||||||
|
| Fields (`CampaignFieldsPage`) | Define recipient/template field schema | Structured editor | Schema changes can invalidate recipient/template data | #74 audit |
|
||||||
|
| Attachments/files (`AttachmentsDataPage`, `AttachmentRulesOverlay`) | Select sources and attachment/ZIP rules | Directory chooser plus adaptive rule editor | Missing or mismatched files affect built messages | #74; attachment-detail [#59](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/59) |
|
||||||
|
| Recipients (`RecipientDataPage`) | Select/import/map/edit recipients, address fields and per-recipient values/files | Import/mapping plus list-detail editor | Personal data, validation, bulk activation, file links | Consolidated editor delivered in #67; #74 remaining audit and guided entry #35 |
|
||||||
|
| Template (`TemplateDataPage`, placeholder/expression dialogs) | Author subject/body and preview substitutions | Adaptive editor plus stable preview | Generated communication content and unresolved expressions | #74; stable overlay [#73](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/73) |
|
||||||
|
| Mail settings (`MailSettingsPage` settings view) | Select/configure campaign mail transport | Adaptive configuration | Credentials, SMTP/IMAP destinations, test outcomes | #74; align with Core #225 mail pattern |
|
||||||
|
| Campaign settings (`GlobalSettingsPage` settings view) | Configure campaign behavior | Adaptive configuration | Can alter validation/build/send behavior | #74 audit |
|
||||||
|
| Mail policy (`MailSettingsPage` policy view) | Inspect/override effective mail policy | Effective policy/provenance editor | Inheritance and locks affect allowed delivery | #74; Core #225 policy pattern |
|
||||||
|
| Campaign policy (`GlobalSettingsPage` policy view) | Inspect/override campaign policy | Effective policy/provenance editor | Inheritance, actor authority, and blocked edits | #74; Core #225 policy pattern |
|
||||||
|
| Review/send (`ReviewSendPage`) | Validate, build, mock-test, confirm/send, inspect results | Guided review/decision plus durable progress | External communication, bounded synchronous execution, persisted queue mode, partial effects, retries, evidence | Bounded synchronous and explicit/persisted queued modes delivered in [#62](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/62) and [#79](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/79); [#63](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/63) wording and #74 audit remain |
|
||||||
|
| Message and attachment detail overlays | Inspect one built/mock message and its attachment links | Stable detail/review dialog | Personal data, exact outbound content, reviewed state | Delivered and verified in [#59](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/59) and [#73](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/73) |
|
||||||
|
| Campaign report (`CampaignReportPage`) | Filter and inspect delivery outcomes | Reporting/list-detail | Partial, failed, explicitly excluded/skipped, SMTP/IMAP outcomes and retries | Server-owned filtering and counts delivered in [#65](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/65) with the full-result DataGrid contract from [Core #263](https://git.add-ideas.de/add-ideas/govoplan-core/issues/263); excluded semantics in [#66](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/66) |
|
||||||
|
| Audit (`CampaignAuditPage`) | Inspect campaign evidence/history | Provenance timeline/report | Actor/action/effect trace | #74 audit |
|
||||||
|
| JSON (`CampaignJsonView`) | Inspect expert representation | Advanced diagnostics/reference | Raw data may contain personal/configuration values; not a primary editor | #74 privacy/redaction audit |
|
||||||
|
| Create wizard (`CreateWizard`) | Seed a campaign through basics, sender, fields, recipients, template, attachments, review, send | Guided setup | Current steps mix creation and later consequential delivery; completion semantics need audit | Guided first campaign [#35](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/35) |
|
||||||
|
| Review/send wizard routes | Alternate guided review/send shells | Guided review | Tracked routes exist; implementation relationship to `ReviewSendPage` must be established, not guessed | #74 inventory decision |
|
||||||
|
| Operator queue (`OperatorQueuePage`) | Monitor jobs and intervene | Monitoring/work queue | Campaign/version/job identity, historical active-version discovery, fixed action positions, authority-aware disabled states, exact non-overlapping queue counts, server-paged jobs, bounded refresh, retry/queue/reconcile per version, campaign-wide pause/resume/cancel, and leave/return progress | Durable operator controls delivered in [#78](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/78); #74 wording/accessibility audit remains |
|
||||||
|
| Aggregate reports (`AggregateReportsPage`) | Compare cross-campaign delivery outcomes | Privacy-preserving aggregate reporting | Tenant/campaign ACL, deployment/tenant small-cell policy, complementary and overlapping-cell suppression, explicit denominator, and no recipient detail/diagnostics/export/drill-down | Separate aggregate-reader surface delivered in [#80](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/80); not parity with the permission-gated per-campaign detail report |
|
||||||
|
| Templates route (`TemplatesPage`) | Browse template records | Directory/list-detail | Template availability and later generated outputs | #74 audit; verify missing route guard intent |
|
||||||
|
|
||||||
|
The five review stages currently named in code are `Validate and inspect`,
|
||||||
|
`Build and review`, `Mock send and verify`, `Confirm and send`, and `Delivery
|
||||||
|
results`. Campaign #63 owns the intervention and status vocabulary; Workflow is
|
||||||
|
not required to define or implement it.
|
||||||
|
|
||||||
|
## Repositories Without A WebUI Route Contribution
|
||||||
|
|
||||||
|
The following local repositories contain a backend manifest but no
|
||||||
|
`webui/src/module.ts` at this snapshot:
|
||||||
|
|
||||||
|
`govoplan-approvals`, `govoplan-assets`, `govoplan-booking`,
|
||||||
|
`govoplan-certificates`, `govoplan-committee`, `govoplan-consultation`,
|
||||||
|
`govoplan-contracts`, `govoplan-dist-lists`, `govoplan-evaluation`,
|
||||||
|
`govoplan-facilities`, `govoplan-forms-runtime`, `govoplan-grants`,
|
||||||
|
`govoplan-helpdesk`, `govoplan-identity`, `govoplan-inspections`,
|
||||||
|
`govoplan-issue-reporting`, `govoplan-learning`, `govoplan-permits`,
|
||||||
|
`govoplan-poll`, `govoplan-procurement`, `govoplan-records`,
|
||||||
|
`govoplan-resources`, `govoplan-rest`, `govoplan-risk-compliance`,
|
||||||
|
`govoplan-soap`, `govoplan-tenancy`, and `govoplan-transparency`.
|
||||||
|
|
||||||
|
This is only negative route evidence. It does not classify the backend module's
|
||||||
|
maturity or decide that it needs a WebUI. Connector-only, capability-only, or
|
||||||
|
backend-only modules may remain intentionally headless.
|
||||||
|
|
||||||
|
## Rollout Matrix
|
||||||
|
|
||||||
|
| Order | Scope | Current evidence | Target | Owner / issue | Verification gate | Status |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| 0 | Product grammar and route inventory | Doctrine, ledger, layout rules, module contract, current route sources | One reconciled pattern language and evidence inventory | Meta [#11](https://git.add-ideas.de/add-ideas/govoplan/issues/11) | Docs links/diff checks; issue/wiki sync after integration | Initial slice in this document |
|
||||||
|
| 1 | Campaign baseline integration | Recipient-editor WIP and tracker state have been reconciled with remote `main` | Integrated, testable baseline before migration claims | Campaign #67 and tracker cleanup | Backend and focused WebUI suites; issue evidence | Complete 2026-07-22 |
|
||||||
|
| 2 | Campaign previews/details | Stable shared dialog with bounded scrolling and fixed responsive preview workspace | Stable header/body/footer, accessible long-content detail | Campaign #59 and #73 | Review-preview and overlay structure tests | Complete 2026-07-22 |
|
||||||
|
| 3 | Campaign review/interventions | Five domain-owned stages with unresolved intervention language | Clear stages, outcomes, blockers, next actor/action, reviewed evidence | Campaign #63 | State matrix behavior/accessibility tests and agreed vocabulary | P1 needs product wording decision |
|
||||||
|
| 4 | Campaign send/progress | A hard deployment ceiling bounds synchronous delivery; the selected synchronous, worker-queue, or database-queue mode is explicit and persisted; progress and recovery survive navigation; immediate-send response and audit evidence are allowlisted | Pre-send mode/consequence plus durable leave/return progress, retry and reconciliation without recipient/provider leakage | Campaign #62 and #79 | Boundary/concurrency/preflight, async selection, persisted mode, sanitized response/audit, partial/failure/retry and reload/return tests | Complete 2026-07-22 (`7e16603`, `60efd1c`, `62a6879`, `b0282eb`, `f095a3e`) |
|
||||||
|
| 5 | Campaign report filtering | Core DataGrid distinguishes client/full-result from server-owned queries; Campaign applies filter/sort/count before pagination and synchronizes count shortcuts with the grid query | One shared server-owned status/list/filter/count model | Campaign #65 and Core #263 | DataGrid contract/build tests plus exact shortcut/query/filter/count and large-result behavior | Complete 2026-07-22 (`e6062fe`, `cece71d`, `aa4ec66`, `4eb651c`) |
|
||||||
|
| 6 | Campaign operator recovery | A durable campaign/version queue page exposes historical work, exact non-overlapping state counts, persisted mode, permission-safe controls, server-paged job evidence, bounded refresh and active-state recovery | Fixed-position actions, disabled explanations, leave/return state, version-scoped retry/queue/reconcile and explicit campaign-wide pause/resume/cancel | Campaign #78 | Queue model/structure, historical-version, permission, paging, recovery-control, stale-response and delta tests | Complete 2026-07-22 (`21f3014`, `99d44ee`, `735e874`) |
|
||||||
|
| 7 | Campaign aggregate reports | A separate aggregate-reader projection and UI expose only policy-suppressed business totals with a stable status domain | Explicit denominator and exclusions, deployment floor plus tenant-strengthened small-cell threshold, complementary and overlapping-cell suppression, no detail/export/diagnostics | Campaign #80 | Aggregate query, cross-metric suppression, route/role/ACL, stable filter and UI structure tests | Complete 2026-07-22 (`06125cc`, `fc36aee`, `8ee87b7`, `ac3329c`, `1225802`) |
|
||||||
|
| 8 | Campaign excluded outcomes | Excluded build rows become explicit skipped transport outcomes and remain protected from queue/cancel/retry ambiguity | One durable source-to-job-to-report meaning with guarded historical normalization | Campaign #66 | Builder/persistence, migration, query/count, queue-control and report-explanation tests | Complete 2026-07-22 (`7229fb8`) |
|
||||||
|
| 9 | Guided first campaign | Existing wizard routes and ordinary workspace overlap | Task-oriented entry that hands off clearly to normal editing/review | Campaign #35 | First-run flow, resume/back, validation, optional modules, no implicit send | P1 after core pilot patterns stabilize |
|
||||||
|
| 10 | Prove/extract generic primitives | Core already exports many primitives; Campaign composition still unreviewed | Extract only contracts with a second consumer or clear platform ownership | Core #225 plus bounded follow-ups | Core behavior/accessibility tests and module-permutation tests | After Campaign proof |
|
||||||
|
| 11 | Configured-system pattern help | Docs route and classification exist | Role/config-aware pattern and route/field/blocker help | Docs #15 | Topic grouping, audience filtering, stable links/anchors | P1 after initial pattern IDs stabilize |
|
||||||
|
| 12 | Admin/configuration family | Phase inventory and connector primitives exist in the ledger | Apply the pattern to files, mail, policy, retention, packages, modules, API keys, settings | Core #225 and module children | Per-surface state/accessibility/consequence evidence | Parallel where independent of Campaign shared decisions |
|
||||||
|
| 13 | Remaining direct routes | Routes are contributed; most are unreviewed | Per-module bounded audit and migration plan | New module issues derived from this inventory | Applicable definition-of-done gates | P2 after Campaign, not a bulk rewrite |
|
||||||
|
| 14 | Manifest/runtime alignment | Several executable routes are absent from manifest metadata | Declared alignment or explicit validated exception | Core contract issue to create | Automated manifest/module route check and configured Docs verification | Discovery follow-up |
|
||||||
|
|
||||||
|
Workflow/user-story implementation is postponed. It is not on the critical path
|
||||||
|
for this rollout matrix. Focused views can be specified, manually selected, and
|
||||||
|
tested through core composition contracts; a later workflow step may become one
|
||||||
|
activation source without changing the proven surface patterns.
|
||||||
|
|
||||||
|
## Inventory Maintenance
|
||||||
|
|
||||||
|
When a route, nav item, named UI capability, host section, or Campaign workspace
|
||||||
|
surface changes:
|
||||||
|
|
||||||
|
1. Update the owner, evidence, task, archetype, and consequence here.
|
||||||
|
2. Link the implementation issue and verification evidence.
|
||||||
|
3. Keep "unreviewed" until state, permission/privacy, consequence/provenance,
|
||||||
|
accessibility, responsive, theme, i18n, and applicable async behavior have
|
||||||
|
been checked.
|
||||||
|
4. Re-scan both `module.ts` and the backend manifest; do not infer one from the
|
||||||
|
other.
|
||||||
|
5. Recreate the inventory from a clean release lockfile before using it as
|
||||||
|
release evidence.
|
||||||
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.
|
||||||
452
docs/REFERENCE_JOURNEY_PROGRAM.md
Normal file
452
docs/REFERENCE_JOURNEY_PROGRAM.md
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
# GovOPlaN Reference Journey Program
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
This is the selected product-development sequence as of 2026-07-21. It turns
|
||||||
|
the long-term connected-platform roadmap into five demonstrable journeys while
|
||||||
|
the Workflow program remains deliberately deferred.
|
||||||
|
|
||||||
|
The stages are ordered, but they are not monolithic releases. Each stage is
|
||||||
|
delivered as small, reviewable, green increments and is complete only when its
|
||||||
|
user journey, failure behavior, documentation, and operator evidence work in a
|
||||||
|
pinned composition.
|
||||||
|
|
||||||
|
## Why this sequence
|
||||||
|
|
||||||
|
The sequence grows one connected product rather than advancing repositories in
|
||||||
|
isolation:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Campaign reference
|
||||||
|
-> function-bound Postbox delivery
|
||||||
|
-> data-backed templates and reports
|
||||||
|
-> governed analytical data products
|
||||||
|
-> collaborative document lifecycle
|
||||||
|
```
|
||||||
|
|
||||||
|
Each stage reuses the preceding contracts:
|
||||||
|
|
||||||
|
- Campaign proves governed recipient selection, files, profiles, preview,
|
||||||
|
durable delivery, retry, reconciliation, audit, and role-specific UI.
|
||||||
|
- Postbox generalizes delivery from an email address to an institutional
|
||||||
|
responsibility that survives personnel changes.
|
||||||
|
- Templates and Reporting turn governed source data into reproducible outputs
|
||||||
|
and provide a safe launch boundary for specialist systems such as HIS.
|
||||||
|
- The BI path scales those source, transformation, quality, lineage, and report
|
||||||
|
contracts from one document to reusable analytical data products.
|
||||||
|
- DMS collaboration turns generated or uploaded artifacts into governed living
|
||||||
|
documents without confusing storage, editing, approval, or records
|
||||||
|
ownership.
|
||||||
|
|
||||||
|
## Continuous foundation lane
|
||||||
|
|
||||||
|
This lane is not a sixth product stage. It advances only as far as the next
|
||||||
|
journey needs and supplies contracts shared by all five stages.
|
||||||
|
|
||||||
|
1. **Governed integrations.** Connector profiles declare endpoint, authority,
|
||||||
|
source-of-truth mode, capabilities, health, limits, credential references,
|
||||||
|
and lifecycle. Network destinations are resolved and pinned at connection
|
||||||
|
time; private-network access is deployment-controlled and unpinned private
|
||||||
|
transports fail closed. Deleting a connector or provider immediately
|
||||||
|
deletes every secret it owns and records a non-secret audit event in the
|
||||||
|
same lifecycle action; an unavailable external secret provider blocks the
|
||||||
|
deletion or retirement rather than leaving an unaudited orphan.
|
||||||
|
2. **External effects.** Requested action, durable command, observed effect,
|
||||||
|
unknown outcome, retry, reconciliation, compensation/correction, and manual
|
||||||
|
intervention remain distinct and auditable.
|
||||||
|
3. **Institutional identity.** Identity owns subjects, Organizations owns units
|
||||||
|
and functions, IDM owns identity-to-function assignments and upstream
|
||||||
|
synchronization, and Access owns permission and acting-context decisions.
|
||||||
|
4. **Stable composition.** Modules cooperate through versioned Core-mediated
|
||||||
|
capabilities and typed references. Optional sibling modules are never
|
||||||
|
imported as implementation dependencies.
|
||||||
|
5. **Evidence and provenance.** Every imported datum, generated artifact,
|
||||||
|
delivery, transformation, and later correction can identify its source,
|
||||||
|
version, actor or system trigger, policy, and relevant execution evidence.
|
||||||
|
6. **Focused experience.** Central components and the interface pattern
|
||||||
|
language are mandatory. Views present only what the current task and actor
|
||||||
|
need while preserving an explainable escape to the configured full system.
|
||||||
|
7. **Adaptive documentation.** The owning module contributes canonical subject
|
||||||
|
matter; GovOPlaN Docs composes it for the installed modules, enabled
|
||||||
|
configuration, role, route, and task.
|
||||||
|
8. **Release proof.** Version alignment, clean install/upgrade, module
|
||||||
|
permutations, backup/restore, degraded-provider behavior, security checks,
|
||||||
|
and target-environment acceptance gate product claims.
|
||||||
|
9. **Artifact and infrastructure egress.** Tenant connectors use the pinned
|
||||||
|
connector boundary. Installer artifacts use a separate deployment-owned
|
||||||
|
trust path: approved origins, bounded pinned download or operator prefetch,
|
||||||
|
signature/checksum verification, safe extraction, and offline package-manager
|
||||||
|
execution. Database, broker, cache, and worker channels are constrained by
|
||||||
|
deployment network policy and authenticated transport rather than treated as
|
||||||
|
tenant connector profiles.
|
||||||
|
|
||||||
|
## Documentation contract for every reference stage
|
||||||
|
|
||||||
|
Documentation is one connected knowledge model rendered through different
|
||||||
|
perspectives, not separately maintained manuals that drift.
|
||||||
|
|
||||||
|
Every demonstrated journey provides:
|
||||||
|
|
||||||
|
- **Task guidance:** “I want to …” instructions using visible UI wording.
|
||||||
|
- **Process view:** actors, inputs, states, decisions, handoffs, exceptions,
|
||||||
|
completion, correction, and retention.
|
||||||
|
- **Concept view:** domain language and ownership boundaries.
|
||||||
|
- **Administrator view:** permissions, policies, profiles, configuration,
|
||||||
|
module requirements, migrations, and audit effects.
|
||||||
|
- **Operator view:** health, queues, retries, reconciliation, backup/restore,
|
||||||
|
incident handling, and target-provider checks.
|
||||||
|
- **Integration view:** capabilities, DTOs, APIs, events, idempotency,
|
||||||
|
compatibility, deep-link/launch contracts, and example payloads.
|
||||||
|
- **Security, privacy, and audit view:** threats, disclosure rules, secrets,
|
||||||
|
provenance, evidence, retention, and destructive actions.
|
||||||
|
- **Acceptance view:** runnable examples, expected results, failure injection,
|
||||||
|
and release gates.
|
||||||
|
|
||||||
|
The Docs module selects and links these views according to installed
|
||||||
|
capabilities and actor context. Feature repositories remain the source of
|
||||||
|
truth for their behavior; Docs owns indexing, conditions, safe disclosure, and
|
||||||
|
presentation. Generated API/contract reference may supplement, but never
|
||||||
|
replace, the maintained behavioral explanation.
|
||||||
|
|
||||||
|
## Stage 1: Campaign as the demonstration module
|
||||||
|
|
||||||
|
### Product promise
|
||||||
|
|
||||||
|
> As a communications team, we can compose a campaign from governed recipient
|
||||||
|
> data, managed files, reusable mail configuration, and policy; preview the
|
||||||
|
> exact effect; authorize delivery; and resolve every recipient to a known or
|
||||||
|
> explicitly uncertain outcome without exposing transport internals to normal
|
||||||
|
> readers.
|
||||||
|
|
||||||
|
Campaign is the first reference composition, not an all-purpose mail client or
|
||||||
|
contact database.
|
||||||
|
|
||||||
|
### Ownership and composition
|
||||||
|
|
||||||
|
- Campaign owns campaign intent, content, campaign-local recipient snapshots,
|
||||||
|
personalization, execution state, and delivery evidence.
|
||||||
|
- Mail owns reusable profiles, credentials, protocol adapters, profile policy,
|
||||||
|
send/append execution, and provider-facing diagnostics. Campaign persists
|
||||||
|
only a stable Mail profile reference, never SMTP/IMAP settings or secrets.
|
||||||
|
- Files owns stored objects, provider connectors, file policy, and provenance.
|
||||||
|
Campaign persists stable attachment references and frozen execution evidence.
|
||||||
|
- Addresses or another directory may supply recipients, but Campaign freezes
|
||||||
|
the execution-time recipient and source evidence needed to explain the send.
|
||||||
|
- Policy, Access, Audit, Docs, Notifications, Organizations, Identity, IDM, and
|
||||||
|
Postbox contribute only through declared capabilities when installed.
|
||||||
|
|
||||||
|
### Implementation slices
|
||||||
|
|
||||||
|
1. Enforce the Mail-profile-only boundary and reject inline transport material
|
||||||
|
on authoring, import, update, build, and delivery paths. Legacy records are
|
||||||
|
preserved but fail closed until explicitly migrated.
|
||||||
|
2. Finish the central-component UI pass and consistent task-focused author,
|
||||||
|
reviewer, operator, and reader surfaces. Normal readers receive business
|
||||||
|
state, not paths, backend keys, worker claims, or raw provider diagnostics.
|
||||||
|
3. Prove test, single-send, resend, batch, append-to-Sent, retry,
|
||||||
|
reconciliation, quarantine, and manual intervention semantics against a
|
||||||
|
target SMTP/IMAP environment.
|
||||||
|
4. Complete adaptive Campaign, Mail, and Files documentation under the shared
|
||||||
|
documentation contract, including runnable reference campaigns and failure
|
||||||
|
drills.
|
||||||
|
5. Package the composition with sample data, policies, roles, configured views,
|
||||||
|
preflight, health checks, and pinned compatible versions.
|
||||||
|
|
||||||
|
### Gate
|
||||||
|
|
||||||
|
A clean installation can run the maintained examples through target providers;
|
||||||
|
no Campaign payload contains transport credentials; worker/provider failures do
|
||||||
|
not cause blind duplicate delivery; each supported role sees an appropriate
|
||||||
|
surface and explanation; install, upgrade, restore, and release evidence are
|
||||||
|
repeatable.
|
||||||
|
|
||||||
|
## Stage 2: Postbox and delivery to institutional responsibility
|
||||||
|
|
||||||
|
### Product promise
|
||||||
|
|
||||||
|
> As a sender, I can deliver a message to a named function in an organizational
|
||||||
|
> unit without knowing which individual currently performs it. As the current
|
||||||
|
> function holder or authorized delegate, I can discover, read, and act on that
|
||||||
|
> message; when assignments change, access follows current responsibility while
|
||||||
|
> the delivery and access history remains explainable.
|
||||||
|
|
||||||
|
### Ownership and composition
|
||||||
|
|
||||||
|
- Postbox owns addressable in-platform postboxes, messages, bindings,
|
||||||
|
participants, attachment references, access-sensitive events, and retention
|
||||||
|
inputs.
|
||||||
|
- Organizations defines units and functions. Identity defines subjects. IDM
|
||||||
|
assigns identities to functions and reconciles upstream directories. Access
|
||||||
|
resolves roles, delegations, acting context, and permissions.
|
||||||
|
- Mail is an optional external channel bridge. A Postbox is not an SMTP/IMAP
|
||||||
|
account and is never owned by one login credential.
|
||||||
|
- Campaign consumes a generic delivery-target capability. It may target an
|
||||||
|
email recipient or a function-bound Postbox without importing either
|
||||||
|
module's internals.
|
||||||
|
- A Postbox exists independently of its current holders. Vacancy, one
|
||||||
|
incumbent, several incumbents, and time-bounded delegation are valid states;
|
||||||
|
none turns the Postbox into a personal account.
|
||||||
|
- The E2EE target uses per-content keys and function/postbox key epochs.
|
||||||
|
Assignment grants device-bound access to policy-selected history, while
|
||||||
|
hand-over, revocation, and delegation expiry rotate or withdraw future key
|
||||||
|
access without claiming to erase plaintext already obtained.
|
||||||
|
- Content and signed manifests are immutable. Correction or replacement is a
|
||||||
|
linked new object/version, never silent substitution.
|
||||||
|
|
||||||
|
### Implementation slices
|
||||||
|
|
||||||
|
1. Stabilize Postbox manifest, permissions, DTOs, migrations, directory,
|
||||||
|
binding administration, access decisions, message create/list/read, and
|
||||||
|
audit events.
|
||||||
|
2. Define a typed delivery target and receipt contract for a Postbox id or an
|
||||||
|
organization-unit/function address. Resolution never copies current holders
|
||||||
|
into permanent mailbox ownership.
|
||||||
|
3. Complete the Organizations–Identity–IDM–Access function-assignment and
|
||||||
|
delegation path, including provenance and compatibility migration from
|
||||||
|
legacy projections.
|
||||||
|
4. Define the E2EE key-grant/epoch profile for vacancy, multiple incumbents,
|
||||||
|
hand-over, history access, time-bounded delegation, recovery, and audit.
|
||||||
|
5. Add Campaign delivery to a function-bound Postbox, file attachment
|
||||||
|
references, optional external-mail bridge, and notification attention
|
||||||
|
signals through capabilities.
|
||||||
|
6. Document reassignment, delegation, vacancy, expired assignment, unavailable
|
||||||
|
optional module, retention, and future E2EE limitations honestly.
|
||||||
|
|
||||||
|
### Gate
|
||||||
|
|
||||||
|
A campaign message can be delivered once to a function-bound Postbox; an
|
||||||
|
authorized holder can act in an explicit context; a reassignment changes future
|
||||||
|
access without moving or rewriting the message. A vacant Postbox remains
|
||||||
|
addressable, retains the delivery, and visibly reports that no holder can
|
||||||
|
decrypt or act until assignment; multiple incumbents and delegates receive
|
||||||
|
independent key/access evidence. Delivery, key access, and later corrections
|
||||||
|
remain auditable.
|
||||||
|
|
||||||
|
## Stage 3: Templates, reports, data sources, and deep launches
|
||||||
|
|
||||||
|
### Product promise
|
||||||
|
|
||||||
|
> As a staff member arriving from a specialist system such as HIS, I can open a
|
||||||
|
> GovOPlaN report or document task with a governed source context already
|
||||||
|
> selected, review the effective data and template version, generate the
|
||||||
|
> document, and return or link the immutable result without re-entering data.
|
||||||
|
|
||||||
|
### Ownership and composition
|
||||||
|
|
||||||
|
- Templates owns reusable definitions, versions, merge schemas, localization,
|
||||||
|
render profiles, preview, and rendering.
|
||||||
|
- Reporting owns report definitions, parameters, curated data selection,
|
||||||
|
execution history, dashboards, sharing, scheduling, publication, and export.
|
||||||
|
- Connectors own protocol-specific access. A governed data-source catalog owns
|
||||||
|
connection profiles, source contracts, freshness, and health if repeated
|
||||||
|
use proves that a separate module is warranted.
|
||||||
|
- Files stores generated bytes. DMS owns a generated artifact once it becomes a
|
||||||
|
governed living document. Records owns later retention/archive semantics.
|
||||||
|
|
||||||
|
### Safe launch and generation contract
|
||||||
|
|
||||||
|
1. The source program opens a stable GovOPlaN route with an opaque, short-lived,
|
||||||
|
single-purpose launch reference—not credentials, arbitrary SQL, or trusted
|
||||||
|
personal data in the URL.
|
||||||
|
2. GovOPlaN authenticates the actor, authorizes the requested report/template
|
||||||
|
and source object, resolves the launch context server-side, and shows source,
|
||||||
|
freshness, purpose, and any blockers.
|
||||||
|
3. A curated connector/read-model capability returns a versioned input snapshot
|
||||||
|
that satisfies the template or report schema.
|
||||||
|
4. Generation records template/report definition version, parameters, input
|
||||||
|
snapshot or reproducible source references, transformation version, output
|
||||||
|
checksum, actor, and policy.
|
||||||
|
5. The result is stored or handed back through an explicit callback/reference
|
||||||
|
contract. Retries are idempotent; expired or replayed launch references fail
|
||||||
|
safely.
|
||||||
|
|
||||||
|
### Implementation slices
|
||||||
|
|
||||||
|
1. Implement the first Templates manifest, version/schema DTOs, safe preview,
|
||||||
|
deterministic render, package fragments, and Files-backed output.
|
||||||
|
2. Implement the first Reporting manifest, parameter/source DTOs, one
|
||||||
|
module-owned read model, execution evidence, and downloadable export.
|
||||||
|
3. Define the shared data-source/profile and launch-context contracts with one
|
||||||
|
read-only target connector and a mock HIS-style launch producer.
|
||||||
|
4. Deliver one end-to-end reference document and one analytical report from the
|
||||||
|
same governed source contract.
|
||||||
|
5. Add role-specific UI/docs, source freshness and validation states, deep-link
|
||||||
|
integration examples, and target-system acceptance tests.
|
||||||
|
|
||||||
|
### Gate
|
||||||
|
|
||||||
|
A user can follow a signed or server-side launch reference from a target system
|
||||||
|
to a prefilled GovOPlaN task, verify the source and freshness, render an output,
|
||||||
|
and reproduce why that exact artifact was produced. The browser cannot inject
|
||||||
|
arbitrary queries or transport secrets, and authorization is re-evaluated in
|
||||||
|
GovOPlaN.
|
||||||
|
|
||||||
|
## Stage 4: Governed BI and reporting
|
||||||
|
|
||||||
|
### Product promise
|
||||||
|
|
||||||
|
> As an institutional analyst, I can combine approved operational sources into
|
||||||
|
> versioned, quality-checked analytical data products, define transparent
|
||||||
|
> measures and reports, drill from aggregates where policy permits, and explain
|
||||||
|
> the source, transformation, effective organizational hierarchy, and reporting
|
||||||
|
> date behind every result.
|
||||||
|
|
||||||
|
### Lessons taken from SuperX
|
||||||
|
|
||||||
|
SuperX is useful inspiration because it combines many university sources in a
|
||||||
|
modular data warehouse, loads and transforms them on a schedule, keeps
|
||||||
|
historical or reporting-date views, supplies prepared subject modules, and
|
||||||
|
serves different aggregation levels through a common reporting surface. Its
|
||||||
|
official overview also emphasizes a lower operational-data layer, validation
|
||||||
|
logs, configurable load routines, organizational hierarchies, and transparent
|
||||||
|
report calculations. See the
|
||||||
|
[SuperX project overview](https://www.superx-projekt.de/__index.htm),
|
||||||
|
[administration and module architecture](https://www.superx-projekt.de/doku/kern_modul/admin/__index.htm),
|
||||||
|
and [cross-institution indicator module](https://www.superx-projekt.de/doku/kenn_modul/benutzer/__index.htm).
|
||||||
|
|
||||||
|
GovOPlaN should adopt the principles, not clone the implementation. Its distinct
|
||||||
|
value is connecting analytical results to governed work, responsibility,
|
||||||
|
evidence, policy, and correction across independently installable modules.
|
||||||
|
|
||||||
|
### Architecture layers
|
||||||
|
|
||||||
|
1. **Source catalog:** governed profiles, owners, purpose, schema/version,
|
||||||
|
classification, credentials, health, extraction mode, and source-of-truth
|
||||||
|
declaration.
|
||||||
|
2. **Ingestion and staging:** immutable run evidence, source snapshots or
|
||||||
|
watermarks, schema-drift detection, quarantine, replay, and personal-data
|
||||||
|
minimization.
|
||||||
|
3. **Transformation and quality:** versioned transformations, dependencies,
|
||||||
|
tests, validation findings, correction policy, and lineage. Silent repair of
|
||||||
|
source facts is not acceptable.
|
||||||
|
4. **Semantic products:** documented dimensions, measures, official key
|
||||||
|
mappings, organizational hierarchies, time/stichtag semantics, ownership,
|
||||||
|
access policy, and version.
|
||||||
|
5. **Consumption:** Reporting definitions, dashboards, drill-down, scheduled
|
||||||
|
outputs, APIs, open-data handoffs, and template generation.
|
||||||
|
6. **Operations:** scheduling, backfill, freshness objectives, observability,
|
||||||
|
cost/resource limits, retention, backup/restore, promotion between systems,
|
||||||
|
and reproducible release recipes.
|
||||||
|
|
||||||
|
### Selected first subject-area direction
|
||||||
|
|
||||||
|
- Start with read-only HIS and CampusOnline source profiles and produce the
|
||||||
|
institution's internal student-statistics data product plus reports derived
|
||||||
|
from it.
|
||||||
|
- Model reference/effective date separately from extraction, validation, and
|
||||||
|
freeze dates. For example, a semester state may describe `1 September`, be
|
||||||
|
accepted and frozen on `12 December`, and remain the reproducible source for
|
||||||
|
that semester's official statistics. Later corrections create a superseding
|
||||||
|
version; they never rewrite the frozen state silently.
|
||||||
|
- Make a graphical, typed data-flow editor the primary analyst surface. Nodes
|
||||||
|
represent governed sources/snapshots, validation, mapping, transformation,
|
||||||
|
aggregation, a list report/data product, template rendering, and publication.
|
||||||
|
The graph is versioned, testable, previewable, acyclic, and shows schema,
|
||||||
|
lineage, quality findings, policy, and pinned upstream versions.
|
||||||
|
- A list report may become a governed source for another flow. This enables
|
||||||
|
`source + transformations/aggregation -> list report/data product -> template
|
||||||
|
-> publishable report` without copying the calculation or losing lineage.
|
||||||
|
|
||||||
|
### Implementation slices
|
||||||
|
|
||||||
|
1. Bound the first HIS/CampusOnline student-statistics dataset and internal
|
||||||
|
report; define its legal/policy basis, source owners, official keys,
|
||||||
|
reference/freeze semantics, privacy level, and acceptance calculation.
|
||||||
|
2. Generalize the Stage 3 source profile into catalog, ingestion-run, staged
|
||||||
|
dataset, validation, transformation, lineage, and data-product contracts.
|
||||||
|
3. Implement one scheduled load and explicit frozen-state lifecycle with
|
||||||
|
quarantine/replay and a fully transparent transformation into the internal
|
||||||
|
student-statistics product.
|
||||||
|
4. Implement the first graphical typed flow editor over those proven nodes,
|
||||||
|
including version/diff, validation, preview, cycle prevention, and pinned
|
||||||
|
upstream product versions.
|
||||||
|
5. Serve the product through Reporting with policy-aware aggregate and
|
||||||
|
drill-down behavior; export reproducible evidence and calculation metadata.
|
||||||
|
6. Chain one list report as a source into a template-backed publishable report.
|
||||||
|
7. Package mappings, transformations, validations, reports, documentation, and
|
||||||
|
tests so they can be promoted from development to test to production.
|
||||||
|
|
||||||
|
### Gate
|
||||||
|
|
||||||
|
One internal student-statistics product can be rebuilt from declared HIS and/or
|
||||||
|
CampusOnline snapshots, preserves its reference and freeze dates, passes
|
||||||
|
explicit quality tests, explains every measure and mapping, enforces disclosure
|
||||||
|
policy, survives schema/freshness failures visibly, and produces the same
|
||||||
|
accepted list and template-backed report across promoted environments.
|
||||||
|
|
||||||
|
## Stage 5: Collaborative document editing
|
||||||
|
|
||||||
|
### Product promise
|
||||||
|
|
||||||
|
> As an authorized group, we can turn an uploaded or generated artifact into a
|
||||||
|
> shared document, edit it concurrently or through controlled check-out,
|
||||||
|
> comment, review, approve, compare, and recover versions, then freeze the
|
||||||
|
> accepted rendition as evidence or a record without losing who changed what.
|
||||||
|
|
||||||
|
### Ownership and composition
|
||||||
|
|
||||||
|
- Files owns bytes, checksums, storage providers, upload/download, and low-level
|
||||||
|
file permissions.
|
||||||
|
- DMS owns document identity, versions, renditions, editing sessions, locks,
|
||||||
|
comments, reviews, approvals, signatures, external DMS references, and
|
||||||
|
document-level evidence.
|
||||||
|
- A collaboration connector owns provider-specific Collabora, OnlyOffice,
|
||||||
|
Nextcloud/OpenDesk, or another office-suite protocol. DMS owns the session
|
||||||
|
lifecycle and authorization even when editing occurs externally.
|
||||||
|
- Templates may create the first version. Cases, Campaign, Postbox, Reporting,
|
||||||
|
and Workflow may reference documents. Records owns classification, legal
|
||||||
|
hold, archive handoff, and disposal after the document enters that lifecycle.
|
||||||
|
|
||||||
|
### Implementation slices
|
||||||
|
|
||||||
|
1. Implement DMS document/version/reference DTOs and one Files-backed version
|
||||||
|
path, including optimistic concurrency, checksums, locks, and audit.
|
||||||
|
2. Add comments, review requests, approval state, immutable accepted
|
||||||
|
renditions, comparison, and recovery of prior versions.
|
||||||
|
3. Define a provider-neutral editing-session capability with short-lived
|
||||||
|
grants, callback authentication, save idempotency, health, and reconciliation.
|
||||||
|
4. Integrate one external collaborative editor, including loss of provider,
|
||||||
|
concurrent save, stale callback, permission revocation, and restore tests.
|
||||||
|
5. Connect generated reports/templates, Postbox/Campaign sharing, focused
|
||||||
|
document views, configured docs, and Records handoff.
|
||||||
|
|
||||||
|
### Gate
|
||||||
|
|
||||||
|
Multiple authorized actors can edit a document without silent lost updates;
|
||||||
|
every accepted version has stable content and provenance; provider callbacks
|
||||||
|
cannot bypass current authorization; degraded or ambiguous saves are visible
|
||||||
|
and reconcilable; the approved rendition can enter records without making Files
|
||||||
|
or the external editor the document-lifecycle owner.
|
||||||
|
|
||||||
|
## Program-wide acceptance and pause rules
|
||||||
|
|
||||||
|
- Do not start a broad generic platform abstraction until two concrete stages
|
||||||
|
demonstrate the repeated contract.
|
||||||
|
- Do not make Workflow a dependency of these stages. Manual transitions,
|
||||||
|
explicit actions, and focused views should become stable providers that a
|
||||||
|
later transition engine can coordinate.
|
||||||
|
- Do not treat a remote system as trusted merely because it supplied a deep
|
||||||
|
link, callback, webhook, file, identity, or data row.
|
||||||
|
- Do not claim a stage complete from local unit tests. Use pinned composition,
|
||||||
|
target integration, failure drills, adaptive docs, and operator evidence.
|
||||||
|
- A later stage may prototype contracts while the preceding gate is being
|
||||||
|
proven, but it may not redefine an owning module's boundary by convenience.
|
||||||
|
|
||||||
|
## Decisions intentionally deferred
|
||||||
|
|
||||||
|
The sequence itself is selected. These bounded choices remain for the stage
|
||||||
|
that first needs them:
|
||||||
|
|
||||||
|
- first target SMTP/IMAP, file/directory, identity, and deployment profile;
|
||||||
|
- first external IDM source and identity conflict/disable policy;
|
||||||
|
- first trusted Postbox assurance/recovery/history-access profile and
|
||||||
|
external-mail bridge;
|
||||||
|
- first HIS or other launch producer and callback/reference contract;
|
||||||
|
- exact HIS/CampusOnline source endpoints, student-statistics keys and accepted
|
||||||
|
calculation, freeze/correction policy, privacy profile, and permitted
|
||||||
|
drill-down level;
|
||||||
|
- whether repeated data-source and dataflow contracts justify separate
|
||||||
|
`govoplan-datasources` and `govoplan-dataflow` modules after the Stage 3 proof;
|
||||||
|
- first collaborative editor/provider and whether the first UX is concurrent
|
||||||
|
editing, controlled check-out, or both; and
|
||||||
|
- first Records/archive target and approval/signature assurance level.
|
||||||
267
docs/RELEASE_CONSOLE.md
Normal file
267
docs/RELEASE_CONSOLE.md
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
# 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
|
||||||
|
- create annotated source release tags for selected clean, version-aligned
|
||||||
|
repositories and publish each branch/tag pair atomically
|
||||||
|
- 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 source release panel closes the former gap between a release plan and a
|
||||||
|
catalog candidate. `Preview Tag + Publish` is non-mutating. `Create Tags`
|
||||||
|
requires `TAG`; `Publish Tags` requires `PUBLISH` and atomically pushes the
|
||||||
|
selected branch and annotated tag. The gate requires an aligned target version,
|
||||||
|
a clean named branch with a HEAD, and a checkout that is not behind. Existing
|
||||||
|
local or remote tags must resolve to the selected HEAD and are never moved.
|
||||||
|
The local and remote annotated tag objects must also be identical, not merely
|
||||||
|
point at the same commit. Before any source tag is created, the console loads
|
||||||
|
the cross-repository module registry. This release gate also rejects every
|
||||||
|
user-facing workflow documentation topic that has no scope condition, or has
|
||||||
|
an alternative condition without `required_scopes` or `any_scopes`.
|
||||||
|
|
||||||
|
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 + Website Tag` requires `APPLY` in the confirmation field.
|
||||||
|
- `Push Website Release` requires `PUSH` in the confirmation field.
|
||||||
|
|
||||||
|
Source release tags belong to Core or module repositories. Website catalog
|
||||||
|
publication creates a separate catalog tag in the website repository; the UI
|
||||||
|
names these independently to avoid confusing the two immutable references.
|
||||||
|
|
||||||
|
Candidate signing is fail-closed: every Core and module source ref in the
|
||||||
|
complete post-update catalog must already have the requested annotated tag both
|
||||||
|
locally and on the configured source remote. Both tags must be the same
|
||||||
|
annotation object with version-aligned tagged package metadata. Selected tags
|
||||||
|
must additionally resolve to the selected clean, version-aligned HEAD, and the
|
||||||
|
signed `selected_units` record captures the peeled commit and tag-object
|
||||||
|
identifiers. Create and publish the source tags before using `Generate`.
|
||||||
|
|
||||||
|
Catalog publication repeats that check for selected units and also verifies
|
||||||
|
every Core and module source ref in the complete candidate, including entries
|
||||||
|
preserved from the previous catalog. Historical refs need not be at the current
|
||||||
|
worktree HEAD, but their local and remote annotated tags must match and their
|
||||||
|
tagged installable package and module-manifest metadata must declare the catalog
|
||||||
|
version. New selected releases additionally pass the stricter current-source
|
||||||
|
alignment gate, including public runtime version declarations. A candidate
|
||||||
|
cannot be applied or published while any referenced source tag is absent or
|
||||||
|
inconsistent; the preview and API response list each repository and missing or
|
||||||
|
invalid ref so the operator can repair the exact source releases first.
|
||||||
|
|
||||||
|
Read-only provenance checks derive a same-host HTTPS Git URL from conventional
|
||||||
|
SSH remotes when possible, with a non-interactive check of the configured remote
|
||||||
|
as fallback. Source tag creation and atomic publication always use the explicit
|
||||||
|
configured Git remote.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
When a selected module exposes a WebUI package, its requested version must also
|
||||||
|
match Core's `webui/package.release.json` input and the resolved
|
||||||
|
`package-lock.release.json` entry. The source-tag preflight, selective plan, and
|
||||||
|
catalog-candidate writer all enforce this composition boundary. Pins for modules
|
||||||
|
that are not part of the selective release remain unchanged.
|
||||||
|
|
||||||
|
Release integration also enforces repository and composition version alignment
|
||||||
|
and generates a CycloneDX SBOM from the resolved Python environment and the
|
||||||
|
release WebUI lockfile. Catalog publication should attach that immutable SBOM
|
||||||
|
and its digest to the corresponding Core/composition release.
|
||||||
|
|
||||||
|
Addresses and Notifications have current WebUI source contributions but are
|
||||||
|
not part of the pinned v0.1.8 WebUI release composition because their v0.1.8
|
||||||
|
tags predate those packages. They re-enter the release composition only through
|
||||||
|
new, immutable module tags whose backend, manifest, frontend, and lock metadata
|
||||||
|
pass the alignment gate.
|
||||||
|
|
||||||
|
Candidate publication verifies signatures against the already published
|
||||||
|
keyring, not against keys supplied only by the candidate. A changed keyring
|
||||||
|
must have its canonical SHA-256 embedded in the signed catalog. The public
|
||||||
|
module-directory files are regenerated from that verified catalog and keyring
|
||||||
|
at publication time; candidate-supplied directory files are never copied as
|
||||||
|
authoritative provenance.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
Selective catalog generation can add a module that is not yet represented in
|
||||||
|
the source channel. Initial entries are synthesized from the selected
|
||||||
|
repository's `[project]` metadata, `govoplan.modules` entry point, and runtime
|
||||||
|
`ModuleManifest`; there is no separate hand-maintained initial-entry list. The
|
||||||
|
release gate requires the entry point to resolve inside the selected checkout,
|
||||||
|
exact package/manifest/frontend version agreement, verified source-tag
|
||||||
|
provenance, a non-conflicting module id, and complete required dependency and
|
||||||
|
interface closure in the resulting candidate. Classification beyond the
|
||||||
|
manifest-derived `official` tag remains an explicit later catalog curation
|
||||||
|
step rather than an inferred business/service label.
|
||||||
|
|
||||||
|
## 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) |
|
||||||
@@ -33,12 +33,37 @@ cd /mnt/DATA/git/govoplan
|
|||||||
tools/checks/security-audit/run.sh --mode full --scope govoplan
|
tools/checks/security-audit/run.sh --mode full --scope govoplan
|
||||||
```
|
```
|
||||||
|
|
||||||
Reports are written to `audit-reports/`, which is intentionally ignored by git.
|
When invoked from a Flatpak development environment without a sandbox-local
|
||||||
|
Docker CLI, the wrapper automatically uses `flatpak-spawn --host docker`. The
|
||||||
|
host account must still be allowed to open the Docker daemon socket. For a
|
||||||
|
conventional rootful installation this commonly means membership in the
|
||||||
|
`docker` group followed by a complete logout/login; that membership is
|
||||||
|
root-equivalent, so rootless Docker is preferable where the deployment policy
|
||||||
|
requires a smaller privilege boundary.
|
||||||
|
|
||||||
The wrapper tags the toolbox image by a fingerprint of the Dockerfile and
|
Reports are written to `audit-reports/`, which is intentionally ignored by git.
|
||||||
`requirements-audit.txt`. If those inputs have not changed, subsequent runs reuse
|
Each run records tool versions, report checksums, and start/end repository
|
||||||
the existing local image instead of reinstalling all tools. The stable alias is
|
revision plus worktree fingerprints. A repository change during scanning makes
|
||||||
`govoplan/security-audit:local` unless `SECURITY_AUDIT_IMAGE` is set.
|
the run fail so a mixed code snapshot cannot be reported as a valid audit.
|
||||||
|
Step exit codes are recorded separately from findings: report-only mode may
|
||||||
|
accept findings, but scanner execution errors and malformed JSON/SARIF reports
|
||||||
|
always fail the run. The manifest lists the expected, present, and missing
|
||||||
|
reports for that invocation. Validation and checksums use that explicit set, so
|
||||||
|
reusing a report directory cannot make stale output look like part of a new run.
|
||||||
|
|
||||||
|
The wrapper tags the toolbox image by a fingerprint of the Dockerfile,
|
||||||
|
`requirements-audit.txt`, and the Semgrep smoke-test inputs. If those inputs have
|
||||||
|
not changed, subsequent runs reuse the existing local image instead of
|
||||||
|
reinstalling all tools. The stable alias is `govoplan/security-audit:local`
|
||||||
|
unless `SECURITY_AUDIT_IMAGE` is set.
|
||||||
|
|
||||||
|
Semgrep is installed separately in the toolbox image because current Semgrep
|
||||||
|
packages pin affected Click and MCP versions. The image upgrades both after
|
||||||
|
Semgrep installation, runs an actual local-rule scan, and audits the final
|
||||||
|
toolbox Python environment during the image build. The compatibility releases
|
||||||
|
are pinned exactly to keep the tested override reproducible. Remove either
|
||||||
|
compatibility override only after Semgrep's own dependency bounds include a
|
||||||
|
fixed version.
|
||||||
|
|
||||||
Force a cached rebuild:
|
Force a cached rebuild:
|
||||||
|
|
||||||
@@ -65,6 +90,17 @@ 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.
|
||||||
|
|
||||||
|
Semgrep and Trivy are invoked with finding-sensitive exit codes. Their exit 1
|
||||||
|
is therefore a finding under the wrapper contract; higher exit codes, missing
|
||||||
|
output, invalid JSON/SARIF, and scanner error payloads are execution failures.
|
||||||
|
|
||||||
|
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 +151,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 +176,11 @@ 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'
|
||||||
|
./.venv/bin/python -m pip install --upgrade --no-deps 'mcp==1.28.1'
|
||||||
|
./.venv/bin/semgrep scan --metrics=off --config tools/checks/security-audit/semgrep-govoplan.yml tools/checks/check-version-alignment.py
|
||||||
|
./.venv/bin/pip-audit --progress-spinner off
|
||||||
```
|
```
|
||||||
|
|
||||||
Then install the non-Python tools (`gitleaks`, `trivy`, `osv-scanner`, `jscpd`)
|
Then install the non-Python tools (`gitleaks`, `trivy`, `osv-scanner`, `jscpd`)
|
||||||
|
|||||||
179
docs/SYSTEM_ADMINISTRATOR_LIFECYCLE_USER_STORY.md
Normal file
179
docs/SYSTEM_ADMINISTRATOR_LIFECYCLE_USER_STORY.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# System Administrator Lifecycle User Story
|
||||||
|
|
||||||
|
## Story
|
||||||
|
|
||||||
|
> As a system administrator, I can execute one shell command that downloads a
|
||||||
|
> verified GovOPlaN distribution and starts a completely configured Core control
|
||||||
|
> plane without optional modules. In the WebUI I can browse compatible signed
|
||||||
|
> module releases, select the modules for this installation, and follow every
|
||||||
|
> download, validation, migration, installation, activation, and health-check
|
||||||
|
> step. When an update is available, I can review its impact and confirm it.
|
||||||
|
>
|
||||||
|
> I can add API or worker replicas on containers or other servers without
|
||||||
|
> copying mutable local state. I can operate development, test, and production
|
||||||
|
> systems, transfer a versioned configuration through the WebUI, undo an applied
|
||||||
|
> configuration version, test an update in one environment, and then apply the
|
||||||
|
> same immutable update recipe to another environment.
|
||||||
|
|
||||||
|
This is a product-level story owned by the GovOPlaN platform rather than by an
|
||||||
|
individual domain module. It joins installation, module lifecycle, operations,
|
||||||
|
configuration packages, and release provenance into one administrator journey.
|
||||||
|
The canonical backlog item is
|
||||||
|
[GovOPlaN #13](https://git.add-ideas.de/add-ideas/govoplan/issues/13).
|
||||||
|
|
||||||
|
## Terms
|
||||||
|
|
||||||
|
- **Core control plane:** the smallest bootable distribution: Core API, Core
|
||||||
|
WebUI, PostgreSQL, Redis, installer worker, migration runner, and durable
|
||||||
|
storage configuration. No optional GovOPlaN module package is installed.
|
||||||
|
- **Bootstrap administrator:** a single-use, time-limited installation identity
|
||||||
|
that may access only first-run and module-lifecycle functions. It is retired
|
||||||
|
when the selected identity/access configuration becomes healthy.
|
||||||
|
- **Module release:** an immutable, signed package plus manifest, compatibility
|
||||||
|
contract, migrations, WebUI contribution, checksums, and SBOM references.
|
||||||
|
- **Configuration revision:** an immutable, schema-versioned export of
|
||||||
|
non-secret system/module settings, policies, compositions, and secret
|
||||||
|
references. Secret values are never exported.
|
||||||
|
- **Update recipe:** the reviewed, immutable plan containing exact Core/module
|
||||||
|
versions, configuration revision, migration order, preflight results,
|
||||||
|
maintenance/drain requirements, health checks, and permitted rollback or
|
||||||
|
forward-recovery actions.
|
||||||
|
|
||||||
|
## Acceptance journeys
|
||||||
|
|
||||||
|
### One-command first installation
|
||||||
|
|
||||||
|
1. The administrator runs one documented command on a supported host.
|
||||||
|
2. The bootstrapper verifies a signed distribution manifest before executing or
|
||||||
|
starting downloaded artifacts.
|
||||||
|
3. It checks container/runtime, ports, storage, entropy, memory, architecture,
|
||||||
|
and connectivity requirements; generates deployment-local secrets with
|
||||||
|
restrictive permissions; and never prints them.
|
||||||
|
4. It starts PostgreSQL, Redis, Core API/WebUI, one installer worker, and the
|
||||||
|
migration runner. Readiness does not pass until migrations and durable
|
||||||
|
dependencies are healthy.
|
||||||
|
5. It prints the local URL and one-time bootstrap credential. Re-running the
|
||||||
|
command is idempotent and shows or repairs the existing installation rather
|
||||||
|
than creating another identity or database.
|
||||||
|
6. No optional module is installed or enabled at this point.
|
||||||
|
|
||||||
|
### Module selection, installation, and update
|
||||||
|
|
||||||
|
1. Core reads an approved signed catalog and trusted keyring through the shared
|
||||||
|
outbound-network policy.
|
||||||
|
2. The WebUI shows available, installed, compatible, blocked, withdrawn, and
|
||||||
|
update-available releases with their channel, provenance, contracts,
|
||||||
|
migrations, permissions, configuration requirements, and release notes.
|
||||||
|
3. Selecting modules produces a dependency/compatibility plan before any
|
||||||
|
mutation. The administrator can amend the selection or confirm the plan.
|
||||||
|
4. Installation executes durably in a worker. The UI receives persisted step
|
||||||
|
state and can reconnect without losing progress.
|
||||||
|
5. Package signature/checksum, version alignment, contract closure, migration
|
||||||
|
graph, configuration schema, and health checks are mandatory gates.
|
||||||
|
6. Remote artifacts are acquired by a deployment-owned downloader that permits
|
||||||
|
approved origins, pins the validated connection peer, enforces size and time
|
||||||
|
limits, and verifies signature/checksum before making a local immutable
|
||||||
|
artifact available. `pip`, `npm`, archive tools, and any compatibility Git
|
||||||
|
importer then run without unrestricted network access. A restricted-network
|
||||||
|
profile can satisfy the same contract through operator-prefetched artifacts.
|
||||||
|
7. A failed or interrupted operation reaches a visible retry, forward-recovery,
|
||||||
|
rollback, or manual-intervention state. It never reports success merely
|
||||||
|
because the initiating request returned.
|
||||||
|
8. An available update follows the same preview and confirmation path. Exact
|
||||||
|
repeated requests are idempotent.
|
||||||
|
|
||||||
|
### Horizontal scaling
|
||||||
|
|
||||||
|
1. API, WebUI, installer, scheduler, and ordinary worker roles are stateless
|
||||||
|
with respect to local container disks. Durable state uses PostgreSQL, Redis,
|
||||||
|
and configured shared file/object storage.
|
||||||
|
2. A documented command can add API or queue-specific worker replicas. The
|
||||||
|
default Compose profile supports local scale-out; an orchestrator profile
|
||||||
|
supplies equivalent health/readiness probes and rolling replacement.
|
||||||
|
3. Only one migration or module-lifecycle mutation may own the deployment lock,
|
||||||
|
while any healthy replica can serve read and normal domain traffic.
|
||||||
|
4. Workers announce identity, queues, software composition, heartbeat, and
|
||||||
|
drain state. Operators can see skew and safely retire a replica.
|
||||||
|
5. Sessions, throttling, idempotency, jobs, installer progress, and scheduled
|
||||||
|
work remain correct when requests move between replicas.
|
||||||
|
|
||||||
|
### Development, test, and production promotion
|
||||||
|
|
||||||
|
1. The administrator exports a configuration revision from development through
|
||||||
|
the WebUI. The package is versioned, checksummed, attributable, and contains
|
||||||
|
secret references or required-secret declarations, never secret values.
|
||||||
|
2. Test imports the package into a preview area. Core reports environment-bound
|
||||||
|
values, missing capabilities/secrets, compatibility changes, and the exact
|
||||||
|
apply plan.
|
||||||
|
3. Applying creates a new revision; the previous effective revision remains
|
||||||
|
addressable. Undo is a new audited revision that restores the earlier
|
||||||
|
configuration where contracts permit it.
|
||||||
|
4. After tests and health checks pass, Core emits an update recipe with exact
|
||||||
|
immutable release and configuration identifiers.
|
||||||
|
5. Production validates the recipe against its own environment, requires a new
|
||||||
|
confirmation, and executes the same ordered plan. Environment-specific
|
||||||
|
secret bindings and endpoints remain local.
|
||||||
|
6. Promotion never copies tenant/business data implicitly. Data migration,
|
||||||
|
anonymized fixtures, and backup/restore are separate explicit operations.
|
||||||
|
|
||||||
|
## Safety and governance requirements
|
||||||
|
|
||||||
|
- Catalogs, artifacts, recipes, and configuration revisions have signatures,
|
||||||
|
checksums, provenance, expiry/revocation semantics, and audit evidence.
|
||||||
|
- Catalog and artifact retrieval is not delegated to an unrestricted package
|
||||||
|
manager. Approved-origin and peer-pinned download, response bounds,
|
||||||
|
signature/checksum verification, extraction safety, offline installation, and
|
||||||
|
deployment egress policy form separate defenses.
|
||||||
|
- The installer has a dedicated narrow authority; normal domain permissions do
|
||||||
|
not imply host/package-management access.
|
||||||
|
- The UI distinguishes reversible configuration rollback from database or
|
||||||
|
package migrations that require forward recovery.
|
||||||
|
- Cluster-wide mutations use fencing/leases so an expired worker cannot later
|
||||||
|
commit a stale result.
|
||||||
|
- Update plans declare availability impact, worker draining, backup/restore
|
||||||
|
prerequisites, database compatibility windows, and post-change probes.
|
||||||
|
- Deployment profiles set secure headers, trusted proxies/hosts, body limits,
|
||||||
|
outbound-network policy, storage, TLS/cookie posture, and observability.
|
||||||
|
- Release publication requires aligned backend/frontend/manifest versions and
|
||||||
|
machine-readable dependency/SBOM provenance.
|
||||||
|
|
||||||
|
## Implementation slices
|
||||||
|
|
||||||
|
1. **Reproducible Core-only distribution.** Publish pinned multi-architecture
|
||||||
|
images, signed distribution manifest, Core-only Compose profile, bootstrap
|
||||||
|
preflight, generated secrets, readiness, and idempotent rerun/repair.
|
||||||
|
2. **First-run control plane.** Add the restricted bootstrap administrator,
|
||||||
|
one-time enrollment, initial catalog/keyring configuration, and retirement
|
||||||
|
after durable administrator access is established.
|
||||||
|
3. **Read-only online module directory.** Move the existing catalog and module
|
||||||
|
directory contracts into the installed Core WebUI with compatibility,
|
||||||
|
provenance, release-note, and update-state presentation.
|
||||||
|
4. **Durable module plan and install.** Reuse the existing installer queue,
|
||||||
|
locks, signed-package validator, rollback drill, and run evidence behind a
|
||||||
|
plan/confirm/progress UI. Add initial catalog-entry synthesis and artifact
|
||||||
|
acquisition where the current release console still assumes local sources.
|
||||||
|
5. **Safe module update.** Add drain/maintenance coordination, backup gate,
|
||||||
|
migration compatibility window, reconnectable progress, health verification,
|
||||||
|
retry/recovery, and update notification.
|
||||||
|
6. **Stateless replica profile.** Remove remaining local-runtime assumptions,
|
||||||
|
expose role-specific commands/images, implement worker registration/drain,
|
||||||
|
and prove multiple API and worker replicas against shared dependencies.
|
||||||
|
7. **Configuration revision model.** Define provider export/import schemas,
|
||||||
|
canonical serialization, secret references, validation/diff, immutable
|
||||||
|
revision storage, audit, apply, and undo-as-new-revision.
|
||||||
|
8. **Environment promotion and recipes.** Add source/target fingerprints,
|
||||||
|
preview, environment bindings, acceptance evidence, exact recipe generation,
|
||||||
|
signed transfer, and independently confirmed application.
|
||||||
|
9. **Operational proof.** Exercise interrupted installs, stale locks, unavailable
|
||||||
|
catalogs, revoked keys, failed migrations, replica loss, configuration undo,
|
||||||
|
and development-to-test-to-production promotion in release CI and target
|
||||||
|
drills.
|
||||||
|
|
||||||
|
## Explicit non-goals for the first distribution slice
|
||||||
|
|
||||||
|
- Shipping optional modules in the Core image.
|
||||||
|
- Exporting secrets or production business data with configuration.
|
||||||
|
- Pretending every schema migration can be reversed automatically.
|
||||||
|
- Building a proprietary orchestrator instead of supporting Compose and a
|
||||||
|
standard cluster scheduler through the same role/readiness contracts.
|
||||||
|
- Allowing the browser process to execute arbitrary shell commands.
|
||||||
976
docs/capability-fit-current.json
Normal file
976
docs/capability-fit-current.json
Normal file
@@ -0,0 +1,976 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./capability-fit.schema.json",
|
||||||
|
"schema_version": "0.1.0",
|
||||||
|
"assessment_id": "campaign-reference-2026-07-22",
|
||||||
|
"assessed_at": "2026-07-22",
|
||||||
|
"scope": {
|
||||||
|
"title": "Campaign-centric internal pilot and small-production candidate",
|
||||||
|
"reference_journeys": [
|
||||||
|
"Internal operator authors, validates, builds, queues, sends and reconciles an email Campaign with managed attachments",
|
||||||
|
"Operator inspects delivery and audit evidence"
|
||||||
|
],
|
||||||
|
"postponed": [
|
||||||
|
"Workflow and workflow-driven user stories"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"release": {
|
||||||
|
"kind": "tagged_release",
|
||||||
|
"ref": "stable-catalog-202607220843",
|
||||||
|
"meta_commit": "5447299289a1",
|
||||||
|
"reproducible": true,
|
||||||
|
"configuration_packages": [],
|
||||||
|
"notes": [
|
||||||
|
"The live stable catalog has a valid Ed25519 signature trusted through release-key-1.",
|
||||||
|
"Core v0.1.13 and Campaign v0.1.10 are tagged and package-integrated; this is not target-environment or production approval.",
|
||||||
|
"No configuration revision or configuration package is pinned yet."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"composition": [
|
||||||
|
{
|
||||||
|
"module_id": "core",
|
||||||
|
"repository": "govoplan-core",
|
||||||
|
"commit": "d487726f4d2c",
|
||||||
|
"manifest_version": "0.1.13",
|
||||||
|
"enabled": true,
|
||||||
|
"dirty": false,
|
||||||
|
"role": "API, registry, migrations, sessions, kernel contracts and shared WebUI"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module_id": "tenancy",
|
||||||
|
"repository": "govoplan-tenancy",
|
||||||
|
"commit": "efbec827616b",
|
||||||
|
"manifest_version": "0.1.8",
|
||||||
|
"enabled": true,
|
||||||
|
"dirty": false,
|
||||||
|
"role": "Tenant context and lifecycle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module_id": "organizations",
|
||||||
|
"repository": "govoplan-organizations",
|
||||||
|
"commit": "39c081c4fb8f",
|
||||||
|
"manifest_version": "0.1.8",
|
||||||
|
"enabled": true,
|
||||||
|
"dirty": false,
|
||||||
|
"role": "Organization model"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module_id": "identity",
|
||||||
|
"repository": "govoplan-identity",
|
||||||
|
"commit": "7a1710af896f",
|
||||||
|
"manifest_version": "0.1.8",
|
||||||
|
"enabled": true,
|
||||||
|
"dirty": false,
|
||||||
|
"role": "Normalized internal identity directory"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module_id": "access",
|
||||||
|
"repository": "govoplan-access",
|
||||||
|
"commit": "f1d64d247e12",
|
||||||
|
"manifest_version": "0.1.11",
|
||||||
|
"enabled": true,
|
||||||
|
"dirty": false,
|
||||||
|
"role": "Local authentication, sessions, API keys and RBAC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module_id": "admin",
|
||||||
|
"repository": "govoplan-admin",
|
||||||
|
"commit": "11ecf362a36d",
|
||||||
|
"manifest_version": "0.1.8",
|
||||||
|
"enabled": true,
|
||||||
|
"dirty": false,
|
||||||
|
"role": "Administration surfaces"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module_id": "dashboard",
|
||||||
|
"repository": "govoplan-dashboard",
|
||||||
|
"commit": "4b960ad37f0d",
|
||||||
|
"manifest_version": "0.1.8",
|
||||||
|
"enabled": true,
|
||||||
|
"dirty": false,
|
||||||
|
"role": "Module-aware home surface"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module_id": "policy",
|
||||||
|
"repository": "govoplan-policy",
|
||||||
|
"commit": "1063622d311a",
|
||||||
|
"manifest_version": "0.1.9",
|
||||||
|
"enabled": true,
|
||||||
|
"dirty": false,
|
||||||
|
"role": "Policy explanation and configuration boundary"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module_id": "audit",
|
||||||
|
"repository": "govoplan-audit",
|
||||||
|
"commit": "d3d2c60d7dc1",
|
||||||
|
"manifest_version": "0.1.8",
|
||||||
|
"enabled": true,
|
||||||
|
"dirty": false,
|
||||||
|
"role": "Database audit records and retrying audit outbox"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module_id": "campaigns",
|
||||||
|
"repository": "govoplan-campaign",
|
||||||
|
"commit": "735e874bd03c",
|
||||||
|
"manifest_version": "0.1.10",
|
||||||
|
"enabled": true,
|
||||||
|
"dirty": false,
|
||||||
|
"role": "Campaign authoring, build, delivery control and reporting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module_id": "files",
|
||||||
|
"repository": "govoplan-files",
|
||||||
|
"commit": "2b34f6e30578",
|
||||||
|
"manifest_version": "0.1.9",
|
||||||
|
"enabled": true,
|
||||||
|
"dirty": false,
|
||||||
|
"role": "Managed files and Campaign attachments"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module_id": "mail",
|
||||||
|
"repository": "govoplan-mail",
|
||||||
|
"commit": "3e2302909022",
|
||||||
|
"manifest_version": "0.1.10",
|
||||||
|
"enabled": true,
|
||||||
|
"dirty": false,
|
||||||
|
"role": "SMTP and IMAP profiles and transports"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module_id": "calendar",
|
||||||
|
"repository": "govoplan-calendar",
|
||||||
|
"commit": "9bcf41bb1fbb",
|
||||||
|
"manifest_version": "0.1.8",
|
||||||
|
"enabled": true,
|
||||||
|
"dirty": false,
|
||||||
|
"role": "Optional calendar outside the Campaign pilot minimum"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module_id": "docs",
|
||||||
|
"repository": "govoplan-docs",
|
||||||
|
"commit": "be52b716caed",
|
||||||
|
"manifest_version": "0.1.10",
|
||||||
|
"enabled": true,
|
||||||
|
"dirty": false,
|
||||||
|
"role": "Configured-system documentation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module_id": "ops",
|
||||||
|
"repository": "govoplan-ops",
|
||||||
|
"commit": "341773a4ff8a",
|
||||||
|
"manifest_version": "0.1.8",
|
||||||
|
"enabled": true,
|
||||||
|
"dirty": false,
|
||||||
|
"role": "Readiness and deployment-profile visibility"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module_id": "addresses",
|
||||||
|
"repository": "govoplan-addresses",
|
||||||
|
"commit": "93dddbb8c52a",
|
||||||
|
"manifest_version": "0.1.9",
|
||||||
|
"enabled": false,
|
||||||
|
"dirty": false,
|
||||||
|
"role": "Optional reusable recipient sources and CardDAV"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"deployment_profile": {
|
||||||
|
"id": "production-like-dev",
|
||||||
|
"status": "partial",
|
||||||
|
"description": "PostgreSQL and Redis run in containers while API, WebUI, worker and scheduler run from editable source trees.",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "configuration",
|
||||||
|
"scope": "current_workspace",
|
||||||
|
"locator": "govoplan/dev/production-like/docker-compose.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "documentation",
|
||||||
|
"scope": "documented_model",
|
||||||
|
"locator": "govoplan/dev/production-like/README.md"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"questionnaire": {
|
||||||
|
"scope_outcomes": [
|
||||||
|
{
|
||||||
|
"id": "outcome.reference_journey",
|
||||||
|
"question": "Which journey is assessed?",
|
||||||
|
"state": "answered",
|
||||||
|
"answer": "An internal operator authors, validates, builds, queues, sends and reconciles a Campaign with managed attachments.",
|
||||||
|
"evidence": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "outcome.workflow",
|
||||||
|
"question": "Is Workflow in scope?",
|
||||||
|
"state": "answered",
|
||||||
|
"answer": "No; Workflow is planned and explicitly postponed.",
|
||||||
|
"evidence": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"data_policy": [
|
||||||
|
{
|
||||||
|
"id": "data.classification",
|
||||||
|
"question": "Which data classes and legal bases apply?",
|
||||||
|
"state": "not_assessed",
|
||||||
|
"answer": null,
|
||||||
|
"evidence": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "data.retention",
|
||||||
|
"question": "What retention, deletion, archive and legal-hold rules apply?",
|
||||||
|
"state": "not_assessed",
|
||||||
|
"answer": null,
|
||||||
|
"evidence": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"identity_integrations": [
|
||||||
|
{
|
||||||
|
"id": "identity.pilot",
|
||||||
|
"question": "May the pilot use local GovOPlaN accounts?",
|
||||||
|
"state": "assumed",
|
||||||
|
"answer": "Yes; federation is outside the verified composition.",
|
||||||
|
"evidence": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "integration.mail",
|
||||||
|
"question": "Which target SMTP/IMAP service and policy apply?",
|
||||||
|
"state": "not_assessed",
|
||||||
|
"answer": null,
|
||||||
|
"evidence": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"workload_growth": [
|
||||||
|
{
|
||||||
|
"id": "workload.campaign",
|
||||||
|
"question": "What are Campaign frequency, recipients per Campaign, send window, import size and attachment volume?",
|
||||||
|
"state": "not_assessed",
|
||||||
|
"answer": null,
|
||||||
|
"evidence": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "workload.platform",
|
||||||
|
"question": "What are tenant, user, concurrency, file, database, queue and audit growth assumptions?",
|
||||||
|
"state": "not_assessed",
|
||||||
|
"answer": null,
|
||||||
|
"evidence": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"availability_operations": [
|
||||||
|
{
|
||||||
|
"id": "availability.rto_rpo",
|
||||||
|
"question": "What availability, RPO and RTO are required?",
|
||||||
|
"state": "not_assessed",
|
||||||
|
"answer": null,
|
||||||
|
"evidence": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "operations.ownership",
|
||||||
|
"question": "Who operates database, queue, storage, TLS, secrets, monitoring, backup and incident response?",
|
||||||
|
"state": "not_assessed",
|
||||||
|
"answer": null,
|
||||||
|
"evidence": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"procurement_decisions": [
|
||||||
|
{
|
||||||
|
"id": "procurement.constraints",
|
||||||
|
"question": "Which licensing, accessibility, security, certification, support and procurement conditions are mandatory?",
|
||||||
|
"state": "not_assessed",
|
||||||
|
"answer": null,
|
||||||
|
"evidence": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"capabilities": [
|
||||||
|
{
|
||||||
|
"id": "platform.composition",
|
||||||
|
"requirement": "Compose enabled backend and WebUI modules without hard optional-module dependencies.",
|
||||||
|
"status": "verified",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "test",
|
||||||
|
"scope": "committed_source",
|
||||||
|
"locator": "govoplan-core/tests/test_module_system.py"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "contract",
|
||||||
|
"scope": "current_workspace",
|
||||||
|
"locator": "govoplan/tools/checks/check-contracts.py",
|
||||||
|
"note": "43 modules, 33 providers, 19 requirements, no issues"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conditions": [
|
||||||
|
"Package integration is verified; repeat checks on the installed target composition."
|
||||||
|
],
|
||||||
|
"gaps": [
|
||||||
|
"No target deployment acceptance is recorded."
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
"A reproducible module graph can still be installed or configured incorrectly."
|
||||||
|
],
|
||||||
|
"recommendation": "Use the signed stable catalog and verify the minimal Campaign composition after installation.",
|
||||||
|
"proof_check": "Run contract, migration, API and WebUI module-permutation gates on the installed release."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "access.local",
|
||||||
|
"requirement": "Provide tenant-scoped local accounts, sessions, API keys and RBAC.",
|
||||||
|
"status": "verified",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "test",
|
||||||
|
"scope": "committed_source",
|
||||||
|
"locator": "govoplan-access/tests/test_auth_dependencies.py"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "test",
|
||||||
|
"scope": "committed_source",
|
||||||
|
"locator": "govoplan-core/tests/test_api_smoke.py#cookie-session-csrf"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conditions": [
|
||||||
|
"Pilot accepts local accounts."
|
||||||
|
],
|
||||||
|
"gaps": [
|
||||||
|
"MFA and federated lifecycle are not part of this conclusion."
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
"Manual account lifecycle may not satisfy production identity policy."
|
||||||
|
],
|
||||||
|
"recommendation": "Use controlled local pilot accounts and define break-glass/bootstrap rules.",
|
||||||
|
"proof_check": "Exercise joiner, role change, suspension and protected-owner recovery."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "campaign.journey",
|
||||||
|
"requirement": "Author, validate, build, queue, send, reconcile and report a Campaign with frozen execution evidence.",
|
||||||
|
"status": "verified",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "test",
|
||||||
|
"scope": "committed_source",
|
||||||
|
"locator": "govoplan-core/tests/test_api_smoke.py#campaign-create-validate-build-mock-send"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "test",
|
||||||
|
"scope": "committed_source",
|
||||||
|
"locator": "govoplan-campaign/tests",
|
||||||
|
"note": "Campaign v0.1.10 is exactly the catalog-selected tagged source"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "configuration",
|
||||||
|
"scope": "committed_source",
|
||||||
|
"locator": "https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json#sequence-202607220843",
|
||||||
|
"note": "Core v0.1.13 and Campaign v0.1.10 have matching catalogued Python and WebUI refs"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conditions": [
|
||||||
|
"This verifies implementation paths, not target-provider delivery."
|
||||||
|
],
|
||||||
|
"gaps": [
|
||||||
|
"Usability and target-provider acceptance remain separate."
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
"Package integration does not prove provider behavior or production operations."
|
||||||
|
],
|
||||||
|
"recommendation": "Use the catalogued Campaign release for usability and target-provider acceptance.",
|
||||||
|
"proof_check": "Run the complete journey with safe data and the target-like mail service."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "files.managed_attachments",
|
||||||
|
"requirement": "Store and resolve managed Campaign attachments on durable storage.",
|
||||||
|
"status": "verified",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "test",
|
||||||
|
"scope": "current_workspace",
|
||||||
|
"locator": "govoplan-files/tests",
|
||||||
|
"note": "14 tests passed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "test",
|
||||||
|
"scope": "current_workspace",
|
||||||
|
"locator": "govoplan-campaign/tests/test_attachment_building.py"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conditions": [
|
||||||
|
"Deployment provides a durable storage root."
|
||||||
|
],
|
||||||
|
"gaps": [
|
||||||
|
"Target backup and restore are not verified."
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
"Node-local storage prevents safe independent API scaling."
|
||||||
|
],
|
||||||
|
"recommendation": "Use durable local storage for the pilot and assess object/shared storage before scaling.",
|
||||||
|
"proof_check": "Back up and restore files together with database references."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mail.smtp_imap",
|
||||||
|
"requirement": "Send Campaign mail through SMTP and optionally append sent messages through IMAP.",
|
||||||
|
"status": "available_unconfigured",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "test",
|
||||||
|
"scope": "current_workspace",
|
||||||
|
"locator": "govoplan-mail/tests",
|
||||||
|
"note": "22 tests passed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "documentation",
|
||||||
|
"scope": "documented_model",
|
||||||
|
"locator": "govoplan-campaign/docs/CAMPAIGN_DELIVERY_RUNBOOK.md"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conditions": [
|
||||||
|
"Use a dedicated non-production service account and safe recipients."
|
||||||
|
],
|
||||||
|
"gaps": [
|
||||||
|
"No target provider, TLS chain, throttling or bounce/reply process was exercised."
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
"Ambiguous provider outcomes can cause duplicate-send risk if reconciled incorrectly."
|
||||||
|
],
|
||||||
|
"recommendation": "Run target-like interoperability and failure drills before production use.",
|
||||||
|
"proof_check": "Prove SMTP acceptance, IMAP append, throttling and outcome reconciliation."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "addresses.recipient_sources",
|
||||||
|
"requirement": "Select reusable address lists as Campaign recipient sources.",
|
||||||
|
"status": "available_unconfigured",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "test",
|
||||||
|
"scope": "current_workspace",
|
||||||
|
"locator": "govoplan-addresses/tests",
|
||||||
|
"note": "14 tests passed"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conditions": [
|
||||||
|
"Enable the Addresses module explicitly."
|
||||||
|
],
|
||||||
|
"gaps": [
|
||||||
|
"Addresses is disabled in the pinned root profile."
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
"Recipient governance may differ between source data and frozen Campaign evidence."
|
||||||
|
],
|
||||||
|
"recommendation": "Enable only when reusable lists are a pilot requirement.",
|
||||||
|
"proof_check": "Build a Campaign from a source list and verify immutable recipient provenance."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit.local",
|
||||||
|
"requirement": "Retain tenant/system audit evidence and retry governed audit events.",
|
||||||
|
"status": "verified",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "test",
|
||||||
|
"scope": "current_workspace",
|
||||||
|
"locator": "govoplan-audit/tests",
|
||||||
|
"note": "5 tests passed"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conditions": [
|
||||||
|
"Conclusion covers local database evidence only."
|
||||||
|
],
|
||||||
|
"gaps": [
|
||||||
|
"No central sink, retention enforcement or tamper-evident archive is verified."
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
"Local audit evidence may not satisfy organizational records or SIEM requirements."
|
||||||
|
],
|
||||||
|
"recommendation": "Define retention and export requirements before production approval.",
|
||||||
|
"proof_check": "Exercise privileged-event review, retention and any required external export."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "identity.federation",
|
||||||
|
"requirement": "Integrate external LDAP/AD, OIDC/SAML or SCIM identity infrastructure.",
|
||||||
|
"status": "scaffold",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "documentation",
|
||||||
|
"scope": "documented_model",
|
||||||
|
"locator": "govoplan-idm/README.md"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conditions": [],
|
||||||
|
"gaps": [
|
||||||
|
"No end-to-end provider connector or federated login is verified."
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
"Federation-dependent organizations cannot use the current pilot composition without extra implementation."
|
||||||
|
],
|
||||||
|
"recommendation": "Use local pilot accounts or assess and implement the selected provider path.",
|
||||||
|
"proof_check": "Run provider metadata, login/provisioning, deprovisioning and failure tests."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "compliance.export_control",
|
||||||
|
"requirement": "Screen persons and organizations against embargo/sanctions lists with review evidence.",
|
||||||
|
"status": "planned",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "issue",
|
||||||
|
"scope": "documented_model",
|
||||||
|
"locator": "https://git.add-ideas.de/add-ideas/govoplan/issues/12"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conditions": [],
|
||||||
|
"gaps": [
|
||||||
|
"No provider, list provenance, match policy, review flow or legal evidence exists."
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
"The current composition must not be represented as performing export-control screening."
|
||||||
|
],
|
||||||
|
"recommendation": "Keep outside pilot claims until the user story is implemented and legally validated.",
|
||||||
|
"proof_check": "Validate list ingestion, versioning, matching, false-positive review and audit evidence."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "workflow",
|
||||||
|
"requirement": "Orchestrate the journey through Workflow.",
|
||||||
|
"status": "planned",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "observation",
|
||||||
|
"scope": "documented_model",
|
||||||
|
"locator": "Assessment scope",
|
||||||
|
"note": "Explicitly postponed"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conditions": [],
|
||||||
|
"gaps": [
|
||||||
|
"Workflow is outside this assessment."
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
"Including it would overstate the assessed composition."
|
||||||
|
],
|
||||||
|
"recommendation": "Do not enable or claim Workflow for this reference pilot.",
|
||||||
|
"proof_check": "Reassess in a later Workflow-focused composition."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"infrastructure": [
|
||||||
|
{
|
||||||
|
"id": "runtime.web_api",
|
||||||
|
"requirement": "Serve matching WebUI and API artifacts with health endpoints.",
|
||||||
|
"status": "verified",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "test",
|
||||||
|
"scope": "committed_source",
|
||||||
|
"locator": "govoplan-core/tests/test_module_system.py"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "route",
|
||||||
|
"scope": "committed_source",
|
||||||
|
"locator": "govoplan-core/src/govoplan_core/server/fastapi.py#/health"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conditions": [
|
||||||
|
"Materialize the matching catalogued artifacts in the target."
|
||||||
|
],
|
||||||
|
"gaps": [
|
||||||
|
"No production image or service bundle is supplied by the profile."
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
"Editable source processes are unsuitable as a production artifact."
|
||||||
|
],
|
||||||
|
"recommendation": "Install matching catalogued WebUI/API refs and supervise them as immutable artifacts.",
|
||||||
|
"proof_check": "Deploy the built artifacts and run health/module-route checks."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "runtime.worker",
|
||||||
|
"requirement": "Run durable asynchronous Campaign jobs.",
|
||||||
|
"status": "available_unconfigured",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "configuration",
|
||||||
|
"scope": "current_workspace",
|
||||||
|
"locator": "govoplan/tools/launch/launch-production-like-dev.sh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conditions": [
|
||||||
|
"Redis and a supervised worker are required when Celery is enabled."
|
||||||
|
],
|
||||||
|
"gaps": [
|
||||||
|
"Target heartbeat, restart and queue-age alerting are not proved."
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
"Queued work can stall silently without monitoring."
|
||||||
|
],
|
||||||
|
"recommendation": "Start one worker for the pilot and split queues only after measurement.",
|
||||||
|
"proof_check": "Interrupt and restart a worker while preserving job/reconciliation safety."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "runtime.scheduler",
|
||||||
|
"requirement": "Run periodic recovery and cleanup safely.",
|
||||||
|
"status": "partial",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "test",
|
||||||
|
"scope": "committed_source",
|
||||||
|
"locator": "govoplan-calendar/tests/test_outbox.py",
|
||||||
|
"note": "Committed and pushed after the catalogued Calendar v0.1.8 tag"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conditions": [
|
||||||
|
"Calendar outbox and recovery work is remote-integrated source but not stable-package-integrated."
|
||||||
|
],
|
||||||
|
"gaps": [
|
||||||
|
"No distributed leader election or target supervision is established."
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
"Multiple schedulers can duplicate periodic dispatch without locking."
|
||||||
|
],
|
||||||
|
"recommendation": "Omit from the Campaign-only pilot or run one supervised instance.",
|
||||||
|
"proof_check": "Prove missed-schedule recovery and single-leader behavior."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "data.postgresql",
|
||||||
|
"requirement": "Persist application state in PostgreSQL with explicit migrations.",
|
||||||
|
"status": "verified",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "configuration",
|
||||||
|
"scope": "committed_source",
|
||||||
|
"locator": "govoplan/dev/postgres"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "test",
|
||||||
|
"scope": "committed_source",
|
||||||
|
"locator": "govoplan/tools/checks/postgres-integration-check.py"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conditions": [
|
||||||
|
"Target database remains deployment-owned."
|
||||||
|
],
|
||||||
|
"gaps": [
|
||||||
|
"HA, patching, WAL policy and capacity are not assessed."
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
"A single unprotected database is a system-wide failure point."
|
||||||
|
],
|
||||||
|
"recommendation": "Use managed or dedicated PostgreSQL with explicit migration and backup controls.",
|
||||||
|
"proof_check": "Run migrations and restore a target-like database."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "queue.redis",
|
||||||
|
"requirement": "Provide the Celery broker and queue persistence.",
|
||||||
|
"status": "available_unconfigured",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "configuration",
|
||||||
|
"scope": "current_workspace",
|
||||||
|
"locator": "govoplan/dev/production-like/docker-compose.yml#redis"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conditions": [],
|
||||||
|
"gaps": [
|
||||||
|
"Authentication, TLS, eviction, HA and queue-loss policy are not assessed."
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
"Broker loss or eviction can delay work even when database business state survives."
|
||||||
|
],
|
||||||
|
"recommendation": "Configure private persistent Redis and monitor queue age/depth.",
|
||||||
|
"proof_check": "Exercise broker interruption and worker recovery."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "storage.local",
|
||||||
|
"requirement": "Persist managed files on a durable single-node/shared path.",
|
||||||
|
"status": "verified",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "contract",
|
||||||
|
"scope": "committed_source",
|
||||||
|
"locator": "govoplan-files/src/govoplan_files/backend/storage/backends.py"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conditions": [
|
||||||
|
"Path is durable, private, writable and backed up."
|
||||||
|
],
|
||||||
|
"gaps": [
|
||||||
|
"Node-local storage cannot support independent API replicas."
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
"Files can be lost or become inconsistent with database state."
|
||||||
|
],
|
||||||
|
"recommendation": "Use for a bounded pilot only with coordinated backup.",
|
||||||
|
"proof_check": "Restore files and verify all database references."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "storage.object",
|
||||||
|
"requirement": "Use S3-compatible storage for independently scalable file persistence.",
|
||||||
|
"status": "partial",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "test",
|
||||||
|
"scope": "current_workspace",
|
||||||
|
"locator": "govoplan-files/tests/test_connector_providers.py"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conditions": [],
|
||||||
|
"gaps": [
|
||||||
|
"No chosen target service or storage-backend interoperability drill."
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
"Provider semantics, CA or lifecycle mismatch can break file access/retention."
|
||||||
|
],
|
||||||
|
"recommendation": "Select and exercise the target object store before horizontal scaling.",
|
||||||
|
"proof_check": "Upload, retrieve, version, back up and restore representative objects."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "edge.proxy_tls",
|
||||||
|
"requirement": "Terminate HTTPS and enforce proxy/security policy.",
|
||||||
|
"status": "external_system",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "route",
|
||||||
|
"scope": "committed_source",
|
||||||
|
"locator": "govoplan-ops/src/govoplan_ops/backend/api/v1/routes.py#deployment-security"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conditions": [],
|
||||||
|
"gaps": [
|
||||||
|
"No proxy, certificates, renewal, header or request-limit configuration is shipped here."
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
"Incorrect proxy/cookie/CORS configuration can expose sessions or block legitimate use."
|
||||||
|
],
|
||||||
|
"recommendation": "Supply and monitor the edge through the target platform.",
|
||||||
|
"proof_check": "Run external TLS/header/cookie/CORS and upload-limit tests."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "security.secret_store",
|
||||||
|
"requirement": "Inject and rotate master, database, mail and connector secrets.",
|
||||||
|
"status": "external_system",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "configuration",
|
||||||
|
"scope": "committed_source",
|
||||||
|
"locator": "govoplan/.env.example"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conditions": [],
|
||||||
|
"gaps": [
|
||||||
|
"No target secret manager or rotation drill is selected."
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
"Loss of the master key makes encrypted credentials unavailable; leakage compromises connectors."
|
||||||
|
],
|
||||||
|
"recommendation": "Use target-native secret injection and document rotation/recovery.",
|
||||||
|
"proof_check": "Rotate a non-production credential and recover from a protected backup."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "operations.monitoring",
|
||||||
|
"requirement": "Detect API, database, worker, queue, storage and delivery degradation.",
|
||||||
|
"status": "partial",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "route",
|
||||||
|
"scope": "committed_source",
|
||||||
|
"locator": "govoplan-ops/src/govoplan_ops/backend/api/v1/routes.py#/ops/readiness"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "contract",
|
||||||
|
"scope": "committed_source",
|
||||||
|
"locator": "govoplan-core/src/govoplan_core/server/fastapi.py#slow-request-logging"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conditions": [],
|
||||||
|
"gaps": [
|
||||||
|
"No metrics exporter, log collector, dashboards, alert routes or SLO is verified."
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
"Failures and queue backlog can remain unnoticed."
|
||||||
|
],
|
||||||
|
"recommendation": "Integrate external monitoring before small production.",
|
||||||
|
"proof_check": "Trigger each readiness/delivery failure and verify an actionable alert."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "operations.backup_restore",
|
||||||
|
"requirement": "Back up and restore database, files, configuration and keys as a coherent service.",
|
||||||
|
"status": "partial",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "documentation",
|
||||||
|
"scope": "documented_model",
|
||||||
|
"locator": "govoplan-core/docs/DEPLOYMENT_OPERATOR_GUIDE.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "issue",
|
||||||
|
"scope": "documented_model",
|
||||||
|
"locator": "https://git.add-ideas.de/add-ideas/govoplan-core/issues/29"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conditions": [],
|
||||||
|
"gaps": [
|
||||||
|
"No target full-service restore drill or measured RPO/RTO exists."
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
"Partial restore can produce missing files, unusable secrets or inconsistent evidence."
|
||||||
|
],
|
||||||
|
"recommendation": "Treat Core #29 and a target restore drill as a production gate.",
|
||||||
|
"proof_check": "Restore the whole service into an isolated environment and measure it."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "operations.disaster_recovery",
|
||||||
|
"requirement": "Recover the service after site or dependency loss within agreed RPO/RTO.",
|
||||||
|
"status": "not_assessed",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "absence",
|
||||||
|
"scope": "current_workspace",
|
||||||
|
"locator": "No target DR plan or exercise evidence supplied"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conditions": [],
|
||||||
|
"gaps": [
|
||||||
|
"RPO/RTO, off-site copies, recovery order, failover, communications and exercise schedule are unknown."
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
"Service and evidence may be unrecoverable after a major incident."
|
||||||
|
],
|
||||||
|
"recommendation": "Define and exercise DR before any availability commitment.",
|
||||||
|
"proof_check": "Run a documented end-to-end recovery exercise."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"data_flows": [
|
||||||
|
{
|
||||||
|
"id": "browser.api",
|
||||||
|
"from": "User browser",
|
||||||
|
"to": "Reverse proxy and GovOPlaN WebUI/API",
|
||||||
|
"data": [
|
||||||
|
"Session and CSRF cookies",
|
||||||
|
"Campaign content",
|
||||||
|
"Recipient personal data",
|
||||||
|
"Managed files"
|
||||||
|
],
|
||||||
|
"trust_boundary": "Client/public to application",
|
||||||
|
"controls": [
|
||||||
|
"HTTPS",
|
||||||
|
"Exact CORS origins",
|
||||||
|
"Secure cookies",
|
||||||
|
"Tenant and RBAC enforcement",
|
||||||
|
"Request limits"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api.database",
|
||||||
|
"from": "GovOPlaN API and workers",
|
||||||
|
"to": "PostgreSQL",
|
||||||
|
"data": [
|
||||||
|
"Tenant and identity records",
|
||||||
|
"Campaign drafts, snapshots and jobs",
|
||||||
|
"Connector metadata",
|
||||||
|
"Audit evidence"
|
||||||
|
],
|
||||||
|
"trust_boundary": "Application to primary state store",
|
||||||
|
"controls": [
|
||||||
|
"Dedicated database identity",
|
||||||
|
"Private or encrypted transport",
|
||||||
|
"Migrations",
|
||||||
|
"Backup and retention"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api.queue.worker",
|
||||||
|
"from": "GovOPlaN API",
|
||||||
|
"to": "Redis and Celery worker",
|
||||||
|
"data": [
|
||||||
|
"Job identifiers",
|
||||||
|
"Queue routing and retry metadata"
|
||||||
|
],
|
||||||
|
"trust_boundary": "Request plane to asynchronous processing plane",
|
||||||
|
"controls": [
|
||||||
|
"Private authenticated broker",
|
||||||
|
"Bounded payloads",
|
||||||
|
"Idempotent claims",
|
||||||
|
"Queue monitoring"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "worker.mail",
|
||||||
|
"from": "GovOPlaN Campaign worker",
|
||||||
|
"to": "External SMTP and IMAP services",
|
||||||
|
"data": [
|
||||||
|
"Recipient addresses",
|
||||||
|
"Message bodies",
|
||||||
|
"Attachments",
|
||||||
|
"Sent-message copy"
|
||||||
|
],
|
||||||
|
"trust_boundary": "GovOPlaN to external communication provider",
|
||||||
|
"controls": [
|
||||||
|
"Scoped service account",
|
||||||
|
"TLS and CA policy",
|
||||||
|
"Sender and recipient policy",
|
||||||
|
"Rate limits",
|
||||||
|
"Outcome reconciliation"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "worker.connectors",
|
||||||
|
"from": "GovOPlaN connector worker",
|
||||||
|
"to": "External address, file, object or calendar service",
|
||||||
|
"data": [
|
||||||
|
"Addresses",
|
||||||
|
"Files and provenance",
|
||||||
|
"Calendar resources"
|
||||||
|
],
|
||||||
|
"trust_boundary": "GovOPlaN to organizational/external content systems",
|
||||||
|
"controls": [
|
||||||
|
"Explicit sync direction",
|
||||||
|
"Scoped credentials",
|
||||||
|
"Endpoint allow-list",
|
||||||
|
"Provenance",
|
||||||
|
"Conflict and reconciliation policy"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"assumptions": [
|
||||||
|
"The pilot can use local accounts and one internal tenant or office.",
|
||||||
|
"A dedicated non-production SMTP/IMAP account and safe recipients are available.",
|
||||||
|
"Pilot load fits one API and one worker until measured otherwise.",
|
||||||
|
"Durable local storage is acceptable for the pilot."
|
||||||
|
],
|
||||||
|
"open_questions": [
|
||||||
|
"What are the target organization's data classes, legal bases, retention and external-disclosure rules?",
|
||||||
|
"Which identity, mail, file, address and monitoring systems are mandatory?",
|
||||||
|
"What are Campaign volume, concurrency, growth, availability, RPO and RTO?",
|
||||||
|
"Who owns each external runtime component and operational control?",
|
||||||
|
"Which accessibility, security, support and procurement constraints are mandatory?"
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
{
|
||||||
|
"id": "risk.reproducibility",
|
||||||
|
"statement": "The signed package selection is reproducible but has not been accepted as an installed target composition.",
|
||||||
|
"impact": "Installation or configuration drift can still produce uncertain deployed behavior.",
|
||||||
|
"treatment": "Materialize the signed catalog in an isolated target and run installed-artifact acceptance gates.",
|
||||||
|
"owner": null,
|
||||||
|
"residual_risk": "Module and environment differences still require release-environment verification."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "risk.delivery_provider",
|
||||||
|
"statement": "Target SMTP/IMAP behavior and failure modes are unproved.",
|
||||||
|
"impact": "Failed, delayed or duplicate communication and incomplete evidence.",
|
||||||
|
"treatment": "Run target-like interoperability, throttling and uncertainty drills.",
|
||||||
|
"owner": null,
|
||||||
|
"residual_risk": "External provider outages and ambiguous outcomes remain operational risks."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "risk.recovery",
|
||||||
|
"statement": "Backup/restore and disaster recovery are not demonstrated across all state and keys.",
|
||||||
|
"impact": "Irrecoverable or inconsistent service after loss.",
|
||||||
|
"treatment": "Complete Core #29 and an isolated full-service restore/DR exercise.",
|
||||||
|
"owner": null,
|
||||||
|
"residual_risk": "Recovery time and data loss remain bounded by the selected external infrastructure."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recommendations": [
|
||||||
|
"Proceed only with a controlled internal Campaign pilot after the bounded proof checks pass.",
|
||||||
|
"Use the minimal composition and enable Addresses only for an explicit reusable-recipient journey.",
|
||||||
|
"Do not claim Workflow, export-control screening, identity federation or production DR as implemented.",
|
||||||
|
"Treat installed-release acceptance, target mail proof, monitoring and a coherent restore drill as production gates."
|
||||||
|
],
|
||||||
|
"proof_checks": [
|
||||||
|
"Materialize the signed catalog into an isolated installation and rerun contract, migration and module-permutation gates against the installed artifacts.",
|
||||||
|
"Run a safe target-like Campaign through SMTP acceptance, IMAP append, reporting and audit.",
|
||||||
|
"Drill worker, Redis and ambiguous-delivery failures without duplicate sends.",
|
||||||
|
"Restore PostgreSQL, managed files, configuration and encrypted credentials and measure RPO/RTO.",
|
||||||
|
"Validate proxy/TLS, cookies/CORS, account bootstrap, secret redaction, monitoring and alert delivery.",
|
||||||
|
"Measure representative Campaign/file/queue/database load and external throttling."
|
||||||
|
]
|
||||||
|
}
|
||||||
298
docs/capability-fit.schema.json
Normal file
298
docs/capability-fit.schema.json
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://git.add-ideas.de/add-ideas/govoplan/src/branch/main/docs/capability-fit.schema.json",
|
||||||
|
"title": "GovOPlaN capability and infrastructure fit assessment",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"schema_version",
|
||||||
|
"assessment_id",
|
||||||
|
"assessed_at",
|
||||||
|
"scope",
|
||||||
|
"release",
|
||||||
|
"composition",
|
||||||
|
"deployment_profile",
|
||||||
|
"questionnaire",
|
||||||
|
"capabilities",
|
||||||
|
"infrastructure",
|
||||||
|
"data_flows",
|
||||||
|
"assumptions",
|
||||||
|
"open_questions",
|
||||||
|
"risks",
|
||||||
|
"recommendations",
|
||||||
|
"proof_checks"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"$schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uri-reference"
|
||||||
|
},
|
||||||
|
"schema_version": {
|
||||||
|
"const": "0.1.0"
|
||||||
|
},
|
||||||
|
"assessment_id": {
|
||||||
|
"$ref": "#/$defs/non_empty_string"
|
||||||
|
},
|
||||||
|
"assessed_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date"
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["title", "reference_journeys", "postponed"],
|
||||||
|
"properties": {
|
||||||
|
"title": { "$ref": "#/$defs/non_empty_string" },
|
||||||
|
"reference_journeys": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": { "$ref": "#/$defs/non_empty_string" }
|
||||||
|
},
|
||||||
|
"postponed": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/non_empty_string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"release": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["kind", "ref", "meta_commit", "reproducible", "configuration_packages"],
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"enum": ["tagged_release", "release_candidate", "workspace_snapshot"]
|
||||||
|
},
|
||||||
|
"ref": { "$ref": "#/$defs/non_empty_string" },
|
||||||
|
"meta_commit": { "$ref": "#/$defs/non_empty_string" },
|
||||||
|
"reproducible": { "type": "boolean" },
|
||||||
|
"configuration_packages": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/non_empty_string" }
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/non_empty_string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"composition": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": { "$ref": "#/$defs/module" }
|
||||||
|
},
|
||||||
|
"deployment_profile": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["id", "status", "description", "evidence"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "$ref": "#/$defs/non_empty_string" },
|
||||||
|
"status": { "$ref": "#/$defs/status" },
|
||||||
|
"description": { "$ref": "#/$defs/non_empty_string" },
|
||||||
|
"evidence": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": { "$ref": "#/$defs/evidence" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"questionnaire": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"scope_outcomes",
|
||||||
|
"data_policy",
|
||||||
|
"identity_integrations",
|
||||||
|
"workload_growth",
|
||||||
|
"availability_operations",
|
||||||
|
"procurement_decisions"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"scope_outcomes": { "$ref": "#/$defs/answers" },
|
||||||
|
"data_policy": { "$ref": "#/$defs/answers" },
|
||||||
|
"identity_integrations": { "$ref": "#/$defs/answers" },
|
||||||
|
"workload_growth": { "$ref": "#/$defs/answers" },
|
||||||
|
"availability_operations": { "$ref": "#/$defs/answers" },
|
||||||
|
"procurement_decisions": { "$ref": "#/$defs/answers" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"capabilities": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": { "$ref": "#/$defs/assessed_item" }
|
||||||
|
},
|
||||||
|
"infrastructure": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": { "$ref": "#/$defs/assessed_item" }
|
||||||
|
},
|
||||||
|
"data_flows": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": { "$ref": "#/$defs/data_flow" }
|
||||||
|
},
|
||||||
|
"assumptions": { "$ref": "#/$defs/string_list" },
|
||||||
|
"open_questions": { "$ref": "#/$defs/string_list" },
|
||||||
|
"risks": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/risk" }
|
||||||
|
},
|
||||||
|
"recommendations": { "$ref": "#/$defs/string_list" },
|
||||||
|
"proof_checks": { "$ref": "#/$defs/string_list" }
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"non_empty_string": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"string_list": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/non_empty_string" }
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"enum": [
|
||||||
|
"verified",
|
||||||
|
"available_unconfigured",
|
||||||
|
"partial",
|
||||||
|
"scaffold",
|
||||||
|
"external_system",
|
||||||
|
"planned",
|
||||||
|
"not_fit",
|
||||||
|
"not_assessed"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["kind", "scope", "locator"],
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"enum": [
|
||||||
|
"test",
|
||||||
|
"route",
|
||||||
|
"contract",
|
||||||
|
"drill",
|
||||||
|
"documentation",
|
||||||
|
"configuration",
|
||||||
|
"issue",
|
||||||
|
"absence",
|
||||||
|
"observation"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"enum": [
|
||||||
|
"committed_source",
|
||||||
|
"current_workspace",
|
||||||
|
"documented_model",
|
||||||
|
"target_environment"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locator": { "$ref": "#/$defs/non_empty_string" },
|
||||||
|
"note": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"module": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"module_id",
|
||||||
|
"repository",
|
||||||
|
"commit",
|
||||||
|
"manifest_version",
|
||||||
|
"enabled",
|
||||||
|
"dirty",
|
||||||
|
"role"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"module_id": { "$ref": "#/$defs/non_empty_string" },
|
||||||
|
"repository": { "$ref": "#/$defs/non_empty_string" },
|
||||||
|
"commit": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[0-9a-f]{7,40}$"
|
||||||
|
},
|
||||||
|
"manifest_version": { "$ref": "#/$defs/non_empty_string" },
|
||||||
|
"enabled": { "type": "boolean" },
|
||||||
|
"dirty": { "type": "boolean" },
|
||||||
|
"role": { "$ref": "#/$defs/non_empty_string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"answers": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/answer" }
|
||||||
|
},
|
||||||
|
"answer": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["id", "question", "state", "answer", "evidence"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "$ref": "#/$defs/non_empty_string" },
|
||||||
|
"question": { "$ref": "#/$defs/non_empty_string" },
|
||||||
|
"state": {
|
||||||
|
"enum": ["answered", "assumed", "not_applicable", "not_assessed"]
|
||||||
|
},
|
||||||
|
"answer": {
|
||||||
|
"type": ["string", "array", "null"],
|
||||||
|
"items": { "type": "string" }
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/evidence" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assessed_item": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"requirement",
|
||||||
|
"status",
|
||||||
|
"evidence",
|
||||||
|
"conditions",
|
||||||
|
"gaps",
|
||||||
|
"risks",
|
||||||
|
"recommendation",
|
||||||
|
"proof_check"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": { "$ref": "#/$defs/non_empty_string" },
|
||||||
|
"requirement": { "$ref": "#/$defs/non_empty_string" },
|
||||||
|
"status": { "$ref": "#/$defs/status" },
|
||||||
|
"evidence": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/evidence" }
|
||||||
|
},
|
||||||
|
"conditions": { "$ref": "#/$defs/string_list" },
|
||||||
|
"gaps": { "$ref": "#/$defs/string_list" },
|
||||||
|
"risks": { "$ref": "#/$defs/string_list" },
|
||||||
|
"recommendation": { "type": "string" },
|
||||||
|
"proof_check": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"data_flow": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["id", "from", "to", "data", "trust_boundary", "controls"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "$ref": "#/$defs/non_empty_string" },
|
||||||
|
"from": { "$ref": "#/$defs/non_empty_string" },
|
||||||
|
"to": { "$ref": "#/$defs/non_empty_string" },
|
||||||
|
"data": { "$ref": "#/$defs/string_list" },
|
||||||
|
"trust_boundary": { "$ref": "#/$defs/non_empty_string" },
|
||||||
|
"controls": { "$ref": "#/$defs/string_list" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"risk": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["id", "statement", "impact", "treatment", "owner", "residual_risk"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "$ref": "#/$defs/non_empty_string" },
|
||||||
|
"statement": { "$ref": "#/$defs/non_empty_string" },
|
||||||
|
"impact": { "$ref": "#/$defs/non_empty_string" },
|
||||||
|
"treatment": { "$ref": "#/$defs/non_empty_string" },
|
||||||
|
"owner": { "type": ["string", "null"] },
|
||||||
|
"residual_risk": { "$ref": "#/$defs/non_empty_string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,12 @@
|
|||||||
"description": "New user-visible behavior or platform capability.",
|
"description": "New user-visible behavior or platform capability.",
|
||||||
"exclusive": true
|
"exclusive": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "type/user-story",
|
||||||
|
"color": "1d76db",
|
||||||
|
"description": "End-to-end user journey or real-world process story used to steer product slices.",
|
||||||
|
"exclusive": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "type/task",
|
"name": "type/task",
|
||||||
"color": "1d76db",
|
"color": "1d76db",
|
||||||
@@ -149,12 +155,24 @@
|
|||||||
"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",
|
||||||
"description": "GovOPlaN Erp module behavior or integration.",
|
"description": "GovOPlaN Erp module behavior or integration.",
|
||||||
"exclusive": false
|
"exclusive": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "module/evaluation",
|
||||||
|
"color": "bfdadc",
|
||||||
|
"description": "GovOPlaN Evaluation module behavior or integration.",
|
||||||
|
"exclusive": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "module/files",
|
"name": "module/files",
|
||||||
"color": "006b75",
|
"color": "006b75",
|
||||||
@@ -227,12 +245,24 @@
|
|||||||
"description": "GovOPlaN Payments module behavior or integration.",
|
"description": "GovOPlaN Payments module behavior or integration.",
|
||||||
"exclusive": false
|
"exclusive": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "module/permits",
|
||||||
|
"color": "fbca04",
|
||||||
|
"description": "GovOPlaN Permits module behavior or integration.",
|
||||||
|
"exclusive": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "module/policy",
|
"name": "module/policy",
|
||||||
"color": "d93f0b",
|
"color": "d93f0b",
|
||||||
"description": "GovOPlaN Policy module behavior or integration.",
|
"description": "GovOPlaN Policy module behavior or integration.",
|
||||||
"exclusive": false
|
"exclusive": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "module/poll",
|
||||||
|
"color": "e4e669",
|
||||||
|
"description": "GovOPlaN Poll module behavior or integration.",
|
||||||
|
"exclusive": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "module/portal",
|
"name": "module/portal",
|
||||||
"color": "1d76db",
|
"color": "1d76db",
|
||||||
|
|||||||
@@ -23,8 +23,10 @@
|
|||||||
{"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-facilities", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-facilities.git", "path": "govoplan-facilities"},
|
{"name": "govoplan-facilities", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-facilities.git", "path": "govoplan-facilities"},
|
||||||
{"name": "govoplan-files", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-files.git", "path": "govoplan-files"},
|
{"name": "govoplan-files", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-files.git", "path": "govoplan-files"},
|
||||||
{"name": "govoplan-fit-connect", "category": "connector", "subtype": "standard", "remote": "git@git.add-ideas.de:add-ideas/govoplan-fit-connect.git", "path": "govoplan-fit-connect"},
|
{"name": "govoplan-fit-connect", "category": "connector", "subtype": "standard", "remote": "git@git.add-ideas.de:add-ideas/govoplan-fit-connect.git", "path": "govoplan-fit-connect"},
|
||||||
@@ -46,6 +48,7 @@
|
|||||||
{"name": "govoplan-payments", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-payments.git", "path": "govoplan-payments"},
|
{"name": "govoplan-payments", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-payments.git", "path": "govoplan-payments"},
|
||||||
{"name": "govoplan-permits", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-permits.git", "path": "govoplan-permits"},
|
{"name": "govoplan-permits", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-permits.git", "path": "govoplan-permits"},
|
||||||
{"name": "govoplan-policy", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-policy.git", "path": "govoplan-policy"},
|
{"name": "govoplan-policy", "category": "module", "subtype": "platform", "remote": "git@git.add-ideas.de:add-ideas/govoplan-policy.git", "path": "govoplan-policy"},
|
||||||
|
{"name": "govoplan-poll", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-poll.git", "path": "govoplan-poll"},
|
||||||
{"name": "govoplan-portal", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-portal.git", "path": "govoplan-portal"},
|
{"name": "govoplan-portal", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-portal.git", "path": "govoplan-portal"},
|
||||||
{"name": "govoplan-postbox", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-postbox.git", "path": "govoplan-postbox"},
|
{"name": "govoplan-postbox", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-postbox.git", "path": "govoplan-postbox"},
|
||||||
{"name": "govoplan-procurement", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-procurement.git", "path": "govoplan-procurement"},
|
{"name": "govoplan-procurement", "category": "module", "subtype": "domain", "remote": "git@git.add-ideas.de:add-ideas/govoplan-procurement.git", "path": "govoplan-procurement"},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -7,15 +7,22 @@
|
|||||||
-e ../govoplan-tenancy
|
-e ../govoplan-tenancy
|
||||||
-e ../govoplan-organizations
|
-e ../govoplan-organizations
|
||||||
-e ../govoplan-identity
|
-e ../govoplan-identity
|
||||||
|
-e ../govoplan-idm
|
||||||
-e ../govoplan-access
|
-e ../govoplan-access
|
||||||
-e ../govoplan-admin
|
-e ../govoplan-admin
|
||||||
-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 +32,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
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
# Whole-product release install from tagged module repositories.
|
# Whole-product release install from immutable, independently versioned module tags.
|
||||||
# Update GOVOPLAN_RELEASE_TAG together with pyproject/package versions when
|
# Only add a module after its referenced tag has been published.
|
||||||
# cutting a release.
|
|
||||||
../govoplan-core[server]
|
../govoplan-core[server]
|
||||||
govoplan-tenancy @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-tenancy.git@v0.1.6
|
govoplan-tenancy @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-tenancy.git@v0.1.8
|
||||||
govoplan-organizations @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git@v0.1.6
|
govoplan-organizations @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git@v0.1.8
|
||||||
govoplan-identity @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-identity.git@v0.1.6
|
govoplan-identity @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-identity.git@v0.1.8
|
||||||
govoplan-idm @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git@v0.1.6
|
govoplan-idm @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git@v0.1.8
|
||||||
govoplan-access @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git@v0.1.6
|
govoplan-access @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git@v0.1.8
|
||||||
govoplan-admin @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git@v0.1.6
|
govoplan-admin @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git@v0.1.8
|
||||||
govoplan-policy @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git@v0.1.6
|
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.6
|
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.6
|
govoplan-dashboard @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git@v0.1.8
|
||||||
govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.6
|
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.6
|
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.6
|
govoplan-campaign @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git@v0.1.10
|
||||||
govoplan-calendar @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git@v0.1.6
|
govoplan-calendar @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git@v0.1.8
|
||||||
govoplan-docs @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git@v0.1.6
|
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.6
|
govoplan-ops @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git@v0.1.8
|
||||||
|
|||||||
144
tests/test_dependency_boundaries.py
Normal file
144
tests/test_dependency_boundaries.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SCRIPT = META_ROOT / "tools" / "checks" / "check_dependency_boundaries.py"
|
||||||
|
|
||||||
|
|
||||||
|
def load_boundary_module():
|
||||||
|
spec = importlib.util.spec_from_file_location("check_dependency_boundaries", SCRIPT)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise RuntimeError(f"Could not load {SCRIPT}")
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[spec.name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class DependencyBoundaryDiscoveryTests(unittest.TestCase):
|
||||||
|
def test_editable_install_metadata_is_not_treated_as_a_source_package(self) -> None:
|
||||||
|
boundary = load_boundary_module()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-boundary-discovery-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
package = root / "govoplan-example" / "src" / "govoplan_example"
|
||||||
|
package.mkdir(parents=True)
|
||||||
|
(package / "__init__.py").write_text("", encoding="utf-8")
|
||||||
|
(package.parent / "govoplan_example.egg-info").mkdir()
|
||||||
|
|
||||||
|
repos, prefixes, errors = boundary._discover_python_repos(root)
|
||||||
|
|
||||||
|
self.assertEqual(errors, ())
|
||||||
|
self.assertEqual(repos, {"govoplan-example": package})
|
||||||
|
self.assertEqual(prefixes, {"govoplan-example": "govoplan_example"})
|
||||||
|
|
||||||
|
def test_ambiguous_or_missing_source_packages_fail_discovery(self) -> None:
|
||||||
|
boundary = load_boundary_module()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-boundary-discovery-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
missing_source = root / "govoplan-missing" / "src"
|
||||||
|
missing_source.mkdir(parents=True)
|
||||||
|
(missing_source / "govoplan_missing.egg-info").mkdir()
|
||||||
|
|
||||||
|
ambiguous_source = root / "govoplan-ambiguous" / "src"
|
||||||
|
for name in ("govoplan_alpha", "govoplan_beta"):
|
||||||
|
package = ambiguous_source / name
|
||||||
|
package.mkdir(parents=True)
|
||||||
|
(package / "__init__.py").write_text("", encoding="utf-8")
|
||||||
|
|
||||||
|
repos, prefixes, errors = boundary._discover_python_repos(root)
|
||||||
|
|
||||||
|
self.assertEqual(repos, {})
|
||||||
|
self.assertEqual(prefixes, {})
|
||||||
|
self.assertEqual(len(errors), 2)
|
||||||
|
self.assertTrue(any("govoplan-missing" in error and "found 0" in error for error in errors))
|
||||||
|
self.assertTrue(any("govoplan-ambiguous" in error and "found 2" in error for error in errors))
|
||||||
|
|
||||||
|
def test_duplicate_python_package_ownership_fails_discovery(self) -> None:
|
||||||
|
boundary = load_boundary_module()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-boundary-discovery-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
for owner in ("govoplan-first", "govoplan-second"):
|
||||||
|
package = root / owner / "src" / "govoplan_shared"
|
||||||
|
package.mkdir(parents=True)
|
||||||
|
(package / "__init__.py").write_text("", encoding="utf-8")
|
||||||
|
|
||||||
|
repos, prefixes, errors = boundary._discover_python_repos(root)
|
||||||
|
|
||||||
|
self.assertEqual(set(repos), {"govoplan-first"})
|
||||||
|
self.assertEqual(prefixes, {"govoplan-first": "govoplan_shared"})
|
||||||
|
self.assertEqual(len(errors), 1)
|
||||||
|
self.assertIn("already owned", errors[0])
|
||||||
|
|
||||||
|
def test_workspace_discovery_covers_changed_capability_repositories(self) -> None:
|
||||||
|
boundary = load_boundary_module()
|
||||||
|
|
||||||
|
self.assertEqual(boundary.PYTHON_DISCOVERY_ERRORS, ())
|
||||||
|
self.assertGreaterEqual(len(boundary.REPOS), 40)
|
||||||
|
for owner in (
|
||||||
|
"govoplan-core",
|
||||||
|
"govoplan-identity",
|
||||||
|
"govoplan-idm",
|
||||||
|
"govoplan-calendar",
|
||||||
|
"govoplan-poll",
|
||||||
|
"govoplan-scheduling",
|
||||||
|
):
|
||||||
|
self.assertIn(owner, boundary.REPOS)
|
||||||
|
|
||||||
|
def test_webui_discovery_is_fail_closed(self) -> None:
|
||||||
|
boundary = load_boundary_module()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-boundary-webui-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
good = root / "govoplan-good" / "webui"
|
||||||
|
good.mkdir(parents=True)
|
||||||
|
(good / "package.json").write_text(
|
||||||
|
json.dumps({"name": "@govoplan/good-webui"}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
missing = root / "govoplan-missing" / "webui"
|
||||||
|
missing.mkdir(parents=True)
|
||||||
|
invalid = root / "govoplan-invalid" / "webui"
|
||||||
|
invalid.mkdir(parents=True)
|
||||||
|
(invalid / "package.json").write_text("{", encoding="utf-8")
|
||||||
|
unnamed = root / "govoplan-unnamed" / "webui"
|
||||||
|
unnamed.mkdir(parents=True)
|
||||||
|
(unnamed / "package.json").write_text("{}", encoding="utf-8")
|
||||||
|
duplicate = root / "govoplan-zduplicate" / "webui"
|
||||||
|
duplicate.mkdir(parents=True)
|
||||||
|
(duplicate / "package.json").write_text(
|
||||||
|
json.dumps({"name": "@govoplan/good-webui"}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
repos, packages, errors = boundary._discover_webui_repos(root)
|
||||||
|
|
||||||
|
self.assertEqual(repos, {"govoplan-good": good})
|
||||||
|
self.assertEqual(packages, {"govoplan-good": "@govoplan/good-webui"})
|
||||||
|
self.assertEqual(len(errors), 4)
|
||||||
|
self.assertTrue(any("govoplan-missing" in error and "no package.json" in error for error in errors))
|
||||||
|
self.assertTrue(any("govoplan-invalid" in error and "invalid" in error for error in errors))
|
||||||
|
self.assertTrue(any("govoplan-unnamed" in error and "package name" in error for error in errors))
|
||||||
|
self.assertTrue(any("govoplan-zduplicate" in error and "already owned" in error for error in errors))
|
||||||
|
|
||||||
|
def test_workspace_webui_discovery_covers_composition_repositories(self) -> None:
|
||||||
|
boundary = load_boundary_module()
|
||||||
|
|
||||||
|
self.assertEqual(boundary.WEBUI_DISCOVERY_ERRORS, ())
|
||||||
|
self.assertGreaterEqual(len(boundary.WEBUI_REPOS), 17)
|
||||||
|
for owner in (
|
||||||
|
"govoplan-core",
|
||||||
|
"govoplan-calendar",
|
||||||
|
"govoplan-scheduling",
|
||||||
|
"govoplan-notifications",
|
||||||
|
):
|
||||||
|
self.assertIn(owner, boundary.WEBUI_REPOS)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
82
tests/test_python_environment_sync.py
Normal file
82
tests/test_python_environment_sync.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SCRIPT = META_ROOT / "tools" / "repo" / "sync-python-environment.py"
|
||||||
|
|
||||||
|
|
||||||
|
def load_sync_module():
|
||||||
|
spec = importlib.util.spec_from_file_location("sync_python_environment", SCRIPT)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise RuntimeError(f"Could not load {SCRIPT}")
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[spec.name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class PythonEnvironmentSyncTests(unittest.TestCase):
|
||||||
|
def test_stale_local_projects_share_one_resolver_transaction(self) -> None:
|
||||||
|
sync = load_sync_module()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-python-sync-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
requirements = root / "requirements-dev.txt"
|
||||||
|
requirements.write_text("-e ./govoplan-core\n-e ./govoplan-access\n", encoding="utf-8")
|
||||||
|
for project in ("govoplan-core", "govoplan-access"):
|
||||||
|
project_root = root / project
|
||||||
|
project_root.mkdir()
|
||||||
|
(project_root / "pyproject.toml").write_text(
|
||||||
|
f'[project]\nname = "{project}"\nversion = "0.1.10"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
local_requirements = sync.local_requirement_entries(requirements)
|
||||||
|
fingerprint = sync.build_fingerprint(
|
||||||
|
requirements=requirements,
|
||||||
|
python="/test/venv/bin/python",
|
||||||
|
local_requirements=local_requirements,
|
||||||
|
)
|
||||||
|
requirements_digest = hashlib.sha256(requirements.read_bytes()).hexdigest()
|
||||||
|
previous = {
|
||||||
|
"version": sync.STAMP_VERSION,
|
||||||
|
"python": "/test/venv/bin/python",
|
||||||
|
"inputs": [
|
||||||
|
{"path": str(requirements), "sha256": requirements_digest},
|
||||||
|
*(
|
||||||
|
{"path": entry.pyproject, "sha256": "stale"}
|
||||||
|
for entry in local_requirements
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"requirements_entries": [
|
||||||
|
entry.as_dict() for entry in sync.parse_requirement_entries(requirements)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
plan = sync.build_install_plan(
|
||||||
|
previous=previous,
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
requirements=requirements,
|
||||||
|
python="/test/venv/bin/python",
|
||||||
|
local_requirements=local_requirements,
|
||||||
|
force=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(plan.mode, "Selective Python environment sync")
|
||||||
|
self.assertIn("one resolver transaction", plan.reason)
|
||||||
|
self.assertEqual(len(plan.commands), 1)
|
||||||
|
command = plan.commands[0]
|
||||||
|
self.assertEqual(command[:5], ("/test/venv/bin/python", "-m", "pip", "install", "-e"))
|
||||||
|
self.assertEqual(command.count("-e"), 2)
|
||||||
|
self.assertIn(str(root / "govoplan-core"), command)
|
||||||
|
self.assertIn(str(root / "govoplan-access"), command)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
84
tests/test_release_catalog_entry_synthesis.py
Normal file
84
tests/test_release_catalog_entry_synthesis.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
RELEASE_ROOT = META_ROOT / "tools" / "release"
|
||||||
|
if str(RELEASE_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(RELEASE_ROOT))
|
||||||
|
|
||||||
|
from govoplan_release.catalog_entry_synthesis import validate_initial_entry_closure # noqa: E402
|
||||||
|
from govoplan_release.selective_catalog import apply_repo_updates # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseCatalogEntrySynthesisTests(unittest.TestCase):
|
||||||
|
def test_selective_update_synthesizes_initial_entries_from_package_manifests(self) -> None:
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"core_release": {},
|
||||||
|
"release": {},
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"module_id": "access",
|
||||||
|
"version": "0.1.11",
|
||||||
|
"python_package": "govoplan-access",
|
||||||
|
"python_ref": "govoplan-access @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git@v0.1.11",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
changes = apply_repo_updates(
|
||||||
|
payload,
|
||||||
|
repo_versions={
|
||||||
|
"govoplan-addresses": "0.1.9",
|
||||||
|
"govoplan-poll": "0.1.11",
|
||||||
|
"govoplan-scheduling": "0.1.11",
|
||||||
|
},
|
||||||
|
repo_contracts={},
|
||||||
|
repository_base="git+ssh://git@git.add-ideas.de/add-ideas",
|
||||||
|
workspace=META_ROOT.parent,
|
||||||
|
)
|
||||||
|
|
||||||
|
modules = {
|
||||||
|
item["module_id"]: item
|
||||||
|
for item in payload["modules"] # type: ignore[index]
|
||||||
|
}
|
||||||
|
self.assertEqual({"access", "addresses", "poll", "scheduling"}, set(modules))
|
||||||
|
self.assertEqual("@govoplan/addresses-webui", modules["addresses"]["webui_package"])
|
||||||
|
self.assertIn(
|
||||||
|
{"name": "addresses.people_search", "version": "0.1.0"},
|
||||||
|
modules["addresses"]["provides_interfaces"],
|
||||||
|
)
|
||||||
|
self.assertEqual("govoplan-poll", modules["poll"]["python_package"])
|
||||||
|
self.assertEqual(["poll"], modules["scheduling"]["dependencies"])
|
||||||
|
self.assertEqual("@govoplan/scheduling-webui", modules["scheduling"]["webui_package"])
|
||||||
|
self.assertEqual("requires_review", modules["scheduling"]["migration_safety"])
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
item["name"] == "poll.governed_participation" and item["optional"] is False
|
||||||
|
for item in modules["scheduling"]["requires_interfaces"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{"govoplan-addresses", "govoplan-poll", "govoplan-scheduling"},
|
||||||
|
{change.repo for change in changes},
|
||||||
|
)
|
||||||
|
self.assertEqual({"catalog_entry"}, {change.field for change in changes})
|
||||||
|
|
||||||
|
def test_initial_required_dependency_must_be_represented(self) -> None:
|
||||||
|
with self.assertRaisesRegex(ValueError, "requires catalog module 'poll'"):
|
||||||
|
validate_initial_entry_closure(
|
||||||
|
catalog_modules=[
|
||||||
|
{
|
||||||
|
"module_id": "scheduling",
|
||||||
|
"dependencies": ["poll"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
initial_module_ids={"scheduling"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
99
tests/test_release_catalog_publication.py
Normal file
99
tests/test_release_catalog_publication.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release"
|
||||||
|
if str(RELEASE_TOOLS_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(RELEASE_TOOLS_ROOT))
|
||||||
|
|
||||||
|
from govoplan_release.publisher import publish_catalog_candidate # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseCatalogPublicationTests(unittest.TestCase):
|
||||||
|
def test_publication_requires_an_existing_trust_anchor(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
candidate = self._candidate(root, key="candidate-key")
|
||||||
|
web_root = root / "website"
|
||||||
|
web_root.mkdir()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_release.publisher.validate_module_package_catalog",
|
||||||
|
return_value={"valid": True, "warnings": [], "error": None},
|
||||||
|
):
|
||||||
|
result = publish_catalog_candidate(
|
||||||
|
candidate_dir=candidate,
|
||||||
|
web_root=web_root,
|
||||||
|
workspace_root=root,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("blocked", result.status)
|
||||||
|
self.assertIn("publication trust anchor is missing", " ".join(result.notes))
|
||||||
|
|
||||||
|
def test_publication_rejects_rebinding_an_existing_key_id(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
candidate = self._candidate(root, key="replacement-key")
|
||||||
|
web_root = root / "website"
|
||||||
|
target_keyring = web_root / "public" / "catalogs" / "v1" / "keyring.json"
|
||||||
|
target_keyring.parent.mkdir(parents=True)
|
||||||
|
target_keyring.write_text(json.dumps(self._keyring("trusted-key")))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_release.publisher.validate_module_package_catalog",
|
||||||
|
return_value={"valid": True, "warnings": [], "error": None},
|
||||||
|
):
|
||||||
|
result = publish_catalog_candidate(
|
||||||
|
candidate_dir=candidate,
|
||||||
|
web_root=web_root,
|
||||||
|
workspace_root=root,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("blocked", result.status)
|
||||||
|
self.assertIn("changes the public key", " ".join(result.notes))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _candidate(root: Path, *, key: str) -> Path:
|
||||||
|
candidate = root / "candidate"
|
||||||
|
channel = candidate / "channels"
|
||||||
|
channel.mkdir(parents=True)
|
||||||
|
channel.joinpath("stable.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"channel": "stable",
|
||||||
|
"core_release": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"python_ref": (
|
||||||
|
"govoplan-core @ git+ssh://git@example.test/acme/"
|
||||||
|
"govoplan-core.git@v1.2.3"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"modules": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
candidate.joinpath("keyring.json").write_text(json.dumps(ReleaseCatalogPublicationTests._keyring(key)))
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _keyring(key: str) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"key_id": "release-key",
|
||||||
|
"status": "active",
|
||||||
|
"public_key": key,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
41
tests/test_release_console_security.py
Normal file
41
tests/test_release_console_security.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
RELEASE_ROOT = META_ROOT / "tools" / "release"
|
||||||
|
if str(RELEASE_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(RELEASE_ROOT))
|
||||||
|
|
||||||
|
from server.app import create_app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseConsoleSecurityTests(unittest.TestCase):
|
||||||
|
def test_api_token_is_accepted_only_from_header(self) -> None:
|
||||||
|
with TestClient(create_app(workspace_root=META_ROOT, token="test-console-token")) as client:
|
||||||
|
query_response = client.get("/api/health?token=test-console-token")
|
||||||
|
header_response = client.get(
|
||||||
|
"/api/health",
|
||||||
|
headers={"X-Release-Console-Token": "test-console-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(query_response.status_code, 401)
|
||||||
|
self.assertEqual(header_response.status_code, 200)
|
||||||
|
|
||||||
|
def test_bootstrap_token_uses_and_clears_url_fragment(self) -> None:
|
||||||
|
launcher = (RELEASE_ROOT / "release-console.py").read_text(encoding="utf-8")
|
||||||
|
webui = (RELEASE_ROOT / "webui" / "index.html").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn("#token={token}", launcher)
|
||||||
|
self.assertNotIn("?token={token}", launcher)
|
||||||
|
self.assertIn("window.location.hash.slice(1)", webui)
|
||||||
|
self.assertIn("history.replaceState", webui)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
207
tests/test_release_doctor.py
Normal file
207
tests/test_release_doctor.py
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release"
|
||||||
|
SCRIPT = RELEASE_TOOLS_ROOT / "release-doctor.py"
|
||||||
|
|
||||||
|
if str(RELEASE_TOOLS_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(RELEASE_TOOLS_ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
def load_doctor_module():
|
||||||
|
spec = importlib.util.spec_from_file_location("release_doctor", SCRIPT)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise RuntimeError(f"Could not load {SCRIPT}")
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[spec.name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseDoctorTests(unittest.TestCase):
|
||||||
|
def test_release_repo_names_include_catalog_and_migration_baseline_owners(self) -> None:
|
||||||
|
doctor = load_doctor_module()
|
||||||
|
|
||||||
|
names = set(doctor.release_repo_names(META_ROOT.parent))
|
||||||
|
|
||||||
|
self.assertIn("govoplan-core", names)
|
||||||
|
self.assertIn("govoplan-files", names)
|
||||||
|
self.assertIn("govoplan-idm", names)
|
||||||
|
|
||||||
|
def test_repo_findings_detect_dirty_repositories(self) -> None:
|
||||||
|
doctor = load_doctor_module()
|
||||||
|
repo = doctor.RepoState(
|
||||||
|
name="govoplan-files",
|
||||||
|
path="/tmp/govoplan-files",
|
||||||
|
exists=True,
|
||||||
|
dirty_entries=(" M pyproject.toml",),
|
||||||
|
pyproject_version="0.1.7",
|
||||||
|
local_tag_exists=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
findings = doctor.repo_findings((repo,), target_version="0.1.7", target_tag="v0.1.7")
|
||||||
|
|
||||||
|
self.assertEqual("blocker", findings[0].severity)
|
||||||
|
self.assertEqual("repo-dirty", findings[0].check_id)
|
||||||
|
self.assertIn("git status --short", [item.command for item in findings[0].commands])
|
||||||
|
|
||||||
|
def test_repo_version_mismatch_blocks_release(self) -> None:
|
||||||
|
doctor = load_doctor_module()
|
||||||
|
repo = doctor.RepoState(
|
||||||
|
name="govoplan-files",
|
||||||
|
path="/tmp/govoplan-files",
|
||||||
|
exists=True,
|
||||||
|
pyproject_version="0.1.9",
|
||||||
|
package_version="0.1.8",
|
||||||
|
webui_package_version="0.1.9",
|
||||||
|
)
|
||||||
|
|
||||||
|
findings = doctor.repo_findings((repo,), target_version="0.1.9", target_tag="v0.1.9")
|
||||||
|
|
||||||
|
mismatch = next(item for item in findings if item.check_id == "repo-version-mismatch")
|
||||||
|
self.assertEqual("blocker", mismatch.severity)
|
||||||
|
|
||||||
|
def test_dubious_ownership_errors_suggest_safe_directory_command(self) -> None:
|
||||||
|
doctor = load_doctor_module()
|
||||||
|
repo = doctor.RepoState(
|
||||||
|
name="govoplan-files",
|
||||||
|
path="/tmp/govoplan-files",
|
||||||
|
exists=True,
|
||||||
|
safe_directory_required=True,
|
||||||
|
safe_directory_command="git config --global --add safe.directory /tmp/govoplan-files",
|
||||||
|
errors=("fatal: detected dubious ownership in repository",),
|
||||||
|
)
|
||||||
|
|
||||||
|
findings = doctor.repo_findings((repo,), target_version="0.1.7", target_tag="v0.1.7")
|
||||||
|
|
||||||
|
self.assertEqual("repo-safe-directory", findings[0].check_id)
|
||||||
|
self.assertEqual("blocker", findings[0].severity)
|
||||||
|
self.assertTrue(findings[0].commands[0].mutating)
|
||||||
|
self.assertIn("safe.directory", findings[0].commands[0].command)
|
||||||
|
|
||||||
|
def test_dubious_ownership_detection_matches_git_error(self) -> None:
|
||||||
|
doctor = load_doctor_module()
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
doctor.is_dubious_ownership_error(
|
||||||
|
"fatal: detected dubious ownership in repository at '/workspace/repo'\n"
|
||||||
|
"git config --global --add safe.directory /workspace/repo"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_release_migration_strict_errors_suggest_recording_baseline(self) -> None:
|
||||||
|
doctor = load_doctor_module()
|
||||||
|
|
||||||
|
findings = doctor.migration_findings(
|
||||||
|
{
|
||||||
|
"release": {
|
||||||
|
"ok": True,
|
||||||
|
"graph_errors": [],
|
||||||
|
"strict_errors": ["current migration heads are not recorded"],
|
||||||
|
},
|
||||||
|
"dev": {"ok": True, "graph_errors": [], "strict_errors": []},
|
||||||
|
},
|
||||||
|
target_version="0.2.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("migration-release-baseline", findings[0].check_id)
|
||||||
|
self.assertEqual("blocker", findings[0].severity)
|
||||||
|
commands = [item.command for item in findings[0].commands]
|
||||||
|
self.assertTrue(any("--record-release 0.2.0" in item for item in commands))
|
||||||
|
|
||||||
|
def test_catalog_findings_detect_missing_signatures(self) -> None:
|
||||||
|
doctor = load_doctor_module()
|
||||||
|
|
||||||
|
findings = doctor.catalog_findings(
|
||||||
|
{
|
||||||
|
"catalog_exists": True,
|
||||||
|
"catalog_path": "/tmp/stable.json",
|
||||||
|
"core_release_version": "0.1.7",
|
||||||
|
"signature_count": 0,
|
||||||
|
"keyring_exists": True,
|
||||||
|
"key_count": 1,
|
||||||
|
},
|
||||||
|
target_version="0.1.7",
|
||||||
|
online=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("catalog-signatures", findings[0].check_id)
|
||||||
|
self.assertEqual("blocker", findings[0].severity)
|
||||||
|
|
||||||
|
def test_suggested_commands_are_deduplicated(self) -> None:
|
||||||
|
doctor = load_doctor_module()
|
||||||
|
suggested = doctor.SuggestedCommand(title="Status", command="git status --short", cwd="/tmp/repo")
|
||||||
|
report = doctor.DoctorReport(
|
||||||
|
generated_at="2026-07-11T00:00:00Z",
|
||||||
|
workspace_root="/tmp",
|
||||||
|
target_version="0.1.7",
|
||||||
|
target_tag="v0.1.7",
|
||||||
|
online=False,
|
||||||
|
overall_status="blocked",
|
||||||
|
repos=(),
|
||||||
|
findings=(
|
||||||
|
doctor.Finding("a", "blocker", "A", "", (suggested,)),
|
||||||
|
doctor.Finding("b", "warning", "B", "", (suggested,)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual((suggested,), doctor.collect_suggested_commands(report))
|
||||||
|
|
||||||
|
def test_default_report_is_concise_and_keeps_details_out(self) -> None:
|
||||||
|
doctor = load_doctor_module()
|
||||||
|
repo = doctor.RepoState(
|
||||||
|
name="govoplan-files",
|
||||||
|
path="/tmp/govoplan-files",
|
||||||
|
exists=True,
|
||||||
|
dirty_entries=(" M pyproject.toml",),
|
||||||
|
)
|
||||||
|
report = doctor.DoctorReport(
|
||||||
|
generated_at="2026-07-11T00:00:00Z",
|
||||||
|
workspace_root="/tmp",
|
||||||
|
target_version="0.1.7",
|
||||||
|
target_tag="v0.1.7",
|
||||||
|
online=False,
|
||||||
|
overall_status="blocked",
|
||||||
|
repos=(repo,),
|
||||||
|
findings=(doctor.Finding("repo-dirty", "blocker", "govoplan-files has uncommitted changes", "M pyproject.toml"),),
|
||||||
|
)
|
||||||
|
|
||||||
|
concise = doctor.render_text_report(report, detailed=False, include_commands=False)
|
||||||
|
detailed = doctor.render_text_report(report, detailed=True, include_commands=False)
|
||||||
|
|
||||||
|
self.assertIn("dirty=1", concise)
|
||||||
|
self.assertNotIn("M pyproject.toml", concise)
|
||||||
|
self.assertIn("M pyproject.toml", detailed)
|
||||||
|
|
||||||
|
def test_interactive_actions_offer_dirty_bulk_commit_push(self) -> None:
|
||||||
|
doctor = load_doctor_module()
|
||||||
|
repo = doctor.RepoState(
|
||||||
|
name="govoplan-files",
|
||||||
|
path="/tmp/govoplan-files",
|
||||||
|
exists=True,
|
||||||
|
dirty_entries=(" M pyproject.toml",),
|
||||||
|
)
|
||||||
|
report = doctor.DoctorReport(
|
||||||
|
generated_at="2026-07-11T00:00:00Z",
|
||||||
|
workspace_root="/tmp",
|
||||||
|
target_version="0.1.7",
|
||||||
|
target_tag="v0.1.7",
|
||||||
|
online=False,
|
||||||
|
overall_status="blocked",
|
||||||
|
repos=(repo,),
|
||||||
|
findings=(),
|
||||||
|
)
|
||||||
|
|
||||||
|
actions = dict(doctor.interactive_actions(report))
|
||||||
|
|
||||||
|
self.assertIn("commit-push-dirty", actions)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
70
tests/test_release_entrypoint_gates.py
Normal file
70
tests/test_release_entrypoint_gates.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseEntrypointGateTests(unittest.TestCase):
|
||||||
|
def test_lockstep_release_includes_every_registered_module_and_connector(self) -> None:
|
||||||
|
script = (META_ROOT / "tools" / "release" / "push-release-tag.sh").read_text()
|
||||||
|
registry = json.loads((META_ROOT / "repositories.json").read_text())
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
item["name"]
|
||||||
|
for item in registry["repositories"]
|
||||||
|
if item["category"] not in {"system", "website"}
|
||||||
|
}
|
||||||
|
missing = sorted(repo for repo in expected if f'$PARENT/{repo}"' not in script)
|
||||||
|
|
||||||
|
self.assertEqual([], missing)
|
||||||
|
|
||||||
|
def test_lockstep_release_runs_source_and_full_gates_before_remote_push(self) -> None:
|
||||||
|
script = (META_ROOT / "tools" / "release" / "push-release-tag.sh").read_text()
|
||||||
|
manifest_gate = script.index("run_manifest_shape_gate\n\nconfirm_release")
|
||||||
|
confirm = script.index("\nconfirm_release\n", manifest_gate)
|
||||||
|
workflow = script[confirm:]
|
||||||
|
|
||||||
|
source_gate = workflow.index("run_version_alignment_gate source")
|
||||||
|
first_commit = workflow.index('run git -C "$repo" commit')
|
||||||
|
lock_generation = workflow.index("generate_release_lock")
|
||||||
|
full_gate = workflow.index("run_version_alignment_gate", source_gate + 1)
|
||||||
|
first_push = workflow.index('run git -C "$repo" push')
|
||||||
|
|
||||||
|
self.assertLess(source_gate, first_commit)
|
||||||
|
self.assertLess(first_commit, lock_generation)
|
||||||
|
self.assertLess(lock_generation, full_gate)
|
||||||
|
self.assertLess(full_gate, first_push)
|
||||||
|
self.assertLess(manifest_gate, confirm)
|
||||||
|
|
||||||
|
def test_source_catalog_generator_enforces_explicit_repo_versions(self) -> None:
|
||||||
|
script = (META_ROOT / "tools" / "release" / "generate-release-catalog.py").read_text()
|
||||||
|
|
||||||
|
gate = script.index("selected_repository_version_issues(")
|
||||||
|
write = script.index("output.write_text(")
|
||||||
|
|
||||||
|
self.assertLess(gate, write)
|
||||||
|
|
||||||
|
def test_candidate_publication_uses_existing_keyring_as_trust_anchor(self) -> None:
|
||||||
|
publisher = (META_ROOT / "tools" / "release" / "govoplan_release" / "publisher.py").read_text()
|
||||||
|
|
||||||
|
self.assertIn("candidate_catalog_version_issues(candidate_payload)", publisher)
|
||||||
|
self.assertIn("trusted_keys_from_keyring(\n target_keyring_payload", publisher)
|
||||||
|
self.assertIn("trusted_keys=publication_trust", publisher)
|
||||||
|
self.assertNotIn("trusted_keys=candidate_keys", publisher)
|
||||||
|
self.assertIn("write_module_directory(", publisher)
|
||||||
|
self.assertNotIn("shutil.copytree(candidate_modules", publisher)
|
||||||
|
|
||||||
|
def test_release_integration_honors_independent_module_versions(self) -> None:
|
||||||
|
script = (META_ROOT / "tools" / "checks" / "check-release-integration.sh").read_text()
|
||||||
|
|
||||||
|
self.assertNotIn('expected_tag = os.environ["RELEASE_TAG"]', script)
|
||||||
|
self.assertIn('dependency_version = tag_match.group(1)', script)
|
||||||
|
self.assertIn('repo_tag="$(release_tag_for_package "$repo")"', script)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
96
tests/test_release_integration.py
Normal file
96
tests/test_release_integration.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from tools.checks.release_integration import (
|
||||||
|
SOURCE_COUPLED_CORE_TESTS,
|
||||||
|
InstalledModuleArtifact,
|
||||||
|
artifact_contract_issues,
|
||||||
|
filter_test_suite,
|
||||||
|
release_package_names,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _named_test(test_id: str) -> unittest.TestCase:
|
||||||
|
class NamedTest(unittest.TestCase):
|
||||||
|
def id(self) -> str:
|
||||||
|
return test_id
|
||||||
|
|
||||||
|
def runTest(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return NamedTest()
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseIntegrationTests(unittest.TestCase):
|
||||||
|
def test_release_requirement_parser_selects_only_immutable_module_refs(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
requirements = Path(temp_dir) / "requirements-release.txt"
|
||||||
|
requirements.write_text(
|
||||||
|
"\n".join(
|
||||||
|
(
|
||||||
|
"../govoplan-core[server]",
|
||||||
|
"govoplan-idm @ git+ssh://git@example.test/add-ideas/govoplan-idm.git@v0.1.8",
|
||||||
|
"govoplan-campaign @ git+ssh://git@example.test/add-ideas/govoplan-campaign.git@v0.1.8",
|
||||||
|
"govoplan-idm @ git+ssh://git@example.test/add-ideas/govoplan-idm.git@v0.1.8",
|
||||||
|
"other-package==1.0",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
release_package_names(requirements),
|
||||||
|
("govoplan-idm", "govoplan-campaign"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_artifact_contracts_compare_distribution_entry_point_and_discovery_versions(self) -> None:
|
||||||
|
artifacts = (
|
||||||
|
InstalledModuleArtifact("govoplan-access", "0.1.8", "access", "0.1.8"),
|
||||||
|
InstalledModuleArtifact("govoplan-idm", "0.1.8", "idm", "0.1.7"),
|
||||||
|
InstalledModuleArtifact("govoplan-shadow-idm", "0.1.8", "idm", "0.1.8"),
|
||||||
|
)
|
||||||
|
|
||||||
|
issues = artifact_contract_issues(
|
||||||
|
artifacts,
|
||||||
|
release_packages=("govoplan-access", "govoplan-idm", "govoplan-shadow-idm", "govoplan-campaign"),
|
||||||
|
available_manifest_versions={"access": "0.1.8", "idm": "0.1.6"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(issues), 5)
|
||||||
|
self.assertTrue(any("metadata version '0.1.8' does not match" in issue for issue in issues))
|
||||||
|
self.assertTrue(any("does not publish a govoplan.modules entry point" in issue for issue in issues))
|
||||||
|
self.assertTrue(any("multiple release packages" in issue for issue in issues))
|
||||||
|
|
||||||
|
def test_release_suite_excludes_only_named_source_coupled_tests(self) -> None:
|
||||||
|
retained_id = "tests.test_core_events.CoreEventTests.test_event_delivery"
|
||||||
|
nested = unittest.TestSuite(
|
||||||
|
(
|
||||||
|
unittest.TestSuite(_named_test(test_id) for test_id in SOURCE_COUPLED_CORE_TESTS),
|
||||||
|
unittest.TestSuite((_named_test(retained_id),)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
filtered, removed = filter_test_suite(nested, tuple(SOURCE_COUPLED_CORE_TESTS))
|
||||||
|
|
||||||
|
self.assertEqual(set(removed), set(SOURCE_COUPLED_CORE_TESTS))
|
||||||
|
self.assertEqual([test.id() for test in filtered], [retained_id])
|
||||||
|
self.assertEqual(len(SOURCE_COUPLED_CORE_TESTS), 4)
|
||||||
|
|
||||||
|
def test_release_entrypoint_uses_artifact_checks_and_filtered_core_suite(self) -> None:
|
||||||
|
meta_root = Path(__file__).resolve().parents[1]
|
||||||
|
script = (meta_root / "tools" / "checks" / "check-release-integration.sh").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
artifact_check = script.index('"$META_ROOT/tools/checks/release_integration.py" artifacts')
|
||||||
|
core_tests = script.index('"$META_ROOT/tools/checks/release_integration.py" core-tests')
|
||||||
|
module_tests = script.index('run_step "Run cloned module backend tests"')
|
||||||
|
|
||||||
|
self.assertLess(artifact_check, core_tests)
|
||||||
|
self.assertLess(core_tests, module_tests)
|
||||||
|
self.assertNotIn('"$PYTHON" -m unittest \\\n tests.test_module_system', script)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
190
tests/test_release_migration_audit.py
Normal file
190
tests/test_release_migration_audit.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SCRIPT = META_ROOT / "tools" / "release" / "release-migration-audit.py"
|
||||||
|
|
||||||
|
|
||||||
|
def load_audit_module():
|
||||||
|
spec = importlib.util.spec_from_file_location("release_migration_audit", SCRIPT)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise RuntimeError(f"Could not load {SCRIPT}")
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[spec.name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseMigrationAuditTests(unittest.TestCase):
|
||||||
|
def test_typed_alembic_variables_are_parsed(self) -> None:
|
||||||
|
audit = load_audit_module()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="migration-audit-test-") as directory:
|
||||||
|
path = Path(directory) / "1234_example.py"
|
||||||
|
path.write_text(
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
revision: str = "1234"
|
||||||
|
down_revision: Union[str, None] = "base"
|
||||||
|
depends_on: Union[str, None] = "core"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
migration = audit.parse_migration_file("govoplan-core", path)
|
||||||
|
|
||||||
|
self.assertIsNotNone(migration)
|
||||||
|
self.assertEqual(migration.revision, "1234")
|
||||||
|
self.assertEqual(migration.down_revisions, ("base",))
|
||||||
|
self.assertEqual(migration.depends_on, ("core",))
|
||||||
|
self.assertEqual(migration.branch_labels, ())
|
||||||
|
|
||||||
|
def test_release_baseline_matches_current_heads_in_strict_report(self) -> None:
|
||||||
|
audit = load_audit_module()
|
||||||
|
migrations = [
|
||||||
|
audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()),
|
||||||
|
audit.Migration("govoplan-core", Path("head.py"), "head", ("base",), (), ()),
|
||||||
|
]
|
||||||
|
report = audit.build_report(
|
||||||
|
migrations,
|
||||||
|
baseline={
|
||||||
|
"version": 1,
|
||||||
|
"releases": [
|
||||||
|
{
|
||||||
|
"release": "0.1.0",
|
||||||
|
"heads": [{"owner": "govoplan-core", "revision": "head"}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
baseline_file=Path("docs/migration-release-baselines.json"),
|
||||||
|
workspace_root=Path("/workspace"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(report["current_heads"], ["head"])
|
||||||
|
self.assertEqual(report["graph_errors"], [])
|
||||||
|
self.assertEqual(report["strict_errors"], [])
|
||||||
|
|
||||||
|
def test_owner_heads_are_accepted_for_subset_installs(self) -> None:
|
||||||
|
audit = load_audit_module()
|
||||||
|
migrations = [
|
||||||
|
audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()),
|
||||||
|
audit.Migration("govoplan-core", Path("core_head.py"), "core-head", ("base",), (), ()),
|
||||||
|
]
|
||||||
|
report = audit.build_report(
|
||||||
|
migrations,
|
||||||
|
baseline={
|
||||||
|
"version": 1,
|
||||||
|
"releases": [
|
||||||
|
{
|
||||||
|
"release": "0.1.0",
|
||||||
|
"heads": [{"owner": "govoplan-files", "revision": "files-head"}],
|
||||||
|
"owner_heads": [{"owner": "govoplan-core", "revisions": ["core-head"]}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
baseline_file=Path("docs/migration-release-baselines.json"),
|
||||||
|
workspace_root=Path("/workspace"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(report["current_heads"], ["core-head"])
|
||||||
|
self.assertEqual(report["strict_errors"], [])
|
||||||
|
|
||||||
|
def test_records_current_release_baseline(self) -> None:
|
||||||
|
audit = load_audit_module()
|
||||||
|
migrations = [
|
||||||
|
audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()),
|
||||||
|
audit.Migration("govoplan-core", Path("core_head.py"), "core-head", ("base",), (), ()),
|
||||||
|
audit.Migration("govoplan-files", Path("files_head.py"), "files-head", ("core-head",), (), ()),
|
||||||
|
]
|
||||||
|
report = audit.build_report(
|
||||||
|
migrations,
|
||||||
|
baseline={"version": 1, "releases": []},
|
||||||
|
baseline_file=Path("docs/migration-release-baselines.json"),
|
||||||
|
workspace_root=Path("/workspace"),
|
||||||
|
)
|
||||||
|
|
||||||
|
baseline = audit.record_release_baseline(
|
||||||
|
{"version": 1, "releases": []},
|
||||||
|
report,
|
||||||
|
release="0.2.0",
|
||||||
|
replace=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
release = baseline["releases"][0]
|
||||||
|
self.assertEqual(release["release"], "0.2.0")
|
||||||
|
self.assertEqual(release["squash_policy"], "reviewed-manual")
|
||||||
|
self.assertEqual(release["heads"], [{"owner": "govoplan-files", "revision": "files-head"}])
|
||||||
|
self.assertIn({"owner": "govoplan-core", "revisions": ["core-head"]}, release["owner_heads"])
|
||||||
|
|
||||||
|
def test_missing_down_revision_is_a_graph_error(self) -> None:
|
||||||
|
audit = load_audit_module()
|
||||||
|
migrations = [
|
||||||
|
audit.Migration("govoplan-core", Path("head.py"), "head", ("missing",), (), ()),
|
||||||
|
]
|
||||||
|
report = audit.build_report(
|
||||||
|
migrations,
|
||||||
|
baseline={"version": 1, "releases": []},
|
||||||
|
baseline_file=Path("docs/migration-release-baselines.json"),
|
||||||
|
workspace_root=Path("/workspace"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("references missing down_revision", report["graph_errors"][0])
|
||||||
|
|
||||||
|
def test_depends_on_participates_in_graph_validation_and_heads(self) -> None:
|
||||||
|
audit = load_audit_module()
|
||||||
|
migrations = [
|
||||||
|
audit.Migration("govoplan-core", Path("core.py"), "core", (), (), ()),
|
||||||
|
audit.Migration("govoplan-files", Path("files.py"), "files", (), ("core",), ()),
|
||||||
|
]
|
||||||
|
report = audit.build_report(
|
||||||
|
migrations,
|
||||||
|
baseline={"version": 1, "releases": []},
|
||||||
|
baseline_file=Path("docs/migration-release-baselines.json"),
|
||||||
|
workspace_root=Path("/workspace"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(report["graph_errors"], [])
|
||||||
|
self.assertEqual(report["current_heads"], ["files"])
|
||||||
|
|
||||||
|
def test_missing_depends_on_is_a_graph_error(self) -> None:
|
||||||
|
audit = load_audit_module()
|
||||||
|
migrations = [
|
||||||
|
audit.Migration("govoplan-files", Path("files.py"), "files", (), ("missing",), ()),
|
||||||
|
]
|
||||||
|
report = audit.build_report(
|
||||||
|
migrations,
|
||||||
|
baseline={"version": 1, "releases": []},
|
||||||
|
baseline_file=Path("docs/migration-release-baselines.json"),
|
||||||
|
workspace_root=Path("/workspace"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("references missing depends_on", report["graph_errors"][0])
|
||||||
|
|
||||||
|
def test_dev_track_does_not_emit_release_baseline_warnings(self) -> None:
|
||||||
|
audit = load_audit_module()
|
||||||
|
migrations = [
|
||||||
|
audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()),
|
||||||
|
audit.Migration("govoplan-core", Path("head.py"), "head", ("base",), (), ()),
|
||||||
|
]
|
||||||
|
report = audit.build_report(
|
||||||
|
migrations,
|
||||||
|
baseline={"version": 1, "releases": []},
|
||||||
|
baseline_file=Path("docs/migration-release-baselines.json"),
|
||||||
|
workspace_root=Path("/workspace"),
|
||||||
|
track="dev",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(report["track"], "dev")
|
||||||
|
self.assertEqual(report["strict_errors"], [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
400
tests/test_release_repository_tag.py
Normal file
400
tests/test_release_repository_tag.py
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
RELEASE_ROOT = META_ROOT / "tools" / "release"
|
||||||
|
if str(RELEASE_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(RELEASE_ROOT))
|
||||||
|
|
||||||
|
from govoplan_release.repository_tag import tag_repositories # noqa: E402
|
||||||
|
from server.app import create_app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseRepositoryTagTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temporary.name)
|
||||||
|
self.workspace = self.root / "workspace"
|
||||||
|
self.workspace.mkdir()
|
||||||
|
self.repo, self.remote = create_release_repo(
|
||||||
|
root=self.root,
|
||||||
|
workspace=self.workspace,
|
||||||
|
name="govoplan-core",
|
||||||
|
version="0.1.10",
|
||||||
|
)
|
||||||
|
self.manifest_repo, self.manifest_remote = create_release_repo(
|
||||||
|
root=self.root,
|
||||||
|
workspace=self.workspace,
|
||||||
|
name="govoplan-access",
|
||||||
|
version="0.1.10",
|
||||||
|
)
|
||||||
|
add_scoped_workflow_manifest(self.manifest_repo)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.temporary.cleanup()
|
||||||
|
|
||||||
|
def test_core_preview_is_non_mutating_and_publish_is_idempotent(self) -> None:
|
||||||
|
preview = tag_repositories(
|
||||||
|
repos=("govoplan-core",),
|
||||||
|
repo_versions={"govoplan-core": "0.1.10"},
|
||||||
|
workspace_root=self.workspace,
|
||||||
|
apply=False,
|
||||||
|
push=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(preview["status"], "planned")
|
||||||
|
row = preview["repositories"][0]
|
||||||
|
self.assertEqual(row["status"], "planned")
|
||||||
|
self.assertIn("git tag -a v0.1.10", row["create_command"])
|
||||||
|
self.assertIn("git push --atomic origin", row["publish_command"])
|
||||||
|
self.assertFalse(ref_exists(self.repo, "refs/tags/v0.1.10"))
|
||||||
|
self.assertFalse(ref_exists(self.remote, "refs/tags/v0.1.10"))
|
||||||
|
|
||||||
|
published = tag_repositories(
|
||||||
|
repos=("govoplan-core",),
|
||||||
|
repo_versions={"govoplan-core": "0.1.10"},
|
||||||
|
workspace_root=self.workspace,
|
||||||
|
apply=True,
|
||||||
|
push=True,
|
||||||
|
)
|
||||||
|
head = git_text(self.repo, "rev-parse", "HEAD")
|
||||||
|
|
||||||
|
self.assertEqual(published["status"], "published")
|
||||||
|
self.assertEqual(git_text(self.repo, "cat-file", "-t", "refs/tags/v0.1.10"), "tag")
|
||||||
|
self.assertEqual(git_text(self.repo, "rev-parse", "refs/tags/v0.1.10^{commit}"), head)
|
||||||
|
self.assertEqual(git_text(self.remote, "rev-parse", "refs/tags/v0.1.10^{commit}"), head)
|
||||||
|
|
||||||
|
repeated = tag_repositories(
|
||||||
|
repos=("govoplan-core",),
|
||||||
|
repo_versions={"govoplan-core": "0.1.10"},
|
||||||
|
workspace_root=self.workspace,
|
||||||
|
apply=True,
|
||||||
|
push=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(repeated["status"], "published")
|
||||||
|
self.assertIn("already published", repeated["repositories"][0]["detail"])
|
||||||
|
self.assertEqual(git_text(self.remote, "rev-parse", "refs/tags/v0.1.10^{commit}"), head)
|
||||||
|
|
||||||
|
def test_core_alignment_and_clean_worktree_are_release_gates(self) -> None:
|
||||||
|
mismatch = tag_repositories(
|
||||||
|
repos=("govoplan-core",),
|
||||||
|
repo_versions={"govoplan-core": "0.1.11"},
|
||||||
|
workspace_root=self.workspace,
|
||||||
|
)
|
||||||
|
self.assertEqual(mismatch["status"], "blocked")
|
||||||
|
self.assertIn("version alignment gate failed", mismatch["repositories"][0]["detail"])
|
||||||
|
|
||||||
|
(self.repo / "uncommitted.txt").write_text("not reviewed\n", encoding="utf-8")
|
||||||
|
dirty = tag_repositories(
|
||||||
|
repos=("govoplan-core",),
|
||||||
|
repo_versions={"govoplan-core": "0.1.10"},
|
||||||
|
workspace_root=self.workspace,
|
||||||
|
)
|
||||||
|
self.assertEqual(dirty["status"], "blocked")
|
||||||
|
self.assertIn("worktree is not clean", dirty["repositories"][0]["detail"])
|
||||||
|
self.assertFalse(ref_exists(self.repo, "refs/tags/v0.1.10"))
|
||||||
|
|
||||||
|
def test_user_workflow_scope_contract_blocks_source_tagging(self) -> None:
|
||||||
|
replace_with_unscoped_workflow_manifest(self.manifest_repo)
|
||||||
|
|
||||||
|
result = tag_repositories(
|
||||||
|
repos=("govoplan-core",),
|
||||||
|
repo_versions={"govoplan-core": "0.1.10"},
|
||||||
|
workspace_root=self.workspace,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "blocked")
|
||||||
|
self.assertIn("scope-conditioned alternative", result["repositories"][0]["detail"])
|
||||||
|
self.assertFalse(ref_exists(self.repo, "refs/tags/v0.1.10"))
|
||||||
|
|
||||||
|
def test_empty_manifest_workspace_blocks_source_tagging(self) -> None:
|
||||||
|
workspace = self.root / "empty-manifest-workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
core, _ = create_release_repo(
|
||||||
|
root=self.root,
|
||||||
|
workspace=workspace,
|
||||||
|
name="govoplan-core",
|
||||||
|
version="0.1.10",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = tag_repositories(
|
||||||
|
repos=("govoplan-core",),
|
||||||
|
repo_versions={"govoplan-core": "0.1.10"},
|
||||||
|
workspace_root=workspace,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "blocked")
|
||||||
|
self.assertIn("No module manifests found", result["repositories"][0]["detail"])
|
||||||
|
self.assertFalse(ref_exists(core, "refs/tags/v0.1.10"))
|
||||||
|
|
||||||
|
def test_batch_preflight_blocks_every_mutation_when_a_later_repo_is_dirty(self) -> None:
|
||||||
|
access = self.manifest_repo
|
||||||
|
access_remote = self.manifest_remote
|
||||||
|
(access / "uncommitted.txt").write_text("not reviewed\n", encoding="utf-8")
|
||||||
|
|
||||||
|
result = tag_repositories(
|
||||||
|
repos=("govoplan-core", "govoplan-access"),
|
||||||
|
repo_versions={"govoplan-core": "0.1.10", "govoplan-access": "0.1.10"},
|
||||||
|
workspace_root=self.workspace,
|
||||||
|
apply=True,
|
||||||
|
push=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "blocked")
|
||||||
|
self.assertIn("no selected repository was mutated", result["detail"])
|
||||||
|
self.assertEqual(result["repositories"][0]["status"], "skipped")
|
||||||
|
self.assertEqual(result["repositories"][1]["status"], "blocked")
|
||||||
|
for repository in (self.repo, self.remote, access, access_remote):
|
||||||
|
self.assertFalse(ref_exists(repository, "refs/tags/v0.1.10"))
|
||||||
|
|
||||||
|
def test_batch_preflight_blocks_selected_webui_version_missing_from_core_bundle(self) -> None:
|
||||||
|
campaign, campaign_remote = create_release_repo(
|
||||||
|
root=self.root,
|
||||||
|
workspace=self.workspace,
|
||||||
|
name="govoplan-campaign",
|
||||||
|
version="0.1.10",
|
||||||
|
)
|
||||||
|
campaign_webui = campaign / "webui"
|
||||||
|
campaign_webui.mkdir()
|
||||||
|
(campaign_webui / "package.json").write_text(
|
||||||
|
'{"name":"@govoplan/campaign-webui","version":"0.1.10"}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
git(campaign, "add", "webui/package.json")
|
||||||
|
git(campaign, "commit", "-m", "Add Campaign WebUI package")
|
||||||
|
git(campaign, "tag", "-a", "v0.1.9", "-m", "Prior Campaign release")
|
||||||
|
git(campaign, "push", "origin", "main", "refs/tags/v0.1.9")
|
||||||
|
campaign_head = git_text(campaign, "rev-parse", "HEAD")
|
||||||
|
|
||||||
|
core_webui = self.repo / "webui"
|
||||||
|
core_webui.mkdir()
|
||||||
|
old_ref = "git+ssh://git@example.test/acme/govoplan-campaign.git#v0.1.9"
|
||||||
|
(core_webui / "package.release.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"name": "@govoplan/core-webui",
|
||||||
|
"version": "0.1.10",
|
||||||
|
"dependencies": {"@govoplan/campaign-webui": old_ref},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
+ "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(core_webui / "package-lock.release.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "@govoplan/core-webui",
|
||||||
|
"version": "0.1.10",
|
||||||
|
"dependencies": {"@govoplan/campaign-webui": old_ref},
|
||||||
|
},
|
||||||
|
"node_modules/@govoplan/campaign-webui": {
|
||||||
|
"version": "0.1.9",
|
||||||
|
"resolved": f"git+ssh://git@example.test/acme/govoplan-campaign.git#{campaign_head}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
+ "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
git(self.repo, "add", "webui/package.release.json", "webui/package-lock.release.json")
|
||||||
|
git(self.repo, "commit", "-m", "Add release WebUI composition")
|
||||||
|
git(self.repo, "push", "origin", "main")
|
||||||
|
|
||||||
|
result = tag_repositories(
|
||||||
|
repos=("govoplan-core", "govoplan-campaign"),
|
||||||
|
repo_versions={"govoplan-core": "0.1.10", "govoplan-campaign": "0.1.10"},
|
||||||
|
workspace_root=self.workspace,
|
||||||
|
apply=True,
|
||||||
|
push=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("blocked", result["status"])
|
||||||
|
self.assertIn("no selected repository was mutated", result["detail"])
|
||||||
|
self.assertEqual("skipped", result["repositories"][0]["status"])
|
||||||
|
self.assertEqual("blocked", result["repositories"][1]["status"])
|
||||||
|
self.assertIn("release WebUI composition gate failed", result["repositories"][1]["detail"])
|
||||||
|
for repository in (self.repo, self.remote, campaign, campaign_remote):
|
||||||
|
self.assertFalse(ref_exists(repository, "refs/tags/v0.1.10"))
|
||||||
|
|
||||||
|
def test_api_requires_explicit_confirmation_and_ui_exposes_source_release(self) -> None:
|
||||||
|
with TestClient(create_app(workspace_root=self.workspace)) as client:
|
||||||
|
rejected = client.post(
|
||||||
|
"/api/repositories/tag",
|
||||||
|
json={
|
||||||
|
"repos": ["govoplan-core"],
|
||||||
|
"repo_versions": {"govoplan-core": "0.1.10"},
|
||||||
|
"apply": True,
|
||||||
|
"push": True,
|
||||||
|
"confirm": "TAG",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
preview = client.post(
|
||||||
|
"/api/repositories/tag",
|
||||||
|
json={
|
||||||
|
"repos": ["govoplan-core"],
|
||||||
|
"repo_versions": {"govoplan-core": "0.1.10"},
|
||||||
|
"apply": False,
|
||||||
|
"push": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ui = client.get("/")
|
||||||
|
|
||||||
|
self.assertEqual(rejected.status_code, 400)
|
||||||
|
self.assertEqual(preview.status_code, 200)
|
||||||
|
self.assertEqual(preview.json()["repositories"][0]["repo"], "govoplan-core")
|
||||||
|
self.assertFalse(ref_exists(self.repo, "refs/tags/v0.1.10"))
|
||||||
|
self.assertIn('id="publishReleaseTags"', ui.text)
|
||||||
|
self.assertIn('postJson("/api/repositories/tag"', ui.text)
|
||||||
|
self.assertIn("Signed Website Catalog", ui.text)
|
||||||
|
self.assertIn("Apply + Website Tag", ui.text)
|
||||||
|
|
||||||
|
|
||||||
|
def git(cwd: Path, *args: str) -> None:
|
||||||
|
result = subprocess.run(
|
||||||
|
("git", *args),
|
||||||
|
cwd=cwd,
|
||||||
|
check=False,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise AssertionError(f"git {' '.join(args)} failed: {result.stderr or result.stdout}")
|
||||||
|
|
||||||
|
|
||||||
|
def create_release_repo(*, root: Path, workspace: Path, name: str, version: str) -> tuple[Path, Path]:
|
||||||
|
remote = root / f"{name}.git"
|
||||||
|
repo = workspace / name
|
||||||
|
git(root, "init", "--bare", str(remote))
|
||||||
|
git(workspace, "init", "-b", "main", str(repo))
|
||||||
|
git(repo, "config", "user.name", "GovOPlaN Release Test")
|
||||||
|
git(repo, "config", "user.email", "release-test@example.invalid")
|
||||||
|
(repo / "pyproject.toml").write_text(
|
||||||
|
f'[project]\nname = "{name}"\nversion = "{version}"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
git(repo, "add", "pyproject.toml")
|
||||||
|
git(repo, "commit", "-m", f"{name} {version}")
|
||||||
|
git(repo, "remote", "add", "origin", str(remote))
|
||||||
|
git(repo, "push", "-u", "origin", "main")
|
||||||
|
return repo, remote
|
||||||
|
|
||||||
|
|
||||||
|
def add_scoped_workflow_manifest(repo: Path) -> None:
|
||||||
|
package = repo / "src" / "govoplan_access"
|
||||||
|
backend = package / "backend"
|
||||||
|
backend.mkdir(parents=True)
|
||||||
|
(package / "__init__.py").write_text("", encoding="utf-8")
|
||||||
|
(backend / "__init__.py").write_text("", encoding="utf-8")
|
||||||
|
(backend / "manifest.py").write_text(
|
||||||
|
"""from govoplan_core.core.modules import DocumentationCondition, DocumentationTopic, ModuleManifest, PermissionDefinition
|
||||||
|
|
||||||
|
|
||||||
|
def get_manifest():
|
||||||
|
return ModuleManifest(
|
||||||
|
id="access",
|
||||||
|
name="Access",
|
||||||
|
version="0.1.10",
|
||||||
|
permissions=(
|
||||||
|
PermissionDefinition(
|
||||||
|
scope="access:item:read",
|
||||||
|
module_id="access",
|
||||||
|
resource="item",
|
||||||
|
action="read",
|
||||||
|
label="Read items",
|
||||||
|
description="Read example items.",
|
||||||
|
category="Example",
|
||||||
|
level="tenant",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="access.workflow.scoped",
|
||||||
|
title="Scoped task",
|
||||||
|
summary="This is a valid release fixture.",
|
||||||
|
documentation_types=("user",),
|
||||||
|
conditions=(DocumentationCondition(required_scopes=("access:item:read",)),),
|
||||||
|
metadata={"kind": "workflow"},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
with (repo / "pyproject.toml").open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write(
|
||||||
|
'\n[project.entry-points."govoplan.modules"]\n'
|
||||||
|
'access = "govoplan_access.backend.manifest:get_manifest"\n'
|
||||||
|
)
|
||||||
|
git(repo, "add", "pyproject.toml", "src")
|
||||||
|
git(repo, "commit", "-m", "Add scoped workflow manifest")
|
||||||
|
git(repo, "push", "origin", "main")
|
||||||
|
|
||||||
|
|
||||||
|
def replace_with_unscoped_workflow_manifest(repo: Path) -> None:
|
||||||
|
manifest = repo / "src" / "govoplan_access" / "backend" / "manifest.py"
|
||||||
|
manifest.write_text(
|
||||||
|
"""from govoplan_core.core.modules import DocumentationTopic, ModuleManifest
|
||||||
|
|
||||||
|
|
||||||
|
def get_manifest():
|
||||||
|
return ModuleManifest(
|
||||||
|
id="access",
|
||||||
|
name="Access",
|
||||||
|
version="0.1.10",
|
||||||
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="access.workflow.unscoped",
|
||||||
|
title="Unscoped task",
|
||||||
|
summary="This must block release.",
|
||||||
|
documentation_types=("user",),
|
||||||
|
metadata={"kind": "workflow"},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
git(repo, "add", str(manifest.relative_to(repo)))
|
||||||
|
git(repo, "commit", "-m", "Replace scoped workflow with unscoped workflow")
|
||||||
|
git(repo, "push", "origin", "main")
|
||||||
|
|
||||||
|
|
||||||
|
def git_text(cwd: Path, *args: str) -> str:
|
||||||
|
result = subprocess.run(
|
||||||
|
("git", *args),
|
||||||
|
cwd=cwd,
|
||||||
|
check=False,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise AssertionError(f"git {' '.join(args)} failed: {result.stderr or result.stdout}")
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def ref_exists(cwd: Path, ref: str) -> bool:
|
||||||
|
return subprocess.run(
|
||||||
|
("git", "rev-parse", "--verify", ref),
|
||||||
|
cwd=cwd,
|
||||||
|
check=False,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
126
tests/test_release_sbom.py
Normal file
126
tests/test_release_sbom.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release"
|
||||||
|
if str(RELEASE_TOOLS_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(RELEASE_TOOLS_ROOT))
|
||||||
|
|
||||||
|
from govoplan_release.sbom import build_release_sbom, render_sbom, sbom_sha256, validate_sbom # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseSbomTests(unittest.TestCase):
|
||||||
|
@staticmethod
|
||||||
|
def _build_at(timestamp: datetime) -> dict:
|
||||||
|
return build_release_sbom(
|
||||||
|
product_version="1.2.3",
|
||||||
|
pip_inspect={
|
||||||
|
"version": "1",
|
||||||
|
"installed": [{"metadata": {"name": "FastAPI", "version": "0.100.0"}}],
|
||||||
|
},
|
||||||
|
npm_lock={"packages": {"": {"name": "govoplan", "version": "1.2.3"}}},
|
||||||
|
timestamp=timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_builds_valid_deduplicated_python_and_npm_components(self) -> None:
|
||||||
|
integrity = base64.b64encode(b"example digest").decode("ascii")
|
||||||
|
payload = build_release_sbom(
|
||||||
|
product_version="1.2.3",
|
||||||
|
pip_inspect={
|
||||||
|
"version": "1",
|
||||||
|
"installed": [
|
||||||
|
{"metadata": {"name": "FastAPI", "version": "0.100.0"}},
|
||||||
|
{"metadata": {"name": "FastAPI", "version": "0.100.0"}},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
npm_lock={
|
||||||
|
"packages": {
|
||||||
|
"": {"name": "govoplan", "version": "1.2.3"},
|
||||||
|
"node_modules/@scope/example": {
|
||||||
|
"name": "@scope/example",
|
||||||
|
"version": "4.5.6",
|
||||||
|
"integrity": f"sha512-{integrity}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
validate_sbom(payload)
|
||||||
|
|
||||||
|
self.assertEqual(2, len(payload["components"]))
|
||||||
|
npm = next(item for item in payload["components"] if item["name"] == "@scope/example")
|
||||||
|
self.assertEqual("SHA-512", npm["hashes"][0]["alg"])
|
||||||
|
self.assertIn("pkg:npm/%40scope/example@4.5.6", npm["purl"])
|
||||||
|
self.assertEqual(
|
||||||
|
hashlib.sha256(render_sbom(payload).encode("utf-8")).hexdigest(),
|
||||||
|
sbom_sha256(payload),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejects_empty_component_inventory(self) -> None:
|
||||||
|
payload = build_release_sbom(
|
||||||
|
product_version="1.2.3",
|
||||||
|
pip_inspect={"version": "1", "installed": []},
|
||||||
|
npm_lock={"packages": {"": {"version": "1.2.3"}}},
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "no dependency components"):
|
||||||
|
validate_sbom(payload)
|
||||||
|
|
||||||
|
def test_fixed_timestamp_produces_identical_bytes_and_digest(self) -> None:
|
||||||
|
timestamp = datetime(2026, 7, 21, 8, 30, tzinfo=UTC)
|
||||||
|
|
||||||
|
first = self._build_at(timestamp)
|
||||||
|
second = self._build_at(timestamp)
|
||||||
|
|
||||||
|
self.assertEqual(render_sbom(first), render_sbom(second))
|
||||||
|
self.assertEqual(sbom_sha256(first), sbom_sha256(second))
|
||||||
|
|
||||||
|
def test_timestamp_changes_serial_and_digest(self) -> None:
|
||||||
|
first = self._build_at(datetime(2026, 7, 21, 8, 30, tzinfo=UTC))
|
||||||
|
second = self._build_at(datetime(2026, 7, 21, 8, 31, tzinfo=UTC))
|
||||||
|
|
||||||
|
self.assertNotEqual(first["serialNumber"], second["serialNumber"])
|
||||||
|
self.assertNotEqual(sbom_sha256(first), sbom_sha256(second))
|
||||||
|
|
||||||
|
def test_local_and_credential_bearing_distribution_urls_are_omitted(self) -> None:
|
||||||
|
payload = build_release_sbom(
|
||||||
|
product_version="1.2.3",
|
||||||
|
pip_inspect={
|
||||||
|
"version": "1",
|
||||||
|
"installed": [
|
||||||
|
{
|
||||||
|
"metadata": {"name": "LocalPackage", "version": "1.0.0"},
|
||||||
|
"direct_url": {"url": "file:///private/build/govoplan-core"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"metadata": {"name": "PrivatePackage", "version": "1.0.0"},
|
||||||
|
"direct_url": {"url": "https://user:secret@example.test/private.whl"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"metadata": {"name": "PublicPackage", "version": "1.0.0"},
|
||||||
|
"direct_url": {"url": "https://example.test/public.whl"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
npm_lock={"packages": {"": {"name": "govoplan", "version": "1.2.3"}}},
|
||||||
|
timestamp=datetime(2026, 7, 21, 8, 30, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
by_name = {component["name"]: component for component in payload["components"]}
|
||||||
|
self.assertNotIn("externalReferences", by_name["LocalPackage"])
|
||||||
|
self.assertNotIn("externalReferences", by_name["PrivatePackage"])
|
||||||
|
self.assertEqual(
|
||||||
|
[{"type": "distribution", "url": "https://example.test/public.whl"}],
|
||||||
|
by_name["PublicPackage"]["externalReferences"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
335
tests/test_release_source_provenance.py
Normal file
335
tests/test_release_source_provenance.py
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
RELEASE_ROOT = META_ROOT / "tools" / "release"
|
||||||
|
if str(RELEASE_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(RELEASE_ROOT))
|
||||||
|
|
||||||
|
from govoplan_release.publisher import publish_catalog_candidate # noqa: E402
|
||||||
|
from govoplan_release.selective_catalog import ( # noqa: E402
|
||||||
|
build_selective_catalog_candidate,
|
||||||
|
enforce_selected_source_provenance,
|
||||||
|
)
|
||||||
|
from govoplan_release.source_provenance import ( # noqa: E402
|
||||||
|
catalog_source_selection,
|
||||||
|
read_only_https_remote,
|
||||||
|
source_tag_provenance_issues,
|
||||||
|
tagged_source_version_issues,
|
||||||
|
)
|
||||||
|
from server.app import create_app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseSourceProvenanceTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temporary.name)
|
||||||
|
self.workspace = self.root / "workspace"
|
||||||
|
self.workspace.mkdir()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.temporary.cleanup()
|
||||||
|
|
||||||
|
def test_selected_source_requires_local_and_remote_annotated_tag_at_clean_head(self) -> None:
|
||||||
|
repo, remote = make_repo(self.workspace, self.root, "govoplan-core", "1.2.3")
|
||||||
|
|
||||||
|
missing = source_tag_provenance_issues(
|
||||||
|
repo_versions={"govoplan-core": "1.2.3"},
|
||||||
|
workspace=self.workspace,
|
||||||
|
require_head_repos=("govoplan-core",),
|
||||||
|
)
|
||||||
|
self.assertIn("missing from the local source repository", messages(missing))
|
||||||
|
self.assertIn("missing from remote 'origin'", messages(missing))
|
||||||
|
|
||||||
|
git(repo, "tag", "-a", "v1.2.3", "-m", "Core 1.2.3")
|
||||||
|
git(repo, "push", "origin", "refs/tags/v1.2.3")
|
||||||
|
self.assertEqual(
|
||||||
|
(),
|
||||||
|
source_tag_provenance_issues(
|
||||||
|
repo_versions={"govoplan-core": "1.2.3"},
|
||||||
|
workspace=self.workspace,
|
||||||
|
require_head_repos=("govoplan-core",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
git(remote, "config", "user.name", "Different Release Actor")
|
||||||
|
git(remote, "config", "user.email", "different@example.invalid")
|
||||||
|
git(remote, "tag", "-f", "-a", "v1.2.3", git_text(repo, "rev-parse", "HEAD"), "-m", "Different annotation")
|
||||||
|
inconsistent = source_tag_provenance_issues(
|
||||||
|
repo_versions={"govoplan-core": "1.2.3"},
|
||||||
|
workspace=self.workspace,
|
||||||
|
require_head_repos=("govoplan-core",),
|
||||||
|
)
|
||||||
|
self.assertIn("local and remote annotated tag objects differ", messages(inconsistent))
|
||||||
|
|
||||||
|
(repo / "dirty.txt").write_text("not reviewed\n", encoding="utf-8")
|
||||||
|
dirty = source_tag_provenance_issues(
|
||||||
|
repo_versions={"govoplan-core": "1.2.3"},
|
||||||
|
workspace=self.workspace,
|
||||||
|
require_head_repos=("govoplan-core",),
|
||||||
|
)
|
||||||
|
self.assertIn("selected source worktree is not clean", messages(dirty))
|
||||||
|
self.assertEqual(git_text(remote, "rev-parse", "refs/tags/v1.2.3^{commit}"), git_text(repo, "rev-parse", "HEAD"))
|
||||||
|
|
||||||
|
def test_selected_source_gate_is_fail_closed_before_signing(self) -> None:
|
||||||
|
make_repo(self.workspace, self.root, "govoplan-core", "1.2.3")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "Source tag provenance gate failed") as error:
|
||||||
|
enforce_selected_source_provenance(
|
||||||
|
repo_versions={"govoplan-core": "1.2.3"},
|
||||||
|
workspace=self.workspace,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("v1.2.3", str(error.exception))
|
||||||
|
self.assertIn("missing", str(error.exception))
|
||||||
|
|
||||||
|
def test_historical_tag_uses_installable_and_manifest_version_metadata(self) -> None:
|
||||||
|
repo, _ = make_repo(self.workspace, self.root, "govoplan-core", "1.2.3")
|
||||||
|
package = repo / "src" / "govoplan_core"
|
||||||
|
package.mkdir(parents=True)
|
||||||
|
(package / "__init__.py").write_text('__version__ = "1.2.2"\n', encoding="utf-8")
|
||||||
|
git(repo, "add", "src/govoplan_core/__init__.py")
|
||||||
|
git(repo, "commit", "-m", "Keep legacy runtime version string")
|
||||||
|
git(repo, "tag", "-a", "v1.2.3", "-m", "Core 1.2.3")
|
||||||
|
|
||||||
|
issues = tagged_source_version_issues(
|
||||||
|
path=repo,
|
||||||
|
repo="govoplan-core",
|
||||||
|
tag="v1.2.3",
|
||||||
|
expected_version="1.2.3",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual((), issues)
|
||||||
|
|
||||||
|
def test_signed_candidate_records_selected_commit_and_tag_object(self) -> None:
|
||||||
|
core, _ = make_repo(self.workspace, self.root, "govoplan-core", "1.2.3")
|
||||||
|
git(core, "tag", "-a", "v1.2.3", "-m", "Core 1.2.3")
|
||||||
|
git(core, "push", "origin", "refs/tags/v1.2.3")
|
||||||
|
base_catalog = self.root / "base.json"
|
||||||
|
base_catalog.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"channel": "stable",
|
||||||
|
"sequence": 1,
|
||||||
|
"core_release": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"python_ref": "govoplan-core @ git+ssh://git@example.test/acme/govoplan-core.git@v1.2.2",
|
||||||
|
},
|
||||||
|
"modules": [],
|
||||||
|
"release": {"version": "1.2.2", "tag": "v1.2.2"},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
private_key = Ed25519PrivateKey.generate()
|
||||||
|
key_path = self.root / "release.pem"
|
||||||
|
key_path.write_bytes(
|
||||||
|
private_key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.PKCS8,
|
||||||
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
output = self.root / "candidate"
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_release.selective_catalog.validate_module_package_catalog",
|
||||||
|
return_value={"valid": True, "warnings": [], "error": None},
|
||||||
|
):
|
||||||
|
result = build_selective_catalog_candidate(
|
||||||
|
repo_versions={"govoplan-core": "1.2.3"},
|
||||||
|
workspace_root=self.workspace,
|
||||||
|
output_dir=output,
|
||||||
|
base_catalog=base_catalog,
|
||||||
|
signing_keys=(f"test-key={key_path}",),
|
||||||
|
check_public=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = json.loads(Path(result.catalog_path).read_text(encoding="utf-8"))
|
||||||
|
selected = payload["release"]["selected_units"][0]
|
||||||
|
self.assertEqual(git_text(core, "rev-parse", "refs/tags/v1.2.3^{commit}"), selected["commit_sha"])
|
||||||
|
self.assertEqual(git_text(core, "rev-parse", "refs/tags/v1.2.3"), selected["tag_object_sha"])
|
||||||
|
self.assertEqual("test-key", payload["signatures"][0]["key_id"])
|
||||||
|
|
||||||
|
def test_catalog_publication_checks_every_preserved_source_ref(self) -> None:
|
||||||
|
core, _ = make_repo(self.workspace, self.root, "govoplan-core", "1.2.3")
|
||||||
|
git(core, "tag", "-a", "v1.2.3", "-m", "Core 1.2.3")
|
||||||
|
git(core, "push", "origin", "refs/tags/v1.2.3")
|
||||||
|
make_repo(self.workspace, self.root, "govoplan-access", "1.1.0")
|
||||||
|
|
||||||
|
candidate = self.root / "candidate"
|
||||||
|
(candidate / "channels").mkdir(parents=True)
|
||||||
|
payload = catalog_payload(
|
||||||
|
commit_sha="f" * 40,
|
||||||
|
tag_object_sha=git_text(core, "rev-parse", "refs/tags/v1.2.3"),
|
||||||
|
)
|
||||||
|
(candidate / "channels" / "stable.json").write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
keyring = {"keys": [{"key_id": "release-key", "status": "active", "public_key": "trusted"}]}
|
||||||
|
(candidate / "keyring.json").write_text(json.dumps(keyring), encoding="utf-8")
|
||||||
|
web_root = self.root / "website"
|
||||||
|
target_keyring = web_root / "public" / "catalogs" / "v1" / "keyring.json"
|
||||||
|
target_keyring.parent.mkdir(parents=True)
|
||||||
|
target_keyring.write_text(json.dumps(keyring), encoding="utf-8")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_release.publisher.validate_module_package_catalog",
|
||||||
|
return_value={"valid": True, "warnings": [], "error": None},
|
||||||
|
):
|
||||||
|
result = publish_catalog_candidate(
|
||||||
|
candidate_dir=candidate,
|
||||||
|
workspace_root=self.workspace,
|
||||||
|
web_root=web_root,
|
||||||
|
)
|
||||||
|
|
||||||
|
detail = " ".join(result.notes)
|
||||||
|
self.assertEqual("blocked", result.status)
|
||||||
|
self.assertIn("govoplan-access v1.1.0", detail)
|
||||||
|
self.assertIn("missing from the local source repository", detail)
|
||||||
|
self.assertIn("missing from remote 'origin'", detail)
|
||||||
|
self.assertIn("no longer matches signed commit_sha", detail)
|
||||||
|
self.assertNotIn("govoplan-core v1.2.3: annotated tag is missing", detail)
|
||||||
|
|
||||||
|
def test_catalog_selection_includes_core_and_all_modules(self) -> None:
|
||||||
|
selection = catalog_source_selection(catalog_payload())
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
{"govoplan-core": "1.2.3", "govoplan-access": "1.1.0"},
|
||||||
|
selection.all_versions,
|
||||||
|
)
|
||||||
|
self.assertEqual({"govoplan-core": "1.2.3"}, selection.selected_versions)
|
||||||
|
self.assertEqual((), selection.issues)
|
||||||
|
|
||||||
|
def test_console_renders_publication_provenance_notes(self) -> None:
|
||||||
|
ui = (RELEASE_ROOT / "webui" / "index.html").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn("Array.isArray(result.notes)", ui)
|
||||||
|
self.assertIn("Validation and provenance details", ui)
|
||||||
|
|
||||||
|
def test_console_returns_actionable_conflict_for_candidate_provenance_gate(self) -> None:
|
||||||
|
with patch(
|
||||||
|
"server.app.build_selective_catalog_candidate",
|
||||||
|
side_effect=ValueError("Complete catalog source provenance gate failed: govoplan-core v1.2.3: missing"),
|
||||||
|
):
|
||||||
|
with TestClient(create_app(workspace_root=self.workspace)) as client:
|
||||||
|
response = client.post(
|
||||||
|
"/api/catalog-candidates",
|
||||||
|
json={
|
||||||
|
"repo_versions": {"govoplan-core": "1.2.3"},
|
||||||
|
"signing_keys": ["test=/unused/key.pem"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(409, response.status_code)
|
||||||
|
self.assertIn("govoplan-core v1.2.3: missing", response.json()["detail"])
|
||||||
|
|
||||||
|
def test_read_only_ref_checks_can_reuse_the_same_gitea_https_endpoint(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
"https://git.example.test/acme/govoplan-core.git",
|
||||||
|
read_only_https_remote("git@git.example.test:acme/govoplan-core.git"),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"https://git.example.test/acme/govoplan-core.git",
|
||||||
|
read_only_https_remote("ssh://git@git.example.test/acme/govoplan-core.git"),
|
||||||
|
)
|
||||||
|
self.assertIsNone(read_only_https_remote("ssh://git@git.example.test:2222/acme/govoplan-core.git"))
|
||||||
|
self.assertIsNone(read_only_https_remote("/srv/git/govoplan-core.git"))
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_payload(
|
||||||
|
*,
|
||||||
|
commit_sha: str = "0" * 40,
|
||||||
|
tag_object_sha: str = "1" * 40,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"channel": "stable",
|
||||||
|
"core_release": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"python_ref": "govoplan-core @ git+ssh://git@example.test/acme/govoplan-core.git@v1.2.3",
|
||||||
|
},
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"module_id": "access",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"python_package": "govoplan-access",
|
||||||
|
"python_ref": "govoplan-access @ git+ssh://git@example.test/acme/govoplan-access.git@v1.1.0",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"release": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"tag": "v1.2.3",
|
||||||
|
"selected_units": [
|
||||||
|
{
|
||||||
|
"repo": "govoplan-core",
|
||||||
|
"version": "1.2.3",
|
||||||
|
"tag": "v1.2.3",
|
||||||
|
"commit_sha": commit_sha,
|
||||||
|
"tag_object_sha": tag_object_sha,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_repo(workspace: Path, root: Path, name: str, version: str) -> tuple[Path, Path]:
|
||||||
|
remote = root / f"{name}.git"
|
||||||
|
repo = workspace / name
|
||||||
|
git(root, "init", "--bare", str(remote))
|
||||||
|
git(workspace, "init", "-b", "main", str(repo))
|
||||||
|
git(repo, "config", "user.name", "GovOPlaN Release Test")
|
||||||
|
git(repo, "config", "user.email", "release-test@example.invalid")
|
||||||
|
(repo / "pyproject.toml").write_text(
|
||||||
|
f'[project]\nname = "{name}"\nversion = "{version}"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
git(repo, "add", "pyproject.toml")
|
||||||
|
git(repo, "commit", "-m", f"{name} {version}")
|
||||||
|
git(repo, "remote", "add", "origin", str(remote))
|
||||||
|
git(repo, "push", "-u", "origin", "main")
|
||||||
|
return repo, remote
|
||||||
|
|
||||||
|
|
||||||
|
def messages(issues: object) -> str:
|
||||||
|
return " ".join(issue.message for issue in issues) # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
|
def git(cwd: Path, *args: str) -> None:
|
||||||
|
result = subprocess.run(
|
||||||
|
("git", *args),
|
||||||
|
cwd=cwd,
|
||||||
|
check=False,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise AssertionError(f"git {' '.join(args)} failed: {result.stderr or result.stdout}")
|
||||||
|
|
||||||
|
|
||||||
|
def git_text(cwd: Path, *args: str) -> str:
|
||||||
|
result = subprocess.run(
|
||||||
|
("git", *args),
|
||||||
|
cwd=cwd,
|
||||||
|
check=False,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise AssertionError(f"git {' '.join(args)} failed: {result.stderr or result.stdout}")
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
358
tests/test_security_audit_wrapper.py
Normal file
358
tests/test_security_audit_wrapper.py
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import textwrap
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
AUDIT_SCRIPT = META_ROOT / "tools" / "checks" / "check-security-audit.sh"
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityAuditWrapperTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._temporary_directory = tempfile.TemporaryDirectory()
|
||||||
|
self.temporary_root = Path(self._temporary_directory.name)
|
||||||
|
self.checkout = self.temporary_root / "checkout"
|
||||||
|
checks = self.checkout / "tools" / "checks"
|
||||||
|
checks.mkdir(parents=True)
|
||||||
|
shutil.copy2(AUDIT_SCRIPT, checks / AUDIT_SCRIPT.name)
|
||||||
|
(self.checkout / "README.md").write_text("audit fixture\n", encoding="utf-8")
|
||||||
|
|
||||||
|
subprocess.run(["git", "init", "-q", str(self.checkout)], check=True)
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
"-C",
|
||||||
|
str(self.checkout),
|
||||||
|
"config",
|
||||||
|
"user.email",
|
||||||
|
"audit@example.invalid",
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(self.checkout), "config", "user.name", "Audit Test"],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
subprocess.run(["git", "-C", str(self.checkout), "add", "."], check=True)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(self.checkout), "commit", "-qm", "audit fixture"],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stub_bin = self.temporary_root / "bin"
|
||||||
|
self.stub_bin.mkdir()
|
||||||
|
self._write_stub(
|
||||||
|
"semgrep",
|
||||||
|
r"""
|
||||||
|
if [[ " $* " == *" --version "* ]]; then
|
||||||
|
echo 'semgrep stub 1.0'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ " $* " != *" --error "* ]]; then
|
||||||
|
echo 'missing --error' >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
output=''
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
if [[ "$1" == '--output' ]]; then
|
||||||
|
output="$2"
|
||||||
|
shift 2
|
||||||
|
else
|
||||||
|
shift
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
case "${STUB_SEMGREP_REPORT:-valid}" in
|
||||||
|
valid)
|
||||||
|
printf '%s\n' '{"version":"2.1.0","runs":[]}' > "$output"
|
||||||
|
;;
|
||||||
|
malformed)
|
||||||
|
printf '%s\n' '{' > "$output"
|
||||||
|
;;
|
||||||
|
invalid-sarif)
|
||||||
|
printf '%s\n' '{}' > "$output"
|
||||||
|
;;
|
||||||
|
missing)
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if [[ -n "${STUB_MUTATE_PATH:-}" ]]; then
|
||||||
|
printf '%s\n' 'changed during audit' >> "$STUB_MUTATE_PATH"
|
||||||
|
fi
|
||||||
|
exit "${STUB_SEMGREP_EXIT:-0}"
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
self._write_stub(
|
||||||
|
"gitleaks",
|
||||||
|
r"""
|
||||||
|
if [[ "${1:-}" == '--version' || "${1:-}" == 'version' ]]; then
|
||||||
|
echo 'gitleaks stub 1.0'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ "${1:-}" == 'git' && "${2:-}" == '--help' ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
output=''
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
if [[ "$1" == '--report-path' ]]; then
|
||||||
|
output="$2"
|
||||||
|
shift 2
|
||||||
|
else
|
||||||
|
shift
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
mkdir -p "$(dirname "$output")"
|
||||||
|
printf '%s\n' '[]' > "$output"
|
||||||
|
exit "${STUB_GITLEAKS_EXIT:-0}"
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
for tool in ("bandit", "ruff"):
|
||||||
|
self._write_stub(
|
||||||
|
tool,
|
||||||
|
f"""
|
||||||
|
if [[ " $* " == *" --version "* ]]; then
|
||||||
|
echo '{tool} stub 1.0'
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self._temporary_directory.cleanup()
|
||||||
|
|
||||||
|
def _write_stub(self, name: str, body: str) -> None:
|
||||||
|
path = self.stub_bin / name
|
||||||
|
path.write_text(
|
||||||
|
"#!/usr/bin/env bash\nset -euo pipefail\n" + textwrap.dedent(body).lstrip(),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
path.chmod(0o755)
|
||||||
|
|
||||||
|
def _install_full_mode_stubs(self) -> None:
|
||||||
|
self._write_stub(
|
||||||
|
"trivy",
|
||||||
|
r"""
|
||||||
|
if [[ " $* " == *" --version "* ]]; then
|
||||||
|
echo 'trivy stub 1.0'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
output=''
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
if [[ "$1" == '--output' ]]; then
|
||||||
|
output="$2"
|
||||||
|
shift 2
|
||||||
|
else
|
||||||
|
shift
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
printf '%s\n' '{"version":"2.1.0","runs":[]}' > "$output"
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
self._write_stub(
|
||||||
|
"osv-scanner",
|
||||||
|
r"""
|
||||||
|
if [[ " $* " == *" --version "* ]]; then
|
||||||
|
echo 'osv-scanner stub 1.0'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ " $* " == *" scan --help "* ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
output=''
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
if [[ "$1" == '--output-file' ]]; then
|
||||||
|
output="$2"
|
||||||
|
shift 2
|
||||||
|
else
|
||||||
|
shift
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
printf '{"results": %s}\n' "${STUB_OSV_RESULTS:-null}" > "$output"
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
self._write_stub(
|
||||||
|
"jscpd",
|
||||||
|
r"""
|
||||||
|
if [[ " $* " == *" --version "* ]]; then
|
||||||
|
echo 'jscpd stub 1.0'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
output=''
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
if [[ "$1" == '--output' ]]; then
|
||||||
|
output="$2"
|
||||||
|
shift 2
|
||||||
|
else
|
||||||
|
shift
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
mkdir -p "$output"
|
||||||
|
printf '%s\n' '{"duplicates":[],"statistics":{}}' > "$output/jscpd-report.json"
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
for tool in ("pip-audit", "npm", "radon", "xenon"):
|
||||||
|
self._write_stub(
|
||||||
|
tool,
|
||||||
|
f"""
|
||||||
|
if [[ " $* " == *" --version "* ]]; then
|
||||||
|
echo '{tool} stub 1.0'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _run(
|
||||||
|
self, *arguments: str, mode: str = "quick", **environment: str
|
||||||
|
) -> tuple[subprocess.CompletedProcess[str], Path]:
|
||||||
|
reports = self.temporary_root / "reports"
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.update(
|
||||||
|
{
|
||||||
|
"PATH": f"{self.stub_bin}:{env['PATH']}",
|
||||||
|
"SECURITY_AUDIT_REQUIRE_TOOLS": "1",
|
||||||
|
"SECURITY_AUDIT_TOOLBOX_FINGERPRINT": 'stub-"safe',
|
||||||
|
**environment,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"bash",
|
||||||
|
str(self.checkout / "tools" / "checks" / AUDIT_SCRIPT.name),
|
||||||
|
"--mode",
|
||||||
|
mode,
|
||||||
|
"--scope",
|
||||||
|
"current",
|
||||||
|
"--reports-dir",
|
||||||
|
str(reports),
|
||||||
|
*arguments,
|
||||||
|
],
|
||||||
|
cwd=self.checkout,
|
||||||
|
env=env,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return result, reports
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _manifest(reports: Path) -> dict[str, object]:
|
||||||
|
return json.loads((reports / "manifest.json").read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
def test_report_only_findings_are_nonfatal_and_strict_findings_fail(self) -> None:
|
||||||
|
result, reports = self._run("--report-only", STUB_SEMGREP_EXIT="1")
|
||||||
|
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
manifest = self._manifest(reports)
|
||||||
|
self.assertEqual(0, manifest["overall_status"])
|
||||||
|
self.assertEqual(1, manifest["finding_status"])
|
||||||
|
self.assertEqual(0, manifest["execution_error_status"])
|
||||||
|
self.assertIn(
|
||||||
|
"Semgrep SAST\t1\tfindings", (reports / "step-status.tsv").read_text()
|
||||||
|
)
|
||||||
|
|
||||||
|
strict_result, strict_reports = self._run("--strict", STUB_SEMGREP_EXIT="1")
|
||||||
|
self.assertEqual(1, strict_result.returncode)
|
||||||
|
self.assertEqual(1, self._manifest(strict_reports)["overall_status"])
|
||||||
|
|
||||||
|
def test_scanner_execution_error_is_fatal_in_report_only_mode(self) -> None:
|
||||||
|
result, reports = self._run("--report-only", STUB_SEMGREP_EXIT="2")
|
||||||
|
|
||||||
|
self.assertEqual(1, result.returncode)
|
||||||
|
manifest = self._manifest(reports)
|
||||||
|
self.assertEqual(1, manifest["overall_status"])
|
||||||
|
self.assertEqual(1, manifest["execution_error_status"])
|
||||||
|
self.assertIn(
|
||||||
|
"Semgrep SAST\t2\texecution-error",
|
||||||
|
(reports / "step-status.tsv").read_text(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_malformed_or_missing_machine_report_is_fatal(self) -> None:
|
||||||
|
for report_kind in ("malformed", "invalid-sarif", "missing"):
|
||||||
|
with self.subTest(report_kind=report_kind):
|
||||||
|
result, reports = self._run(
|
||||||
|
"--report-only",
|
||||||
|
STUB_SEMGREP_REPORT=report_kind,
|
||||||
|
)
|
||||||
|
self.assertEqual(1, result.returncode)
|
||||||
|
manifest = self._manifest(reports)
|
||||||
|
self.assertEqual(1, manifest["execution_error_status"])
|
||||||
|
if report_kind == "missing":
|
||||||
|
self.assertIn("semgrep.sarif", manifest["missing_reports"])
|
||||||
|
self.assertIn(
|
||||||
|
"Validate machine-readable audit reports\t2\texecution-error",
|
||||||
|
(reports / "step-status.tsv").read_text(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_osv_null_results_are_valid_but_wrong_shapes_fail(self) -> None:
|
||||||
|
self._install_full_mode_stubs()
|
||||||
|
|
||||||
|
result, reports = self._run("--report-only", mode="full")
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
osv_report = json.loads(
|
||||||
|
(reports / "osv-scanner-checkout.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
self.assertIsNone(osv_report["results"])
|
||||||
|
|
||||||
|
invalid_result, invalid_reports = self._run(
|
||||||
|
"--report-only",
|
||||||
|
mode="full",
|
||||||
|
STUB_OSV_RESULTS="{}",
|
||||||
|
)
|
||||||
|
self.assertEqual(1, invalid_result.returncode)
|
||||||
|
self.assertIn(
|
||||||
|
"invalid OSV-Scanner report",
|
||||||
|
invalid_result.stderr,
|
||||||
|
)
|
||||||
|
self.assertEqual(1, self._manifest(invalid_reports)["execution_error_status"])
|
||||||
|
|
||||||
|
def test_workspace_mutation_invalidates_the_audit(self) -> None:
|
||||||
|
result, reports = self._run(
|
||||||
|
"--report-only",
|
||||||
|
STUB_MUTATE_PATH=str(self.checkout / "README.md"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, result.returncode)
|
||||||
|
manifest = self._manifest(reports)
|
||||||
|
self.assertFalse(manifest["workspace_unchanged"])
|
||||||
|
self.assertEqual(1, manifest["overall_status"])
|
||||||
|
self.assertNotEqual(
|
||||||
|
(reports / "workspace-state-start.tsv").read_text(),
|
||||||
|
(reports / "workspace-state-end.tsv").read_text(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_manifest_and_checksums_cover_only_current_run_reports(self) -> None:
|
||||||
|
reports = self.temporary_root / "reports"
|
||||||
|
reports.mkdir()
|
||||||
|
(reports / "stale-malformed.json").write_text("{", encoding="utf-8")
|
||||||
|
|
||||||
|
result, reports = self._run("--report-only")
|
||||||
|
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
manifest = self._manifest(reports)
|
||||||
|
self.assertEqual('stub-"safe', manifest["toolbox_fingerprint"])
|
||||||
|
self.assertNotIn("stale-malformed.json", manifest["reports"])
|
||||||
|
self.assertEqual([], manifest["missing_reports"])
|
||||||
|
checksums = (reports / "report-sha256.txt").read_text(encoding="utf-8")
|
||||||
|
self.assertNotIn("stale-malformed.json", checksums)
|
||||||
|
verification = subprocess.run(
|
||||||
|
["sha256sum", "--check", "report-sha256.txt"],
|
||||||
|
cwd=reports,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(0, verification.returncode, verification.stderr)
|
||||||
|
checksummed_paths = {
|
||||||
|
line.split(" ", 1)[1] for line in checksums.splitlines() if " " in line
|
||||||
|
}
|
||||||
|
self.assertEqual(set(manifest["reports"]), checksummed_paths)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
395
tests/test_version_alignment.py
Normal file
395
tests/test_version_alignment.py
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release"
|
||||||
|
if str(RELEASE_TOOLS_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(RELEASE_TOOLS_ROOT))
|
||||||
|
|
||||||
|
from govoplan_release.version_alignment import ( # noqa: E402
|
||||||
|
candidate_catalog_version_issues,
|
||||||
|
release_composition_issues,
|
||||||
|
selected_release_webui_bundle_issues,
|
||||||
|
repository_version_issues,
|
||||||
|
selected_repository_version_issues,
|
||||||
|
)
|
||||||
|
from govoplan_release.model import ( # noqa: E402
|
||||||
|
CatalogSnapshot,
|
||||||
|
DashboardSummary,
|
||||||
|
ReleaseDashboard,
|
||||||
|
RepositorySnapshot,
|
||||||
|
RepositorySpec,
|
||||||
|
VersionSnapshot,
|
||||||
|
)
|
||||||
|
from govoplan_release.selective_planner import build_selective_release_plan, build_unit # noqa: E402
|
||||||
|
from govoplan_release.selective_catalog import enforce_selected_version_alignment # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class VersionAlignmentTests(unittest.TestCase):
|
||||||
|
def test_candidate_catalog_refs_and_selected_units_must_match_versions(self) -> None:
|
||||||
|
payload = {
|
||||||
|
"core_release": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"python_ref": "govoplan-core @ git+ssh://git@example.test/acme/govoplan-core.git@v1.2.3",
|
||||||
|
},
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"module_id": "files",
|
||||||
|
"version": "1.2.3",
|
||||||
|
"python_ref": "govoplan-files @ git+ssh://git@example.test/acme/govoplan-files.git@v1.2.4",
|
||||||
|
"webui_package": "@govoplan/files-webui",
|
||||||
|
"webui_ref": "git+ssh://git@example.test/acme/govoplan-files.git#v1.2.3",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"release": {
|
||||||
|
"selected_units": [
|
||||||
|
{"repo": "govoplan-files", "version": "1.2.5", "tag": "v1.2.6"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
issues = candidate_catalog_version_issues(payload)
|
||||||
|
|
||||||
|
messages = {issue.message for issue in issues}
|
||||||
|
self.assertIn("Python ref tag must match the catalog entry version", messages)
|
||||||
|
self.assertIn("selected-unit tag must match its version", messages)
|
||||||
|
self.assertIn("selected-unit version must match the catalog entry", messages)
|
||||||
|
|
||||||
|
def test_catalog_candidate_gate_rejects_selected_repo_drift(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
workspace = Path(tmp)
|
||||||
|
repo = workspace / "govoplan-files"
|
||||||
|
(repo / "webui").mkdir(parents=True)
|
||||||
|
(repo / "pyproject.toml").write_text('[project]\nname="govoplan-files"\nversion="1.2.3"\n')
|
||||||
|
(repo / "webui" / "package.json").write_text('{"version":"1.2.4"}\n')
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "Version alignment gate failed"):
|
||||||
|
enforce_selected_version_alignment(
|
||||||
|
repo_versions={"govoplan-files": "1.2.5"},
|
||||||
|
workspace=workspace,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_catalog_candidate_gate_rejects_requested_version_that_differs_from_source(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
workspace = Path(tmp)
|
||||||
|
repo = workspace / "govoplan-files"
|
||||||
|
(repo / "webui").mkdir(parents=True)
|
||||||
|
(repo / "pyproject.toml").write_text('[project]\nname="govoplan-files"\nversion="1.2.3"\n')
|
||||||
|
(repo / "webui" / "package.json").write_text('{"version":"1.2.3"}\n')
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "source version must match the requested release version"):
|
||||||
|
enforce_selected_version_alignment(
|
||||||
|
repo_versions={"govoplan-files": "1.2.4"},
|
||||||
|
workspace=workspace,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_selected_webui_release_must_match_core_bundle_input_and_lock(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
workspace = Path(tmp)
|
||||||
|
core_webui = workspace / "govoplan-core" / "webui"
|
||||||
|
campaign_webui = workspace / "govoplan-campaign" / "webui"
|
||||||
|
core_webui.mkdir(parents=True)
|
||||||
|
campaign_webui.mkdir(parents=True)
|
||||||
|
dependency = "@govoplan/campaign-webui"
|
||||||
|
old_ref = "git+ssh://git@example.test/acme/govoplan-campaign.git#v1.2.2"
|
||||||
|
(campaign_webui / "package.json").write_text(
|
||||||
|
json.dumps({"name": dependency, "version": "1.2.3"})
|
||||||
|
)
|
||||||
|
(core_webui / "package.release.json").write_text(
|
||||||
|
json.dumps({"dependencies": {dependency: old_ref}})
|
||||||
|
)
|
||||||
|
(core_webui / "package-lock.release.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"packages": {
|
||||||
|
"": {"dependencies": {dependency: old_ref}},
|
||||||
|
f"node_modules/{dependency}": {"version": "1.2.2"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
issues = selected_release_webui_bundle_issues(
|
||||||
|
repo_versions={"govoplan-core": "2.0.0", "govoplan-campaign": "1.2.3"},
|
||||||
|
workspace=workspace,
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ValueError, "Core release WebUI dependency"):
|
||||||
|
enforce_selected_version_alignment(
|
||||||
|
repo_versions={"govoplan-campaign": "1.2.3"},
|
||||||
|
workspace=workspace,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(2, len(issues))
|
||||||
|
self.assertTrue(all(issue.repo == "govoplan-campaign" for issue in issues))
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"Core release WebUI dependency must match the selected module version",
|
||||||
|
"Core release lock must resolve the selected module version",
|
||||||
|
},
|
||||||
|
{issue.message for issue in issues},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_selected_webui_gate_ignores_unselected_module_pins(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
workspace = Path(tmp)
|
||||||
|
core_webui = workspace / "govoplan-core" / "webui"
|
||||||
|
campaign_webui = workspace / "govoplan-campaign" / "webui"
|
||||||
|
core_webui.mkdir(parents=True)
|
||||||
|
campaign_webui.mkdir(parents=True)
|
||||||
|
campaign_package = "@govoplan/campaign-webui"
|
||||||
|
files_package = "@govoplan/files-webui"
|
||||||
|
campaign_ref = "git+ssh://git@example.test/acme/govoplan-campaign.git#v1.2.3"
|
||||||
|
files_ref = "git+ssh://git@example.test/acme/govoplan-files.git#v0.9.0"
|
||||||
|
(campaign_webui / "package.json").write_text(
|
||||||
|
json.dumps({"name": campaign_package, "version": "1.2.3"})
|
||||||
|
)
|
||||||
|
dependencies = {campaign_package: campaign_ref, files_package: files_ref}
|
||||||
|
(core_webui / "package.release.json").write_text(json.dumps({"dependencies": dependencies}))
|
||||||
|
(core_webui / "package-lock.release.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"packages": {
|
||||||
|
"": {"dependencies": dependencies},
|
||||||
|
f"node_modules/{campaign_package}": {"version": "1.2.3"},
|
||||||
|
f"node_modules/{files_package}": {"version": "0.9.0"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
issues = selected_release_webui_bundle_issues(
|
||||||
|
repo_versions={"govoplan-campaign": "1.2.3"},
|
||||||
|
workspace=workspace,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual((), issues)
|
||||||
|
|
||||||
|
def test_selective_plan_blocks_mismatched_selected_webui_bundle(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
workspace = Path(tmp)
|
||||||
|
core_webui = workspace / "govoplan-core" / "webui"
|
||||||
|
campaign_webui = workspace / "govoplan-campaign" / "webui"
|
||||||
|
core_webui.mkdir(parents=True)
|
||||||
|
campaign_webui.mkdir(parents=True)
|
||||||
|
package_name = "@govoplan/campaign-webui"
|
||||||
|
old_ref = "git+ssh://git@example.test/acme/govoplan-campaign.git#v1.2.2"
|
||||||
|
(campaign_webui / "package.json").write_text(
|
||||||
|
json.dumps({"name": package_name, "version": "1.2.3"})
|
||||||
|
)
|
||||||
|
(core_webui / "package.release.json").write_text(
|
||||||
|
json.dumps({"dependencies": {package_name: old_ref}})
|
||||||
|
)
|
||||||
|
(core_webui / "package-lock.release.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"packages": {
|
||||||
|
"": {"dependencies": {package_name: old_ref}},
|
||||||
|
f"node_modules/{package_name}": {"version": "1.2.2"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
campaign = RepositorySnapshot(
|
||||||
|
spec=RepositorySpec(
|
||||||
|
name="govoplan-campaign",
|
||||||
|
category="module",
|
||||||
|
subtype="domain",
|
||||||
|
remote="git@example.test:acme/govoplan-campaign.git",
|
||||||
|
path="govoplan-campaign",
|
||||||
|
),
|
||||||
|
absolute_path=str(workspace / "govoplan-campaign"),
|
||||||
|
exists=True,
|
||||||
|
is_git=True,
|
||||||
|
has_head=True,
|
||||||
|
branch="main",
|
||||||
|
versions=VersionSnapshot(pyproject="1.2.3", webui_package="1.2.3"),
|
||||||
|
)
|
||||||
|
dashboard = ReleaseDashboard(
|
||||||
|
generated_at="2026-01-01T00:00:00Z",
|
||||||
|
meta_root=str(workspace / "govoplan"),
|
||||||
|
workspace_root=str(workspace),
|
||||||
|
target_version=None,
|
||||||
|
target_tag=None,
|
||||||
|
online=False,
|
||||||
|
include_migrations=False,
|
||||||
|
summary=DashboardSummary(
|
||||||
|
repository_count=1,
|
||||||
|
missing_count=0,
|
||||||
|
dirty_count=0,
|
||||||
|
ahead_count=0,
|
||||||
|
behind_count=0,
|
||||||
|
no_head_count=0,
|
||||||
|
error_count=0,
|
||||||
|
safe_directory_count=0,
|
||||||
|
local_target_tag_missing_count=0,
|
||||||
|
status="ready",
|
||||||
|
),
|
||||||
|
repositories=(campaign,),
|
||||||
|
catalog=CatalogSnapshot(
|
||||||
|
channel="stable",
|
||||||
|
website_path="",
|
||||||
|
catalog_path="",
|
||||||
|
catalog_exists=False,
|
||||||
|
keyring_path="",
|
||||||
|
keyring_exists=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
plan = build_selective_release_plan(
|
||||||
|
dashboard,
|
||||||
|
selected_repos=("govoplan-campaign",),
|
||||||
|
repo_versions={"govoplan-campaign": "1.2.3"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("blocked", plan.status)
|
||||||
|
self.assertEqual("blocked", plan.units[0].status)
|
||||||
|
self.assertTrue(any("Core release WebUI dependency" in item for item in plan.units[0].blockers))
|
||||||
|
|
||||||
|
def test_selected_repository_gate_reports_missing_version_metadata(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
workspace = Path(tmp)
|
||||||
|
(workspace / "govoplan-files").mkdir()
|
||||||
|
|
||||||
|
issues = selected_repository_version_issues(
|
||||||
|
repo_versions={"govoplan-files": "1.2.3"},
|
||||||
|
workspace=workspace,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, len(issues))
|
||||||
|
self.assertEqual("repository has no version metadata", issues[0].message)
|
||||||
|
|
||||||
|
def test_selective_release_unit_is_blocked_by_version_drift(self) -> None:
|
||||||
|
repo = RepositorySnapshot(
|
||||||
|
spec=RepositorySpec(
|
||||||
|
name="govoplan-example",
|
||||||
|
category="module",
|
||||||
|
subtype="domain",
|
||||||
|
remote="git@example.test:acme/govoplan-example.git",
|
||||||
|
path="govoplan-example",
|
||||||
|
),
|
||||||
|
absolute_path="/tmp/govoplan-example",
|
||||||
|
exists=True,
|
||||||
|
is_git=True,
|
||||||
|
has_head=True,
|
||||||
|
versions=VersionSnapshot(pyproject="1.2.3", webui_package="1.2.4", manifests=("1.2.3",)),
|
||||||
|
)
|
||||||
|
|
||||||
|
unit = build_unit(repo, target_version="1.2.5", contracts=None)
|
||||||
|
|
||||||
|
self.assertEqual("blocked", unit.status)
|
||||||
|
self.assertIn("version metadata is not aligned", unit.blockers[0])
|
||||||
|
|
||||||
|
def test_repository_versions_and_lockfiles_must_match(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp) / "govoplan-example"
|
||||||
|
(root / "src" / "govoplan_example" / "backend").mkdir(parents=True)
|
||||||
|
(root / "webui").mkdir()
|
||||||
|
(root / "pyproject.toml").write_text('[project]\nname = "govoplan-example"\nversion = "1.2.3"\n')
|
||||||
|
(root / "package.json").write_text('{"name":"@govoplan/example","version":"1.2.3"}\n')
|
||||||
|
(root / "webui" / "package.json").write_text('{"name":"@govoplan/example-webui","version":"1.2.4"}\n')
|
||||||
|
(root / "webui" / "package-lock.json").write_text(
|
||||||
|
json.dumps({"packages": {"": {"version": "1.2.2"}}})
|
||||||
|
)
|
||||||
|
(root / "src" / "govoplan_example" / "backend" / "manifest.py").write_text(
|
||||||
|
'manifest = ModuleManifest(\n version="1.2.3",\n)\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
issues = repository_version_issues(root)
|
||||||
|
|
||||||
|
self.assertEqual(2, len(issues))
|
||||||
|
self.assertEqual({"webui/package.json", "webui/package-lock.json"}, {issue.source for issue in issues})
|
||||||
|
|
||||||
|
def test_public_runtime_version_must_match_package_metadata(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp) / "govoplan-example"
|
||||||
|
package = root / "src" / "govoplan_example"
|
||||||
|
package.mkdir(parents=True)
|
||||||
|
(root / "pyproject.toml").write_text(
|
||||||
|
'[project]\nname = "govoplan-example"\nversion = "1.2.3"\n'
|
||||||
|
)
|
||||||
|
(package / "__init__.py").write_text('__version__ = "1.2.2"\n')
|
||||||
|
|
||||||
|
issues = repository_version_issues(root)
|
||||||
|
|
||||||
|
self.assertEqual(1, len(issues))
|
||||||
|
self.assertEqual("package __init__.py 1", issues[0].source)
|
||||||
|
self.assertEqual("1.2.3", issues[0].expected)
|
||||||
|
self.assertEqual("1.2.2", issues[0].actual)
|
||||||
|
|
||||||
|
def test_release_backend_and_frontend_refs_must_match(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
meta = Path(tmp) / "govoplan"
|
||||||
|
core = Path(tmp) / "govoplan-core"
|
||||||
|
meta.mkdir()
|
||||||
|
(core / "webui").mkdir(parents=True)
|
||||||
|
(meta / "requirements-release.txt").write_text(
|
||||||
|
"govoplan-example @ git+ssh://git@example.test/acme/govoplan-example.git@v1.2.3\n"
|
||||||
|
)
|
||||||
|
(core / "webui" / "package.release.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"@govoplan/example-webui": (
|
||||||
|
"git+ssh://git@example.test/acme/govoplan-example.git#v1.2.4"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
issues = release_composition_issues(meta, core_root=core)
|
||||||
|
|
||||||
|
self.assertEqual(1, len(issues))
|
||||||
|
self.assertEqual("1.2.3", issues[0].expected)
|
||||||
|
self.assertEqual("1.2.4", issues[0].actual)
|
||||||
|
|
||||||
|
def test_release_refs_must_point_at_installable_versioned_artifacts(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
workspace = Path(tmp)
|
||||||
|
meta = workspace / "govoplan"
|
||||||
|
core = workspace / "govoplan-core"
|
||||||
|
module = workspace / "govoplan-example"
|
||||||
|
meta.mkdir()
|
||||||
|
(core / "webui").mkdir(parents=True)
|
||||||
|
module.mkdir()
|
||||||
|
(meta / "requirements-release.txt").write_text(
|
||||||
|
"govoplan-example @ git+ssh://git@example.test/acme/govoplan-example.git@v1.2.3\n"
|
||||||
|
)
|
||||||
|
(core / "webui" / "package.release.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"@govoplan/example-webui": (
|
||||||
|
"git+ssh://git@example.test/acme/govoplan-example.git#v1.2.3"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
subprocess.run(["git", "init", "-q", str(module)], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(module), "config", "user.email", "test@example.test"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(module), "config", "user.name", "Test"], check=True)
|
||||||
|
(module / "README.md").write_text("uninstallable release\n")
|
||||||
|
subprocess.run(["git", "-C", str(module), "add", "README.md"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(module), "commit", "-qm", "release"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(module), "tag", "v1.2.3"], check=True)
|
||||||
|
|
||||||
|
issues = release_composition_issues(meta, core_root=core)
|
||||||
|
|
||||||
|
self.assertEqual(2, len(issues))
|
||||||
|
self.assertEqual(
|
||||||
|
{"v1.2.3:pyproject.toml", "v1.2.3:webui/package.json"},
|
||||||
|
{issue.source for issue in issues},
|
||||||
|
)
|
||||||
|
self.assertTrue(all("must contain" in issue.message for issue in issues))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
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,59 +33,14 @@ 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
|
||||||
|
|
||||||
root = pathlib.Path.cwd()
|
root = pathlib.Path.cwd()
|
||||||
scan_roots = [
|
scan_roots = [root / "tests"] + [
|
||||||
root / "src",
|
repo / "src"
|
||||||
root / "tests",
|
for repo in sorted(root.parent.glob("govoplan*"))
|
||||||
root.parent / "govoplan-access" / "src",
|
if (repo / "src").is_dir()
|
||||||
root.parent / "govoplan-admin" / "src",
|
|
||||||
root.parent / "govoplan-audit" / "src",
|
|
||||||
root.parent / "govoplan-calendar" / "src",
|
|
||||||
root.parent / "govoplan-campaign" / "src",
|
|
||||||
root.parent / "govoplan-dashboard" / "src",
|
|
||||||
root.parent / "govoplan-files" / "src",
|
|
||||||
root.parent / "govoplan-identity" / "src",
|
|
||||||
root.parent / "govoplan-mail" / "src",
|
|
||||||
root.parent / "govoplan-ops" / "src",
|
|
||||||
root.parent / "govoplan-organizations" / "src",
|
|
||||||
root.parent / "govoplan-policy" / "src",
|
|
||||||
root.parent / "govoplan-tenancy" / "src",
|
|
||||||
]
|
]
|
||||||
deprecated_tokens = {
|
deprecated_tokens = {
|
||||||
"HTTP_422_UNPROCESSABLE_ENTITY": "Use HTTP_422_UNPROCESSABLE_CONTENT; the numeric status remains 422.",
|
"HTTP_422_UNPROCESSABLE_ENTITY": "Use HTTP_422_UNPROCESSABLE_CONTENT; the numeric status remains 422.",
|
||||||
|
|||||||
@@ -27,26 +27,25 @@ 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
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 "$PYTHON" "$META_ROOT/tools/checks/check-manifest-shapes.py"
|
||||||
|
|
||||||
"$PYTHON" - <<'PY'
|
"$PYTHON" - <<'PY'
|
||||||
import ast
|
import ast
|
||||||
import pathlib
|
import pathlib
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
repos_root = pathlib.Path("/mnt/DATA/git")
|
||||||
roots = [
|
roots = [
|
||||||
pathlib.Path("/mnt/DATA/git/govoplan-core/src"),
|
repo / "src"
|
||||||
pathlib.Path("/mnt/DATA/git/govoplan-access/src"),
|
for repo in sorted(repos_root.glob("govoplan*"))
|
||||||
pathlib.Path("/mnt/DATA/git/govoplan-admin/src"),
|
if (repo / "src").is_dir()
|
||||||
pathlib.Path("/mnt/DATA/git/govoplan-tenancy/src"),
|
|
||||||
pathlib.Path("/mnt/DATA/git/govoplan-organizations/src"),
|
|
||||||
pathlib.Path("/mnt/DATA/git/govoplan-identity/src"),
|
|
||||||
pathlib.Path("/mnt/DATA/git/govoplan-policy/src"),
|
|
||||||
pathlib.Path("/mnt/DATA/git/govoplan-audit/src"),
|
|
||||||
pathlib.Path("/mnt/DATA/git/govoplan-mail/src"),
|
|
||||||
pathlib.Path("/mnt/DATA/git/govoplan-files/src"),
|
|
||||||
pathlib.Path("/mnt/DATA/git/govoplan-campaign/src"),
|
|
||||||
pathlib.Path("/mnt/DATA/git/govoplan-mail/tests"),
|
|
||||||
]
|
]
|
||||||
|
roots.extend(
|
||||||
|
repo / "tests"
|
||||||
|
for repo in sorted(repos_root.glob("govoplan*"))
|
||||||
|
if (repo / "tests").is_dir()
|
||||||
|
)
|
||||||
|
|
||||||
errors = []
|
errors = []
|
||||||
count = 0
|
count = 0
|
||||||
@@ -67,7 +66,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
|
||||||
|
|||||||
124
tools/checks/check-manifest-shapes.py
Normal file
124
tools/checks/check-manifest-shapes.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Load and validate every available GovOPlaN module manifest from source."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import importlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
MODULE_NAME_PATTERN = re.compile(
|
||||||
|
r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--workspace-root",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
help="Directory containing the GovOPlaN repositories. Defaults to repositories.json default_parent.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
catalog = json.loads((META_ROOT / "repositories.json").read_text(encoding="utf-8"))
|
||||||
|
workspace_root = (args.workspace_root or Path(catalog["default_parent"])).resolve()
|
||||||
|
repositories = tuple(catalog["repositories"])
|
||||||
|
|
||||||
|
source_roots: list[Path] = []
|
||||||
|
manifest_sources: list[tuple[str, Path, Path]] = []
|
||||||
|
for repository in repositories:
|
||||||
|
repository_root = workspace_root / repository["path"]
|
||||||
|
source_root = repository_root / "src"
|
||||||
|
if not source_root.is_dir():
|
||||||
|
continue
|
||||||
|
source_roots.append(source_root)
|
||||||
|
manifest_sources.extend(
|
||||||
|
(repository["name"], source_root, manifest_path)
|
||||||
|
for manifest_path in sorted(source_root.glob("*/backend/manifest.py"))
|
||||||
|
)
|
||||||
|
|
||||||
|
if not manifest_sources:
|
||||||
|
print(f"No module manifests found below {workspace_root}.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
core_source = workspace_root / "govoplan-core" / "src"
|
||||||
|
ordered_source_roots = [core_source, *(root for root in source_roots if root != core_source)]
|
||||||
|
sys.path[:0] = [str(root) for root in ordered_source_roots]
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import ModuleManifest # noqa: PLC0415
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry, RegistryError # noqa: PLC0415
|
||||||
|
|
||||||
|
manifests: list[ModuleManifest] = []
|
||||||
|
errors: list[str] = []
|
||||||
|
for repository_name, source_root, manifest_path in manifest_sources:
|
||||||
|
module_name = ".".join(manifest_path.relative_to(source_root).with_suffix("").parts)
|
||||||
|
if MODULE_NAME_PATTERN.fullmatch(module_name) is None:
|
||||||
|
errors.append(
|
||||||
|
f"{repository_name}: manifest path does not map to a safe Python module name: {module_name!r}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
# Module names are derived from repository-owned manifest paths and
|
||||||
|
# constrained to canonical Python identifiers immediately above.
|
||||||
|
loaded_module = importlib.import_module(module_name) # nosemgrep: python.lang.security.audit.non-literal-import.non-literal-import
|
||||||
|
get_manifest = getattr(loaded_module, "get_manifest")
|
||||||
|
manifest = get_manifest()
|
||||||
|
except Exception as exc: # pragma: no cover - rendered as a check failure
|
||||||
|
errors.append(f"{repository_name}: could not load {module_name}: {exc}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not isinstance(manifest, ModuleManifest):
|
||||||
|
errors.append(f"{repository_name}: {module_name}.get_manifest() did not return ModuleManifest")
|
||||||
|
continue
|
||||||
|
|
||||||
|
entry_points = _module_entry_points(manifest_path.parents[3] / "pyproject.toml")
|
||||||
|
expected_target = f"{module_name}:get_manifest"
|
||||||
|
actual_target = entry_points.get(manifest.id)
|
||||||
|
if actual_target != expected_target:
|
||||||
|
declared = ", ".join(f"{name}={target}" for name, target in sorted(entry_points.items())) or "none"
|
||||||
|
errors.append(
|
||||||
|
f"{repository_name}: module {manifest.id!r} must declare entry point "
|
||||||
|
f"{manifest.id}={expected_target}; found {declared}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
manifests.append(manifest)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print("\n".join(errors), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
try:
|
||||||
|
for manifest in manifests:
|
||||||
|
registry.register(manifest)
|
||||||
|
snapshot = registry.validate()
|
||||||
|
except RegistryError as exc:
|
||||||
|
print(f"Manifest registry validation failed: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"Manifest registry check passed: {len(snapshot.manifests)} manifests "
|
||||||
|
f"from {len(manifest_sources)} source files."
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _module_entry_points(pyproject_path: Path) -> dict[str, str]:
|
||||||
|
if not pyproject_path.is_file():
|
||||||
|
return {}
|
||||||
|
pyproject = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
|
||||||
|
values = pyproject.get("project", {}).get("entry-points", {}).get("govoplan.modules", {})
|
||||||
|
return {str(name): str(target) for name, target in values.items()}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -32,9 +32,8 @@ with (Path.cwd() / "pyproject.toml").open("rb") as handle:
|
|||||||
print(tomllib.load(handle)["project"]["version"])
|
print(tomllib.load(handle)["project"]["version"])
|
||||||
PY
|
PY
|
||||||
)"
|
)"
|
||||||
RELEASE_TAG="${GOVOPLAN_RELEASE_TAG:-v$RELEASE_VERSION}"
|
|
||||||
|
|
||||||
export RELEASE_TAG RELEASE_VERSION
|
export RELEASE_VERSION
|
||||||
export META_ROOT
|
export META_ROOT
|
||||||
export GOVOPLAN_CORE_SOURCE_ROOT="$ROOT"
|
export GOVOPLAN_CORE_SOURCE_ROOT="$ROOT"
|
||||||
export GOVOPLAN_MIGRATION_TRACK=release
|
export GOVOPLAN_MIGRATION_TRACK=release
|
||||||
@@ -91,7 +90,33 @@ install_cloned_webui_dependencies() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
release_tag_for_package() {
|
||||||
|
local package_name="$1"
|
||||||
|
"$PYTHON" - "$META_ROOT/requirements-release.txt" "$package_name" <<'PY'
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
requirements_path = Path(sys.argv[1])
|
||||||
|
package_name = sys.argv[2]
|
||||||
|
pattern = re.compile(
|
||||||
|
rf"^{re.escape(package_name)}\s+@\s+git\+ssh://.+?\.git@(v[0-9]+\.[0-9]+\.[0-9]+)$"
|
||||||
|
)
|
||||||
|
for line in requirements_path.read_text(encoding="utf-8").splitlines():
|
||||||
|
match = pattern.match(line.strip())
|
||||||
|
if match:
|
||||||
|
print(match.group(1))
|
||||||
|
raise SystemExit(0)
|
||||||
|
raise SystemExit(f"No immutable release reference found for {package_name}")
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
run_step "Validate release refs and installed package metadata"
|
run_step "Validate release refs and installed package metadata"
|
||||||
|
"$PYTHON" "$META_ROOT/tools/checks/check-version-alignment.py" --release-composition
|
||||||
|
"$PYTHON" "$META_ROOT/tools/checks/check-contracts.py" --no-impact
|
||||||
"$PYTHON" - <<'PY'
|
"$PYTHON" - <<'PY'
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -107,7 +132,6 @@ root = Path.cwd()
|
|||||||
release_requirements = Path(os.environ["META_ROOT"]) / "requirements-release.txt"
|
release_requirements = Path(os.environ["META_ROOT"]) / "requirements-release.txt"
|
||||||
release_webui = root / "webui" / "package.release.json"
|
release_webui = root / "webui" / "package.release.json"
|
||||||
expected_version = os.environ["RELEASE_VERSION"]
|
expected_version = os.environ["RELEASE_VERSION"]
|
||||||
expected_tag = os.environ["RELEASE_TAG"]
|
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
|
|
||||||
python_requirements: list[tuple[str, str, str]] = []
|
python_requirements: list[tuple[str, str, str]] = []
|
||||||
@@ -120,8 +144,6 @@ if not python_requirements:
|
|||||||
errors.append("No GovOPlaN git+ssh release requirements found.")
|
errors.append("No GovOPlaN git+ssh release requirements found.")
|
||||||
|
|
||||||
for package_name, repo_url, tag in python_requirements:
|
for package_name, repo_url, tag in python_requirements:
|
||||||
if tag != expected_tag:
|
|
||||||
errors.append(f"{package_name} release requirement uses {tag!r}, expected {expected_tag!r}.")
|
|
||||||
version = metadata.version(package_name)
|
version = metadata.version(package_name)
|
||||||
if version != tag.removeprefix("v"):
|
if version != tag.removeprefix("v"):
|
||||||
errors.append(f"{package_name} installed version {version!r} does not match {tag!r}.")
|
errors.append(f"{package_name} installed version {version!r} does not match {tag!r}.")
|
||||||
@@ -145,15 +167,18 @@ for package_name, spec in sorted(webui_dependencies.items()):
|
|||||||
if not package_name.startswith("@govoplan/"):
|
if not package_name.startswith("@govoplan/"):
|
||||||
continue
|
continue
|
||||||
tag = spec.rsplit("#", 1)[-1] if "#" in spec else ""
|
tag = spec.rsplit("#", 1)[-1] if "#" in spec else ""
|
||||||
if tag != expected_tag:
|
tag_match = re.fullmatch(r"v([0-9]+\.[0-9]+\.[0-9]+)", tag)
|
||||||
errors.append(f"{package_name} release dependency uses {tag!r}, expected {expected_tag}.")
|
if tag_match is None:
|
||||||
|
errors.append(f"{package_name} release dependency has invalid immutable tag {tag!r}.")
|
||||||
|
continue
|
||||||
|
dependency_version = tag_match.group(1)
|
||||||
package_json = root / "webui" / "node_modules" / package_name / "package.json"
|
package_json = root / "webui" / "node_modules" / package_name / "package.json"
|
||||||
if not package_json.exists():
|
if not package_json.exists():
|
||||||
errors.append(f"{package_name} is missing from release node_modules.")
|
errors.append(f"{package_name} is missing from release node_modules.")
|
||||||
continue
|
continue
|
||||||
installed = json.loads(package_json.read_text(encoding="utf-8"))
|
installed = json.loads(package_json.read_text(encoding="utf-8"))
|
||||||
if installed.get("version") != expected_version:
|
if installed.get("version") != dependency_version:
|
||||||
errors.append(f"{package_name} installed version {installed.get('version')!r}, expected {expected_version!r}.")
|
errors.append(f"{package_name} installed version {installed.get('version')!r}, expected {dependency_version!r} from {tag!r}.")
|
||||||
|
|
||||||
shared_peers = ("lucide-react", "react", "react-dom", "react-router-dom")
|
shared_peers = ("lucide-react", "react", "react-dom", "react-router-dom")
|
||||||
root_peers = release_package.get("peerDependencies", {})
|
root_peers = release_package.get("peerDependencies", {})
|
||||||
@@ -172,40 +197,14 @@ print(f"Release refs and metadata passed for {len(python_requirements)} Python p
|
|||||||
PY
|
PY
|
||||||
|
|
||||||
run_step "Validate installed module manifests and registry"
|
run_step "Validate installed module manifests and registry"
|
||||||
"$PYTHON" - <<'PY'
|
"$PYTHON" "$META_ROOT/tools/checks/release_integration.py" artifacts \
|
||||||
from __future__ import annotations
|
--requirements "$META_ROOT/requirements-release.txt"
|
||||||
|
|
||||||
import sys
|
run_step "Generate release dependency provenance"
|
||||||
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
"$PYTHON" "$META_ROOT/tools/release/generate-release-sbom.py" \
|
||||||
|
--python "$PYTHON" \
|
||||||
expected = {
|
--core-root "$ROOT" \
|
||||||
"access",
|
--output "$WORK_ROOT/govoplan-sbom.cdx.json"
|
||||||
"admin",
|
|
||||||
"audit",
|
|
||||||
"calendar",
|
|
||||||
"campaigns",
|
|
||||||
"dashboard",
|
|
||||||
"docs",
|
|
||||||
"files",
|
|
||||||
"identity",
|
|
||||||
"idm",
|
|
||||||
"mail",
|
|
||||||
"ops",
|
|
||||||
"organizations",
|
|
||||||
"policy",
|
|
||||||
"tenancy",
|
|
||||||
}
|
|
||||||
|
|
||||||
manifests = available_module_manifests()
|
|
||||||
missing = sorted(expected - set(manifests))
|
|
||||||
if missing:
|
|
||||||
print(f"Missing installed module manifests: {', '.join(missing)}", file=sys.stderr)
|
|
||||||
raise SystemExit(1)
|
|
||||||
|
|
||||||
registry = build_platform_registry(tuple(sorted(expected)))
|
|
||||||
snapshot = registry.validate()
|
|
||||||
print("Registry modules:", ", ".join(manifest.id for manifest in snapshot.manifests))
|
|
||||||
PY
|
|
||||||
|
|
||||||
run_step "Run SQLite migration smoke for release modules"
|
run_step "Run SQLite migration smoke for release modules"
|
||||||
"$PYTHON" - <<'PY'
|
"$PYTHON" - <<'PY'
|
||||||
@@ -244,16 +243,13 @@ PY
|
|||||||
|
|
||||||
run_step "Clone release module test sources"
|
run_step "Clone release module test sources"
|
||||||
for repo in govoplan-mail govoplan-calendar govoplan-campaign; do
|
for repo in govoplan-mail govoplan-calendar govoplan-campaign; do
|
||||||
git clone --depth 1 --branch "$RELEASE_TAG" "git@git.add-ideas.de:add-ideas/${repo}.git" "$WORK_ROOT/$repo"
|
repo_tag="$(release_tag_for_package "$repo")"
|
||||||
|
git clone --depth 1 --branch "$repo_tag" "git@git.add-ideas.de:add-ideas/${repo}.git" "$WORK_ROOT/$repo"
|
||||||
done
|
done
|
||||||
|
|
||||||
run_step "Run core backend release tests"
|
run_step "Run core backend release tests"
|
||||||
"$PYTHON" -m unittest \
|
"$PYTHON" "$META_ROOT/tools/checks/release_integration.py" core-tests \
|
||||||
tests.test_module_system \
|
--core-root "$ROOT"
|
||||||
tests.test_access_contracts \
|
|
||||||
tests.test_identity_organization_contracts \
|
|
||||||
tests.test_core_events \
|
|
||||||
tests.test_policy_contracts
|
|
||||||
"$PYTHON" "$META_ROOT/tools/checks/check_dependency_boundaries.py"
|
"$PYTHON" "$META_ROOT/tools/checks/check_dependency_boundaries.py"
|
||||||
|
|
||||||
run_step "Run cloned module backend tests"
|
run_step "Run cloned module backend tests"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
MODE="${SECURITY_AUDIT_MODE:-ci}"
|
MODE="${SECURITY_AUDIT_MODE:-ci}"
|
||||||
SCOPE="${SECURITY_AUDIT_SCOPE:-current}"
|
SCOPE="${SECURITY_AUDIT_SCOPE:-current}"
|
||||||
REPORTS_DIR="${SECURITY_AUDIT_REPORTS_DIR:-$ROOT/audit-reports}"
|
REPORTS_DIR="${SECURITY_AUDIT_REPORTS_DIR:-$ROOT/audit-reports}"
|
||||||
@@ -77,6 +77,15 @@ if [[ "$REPORTS_DIR" != /* ]]; then
|
|||||||
REPORTS_DIR="$ROOT/$REPORTS_DIR"
|
REPORTS_DIR="$ROOT/$REPORTS_DIR"
|
||||||
fi
|
fi
|
||||||
mkdir -p "$REPORTS_DIR"
|
mkdir -p "$REPORTS_DIR"
|
||||||
|
REPORTS_DIR="$(cd "$REPORTS_DIR" && pwd -P)"
|
||||||
|
|
||||||
|
for flag_name in FAIL_ON_FINDINGS REQUIRE_TOOLS; do
|
||||||
|
flag_value="${!flag_name}"
|
||||||
|
if [[ "$flag_value" != "0" && "$flag_value" != "1" ]]; then
|
||||||
|
echo "$flag_name must be 0 or 1, got: $flag_value" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
declare -a REPOS=()
|
declare -a REPOS=()
|
||||||
if [[ "$SCOPE" == "govoplan" ]]; then
|
if [[ "$SCOPE" == "govoplan" ]]; then
|
||||||
@@ -95,14 +104,30 @@ if [[ "${#REPOS[@]}" -eq 0 ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo "Security audit mode: $MODE"
|
||||||
|
echo "Security audit scope: $SCOPE"
|
||||||
|
echo "Security audit reports: $REPORTS_DIR"
|
||||||
|
echo "Security audit repositories: ${#REPOS[@]}"
|
||||||
|
if [[ "${#REPOS[@]}" -le 12 ]]; then
|
||||||
|
printf ' %s\n' "${REPOS[@]}"
|
||||||
|
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() {
|
||||||
basename "$1" | tr -c 'A-Za-z0-9_.-' '_'
|
printf '%s' "$(basename "$1")" | tr -c 'A-Za-z0-9_.-' '_'
|
||||||
}
|
}
|
||||||
|
|
||||||
has_tool() {
|
has_tool() {
|
||||||
@@ -112,77 +137,386 @@ has_tool() {
|
|||||||
overall_status=0
|
overall_status=0
|
||||||
finding_status=0
|
finding_status=0
|
||||||
missing_status=0
|
missing_status=0
|
||||||
|
execution_status=0
|
||||||
|
audit_started_at="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
|
||||||
|
audit_completed_at=""
|
||||||
|
workspace_unchanged="unknown"
|
||||||
|
audit_toolbox_fingerprint="${SECURITY_AUDIT_TOOLBOX_FINGERPRINT:-direct-host}"
|
||||||
|
declare -A REPORT_FILES=()
|
||||||
|
declare -A MACHINE_REPORTS=()
|
||||||
|
|
||||||
|
register_report() {
|
||||||
|
local path="$1"
|
||||||
|
local report_kind="${2:-text}"
|
||||||
|
case "$path" in
|
||||||
|
"$REPORTS_DIR"/*) ;;
|
||||||
|
*)
|
||||||
|
echo "Refusing to register an audit report outside $REPORTS_DIR: $path" >&2
|
||||||
|
return 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
REPORT_FILES["$path"]=1
|
||||||
|
if [[ "$report_kind" == "machine" ]]; then
|
||||||
|
MACHINE_REPORTS["$path"]=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare_machine_report() {
|
||||||
|
local path="$1"
|
||||||
|
register_report "$path" machine || return 2
|
||||||
|
if [[ -e "$path" || -L "$path" ]]; then
|
||||||
|
rm -f -- "$path" || return 2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
run_step() {
|
run_step() {
|
||||||
local name="$1"
|
local name="$1"
|
||||||
shift
|
local exit_contract="$2"
|
||||||
|
shift 2
|
||||||
echo
|
echo
|
||||||
echo "==> $name"
|
echo "==> $name"
|
||||||
"$@"
|
"$@"
|
||||||
local status=$?
|
local status=$?
|
||||||
if [[ "$status" -ne 0 ]]; then
|
local outcome="passed"
|
||||||
echo "Audit step reported findings or failed: $name (exit $status)" >&2
|
if [[ "$status" -eq 0 ]]; then
|
||||||
|
:
|
||||||
|
elif [[ "$exit_contract" == "findings-exit-one" && "$status" -eq 1 ]]; then
|
||||||
|
outcome="findings"
|
||||||
|
echo "Audit step reported findings: $name (exit $status)" >&2
|
||||||
finding_status=1
|
finding_status=1
|
||||||
if [[ "$FAIL_ON_FINDINGS" == "1" ]]; then
|
if [[ "$FAIL_ON_FINDINGS" == "1" ]]; then
|
||||||
overall_status=1
|
overall_status=1
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
outcome="execution-error"
|
||||||
|
echo "Audit step failed to execute successfully: $name (exit $status)" >&2
|
||||||
|
execution_status=1
|
||||||
|
overall_status=1
|
||||||
fi
|
fi
|
||||||
|
printf '%s\t%s\t%s\n' "$name" "$status" "$outcome" >> "$REPORTS_DIR/step-status.tsv"
|
||||||
}
|
}
|
||||||
|
|
||||||
skip_or_fail_missing() {
|
skip_or_fail_missing() {
|
||||||
local tool="$1"
|
local tool="$1"
|
||||||
echo "Skipping $tool: command not found. Use tools/checks/security-audit/run.sh for the containerized toolbox." >&2
|
echo "Skipping $tool: command not found. Use tools/checks/security-audit/run.sh for the containerized toolbox." >&2
|
||||||
|
missing_status=1
|
||||||
if [[ "$REQUIRE_TOOLS" == "1" ]]; then
|
if [[ "$REQUIRE_TOOLS" == "1" ]]; then
|
||||||
missing_status=1
|
|
||||||
overall_status=1
|
overall_status=1
|
||||||
fi
|
fi
|
||||||
|
printf '%s\t127\tmissing\n' "$tool" >> "$REPORTS_DIR/step-status.tsv"
|
||||||
}
|
}
|
||||||
|
|
||||||
write_manifest() {
|
write_manifest() {
|
||||||
{
|
local -a present_reports=()
|
||||||
printf '{\n'
|
local -a expected_reports=()
|
||||||
printf ' "mode": "%s",\n' "$MODE"
|
local path
|
||||||
printf ' "scope": "%s",\n' "$SCOPE"
|
while IFS= read -r path; do
|
||||||
printf ' "fail_on_findings": "%s",\n' "$FAIL_ON_FINDINGS"
|
expected_reports+=("${path#"$REPORTS_DIR"/}")
|
||||||
printf ' "repositories": [\n'
|
if [[ -f "$path" ]]; then
|
||||||
local index=0
|
present_reports+=("${path#"$REPORTS_DIR"/}")
|
||||||
for repo in "${REPOS[@]}"; do
|
fi
|
||||||
[[ "$index" -gt 0 ]] && printf ',\n'
|
done < <(printf '%s\n' "${!REPORT_FILES[@]}" | sort)
|
||||||
printf ' "%s"' "$repo"
|
|
||||||
index=$((index + 1))
|
python3 - \
|
||||||
done
|
"$REPORTS_DIR/manifest.json" \
|
||||||
printf '\n ]\n'
|
"$MODE" \
|
||||||
printf '}\n'
|
"$SCOPE" \
|
||||||
} > "$REPORTS_DIR/manifest.json"
|
"$FAIL_ON_FINDINGS" \
|
||||||
|
"$audit_started_at" \
|
||||||
|
"$audit_completed_at" \
|
||||||
|
"$workspace_unchanged" \
|
||||||
|
"$overall_status" \
|
||||||
|
"$finding_status" \
|
||||||
|
"$execution_status" \
|
||||||
|
"$missing_status" \
|
||||||
|
"$audit_toolbox_fingerprint" \
|
||||||
|
"${#REPOS[@]}" \
|
||||||
|
"${REPOS[@]}" \
|
||||||
|
"${#present_reports[@]}" \
|
||||||
|
"${present_reports[@]}" \
|
||||||
|
"${expected_reports[@]}" <<'PY'
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
(
|
||||||
|
manifest_path,
|
||||||
|
mode,
|
||||||
|
scope,
|
||||||
|
fail_on_findings,
|
||||||
|
started_at,
|
||||||
|
completed_at,
|
||||||
|
workspace_unchanged,
|
||||||
|
overall_status,
|
||||||
|
finding_status,
|
||||||
|
execution_status,
|
||||||
|
missing_status,
|
||||||
|
toolbox_fingerprint,
|
||||||
|
repository_count,
|
||||||
|
*remaining,
|
||||||
|
) = sys.argv[1:]
|
||||||
|
repo_count = int(repository_count)
|
||||||
|
repositories = remaining[:repo_count]
|
||||||
|
remaining = remaining[repo_count:]
|
||||||
|
report_count = int(remaining[0])
|
||||||
|
reports = remaining[1 : report_count + 1]
|
||||||
|
expected_reports = remaining[report_count + 1 :]
|
||||||
|
payload = {
|
||||||
|
"mode": mode,
|
||||||
|
"scope": scope,
|
||||||
|
"fail_on_findings": fail_on_findings == "1",
|
||||||
|
"started_at_utc": started_at,
|
||||||
|
"completed_at_utc": completed_at,
|
||||||
|
"toolbox_fingerprint": toolbox_fingerprint,
|
||||||
|
"workspace_unchanged": workspace_unchanged == "true",
|
||||||
|
"overall_status": int(overall_status),
|
||||||
|
"finding_status": int(finding_status),
|
||||||
|
"execution_error_status": int(execution_status),
|
||||||
|
"missing_tool_status": int(missing_status),
|
||||||
|
"tool_versions": "tool-versions.txt",
|
||||||
|
"workspace_state_start": "workspace-state-start.tsv",
|
||||||
|
"workspace_state_end": "workspace-state-end.tsv",
|
||||||
|
"report_checksums": "report-sha256.txt",
|
||||||
|
"step_status": "step-status.tsv",
|
||||||
|
"repositories": repositories,
|
||||||
|
"reports": reports,
|
||||||
|
"expected_reports": expected_reports,
|
||||||
|
"missing_reports": sorted(set(expected_reports) - set(reports)),
|
||||||
|
}
|
||||||
|
Path(manifest_path).write_text(
|
||||||
|
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
accumulate_exit_status() {
|
||||||
|
local -n aggregate="$1"
|
||||||
|
local child_status="$2"
|
||||||
|
if [[ "$child_status" -gt 1 ]]; then
|
||||||
|
aggregate=2
|
||||||
|
elif [[ "$child_status" -eq 1 && "$aggregate" -eq 0 ]]; then
|
||||||
|
aggregate=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
repository_fingerprint() {
|
||||||
|
local repo="$1"
|
||||||
|
(
|
||||||
|
set -e
|
||||||
|
cd "$repo" || exit 1
|
||||||
|
git rev-parse HEAD
|
||||||
|
git status --porcelain=v1 -z
|
||||||
|
git diff --binary --no-ext-diff HEAD --
|
||||||
|
while IFS= read -r -d '' path; do
|
||||||
|
printf '%s\0' "$path"
|
||||||
|
if [[ -L "$path" ]]; then
|
||||||
|
printf 'symlink\0'
|
||||||
|
readlink -- "$path"
|
||||||
|
else
|
||||||
|
sha256sum -- "$path"
|
||||||
|
fi
|
||||||
|
done < <(git ls-files --others --exclude-standard -z)
|
||||||
|
) | sha256sum | cut -d' ' -f1
|
||||||
|
}
|
||||||
|
|
||||||
|
write_workspace_state() {
|
||||||
|
local destination="$1"
|
||||||
|
local head
|
||||||
|
local fingerprint
|
||||||
|
: > "$destination"
|
||||||
|
for repo in "${REPOS[@]}"; do
|
||||||
|
if ! head="$(git -C "$repo" rev-parse HEAD)"; then
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
if ! fingerprint="$(repository_fingerprint "$repo")"; then
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
printf '%s\t%s\t%s\n' \
|
||||||
|
"$repo" \
|
||||||
|
"$head" \
|
||||||
|
"$fingerprint" \
|
||||||
|
>> "$destination"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
write_tool_versions() {
|
||||||
|
local destination="$REPORTS_DIR/tool-versions.txt"
|
||||||
|
local status=0
|
||||||
|
local version
|
||||||
|
: > "$destination"
|
||||||
|
local tool
|
||||||
|
for tool in semgrep bandit ruff gitleaks trivy pip-audit npm osv-scanner jscpd radon xenon; do
|
||||||
|
has_tool "$tool" || continue
|
||||||
|
if [[ "$tool" == "gitleaks" ]]; then
|
||||||
|
version="$("$tool" version 2>&1)"
|
||||||
|
else
|
||||||
|
version="$("$tool" --version 2>&1)"
|
||||||
|
fi
|
||||||
|
local version_status=$?
|
||||||
|
version="${version%%$'\n'*}"
|
||||||
|
if [[ "$version_status" -ne 0 || -z "$version" ]]; then
|
||||||
|
printf '%s\t%s\n' "$tool" "version-command-failed:$version_status" >> "$destination"
|
||||||
|
status=2
|
||||||
|
else
|
||||||
|
printf '%s\t%s\n' "$tool" "$version" >> "$destination"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return "$status"
|
||||||
|
}
|
||||||
|
|
||||||
|
write_report_checksums() {
|
||||||
|
local path
|
||||||
|
local relative_path
|
||||||
|
: > "$REPORTS_DIR/report-sha256.txt"
|
||||||
|
while IFS= read -r path; do
|
||||||
|
[[ -f "$path" ]] || continue
|
||||||
|
relative_path="${path#"$REPORTS_DIR"/}"
|
||||||
|
(cd "$REPORTS_DIR" && sha256sum -- "$relative_path") \
|
||||||
|
>> "$REPORTS_DIR/report-sha256.txt" || return 2
|
||||||
|
done < <(printf '%s\n' "${!REPORT_FILES[@]}" | sort)
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_machine_reports() {
|
||||||
|
python3 - "$REPORTS_DIR" "${!MACHINE_REPORTS[@]}" <<'PY'
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
root = Path(sys.argv[1]).resolve()
|
||||||
|
errors: list[str] = []
|
||||||
|
validated = 0
|
||||||
|
for raw_path in sorted(set(sys.argv[2:])):
|
||||||
|
path = Path(raw_path)
|
||||||
|
try:
|
||||||
|
relative_path = path.resolve().relative_to(root)
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
errors.append(f"{path}: invalid report path: {exc}")
|
||||||
|
continue
|
||||||
|
if not path.is_file():
|
||||||
|
errors.append(f"{relative_path}: expected report was not produced")
|
||||||
|
continue
|
||||||
|
if path.stat().st_size == 0:
|
||||||
|
errors.append(f"{relative_path}: report is empty")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||||
|
errors.append(f"{relative_path}: invalid JSON: {exc}")
|
||||||
|
continue
|
||||||
|
validated += 1
|
||||||
|
if path.suffix == ".sarif" and (
|
||||||
|
not isinstance(payload, dict)
|
||||||
|
or not isinstance(payload.get("version"), str)
|
||||||
|
or not isinstance(payload.get("runs"), list)
|
||||||
|
):
|
||||||
|
errors.append(f"{relative_path}: invalid SARIF document")
|
||||||
|
continue
|
||||||
|
report_name = path.name
|
||||||
|
if report_name.startswith("bandit") and (
|
||||||
|
not isinstance(payload, dict)
|
||||||
|
or not isinstance(payload.get("errors"), list)
|
||||||
|
or not isinstance(payload.get("metrics"), dict)
|
||||||
|
or not isinstance(payload.get("results"), list)
|
||||||
|
):
|
||||||
|
errors.append(f"{relative_path}: invalid Bandit report")
|
||||||
|
elif report_name.startswith("ruff-security") and not isinstance(payload, list):
|
||||||
|
errors.append(f"{relative_path}: invalid Ruff report")
|
||||||
|
elif report_name.startswith("gitleaks-") and not isinstance(payload, list):
|
||||||
|
errors.append(f"{relative_path}: invalid Gitleaks report")
|
||||||
|
elif report_name.startswith("pip-audit-") and (
|
||||||
|
not isinstance(payload, dict)
|
||||||
|
or not isinstance(payload.get("dependencies"), list)
|
||||||
|
):
|
||||||
|
errors.append(f"{relative_path}: invalid pip-audit report")
|
||||||
|
elif report_name.startswith("npm-audit-") and (
|
||||||
|
not isinstance(payload, dict)
|
||||||
|
or payload.get("error")
|
||||||
|
or not isinstance(payload.get("metadata"), dict)
|
||||||
|
or not isinstance(payload.get("vulnerabilities"), dict)
|
||||||
|
):
|
||||||
|
errors.append(f"{relative_path}: npm audit returned an invalid or error report")
|
||||||
|
elif report_name.startswith("osv-scanner-") and (
|
||||||
|
not isinstance(payload, dict)
|
||||||
|
or "results" not in payload
|
||||||
|
or (payload["results"] is not None and not isinstance(payload["results"], list))
|
||||||
|
):
|
||||||
|
errors.append(f"{relative_path}: invalid OSV-Scanner report")
|
||||||
|
elif report_name == "jscpd-report.json" and (
|
||||||
|
not isinstance(payload, dict)
|
||||||
|
or not isinstance(payload.get("duplicates"), list)
|
||||||
|
or not isinstance(payload.get("statistics"), dict)
|
||||||
|
):
|
||||||
|
errors.append(f"{relative_path}: invalid jscpd report")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print("\n".join(errors), file=sys.stderr)
|
||||||
|
raise SystemExit(2)
|
||||||
|
print(f"Validated {validated} JSON/SARIF reports.")
|
||||||
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
run_semgrep() {
|
run_semgrep() {
|
||||||
local report="$REPORTS_DIR/semgrep.sarif"
|
local report="$REPORTS_DIR/semgrep.sarif"
|
||||||
|
prepare_machine_report "$report" || return 2
|
||||||
local config_args=(--config "$ROOT/tools/checks/security-audit/semgrep-govoplan.yml")
|
local config_args=(--config "$ROOT/tools/checks/security-audit/semgrep-govoplan.yml")
|
||||||
if [[ "$MODE" != "quick" ]]; then
|
if [[ "$MODE" != "quick" ]]; then
|
||||||
config_args+=(--config p/default --config p/owasp-top-ten)
|
config_args+=(--config p/default --config p/owasp-top-ten)
|
||||||
fi
|
fi
|
||||||
semgrep scan \
|
semgrep scan \
|
||||||
--metrics=off \
|
--metrics=off \
|
||||||
|
--error \
|
||||||
|
--no-git-ignore \
|
||||||
--sarif \
|
--sarif \
|
||||||
--output "$report" \
|
--output "$report" \
|
||||||
|
--exclude .git \
|
||||||
--exclude node_modules \
|
--exclude node_modules \
|
||||||
--exclude .venv \
|
--exclude .venv \
|
||||||
--exclude dist \
|
--exclude dist \
|
||||||
--exclude build \
|
--exclude build \
|
||||||
--exclude runtime \
|
--exclude runtime \
|
||||||
|
--exclude audit-reports \
|
||||||
|
--exclude '**/.*-test-build/**' \
|
||||||
"${config_args[@]}" \
|
"${config_args[@]}" \
|
||||||
"${REPOS[@]}"
|
"${REPOS[@]}"
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
prepare_machine_report "$REPORTS_DIR/bandit.json" || return 2
|
||||||
|
bandit -r "${PY_PROD_ROOTS[@]}" -f json -o "$REPORTS_DIR/bandit.json"
|
||||||
|
accumulate_exit_status status "$?"
|
||||||
|
fi
|
||||||
|
if [[ "${#PY_TEST_ROOTS[@]}" -gt 0 ]]; then
|
||||||
|
prepare_machine_report "$REPORTS_DIR/bandit-tests.json" || return 2
|
||||||
|
bandit -r "${PY_TEST_ROOTS[@]}" -f json -o "$REPORTS_DIR/bandit-tests.json"
|
||||||
|
local test_status=$?
|
||||||
|
[[ "$test_status" -le 1 ]] || status=2
|
||||||
|
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
|
||||||
|
prepare_machine_report "$REPORTS_DIR/ruff-security.json" || return 2
|
||||||
|
ruff check --select S --output-format json "${PY_PROD_ROOTS[@]}" > "$REPORTS_DIR/ruff-security.json"
|
||||||
|
accumulate_exit_status status "$?"
|
||||||
|
fi
|
||||||
|
if [[ "${#PY_TEST_ROOTS[@]}" -gt 0 ]]; then
|
||||||
|
prepare_machine_report "$REPORTS_DIR/ruff-security-tests.json" || return 2
|
||||||
|
ruff check --select S --output-format json "${PY_TEST_ROOTS[@]}" > "$REPORTS_DIR/ruff-security-tests.json"
|
||||||
|
local test_status=$?
|
||||||
|
[[ "$test_status" -le 1 ]] || status=2
|
||||||
|
fi
|
||||||
|
return "$status"
|
||||||
}
|
}
|
||||||
|
|
||||||
run_gitleaks() {
|
run_gitleaks() {
|
||||||
@@ -191,19 +525,34 @@ run_gitleaks() {
|
|||||||
local name
|
local name
|
||||||
name="$(safe_name "$repo")"
|
name="$(safe_name "$repo")"
|
||||||
if gitleaks git --help >/dev/null 2>&1; then
|
if gitleaks git --help >/dev/null 2>&1; then
|
||||||
|
prepare_machine_report "$REPORTS_DIR/gitleaks-history-$name.json" || return 2
|
||||||
|
prepare_machine_report "$REPORTS_DIR/gitleaks-worktree-$name.json" || return 2
|
||||||
gitleaks git \
|
gitleaks git \
|
||||||
--config "$ROOT/.gitleaks.toml" \
|
--config "$ROOT/.gitleaks.toml" \
|
||||||
--report-format json \
|
--report-format json \
|
||||||
--report-path "$REPORTS_DIR/gitleaks-$name.json" \
|
--report-path "$REPORTS_DIR/gitleaks-history-$name.json" \
|
||||||
--no-banner \
|
--no-banner \
|
||||||
"$repo" || status=1
|
"$repo"
|
||||||
|
accumulate_exit_status status "$?"
|
||||||
|
# History scanning does not include new or modified working-tree files.
|
||||||
|
# Scan the directory as well so pre-commit audits cover the exact code
|
||||||
|
# under review, while retaining the history scan above.
|
||||||
|
gitleaks dir \
|
||||||
|
--config "$ROOT/.gitleaks.toml" \
|
||||||
|
--report-format json \
|
||||||
|
--report-path "$REPORTS_DIR/gitleaks-worktree-$name.json" \
|
||||||
|
--no-banner \
|
||||||
|
"$repo"
|
||||||
|
accumulate_exit_status status "$?"
|
||||||
else
|
else
|
||||||
|
prepare_machine_report "$REPORTS_DIR/gitleaks-$name.json" || return 2
|
||||||
gitleaks detect \
|
gitleaks detect \
|
||||||
--source "$repo" \
|
--source "$repo" \
|
||||||
--config "$ROOT/.gitleaks.toml" \
|
--config "$ROOT/.gitleaks.toml" \
|
||||||
--report-format json \
|
--report-format json \
|
||||||
--report-path "$REPORTS_DIR/gitleaks-$name.json" \
|
--report-path "$REPORTS_DIR/gitleaks-$name.json" \
|
||||||
--no-banner || status=1
|
--no-banner
|
||||||
|
accumulate_exit_status status "$?"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
return "$status"
|
return "$status"
|
||||||
@@ -214,6 +563,7 @@ run_trivy() {
|
|||||||
for repo in "${REPOS[@]}"; do
|
for repo in "${REPOS[@]}"; do
|
||||||
local name
|
local name
|
||||||
name="$(safe_name "$repo")"
|
name="$(safe_name "$repo")"
|
||||||
|
prepare_machine_report "$REPORTS_DIR/trivy-$name.sarif" || return 2
|
||||||
trivy fs \
|
trivy fs \
|
||||||
--scanners vuln,secret,misconfig \
|
--scanners vuln,secret,misconfig \
|
||||||
--skip-dirs node_modules \
|
--skip-dirs node_modules \
|
||||||
@@ -221,8 +571,10 @@ run_trivy() {
|
|||||||
--skip-dirs dist \
|
--skip-dirs dist \
|
||||||
--skip-dirs build \
|
--skip-dirs build \
|
||||||
--format sarif \
|
--format sarif \
|
||||||
|
--exit-code 1 \
|
||||||
--output "$REPORTS_DIR/trivy-$name.sarif" \
|
--output "$REPORTS_DIR/trivy-$name.sarif" \
|
||||||
"$repo" || status=1
|
"$repo"
|
||||||
|
accumulate_exit_status status "$?"
|
||||||
done
|
done
|
||||||
return "$status"
|
return "$status"
|
||||||
}
|
}
|
||||||
@@ -233,7 +585,11 @@ run_pip_audit_manifests() {
|
|||||||
[[ -f "$repo/requirements.txt" ]] || continue
|
[[ -f "$repo/requirements.txt" ]] || continue
|
||||||
local name
|
local name
|
||||||
name="$(safe_name "$repo")"
|
name="$(safe_name "$repo")"
|
||||||
pip-audit -r "$repo/requirements.txt" --progress-spinner off --format json --output "$REPORTS_DIR/pip-audit-$name.json" || status=1
|
prepare_machine_report "$REPORTS_DIR/pip-audit-$name.json" || return 2
|
||||||
|
# Requirements may contain project-relative entries such as `.[server]`.
|
||||||
|
# Resolve them from the owning repository instead of the meta-repository.
|
||||||
|
(cd "$repo" && pip-audit -r requirements.txt --progress-spinner off --format json --output "$REPORTS_DIR/pip-audit-$name.json")
|
||||||
|
accumulate_exit_status status "$?"
|
||||||
done
|
done
|
||||||
return "$status"
|
return "$status"
|
||||||
}
|
}
|
||||||
@@ -244,7 +600,9 @@ run_npm_audit_manifests() {
|
|||||||
[[ -f "$repo/webui/package-lock.json" ]] || continue
|
[[ -f "$repo/webui/package-lock.json" ]] || continue
|
||||||
local name
|
local name
|
||||||
name="$(safe_name "$repo")"
|
name="$(safe_name "$repo")"
|
||||||
(cd "$repo/webui" && npm audit --omit=dev --json > "$REPORTS_DIR/npm-audit-$name.json") || status=1
|
prepare_machine_report "$REPORTS_DIR/npm-audit-$name.json" || return 2
|
||||||
|
(cd "$repo/webui" && npm audit --omit=dev --json > "$REPORTS_DIR/npm-audit-$name.json")
|
||||||
|
accumulate_exit_status status "$?"
|
||||||
done
|
done
|
||||||
return "$status"
|
return "$status"
|
||||||
}
|
}
|
||||||
@@ -255,75 +613,122 @@ run_osv_scanner() {
|
|||||||
local name
|
local name
|
||||||
name="$(safe_name "$repo")"
|
name="$(safe_name "$repo")"
|
||||||
if osv-scanner scan --help >/dev/null 2>&1; then
|
if osv-scanner scan --help >/dev/null 2>&1; then
|
||||||
osv-scanner scan -r --format json --output "$REPORTS_DIR/osv-scanner-$name.json" "$repo" || status=1
|
prepare_machine_report "$REPORTS_DIR/osv-scanner-$name.json" || return 2
|
||||||
|
osv-scanner scan -r \
|
||||||
|
--allow-no-lockfiles \
|
||||||
|
--format json \
|
||||||
|
--output-file "$REPORTS_DIR/osv-scanner-$name.json" \
|
||||||
|
"$repo"
|
||||||
|
accumulate_exit_status status "$?"
|
||||||
else
|
else
|
||||||
osv-scanner -r --format json --output "$REPORTS_DIR/osv-scanner-$name.json" "$repo" || status=1
|
prepare_machine_report "$REPORTS_DIR/osv-scanner-$name.json" || return 2
|
||||||
|
osv-scanner -r --format json --output "$REPORTS_DIR/osv-scanner-$name.json" "$repo"
|
||||||
|
accumulate_exit_status status "$?"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
return "$status"
|
return "$status"
|
||||||
}
|
}
|
||||||
|
|
||||||
run_jscpd() {
|
run_jscpd() {
|
||||||
|
prepare_machine_report "$REPORTS_DIR/jscpd/jscpd-report.json" || return 2
|
||||||
|
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[@]}"
|
||||||
}
|
}
|
||||||
|
|
||||||
run_radon() {
|
run_radon() {
|
||||||
[[ "${#PY_ROOTS[@]}" -gt 0 ]] || return 0
|
[[ "${#PY_ROOTS[@]}" -gt 0 ]] || return 0
|
||||||
|
register_report "$REPORTS_DIR/radon-cc.txt" || return 2
|
||||||
radon cc -s -a "${PY_ROOTS[@]}" > "$REPORTS_DIR/radon-cc.txt"
|
radon cc -s -a "${PY_ROOTS[@]}" > "$REPORTS_DIR/radon-cc.txt"
|
||||||
}
|
}
|
||||||
|
|
||||||
run_xenon() {
|
run_xenon() {
|
||||||
[[ "${#PY_ROOTS[@]}" -gt 0 ]] || return 0
|
[[ "${#PY_ROOTS[@]}" -gt 0 ]] || return 0
|
||||||
|
register_report "$REPORTS_DIR/xenon.txt" || return 2
|
||||||
xenon --max-absolute C --max-modules B --max-average A "${PY_ROOTS[@]}" > "$REPORTS_DIR/xenon.txt" 2>&1
|
xenon --max-absolute C --max-modules B --max-average A "${PY_ROOTS[@]}" > "$REPORTS_DIR/xenon.txt" 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
write_manifest
|
register_report "$REPORTS_DIR/step-status.tsv"
|
||||||
|
register_report "$REPORTS_DIR/tool-versions.txt"
|
||||||
|
register_report "$REPORTS_DIR/workspace-state-start.tsv"
|
||||||
|
register_report "$REPORTS_DIR/workspace-state-end.tsv"
|
||||||
|
: > "$REPORTS_DIR/step-status.tsv"
|
||||||
|
if ! write_workspace_state "$REPORTS_DIR/workspace-state-start.tsv"; then
|
||||||
|
echo "Could not capture the repository state before the audit." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
run_step "Record audit tool versions" execution-only write_tool_versions
|
||||||
|
|
||||||
if has_tool semgrep; then
|
if has_tool semgrep; then
|
||||||
run_step "Semgrep SAST" run_semgrep
|
run_step "Semgrep SAST" findings-exit-one run_semgrep
|
||||||
else
|
else
|
||||||
skip_or_fail_missing semgrep
|
skip_or_fail_missing semgrep
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if has_tool bandit; then
|
if has_tool bandit; then
|
||||||
run_step "Bandit Python security scan" run_bandit
|
run_step "Bandit Python security scan" findings-exit-one run_bandit
|
||||||
else
|
else
|
||||||
skip_or_fail_missing bandit
|
skip_or_fail_missing bandit
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if has_tool ruff; then
|
if has_tool ruff; then
|
||||||
run_step "Ruff flake8-bandit security rules" run_ruff_security
|
run_step "Ruff flake8-bandit security rules" findings-exit-one run_ruff_security
|
||||||
else
|
else
|
||||||
skip_or_fail_missing ruff
|
skip_or_fail_missing ruff
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if has_tool gitleaks; then
|
if has_tool gitleaks; then
|
||||||
run_step "Gitleaks secret scan" run_gitleaks
|
run_step "Gitleaks secret scan" findings-exit-one run_gitleaks
|
||||||
else
|
else
|
||||||
skip_or_fail_missing gitleaks
|
skip_or_fail_missing gitleaks
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$MODE" != "quick" ]]; then
|
if [[ "$MODE" != "quick" ]]; then
|
||||||
if has_tool trivy; then
|
if has_tool trivy; then
|
||||||
run_step "Trivy filesystem vulnerability/secret/misconfig scan" run_trivy
|
run_step "Trivy filesystem vulnerability/secret/misconfig scan" findings-exit-one run_trivy
|
||||||
else
|
else
|
||||||
skip_or_fail_missing trivy
|
skip_or_fail_missing trivy
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if has_tool pip-audit; then
|
if has_tool pip-audit; then
|
||||||
run_step "pip-audit requirements scan" run_pip_audit_manifests
|
run_step "pip-audit requirements scan" findings-exit-one run_pip_audit_manifests
|
||||||
else
|
else
|
||||||
skip_or_fail_missing pip-audit
|
skip_or_fail_missing pip-audit
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if has_tool npm; then
|
if has_tool npm; then
|
||||||
run_step "npm audit lockfile scan" run_npm_audit_manifests
|
run_step "npm audit lockfile scan" findings-exit-one run_npm_audit_manifests
|
||||||
else
|
else
|
||||||
skip_or_fail_missing npm
|
skip_or_fail_missing npm
|
||||||
fi
|
fi
|
||||||
@@ -331,37 +736,62 @@ fi
|
|||||||
|
|
||||||
if [[ "$MODE" == "full" ]]; then
|
if [[ "$MODE" == "full" ]]; then
|
||||||
if has_tool osv-scanner; then
|
if has_tool osv-scanner; then
|
||||||
run_step "OSV-Scanner recursive dependency scan" run_osv_scanner
|
run_step "OSV-Scanner recursive dependency scan" findings-exit-one run_osv_scanner
|
||||||
else
|
else
|
||||||
skip_or_fail_missing osv-scanner
|
skip_or_fail_missing osv-scanner
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if has_tool jscpd; then
|
if has_tool jscpd; then
|
||||||
run_step "jscpd duplicate code scan" run_jscpd
|
run_step "jscpd duplicate code scan" execution-only run_jscpd
|
||||||
else
|
else
|
||||||
skip_or_fail_missing jscpd
|
skip_or_fail_missing jscpd
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if has_tool radon; then
|
if has_tool radon; then
|
||||||
run_step "Radon complexity report" run_radon
|
run_step "Radon complexity report" execution-only run_radon
|
||||||
else
|
else
|
||||||
skip_or_fail_missing radon
|
skip_or_fail_missing radon
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if has_tool xenon; then
|
if has_tool xenon; then
|
||||||
run_step "Xenon complexity threshold scan" run_xenon
|
run_step "Xenon complexity threshold scan" findings-exit-one run_xenon
|
||||||
else
|
else
|
||||||
skip_or_fail_missing xenon
|
skip_or_fail_missing xenon
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
run_step "Validate machine-readable audit reports" execution-only validate_machine_reports
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Security audit reports written to: $REPORTS_DIR"
|
echo "Security audit reports written to: $REPORTS_DIR"
|
||||||
if [[ "$finding_status" -ne 0 && "$FAIL_ON_FINDINGS" != "1" ]]; then
|
if [[ "$finding_status" -ne 0 && "$FAIL_ON_FINDINGS" != "1" ]]; then
|
||||||
echo "Findings were reported, but this run is report-only. Re-run with --strict to fail on findings."
|
echo "Findings were reported, but this run is report-only. Re-run with --strict to fail on findings."
|
||||||
fi
|
fi
|
||||||
if [[ "$missing_status" -ne 0 ]]; then
|
if [[ "$missing_status" -ne 0 ]]; then
|
||||||
echo "One or more required tools were missing." >&2
|
echo "One or more audit tools were missing." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! write_workspace_state "$REPORTS_DIR/workspace-state-end.tsv"; then
|
||||||
|
workspace_unchanged=false
|
||||||
|
execution_status=1
|
||||||
|
overall_status=1
|
||||||
|
echo "Could not capture the repository state after the audit." >&2
|
||||||
|
elif cmp -s "$REPORTS_DIR/workspace-state-start.tsv" "$REPORTS_DIR/workspace-state-end.tsv"; then
|
||||||
|
workspace_unchanged=true
|
||||||
|
else
|
||||||
|
workspace_unchanged=false
|
||||||
|
overall_status=1
|
||||||
|
echo "Repository state changed during the audit; discard this mixed snapshot and rerun." >&2
|
||||||
|
fi
|
||||||
|
audit_completed_at="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
|
||||||
|
if ! write_report_checksums; then
|
||||||
|
execution_status=1
|
||||||
|
overall_status=1
|
||||||
|
echo "Could not generate report checksums." >&2
|
||||||
|
fi
|
||||||
|
if ! write_manifest; then
|
||||||
|
echo "Could not write the audit manifest." >&2
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exit "$overall_status"
|
exit "$overall_status"
|
||||||
|
|||||||
130
tools/checks/check-version-alignment.py
Normal file
130
tools/checks/check-version-alignment.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fail when backend, manifest, frontend, lock, or release-ref versions drift."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from dataclasses import asdict
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(META_ROOT / "tools" / "release"))
|
||||||
|
|
||||||
|
from govoplan_release.version_alignment import ( # noqa: E402
|
||||||
|
release_composition_issues,
|
||||||
|
repository_version_issues,
|
||||||
|
selected_repository_version_issues,
|
||||||
|
)
|
||||||
|
from govoplan_release.workspace import ( # noqa: E402
|
||||||
|
load_repository_specs,
|
||||||
|
resolve_repo_path,
|
||||||
|
resolve_workspace_root,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--workspace-root", type=Path, default=None)
|
||||||
|
parser.add_argument("--repo", action="append", default=[], help="Repository name to check; may be repeated.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--repo-version",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
metavar="REPO=VERSION",
|
||||||
|
help="Require a repository's aligned source metadata to equal VERSION; may be repeated.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--release-composition",
|
||||||
|
action="store_true",
|
||||||
|
help="Also compare tagged backend and WebUI references in the release composition.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--source-metadata-only",
|
||||||
|
action="store_true",
|
||||||
|
help="Skip lockfile checks for the pre-commit source gate; never use for publication.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--json", action="store_true", help="Print machine-readable output.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
workspace_root = resolve_workspace_root(args.workspace_root)
|
||||||
|
expected_versions = _parse_repo_versions(parser, args.repo_version)
|
||||||
|
selected = {*args.repo, *expected_versions}
|
||||||
|
known = {spec.name for spec in load_repository_specs()}
|
||||||
|
unknown = sorted(selected - known)
|
||||||
|
if unknown:
|
||||||
|
parser.error(f"unknown repository name(s): {', '.join(unknown)}")
|
||||||
|
|
||||||
|
checked: list[str] = []
|
||||||
|
issues = list(
|
||||||
|
selected_repository_version_issues(
|
||||||
|
repo_versions=expected_versions,
|
||||||
|
workspace=workspace_root,
|
||||||
|
include_lockfiles=not args.source_metadata_only,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for spec in load_repository_specs():
|
||||||
|
if selected and spec.name not in selected:
|
||||||
|
continue
|
||||||
|
repo_path = resolve_repo_path(spec, workspace_root)
|
||||||
|
if not repo_path.exists():
|
||||||
|
continue
|
||||||
|
checked.append(spec.name)
|
||||||
|
if spec.name in expected_versions:
|
||||||
|
continue
|
||||||
|
issues.extend(
|
||||||
|
repository_version_issues(
|
||||||
|
repo_path,
|
||||||
|
include_lockfiles=not args.source_metadata_only,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.release_composition:
|
||||||
|
issues.extend(
|
||||||
|
release_composition_issues(
|
||||||
|
META_ROOT,
|
||||||
|
core_root=workspace_root / "govoplan-core",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"workspace_root": str(workspace_root),
|
||||||
|
"repositories_checked": checked,
|
||||||
|
"release_composition_checked": args.release_composition,
|
||||||
|
"issues": [asdict(issue) for issue in issues],
|
||||||
|
}
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(payload, indent=2, sort_keys=True))
|
||||||
|
elif issues:
|
||||||
|
print("Version alignment failed:", file=sys.stderr)
|
||||||
|
for issue in issues:
|
||||||
|
print(
|
||||||
|
f"- {issue.repo}: {issue.source}: {issue.actual!r}; expected {issue.expected!r} "
|
||||||
|
f"({issue.message})",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
suffix = " including release composition" if args.release_composition else ""
|
||||||
|
print(f"Version alignment passed for {len(checked)} repositories{suffix}.")
|
||||||
|
return 1 if issues else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_repo_versions(parser: argparse.ArgumentParser, values: list[str]) -> dict[str, str]:
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
for value in values:
|
||||||
|
if "=" not in value:
|
||||||
|
parser.error(f"--repo-version must use REPO=VERSION: {value}")
|
||||||
|
repo, version = (item.strip() for item in value.split("=", 1))
|
||||||
|
version = version.removeprefix("v")
|
||||||
|
if not repo or not version:
|
||||||
|
parser.error(f"--repo-version must use REPO=VERSION: {value}")
|
||||||
|
if repo in result and result[repo] != version:
|
||||||
|
parser.error(f"conflicting versions requested for {repo}")
|
||||||
|
result[repo] = version
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -11,48 +11,89 @@ from pathlib import Path
|
|||||||
META_ROOT = Path(__file__).resolve().parents[2]
|
META_ROOT = Path(__file__).resolve().parents[2]
|
||||||
CORE_ROOT = Path(os.environ.get("GOVOPLAN_CORE_ROOT", META_ROOT.parent / "govoplan-core")).resolve()
|
CORE_ROOT = Path(os.environ.get("GOVOPLAN_CORE_ROOT", META_ROOT.parent / "govoplan-core")).resolve()
|
||||||
REPOS_ROOT = Path(os.environ.get("GOVOPLAN_REPOS_ROOT", CORE_ROOT.parent)).resolve()
|
REPOS_ROOT = Path(os.environ.get("GOVOPLAN_REPOS_ROOT", CORE_ROOT.parent)).resolve()
|
||||||
REPOS = {
|
|
||||||
"core": CORE_ROOT / "src" / "govoplan_core",
|
|
||||||
"access": REPOS_ROOT / "govoplan-access" / "src" / "govoplan_access",
|
def _discover_python_repos(
|
||||||
"admin": REPOS_ROOT / "govoplan-admin" / "src" / "govoplan_admin",
|
repos_root: Path = REPOS_ROOT,
|
||||||
"tenancy": REPOS_ROOT / "govoplan-tenancy" / "src" / "govoplan_tenancy",
|
) -> tuple[dict[str, Path], dict[str, str], tuple[str, ...]]:
|
||||||
"organizations": REPOS_ROOT / "govoplan-organizations" / "src" / "govoplan_organizations",
|
repos: dict[str, Path] = {}
|
||||||
"identity": REPOS_ROOT / "govoplan-identity" / "src" / "govoplan_identity",
|
prefixes: dict[str, str] = {}
|
||||||
"policy": REPOS_ROOT / "govoplan-policy" / "src" / "govoplan_policy",
|
errors: list[str] = []
|
||||||
"audit": REPOS_ROOT / "govoplan-audit" / "src" / "govoplan_audit",
|
for repo_root in sorted(repos_root.glob("govoplan*")):
|
||||||
"dashboard": REPOS_ROOT / "govoplan-dashboard" / "src" / "govoplan_dashboard",
|
source_root = repo_root / "src"
|
||||||
"files": REPOS_ROOT / "govoplan-files" / "src" / "govoplan_files",
|
if not source_root.is_dir():
|
||||||
"mail": REPOS_ROOT / "govoplan-mail" / "src" / "govoplan_mail",
|
continue
|
||||||
"campaign": REPOS_ROOT / "govoplan-campaign" / "src" / "govoplan_campaign",
|
packages = sorted(
|
||||||
}
|
path
|
||||||
PREFIXES = {
|
for path in source_root.glob("govoplan_*")
|
||||||
"core": "govoplan_core",
|
if path.is_dir() and (path / "__init__.py").is_file()
|
||||||
"access": "govoplan_access",
|
)
|
||||||
"admin": "govoplan_admin",
|
if len(packages) != 1:
|
||||||
"tenancy": "govoplan_tenancy",
|
discovered = ", ".join(path.name for path in packages) or "none"
|
||||||
"organizations": "govoplan_organizations",
|
errors.append(
|
||||||
"identity": "govoplan_identity",
|
f"{repo_root.name}: expected exactly one importable govoplan_* package "
|
||||||
"policy": "govoplan_policy",
|
f"under {source_root}, found {len(packages)} ({discovered})"
|
||||||
"audit": "govoplan_audit",
|
)
|
||||||
"dashboard": "govoplan_dashboard",
|
continue
|
||||||
"files": "govoplan_files",
|
owner = repo_root.name
|
||||||
"mail": "govoplan_mail",
|
prefix = packages[0].name
|
||||||
"campaign": "govoplan_campaign",
|
duplicate_owner = next(
|
||||||
}
|
(candidate_owner for candidate_owner, candidate_prefix in prefixes.items() if candidate_prefix == prefix),
|
||||||
FEATURE_OWNERS = ("files", "mail", "campaign")
|
None,
|
||||||
PLATFORM_OWNERS = ("admin", "tenancy", "organizations", "identity", "policy", "audit", "dashboard")
|
)
|
||||||
WEBUI_REPOS = {
|
if duplicate_owner is not None:
|
||||||
"core": CORE_ROOT / "webui",
|
errors.append(
|
||||||
"files": REPOS_ROOT / "govoplan-files" / "webui",
|
f"{owner}: import package {prefix!r} is already owned by {duplicate_owner}; "
|
||||||
"mail": REPOS_ROOT / "govoplan-mail" / "webui",
|
"Python package ownership must be unique"
|
||||||
"campaign": REPOS_ROOT / "govoplan-campaign" / "webui",
|
)
|
||||||
}
|
continue
|
||||||
WEBUI_PACKAGES = {
|
repos[owner] = packages[0]
|
||||||
"core": "@govoplan/core-webui",
|
prefixes[owner] = prefix
|
||||||
"files": "@govoplan/files-webui",
|
return repos, prefixes, tuple(errors)
|
||||||
"mail": "@govoplan/mail-webui",
|
|
||||||
"campaign": "@govoplan/campaign-webui",
|
|
||||||
}
|
def _discover_webui_repos(
|
||||||
|
repos_root: Path = REPOS_ROOT,
|
||||||
|
) -> tuple[dict[str, Path], dict[str, str], tuple[str, ...]]:
|
||||||
|
repos: dict[str, Path] = {}
|
||||||
|
packages: dict[str, str] = {}
|
||||||
|
errors: list[str] = []
|
||||||
|
for repo_root in sorted(repos_root.glob("govoplan*")):
|
||||||
|
webui_root = repo_root / "webui"
|
||||||
|
if not webui_root.is_dir():
|
||||||
|
continue
|
||||||
|
package_path = webui_root / "package.json"
|
||||||
|
if not package_path.is_file():
|
||||||
|
errors.append(f"{repo_root.name}: WebUI directory has no package.json: {package_path}")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
payload = json.loads(package_path.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
errors.append(f"{repo_root.name}: invalid {package_path}: {exc}")
|
||||||
|
continue
|
||||||
|
package_name = payload.get("name") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(package_name, str) or not package_name:
|
||||||
|
errors.append(f"{repo_root.name}: {package_path} must declare a non-empty package name")
|
||||||
|
continue
|
||||||
|
owner = repo_root.name
|
||||||
|
duplicate_owner = next(
|
||||||
|
(candidate_owner for candidate_owner, candidate_package in packages.items() if candidate_package == package_name),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if duplicate_owner is not None:
|
||||||
|
errors.append(
|
||||||
|
f"{owner}: WebUI package {package_name!r} is already owned by {duplicate_owner}; "
|
||||||
|
"WebUI package ownership must be unique"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
repos[owner] = webui_root
|
||||||
|
packages[owner] = package_name
|
||||||
|
return repos, packages, tuple(errors)
|
||||||
|
|
||||||
|
|
||||||
|
REPOS, PREFIXES, PYTHON_DISCOVERY_ERRORS = _discover_python_repos()
|
||||||
|
WEBUI_REPOS, WEBUI_PACKAGES, WEBUI_DISCOVERY_ERRORS = _discover_webui_repos()
|
||||||
|
CORE_OWNER = CORE_ROOT.name
|
||||||
WEBUI_IMPORT_RE = re.compile(
|
WEBUI_IMPORT_RE = re.compile(
|
||||||
r"(?:from\s+|import\s*\(\s*|require\s*\(\s*|export\s+[^;]*?\s+from\s+)"
|
r"(?:from\s+|import\s*\(\s*|require\s*\(\s*|export\s+[^;]*?\s+from\s+)"
|
||||||
r"[\"'](?P<package>@govoplan/[a-z0-9_-]+-webui)(?:/[A-Za-z0-9_./-]+)?[\"']"
|
r"[\"'](?P<package>@govoplan/[a-z0-9_-]+-webui)(?:/[A-Za-z0-9_./-]+)?[\"']"
|
||||||
@@ -123,18 +164,7 @@ def _violations() -> list[Violation]:
|
|||||||
imported_owner = _import_owner(imported)
|
imported_owner = _import_owner(imported)
|
||||||
if imported_owner is None or imported_owner == owner:
|
if imported_owner is None or imported_owner == owner:
|
||||||
continue
|
continue
|
||||||
if owner == "core" and imported_owner in FEATURE_OWNERS:
|
if imported_owner != CORE_OWNER and not _allowed(owner, path, imported_owner):
|
||||||
if not _allowed(owner, path, imported_owner):
|
|
||||||
violations.append(Violation("python", owner, path, lineno, imported, imported_owner))
|
|
||||||
elif owner == "access" and imported_owner in FEATURE_OWNERS:
|
|
||||||
violations.append(Violation("python", owner, path, lineno, imported, imported_owner))
|
|
||||||
elif owner in PLATFORM_OWNERS and imported_owner == "access":
|
|
||||||
if not _allowed(owner, path, imported_owner):
|
|
||||||
violations.append(Violation("python", owner, path, lineno, imported, imported_owner))
|
|
||||||
elif owner in FEATURE_OWNERS and imported_owner in FEATURE_OWNERS:
|
|
||||||
if not _allowed(owner, path, imported_owner):
|
|
||||||
violations.append(Violation("python", owner, path, lineno, imported, imported_owner))
|
|
||||||
elif owner in FEATURE_OWNERS and imported_owner == "access":
|
|
||||||
violations.append(Violation("python", owner, path, lineno, imported, imported_owner))
|
violations.append(Violation("python", owner, path, lineno, imported, imported_owner))
|
||||||
violations.extend(_webui_violations())
|
violations.extend(_webui_violations())
|
||||||
return violations
|
return violations
|
||||||
@@ -187,7 +217,12 @@ def _webui_package_json_violations(owner: str, root: Path) -> list[Violation]:
|
|||||||
imported_owner = _webui_package_owner(str(package_name))
|
imported_owner = _webui_package_owner(str(package_name))
|
||||||
if imported_owner is None or imported_owner == owner:
|
if imported_owner is None or imported_owner == owner:
|
||||||
continue
|
continue
|
||||||
if owner in FEATURE_OWNERS and imported_owner in FEATURE_OWNERS:
|
# The core WebUI package is also the development composition host;
|
||||||
|
# package declarations make independently built module frontends
|
||||||
|
# available to Vite without authorizing source-level imports.
|
||||||
|
if owner == CORE_OWNER:
|
||||||
|
continue
|
||||||
|
if imported_owner != CORE_OWNER:
|
||||||
violations.append(Violation("webui-package", owner, package_path, 1, str(package_name), imported_owner))
|
violations.append(Violation("webui-package", owner, package_path, 1, str(package_name), imported_owner))
|
||||||
return violations
|
return violations
|
||||||
|
|
||||||
@@ -202,9 +237,7 @@ def _webui_source_violations(owner: str, root: Path) -> list[Violation]:
|
|||||||
if imported_owner is None or imported_owner == owner:
|
if imported_owner is None or imported_owner == owner:
|
||||||
continue
|
continue
|
||||||
lineno = text.count("\n", 0, match.start()) + 1
|
lineno = text.count("\n", 0, match.start()) + 1
|
||||||
if owner == "core" and imported_owner in FEATURE_OWNERS:
|
if imported_owner != CORE_OWNER:
|
||||||
violations.append(Violation("webui-source", owner, path, lineno, package_name, imported_owner))
|
|
||||||
elif owner in FEATURE_OWNERS and imported_owner in FEATURE_OWNERS:
|
|
||||||
violations.append(Violation("webui-source", owner, path, lineno, package_name, imported_owner))
|
violations.append(Violation("webui-source", owner, path, lineno, package_name, imported_owner))
|
||||||
return violations
|
return violations
|
||||||
|
|
||||||
@@ -220,9 +253,21 @@ def _webui_violations() -> list[Violation]:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
|
discovery_errors = (*PYTHON_DISCOVERY_ERRORS, *WEBUI_DISCOVERY_ERRORS)
|
||||||
|
if discovery_errors:
|
||||||
|
print("Dependency boundary discovery errors:")
|
||||||
|
for error in discovery_errors:
|
||||||
|
print(f"- {error}")
|
||||||
|
print("\nThe boundary scan is incomplete; fix package discovery before accepting this gate.")
|
||||||
|
return 1
|
||||||
|
|
||||||
violations = _violations()
|
violations = _violations()
|
||||||
if not violations:
|
if not violations:
|
||||||
print(f"Dependency boundary check passed ({len(ALLOWLIST)} transitional exceptions tracked)")
|
print(
|
||||||
|
"Dependency boundary check passed "
|
||||||
|
f"({len(REPOS)} Python repos, {len(WEBUI_REPOS)} WebUI repos, "
|
||||||
|
f"{len(ALLOWLIST)} transitional exceptions tracked)"
|
||||||
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
print("Dependency boundary violations:")
|
print("Dependency boundary violations:")
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# Application imports intentionally follow the test environment and source-path setup below.
|
||||||
|
# ruff: noqa: E402
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -31,7 +34,6 @@ from govoplan_core.core.maintenance import MaintenanceMode, save_maintenance_mod
|
|||||||
from govoplan_core.core.module_installer import (
|
from govoplan_core.core.module_installer import (
|
||||||
cancel_module_installer_request,
|
cancel_module_installer_request,
|
||||||
claim_next_module_installer_request,
|
claim_next_module_installer_request,
|
||||||
module_installer_daemon_status,
|
|
||||||
module_installer_lock_status,
|
module_installer_lock_status,
|
||||||
queue_module_installer_request,
|
queue_module_installer_request,
|
||||||
read_module_installer_run,
|
read_module_installer_run,
|
||||||
@@ -44,8 +46,6 @@ from govoplan_core.core.module_installer import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.core.module_management import (
|
from govoplan_core.core.module_management import (
|
||||||
ModuleInstallPlan,
|
ModuleInstallPlan,
|
||||||
ModuleInstallPlanItem,
|
|
||||||
saved_desired_enabled_modules,
|
|
||||||
saved_module_install_plan,
|
saved_module_install_plan,
|
||||||
save_desired_enabled_modules,
|
save_desired_enabled_modules,
|
||||||
save_module_install_plan,
|
save_module_install_plan,
|
||||||
|
|||||||
@@ -47,6 +47,14 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
parser.add_argument("--skip-migrate", action="store_true", help="Skip govoplan_core.commands.init_db.")
|
parser.add_argument("--skip-migrate", action="store_true", help="Skip govoplan_core.commands.init_db.")
|
||||||
parser.add_argument("--skip-smoke", action="store_true", help="Skip govoplan_core.devserver --smoke.")
|
parser.add_argument("--skip-smoke", action="store_true", help="Skip govoplan_core.devserver --smoke.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--skip-retirement-atomicity",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"Skip the isolated destructive-retirement atomicity proof. "
|
||||||
|
"Do not use this option for a release gate."
|
||||||
|
),
|
||||||
|
)
|
||||||
parser.add_argument("--python", default=sys.executable, help="Python executable to use. Defaults to the current interpreter.")
|
parser.add_argument("--python", default=sys.executable, help="Python executable to use. Defaults to the current interpreter.")
|
||||||
parser.add_argument("--app-env", default="staging", help="APP_ENV value to pass to subprocesses. Defaults to staging.")
|
parser.add_argument("--app-env", default="staging", help="APP_ENV value to pass to subprocesses. Defaults to staging.")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
@@ -59,8 +67,21 @@ def main() -> int:
|
|||||||
|
|
||||||
module_sets = _parse_module_sets(args.module_set)
|
module_sets = _parse_module_sets(args.module_set)
|
||||||
ok = True
|
ok = True
|
||||||
|
if not args.skip_retirement_atomicity:
|
||||||
|
print("\n== destructive retirement atomicity ==", flush=True)
|
||||||
|
retirement_env = _check_env(
|
||||||
|
database_url=database_url,
|
||||||
|
modules="tenancy,access,admin,audit,files",
|
||||||
|
app_env=args.app_env,
|
||||||
|
)
|
||||||
|
retirement_env["GOVOPLAN_POSTGRES_DATABASE_URL"] = database_url
|
||||||
|
retirement_env["GOVOPLAN_REQUIRE_POSTGRES_RETIREMENT_PROOF"] = "1"
|
||||||
|
ok = _run(
|
||||||
|
[args.python, "-m", "unittest", "-v", "tests.test_postgres_retirement_atomicity"],
|
||||||
|
env=retirement_env,
|
||||||
|
) and ok
|
||||||
for name, modules in module_sets:
|
for name, modules in module_sets:
|
||||||
print(f"\n== {name}: {modules} ==")
|
print(f"\n== {name}: {modules} ==", flush=True)
|
||||||
if args.reset_schema:
|
if args.reset_schema:
|
||||||
_reset_public_schema(database_url)
|
_reset_public_schema(database_url)
|
||||||
env = _check_env(database_url=database_url, modules=modules, app_env=args.app_env)
|
env = _check_env(database_url=database_url, modules=modules, app_env=args.app_env)
|
||||||
@@ -100,7 +121,7 @@ def _check_env(*, database_url: str, modules: str, app_env: str) -> dict[str, st
|
|||||||
|
|
||||||
|
|
||||||
def _run(command: list[str], *, env: dict[str, str]) -> bool:
|
def _run(command: list[str], *, env: dict[str, str]) -> bool:
|
||||||
print("+ " + " ".join(command))
|
print("+ " + " ".join(command), flush=True)
|
||||||
result = subprocess.run(command, cwd=ROOT, env=env, check=False)
|
result = subprocess.run(command, cwd=ROOT, env=env, check=False)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
print(f"Command failed with exit code {result.returncode}: {' '.join(command)}", file=sys.stderr)
|
print(f"Command failed with exit code {result.returncode}: {' '.join(command)}", file=sys.stderr)
|
||||||
|
|||||||
380
tools/checks/release_integration.py
Normal file
380
tools/checks/release_integration.py
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import importlib.metadata as metadata
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from collections.abc import Iterator, Mapping, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
CORE_RELEASE_TEST_MODULES = (
|
||||||
|
"tests.test_module_system",
|
||||||
|
"tests.test_access_contracts",
|
||||||
|
"tests.test_identity_organization_contracts",
|
||||||
|
"tests.test_core_events",
|
||||||
|
"tests.test_policy_contracts",
|
||||||
|
)
|
||||||
|
|
||||||
|
# These tests intentionally compare the current source workspace with sibling
|
||||||
|
# source repositories. The release gate instead installs independently tagged
|
||||||
|
# wheels and verifies the equivalent contracts below from artifact metadata and
|
||||||
|
# public module capability factories.
|
||||||
|
SOURCE_COUPLED_CORE_TESTS: Mapping[str, str] = {
|
||||||
|
"tests.test_module_system.ModuleSystemTests.test_discovers_installed_core_and_product_module_manifests": (
|
||||||
|
"asserts the current Campaign source manifest rather than the independently tagged artifact"
|
||||||
|
),
|
||||||
|
"tests.test_module_system.ModuleSystemTests.test_module_manifest_versions_match_source_project_versions": (
|
||||||
|
"requires sibling source pyproject.toml files that wheels do not contain"
|
||||||
|
),
|
||||||
|
"tests.test_module_system.ModuleSystemTests.test_release_catalog_generator_reads_manifest_interface_contracts": (
|
||||||
|
"requires the sibling meta/source workspace and is retained by the normal source gate"
|
||||||
|
),
|
||||||
|
"tests.test_identity_organization_contracts.IdentityOrganizationContractTests."
|
||||||
|
"test_sql_identity_and_organization_directories_return_dtos": (
|
||||||
|
"directly constructs the current IDM source implementation instead of using its public capability factory"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
_RELEASE_REQUIREMENT_PATTERN = re.compile(
|
||||||
|
r"^(govoplan-[a-z0-9-]+)\s+@\s+git\+ssh://.+?\.git@v[0-9]+\.[0-9]+\.[0-9]+$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class InstalledModuleArtifact:
|
||||||
|
package_name: str
|
||||||
|
package_version: str
|
||||||
|
manifest_id: str
|
||||||
|
manifest_version: str
|
||||||
|
|
||||||
|
|
||||||
|
def release_package_names(path: Path) -> tuple[str, ...]:
|
||||||
|
names: list[str] = []
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
match = _RELEASE_REQUIREMENT_PATTERN.match(line.strip())
|
||||||
|
if match:
|
||||||
|
names.append(match.group(1))
|
||||||
|
return tuple(dict.fromkeys(names))
|
||||||
|
|
||||||
|
|
||||||
|
def artifact_contract_issues(
|
||||||
|
artifacts: Sequence[InstalledModuleArtifact],
|
||||||
|
*,
|
||||||
|
release_packages: Sequence[str],
|
||||||
|
available_manifest_versions: Mapping[str, str],
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
issues: list[str] = []
|
||||||
|
artifacts_by_package: dict[str, list[InstalledModuleArtifact]] = {}
|
||||||
|
artifacts_by_module: dict[str, list[InstalledModuleArtifact]] = {}
|
||||||
|
for artifact in artifacts:
|
||||||
|
artifacts_by_package.setdefault(artifact.package_name, []).append(artifact)
|
||||||
|
artifacts_by_module.setdefault(artifact.manifest_id, []).append(artifact)
|
||||||
|
if artifact.package_version != artifact.manifest_version:
|
||||||
|
issues.append(
|
||||||
|
f"{artifact.package_name} metadata version {artifact.package_version!r} does not match "
|
||||||
|
f"manifest {artifact.manifest_id!r} version {artifact.manifest_version!r}"
|
||||||
|
)
|
||||||
|
available_version = available_manifest_versions.get(artifact.manifest_id)
|
||||||
|
if available_version is None:
|
||||||
|
issues.append(f"Installed manifest {artifact.manifest_id!r} is not discoverable through Core")
|
||||||
|
elif available_version != artifact.manifest_version:
|
||||||
|
issues.append(
|
||||||
|
f"Discovered manifest {artifact.manifest_id!r} version {available_version!r} does not match "
|
||||||
|
f"its {artifact.package_name} entry point version {artifact.manifest_version!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for package_name in release_packages:
|
||||||
|
if package_name not in artifacts_by_package:
|
||||||
|
issues.append(f"{package_name} does not publish a govoplan.modules entry point")
|
||||||
|
for module_id, providers in artifacts_by_module.items():
|
||||||
|
if len(providers) > 1:
|
||||||
|
packages = ", ".join(sorted(item.package_name for item in providers))
|
||||||
|
issues.append(f"Module id {module_id!r} is published by multiple release packages: {packages}")
|
||||||
|
return tuple(issues)
|
||||||
|
|
||||||
|
|
||||||
|
def iter_test_cases(suite: unittest.TestSuite) -> Iterator[unittest.TestCase]:
|
||||||
|
for test in suite:
|
||||||
|
if isinstance(test, unittest.TestSuite):
|
||||||
|
yield from iter_test_cases(test)
|
||||||
|
else:
|
||||||
|
yield test
|
||||||
|
|
||||||
|
|
||||||
|
def filter_test_suite(
|
||||||
|
suite: unittest.TestSuite,
|
||||||
|
excluded_ids: Sequence[str],
|
||||||
|
) -> tuple[unittest.TestSuite, tuple[str, ...]]:
|
||||||
|
excluded = set(excluded_ids)
|
||||||
|
kept: list[unittest.TestCase] = []
|
||||||
|
removed: list[str] = []
|
||||||
|
for test in iter_test_cases(suite):
|
||||||
|
test_id = test.id()
|
||||||
|
if test_id in excluded:
|
||||||
|
removed.append(test_id)
|
||||||
|
else:
|
||||||
|
kept.append(test)
|
||||||
|
return unittest.TestSuite(kept), tuple(removed)
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_installed_artifacts(
|
||||||
|
package_names: Sequence[str],
|
||||||
|
) -> tuple[tuple[InstalledModuleArtifact, ...], tuple[str, ...]]:
|
||||||
|
artifacts: list[InstalledModuleArtifact] = []
|
||||||
|
issues: list[str] = []
|
||||||
|
for package_name in package_names:
|
||||||
|
try:
|
||||||
|
distribution = metadata.distribution(package_name)
|
||||||
|
except metadata.PackageNotFoundError:
|
||||||
|
issues.append(f"Release package is not installed: {package_name}")
|
||||||
|
continue
|
||||||
|
entry_points = tuple(
|
||||||
|
entry_point
|
||||||
|
for entry_point in distribution.entry_points
|
||||||
|
if entry_point.group == "govoplan.modules"
|
||||||
|
)
|
||||||
|
if not entry_points:
|
||||||
|
continue
|
||||||
|
for entry_point in entry_points:
|
||||||
|
try:
|
||||||
|
factory = entry_point.load()
|
||||||
|
manifest = factory()
|
||||||
|
manifest_id = str(manifest.id)
|
||||||
|
manifest_version = str(manifest.version)
|
||||||
|
except Exception as exc:
|
||||||
|
issues.append(f"Could not load {package_name} module entry point {entry_point.name!r}: {exc}")
|
||||||
|
continue
|
||||||
|
artifacts.append(
|
||||||
|
InstalledModuleArtifact(
|
||||||
|
package_name=package_name,
|
||||||
|
package_version=distribution.version,
|
||||||
|
manifest_id=manifest_id,
|
||||||
|
manifest_version=manifest_version,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(artifacts), tuple(issues)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_equal(actual: object, expected: object, message: str) -> None:
|
||||||
|
if actual != expected:
|
||||||
|
raise RuntimeError(f"{message}: expected {expected!r}, got {actual!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _run_directory_capability_smoke(registry: object) -> None:
|
||||||
|
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
|
||||||
|
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory
|
||||||
|
from govoplan_core.core.modules import ModuleContext
|
||||||
|
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, OrganizationDirectory
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.db.session import configure_database, reset_database, set_database
|
||||||
|
from govoplan_core.tenancy.scope import create_scope_tables
|
||||||
|
from govoplan_identity.backend.db.models import Identity, IdentityAccountLink
|
||||||
|
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment
|
||||||
|
from govoplan_organizations.backend.db.models import (
|
||||||
|
OrganizationFunction,
|
||||||
|
OrganizationFunctionType,
|
||||||
|
OrganizationUnit,
|
||||||
|
OrganizationUnitType,
|
||||||
|
)
|
||||||
|
from govoplan_tenancy.backend.db.models import Tenant
|
||||||
|
|
||||||
|
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
|
||||||
|
identity_directory = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
|
||||||
|
organization_directory = registry.require_capability(CAPABILITY_ORGANIZATION_DIRECTORY)
|
||||||
|
idm_directory = registry.require_capability(CAPABILITY_IDM_DIRECTORY)
|
||||||
|
if not isinstance(identity_directory, IdentityDirectory):
|
||||||
|
raise RuntimeError("Installed Identity capability does not satisfy IdentityDirectory")
|
||||||
|
if not isinstance(organization_directory, OrganizationDirectory):
|
||||||
|
raise RuntimeError("Installed Organizations capability does not satisfy OrganizationDirectory")
|
||||||
|
if not isinstance(idm_directory, IdmDirectory):
|
||||||
|
raise RuntimeError("Installed IDM capability does not satisfy IdmDirectory")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-release-directory-") as temp_dir:
|
||||||
|
database = configure_database(
|
||||||
|
f"sqlite:///{Path(temp_dir) / 'directory.sqlite3'}",
|
||||||
|
dispose_previous=True,
|
||||||
|
)
|
||||||
|
set_database(database)
|
||||||
|
try:
|
||||||
|
create_scope_tables(database.engine)
|
||||||
|
Base.metadata.create_all(bind=database.engine)
|
||||||
|
with database.session() as session:
|
||||||
|
tenant = Tenant(
|
||||||
|
id="release-tenant",
|
||||||
|
slug="release-tenant",
|
||||||
|
name="Release Tenant",
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
identity = Identity(
|
||||||
|
id="release-identity",
|
||||||
|
display_name="Release Person",
|
||||||
|
source="local",
|
||||||
|
)
|
||||||
|
link = IdentityAccountLink(
|
||||||
|
identity_id=identity.id,
|
||||||
|
account_id="release-account",
|
||||||
|
is_primary=True,
|
||||||
|
)
|
||||||
|
unit_type = OrganizationUnitType(
|
||||||
|
id="release-unit-type",
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
slug="office",
|
||||||
|
name="Office",
|
||||||
|
)
|
||||||
|
unit = OrganizationUnit(
|
||||||
|
id="release-unit",
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
unit_type_id=unit_type.id,
|
||||||
|
slug="registry",
|
||||||
|
name="Registry",
|
||||||
|
)
|
||||||
|
function_type = OrganizationFunctionType(
|
||||||
|
id="release-function-type",
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
slug="clerk",
|
||||||
|
name="Clerk",
|
||||||
|
organization_unit_type_id=unit_type.id,
|
||||||
|
)
|
||||||
|
function = OrganizationFunction(
|
||||||
|
id="release-function",
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
function_type_id=function_type.id,
|
||||||
|
organization_unit_id=unit.id,
|
||||||
|
slug="registry-clerk",
|
||||||
|
name="Registry Clerk",
|
||||||
|
)
|
||||||
|
assignment = IdmOrganizationFunctionAssignment(
|
||||||
|
id="release-assignment",
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
identity_id=identity.id,
|
||||||
|
function_id=function.id,
|
||||||
|
organization_unit_id=unit.id,
|
||||||
|
applies_to_subunits=True,
|
||||||
|
)
|
||||||
|
session.add_all((tenant, identity, link, unit_type, unit, function_type, function, assignment))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
identity_ref = identity_directory.identity_for_account("release-account")
|
||||||
|
_require_equal(
|
||||||
|
identity_ref.id if identity_ref is not None else None,
|
||||||
|
"release-identity",
|
||||||
|
"Identity capability account projection",
|
||||||
|
)
|
||||||
|
function_ref = organization_directory.get_function("release-function")
|
||||||
|
_require_equal(
|
||||||
|
function_ref.name if function_ref is not None else None,
|
||||||
|
"Registry Clerk",
|
||||||
|
"Organizations capability function projection",
|
||||||
|
)
|
||||||
|
identity_assignments = idm_directory.organization_function_assignments_for_identity(
|
||||||
|
"release-identity",
|
||||||
|
tenant_id="release-tenant",
|
||||||
|
)
|
||||||
|
account_assignments = idm_directory.organization_function_assignments_for_account(
|
||||||
|
"release-account",
|
||||||
|
tenant_id="release-tenant",
|
||||||
|
)
|
||||||
|
_require_equal(
|
||||||
|
tuple(item.id for item in identity_assignments),
|
||||||
|
("release-assignment",),
|
||||||
|
"IDM identity assignment projection",
|
||||||
|
)
|
||||||
|
_require_equal(
|
||||||
|
tuple(item.id for item in account_assignments),
|
||||||
|
("release-assignment",),
|
||||||
|
"IDM account assignment projection",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
reset_database(dispose=True)
|
||||||
|
|
||||||
|
|
||||||
|
def run_installed_artifact_checks(requirements_path: Path) -> int:
|
||||||
|
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
||||||
|
|
||||||
|
package_names = release_package_names(requirements_path)
|
||||||
|
if not package_names:
|
||||||
|
print(f"No tagged GovOPlaN packages found in {requirements_path}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
artifacts, collection_issues = _collect_installed_artifacts(package_names)
|
||||||
|
available = available_module_manifests()
|
||||||
|
contract_issues = artifact_contract_issues(
|
||||||
|
artifacts,
|
||||||
|
release_packages=package_names,
|
||||||
|
available_manifest_versions={module_id: str(manifest.version) for module_id, manifest in available.items()},
|
||||||
|
)
|
||||||
|
issues = (*collection_issues, *contract_issues)
|
||||||
|
if issues:
|
||||||
|
print("\n".join(issues), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
module_ids = tuple(dict.fromkeys(artifact.manifest_id for artifact in artifacts))
|
||||||
|
registry = build_platform_registry(module_ids)
|
||||||
|
snapshot = registry.validate()
|
||||||
|
discovered_ids = {manifest.id for manifest in snapshot.manifests}
|
||||||
|
if discovered_ids != set(module_ids):
|
||||||
|
print(
|
||||||
|
"Release registry module mismatch: "
|
||||||
|
f"expected {', '.join(sorted(module_ids))}; got {', '.join(sorted(discovered_ids))}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
_run_directory_capability_smoke(registry)
|
||||||
|
versions = ", ".join(
|
||||||
|
f"{artifact.manifest_id}={artifact.manifest_version}"
|
||||||
|
for artifact in sorted(artifacts, key=lambda item: item.manifest_id)
|
||||||
|
)
|
||||||
|
print(f"Installed release artifact contracts passed: {versions}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def run_core_release_tests(core_root: Path) -> int:
|
||||||
|
resolved_root = core_root.resolve()
|
||||||
|
os.chdir(resolved_root)
|
||||||
|
sys.path.insert(0, str(resolved_root))
|
||||||
|
loader = unittest.defaultTestLoader
|
||||||
|
loaded = loader.loadTestsFromNames(CORE_RELEASE_TEST_MODULES)
|
||||||
|
suite, removed = filter_test_suite(loaded, tuple(SOURCE_COUPLED_CORE_TESTS))
|
||||||
|
missing_exclusions = set(SOURCE_COUPLED_CORE_TESTS) - set(removed)
|
||||||
|
if missing_exclusions:
|
||||||
|
print(
|
||||||
|
"Release source-test exclusions no longer match the Core suite: "
|
||||||
|
+ ", ".join(sorted(missing_exclusions)),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
for test_id in removed:
|
||||||
|
print(f"Artifact contract replaces source-coupled test: {test_id} ({SOURCE_COUPLED_CORE_TESTS[test_id]})")
|
||||||
|
result = unittest.TextTestRunner(verbosity=1).run(suite)
|
||||||
|
return 0 if result.wasSuccessful() else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _parser() -> argparse.ArgumentParser:
|
||||||
|
meta_root = Path(__file__).resolve().parents[2]
|
||||||
|
parser = argparse.ArgumentParser(description="Artifact-aware GovOPlaN release integration checks")
|
||||||
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
artifact_parser = subparsers.add_parser("artifacts", help="Validate installed module artifacts and capabilities")
|
||||||
|
artifact_parser.add_argument(
|
||||||
|
"--requirements",
|
||||||
|
type=Path,
|
||||||
|
default=meta_root / "requirements-release.txt",
|
||||||
|
)
|
||||||
|
core_parser = subparsers.add_parser("core-tests", help="Run Core tests that are valid for tagged module artifacts")
|
||||||
|
core_parser.add_argument("--core-root", type=Path, required=True)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
args = _parser().parse_args(argv)
|
||||||
|
if args.command == "artifacts":
|
||||||
|
return run_installed_artifact_checks(args.requirements)
|
||||||
|
return run_core_release_tests(args.core_root)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -31,17 +31,24 @@ RUN apt-get update \
|
|||||||
COPY --from=go-tools /go/bin/gitleaks /usr/local/bin/gitleaks
|
COPY --from=go-tools /go/bin/gitleaks /usr/local/bin/gitleaks
|
||||||
COPY --from=go-tools /go/bin/osv-scanner /usr/local/bin/osv-scanner
|
COPY --from=go-tools /go/bin/osv-scanner /usr/local/bin/osv-scanner
|
||||||
COPY requirements-audit.txt /tmp/requirements-audit.txt
|
COPY requirements-audit.txt /tmp/requirements-audit.txt
|
||||||
|
COPY tools/checks/security-audit/semgrep-govoplan.yml /tmp/semgrep-govoplan.yml
|
||||||
|
COPY tools/checks/check-version-alignment.py /tmp/semgrep-smoke.py
|
||||||
|
|
||||||
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" \
|
||||||
|
&& python -m pip install --no-cache-dir --upgrade --no-deps "mcp==1.28.1" \
|
||||||
&& npm install -g jscpd \
|
&& npm install -g jscpd \
|
||||||
&& semgrep --version \
|
&& semgrep --version \
|
||||||
|
&& semgrep scan --metrics=off --config /tmp/semgrep-govoplan.yml /tmp/semgrep-smoke.py \
|
||||||
&& bandit --version \
|
&& bandit --version \
|
||||||
&& ruff --version \
|
&& ruff --version \
|
||||||
&& gitleaks version \
|
&& gitleaks version \
|
||||||
&& osv-scanner --version \
|
&& osv-scanner --version \
|
||||||
&& trivy --version \
|
&& trivy --version \
|
||||||
&& jscpd --version
|
&& jscpd --version \
|
||||||
|
&& pip-audit --progress-spinner off
|
||||||
|
|
||||||
RUN mkdir -p /workspace \
|
RUN mkdir -p /workspace \
|
||||||
&& chmod 0777 /workspace
|
&& chmod 0777 /workspace
|
||||||
|
|||||||
@@ -71,6 +71,32 @@ REBUILD="${REBUILD:-0}"
|
|||||||
UPDATE="${UPDATE:-0}"
|
UPDATE="${UPDATE:-0}"
|
||||||
BUILD_ONLY="${BUILD_ONLY:-0}"
|
BUILD_ONLY="${BUILD_ONLY:-0}"
|
||||||
|
|
||||||
|
declare -a DOCKER_CLI=()
|
||||||
|
DOCKER_LOCATION=""
|
||||||
|
if command -v docker >/dev/null 2>&1; then
|
||||||
|
DOCKER_CLI=(docker)
|
||||||
|
DOCKER_LOCATION="current environment"
|
||||||
|
elif command -v flatpak-spawn >/dev/null 2>&1 \
|
||||||
|
&& flatpak-spawn --host sh -lc 'command -v docker >/dev/null 2>&1'; then
|
||||||
|
DOCKER_CLI=(flatpak-spawn --host docker)
|
||||||
|
DOCKER_LOCATION="Flatpak host"
|
||||||
|
else
|
||||||
|
echo "Docker is not available in this environment or through a Flatpak host bridge." >&2
|
||||||
|
echo "Install Docker, expose its CLI/socket, or run this script from the host." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! DOCKER_VERSION_OUTPUT="$("${DOCKER_CLI[@]}" version --format '{{.Server.Version}}' 2>&1)"; then
|
||||||
|
echo "Docker was found on the $DOCKER_LOCATION, but its daemon is not accessible:" >&2
|
||||||
|
printf ' %s\n' "$DOCKER_VERSION_OUTPUT" >&2
|
||||||
|
if [[ "$DOCKER_LOCATION" == "Flatpak host" && "$DOCKER_VERSION_OUTPUT" == *"permission denied"* ]]; then
|
||||||
|
echo "Grant the host account access to the Docker socket, then fully log out and back in." >&2
|
||||||
|
echo "On a conventional rootful Docker installation: sudo usermod -aG docker \"$(id -un)\"" >&2
|
||||||
|
echo "Membership in the docker group is root-equivalent; use rootless Docker instead where that is preferred." >&2
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
IMAGE_NAME="${IMAGE##*/}"
|
IMAGE_NAME="${IMAGE##*/}"
|
||||||
if [[ "$IMAGE_NAME" == *:* ]]; then
|
if [[ "$IMAGE_NAME" == *:* ]]; then
|
||||||
IMAGE_REPOSITORY="${IMAGE%:*}"
|
IMAGE_REPOSITORY="${IMAGE%:*}"
|
||||||
@@ -84,6 +110,8 @@ IMAGE_FINGERPRINT="$(
|
|||||||
{
|
{
|
||||||
sha256sum "$ROOT/tools/checks/security-audit/Dockerfile"
|
sha256sum "$ROOT/tools/checks/security-audit/Dockerfile"
|
||||||
sha256sum "$ROOT/requirements-audit.txt"
|
sha256sum "$ROOT/requirements-audit.txt"
|
||||||
|
sha256sum "$ROOT/tools/checks/security-audit/semgrep-govoplan.yml"
|
||||||
|
sha256sum "$ROOT/tools/checks/check-version-alignment.py"
|
||||||
} | sha256sum | cut -c1-16
|
} | sha256sum | cut -c1-16
|
||||||
)"
|
)"
|
||||||
FINGERPRINT_IMAGE="${SECURITY_AUDIT_FINGERPRINT_IMAGE:-$IMAGE_REPOSITORY:$IMAGE_FINGERPRINT}"
|
FINGERPRINT_IMAGE="${SECURITY_AUDIT_FINGERPRINT_IMAGE:-$IMAGE_REPOSITORY:$IMAGE_FINGERPRINT}"
|
||||||
@@ -93,21 +121,21 @@ build_image() {
|
|||||||
if [[ "$UPDATE" == "1" ]]; then
|
if [[ "$UPDATE" == "1" ]]; then
|
||||||
build_args=(--pull --no-cache "${build_args[@]}")
|
build_args=(--pull --no-cache "${build_args[@]}")
|
||||||
fi
|
fi
|
||||||
docker build "${build_args[@]}" "$ROOT"
|
"${DOCKER_CLI[@]}" build "${build_args[@]}" "$ROOT"
|
||||||
}
|
}
|
||||||
|
|
||||||
if [[ "$REBUILD" == "1" ]] || ! docker image inspect "$FINGERPRINT_IMAGE" >/dev/null 2>&1; then
|
if [[ "$REBUILD" == "1" ]] || ! "${DOCKER_CLI[@]}" image inspect "$FINGERPRINT_IMAGE" >/dev/null 2>&1; then
|
||||||
echo "Building security audit toolbox image: $FINGERPRINT_IMAGE"
|
echo "Building security audit toolbox image: $FINGERPRINT_IMAGE"
|
||||||
build_image
|
build_image
|
||||||
else
|
else
|
||||||
echo "Reusing security audit toolbox image: $FINGERPRINT_IMAGE"
|
echo "Reusing security audit toolbox image: $FINGERPRINT_IMAGE"
|
||||||
if ! docker image inspect "$IMAGE_ALIAS" >/dev/null 2>&1; then
|
if ! "${DOCKER_CLI[@]}" image inspect "$IMAGE_ALIAS" >/dev/null 2>&1; then
|
||||||
docker tag "$FINGERPRINT_IMAGE" "$IMAGE_ALIAS"
|
"${DOCKER_CLI[@]}" tag "$FINGERPRINT_IMAGE" "$IMAGE_ALIAS"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$BUILD_ONLY" == "1" ]]; then
|
if [[ "$BUILD_ONLY" == "1" ]]; then
|
||||||
docker image inspect "$FINGERPRINT_IMAGE" --format 'Built audit toolbox image {{.RepoTags}} {{.Id}}'
|
"${DOCKER_CLI[@]}" image inspect "$FINGERPRINT_IMAGE" --format 'Built audit toolbox image {{.RepoTags}} {{.Id}}'
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -121,7 +149,7 @@ else
|
|||||||
EXTRA_ENV=()
|
EXTRA_ENV=()
|
||||||
fi
|
fi
|
||||||
|
|
||||||
docker run --rm \
|
"${DOCKER_CLI[@]}" run --rm \
|
||||||
--user "$(id -u):$(id -g)" \
|
--user "$(id -u):$(id -g)" \
|
||||||
-e HOME=/tmp/security-audit-home \
|
-e HOME=/tmp/security-audit-home \
|
||||||
-e SECURITY_AUDIT_SCOPE="$SCOPE" \
|
-e SECURITY_AUDIT_SCOPE="$SCOPE" \
|
||||||
@@ -129,6 +157,7 @@ docker run --rm \
|
|||||||
-e SECURITY_AUDIT_REPORTS_DIR="$REPORTS_DIR" \
|
-e SECURITY_AUDIT_REPORTS_DIR="$REPORTS_DIR" \
|
||||||
-e SECURITY_AUDIT_FAIL_ON_FINDINGS="${SECURITY_AUDIT_FAIL_ON_FINDINGS:-0}" \
|
-e SECURITY_AUDIT_FAIL_ON_FINDINGS="${SECURITY_AUDIT_FAIL_ON_FINDINGS:-0}" \
|
||||||
-e SECURITY_AUDIT_REQUIRE_TOOLS="${SECURITY_AUDIT_REQUIRE_TOOLS:-0}" \
|
-e SECURITY_AUDIT_REQUIRE_TOOLS="${SECURITY_AUDIT_REQUIRE_TOOLS:-0}" \
|
||||||
|
-e SECURITY_AUDIT_TOOLBOX_FINGERPRINT="$IMAGE_FINGERPRINT" \
|
||||||
"${EXTRA_ENV[@]}" \
|
"${EXTRA_ENV[@]}" \
|
||||||
-v "$MOUNT_ROOT:/workspace" \
|
-v "$MOUNT_ROOT:/workspace" \
|
||||||
-w "$WORKDIR" \
|
-w "$WORKDIR" \
|
||||||
|
|||||||
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__")
|
||||||
@@ -7,7 +7,6 @@ import argparse
|
|||||||
import base64
|
import base64
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
|||||||
@@ -4,14 +4,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
|
import http.client
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import urllib.error
|
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
@@ -31,7 +30,7 @@ class RepoTarget:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def api_base(self) -> str:
|
def api_base(self) -> str:
|
||||||
return f"{self.base_url.rstrip('/')}/api/v1"
|
return f"{validated_http_base_url(self.base_url)}/api/v1"
|
||||||
|
|
||||||
|
|
||||||
def repo_root(start: pathlib.Path) -> pathlib.Path:
|
def repo_root(start: pathlib.Path) -> pathlib.Path:
|
||||||
@@ -80,10 +79,33 @@ def quote_path(value: str) -> str:
|
|||||||
return urllib.parse.quote(value, safe="")
|
return urllib.parse.quote(value, safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def validated_http_base_url(value: str) -> str:
|
||||||
|
parsed = urllib.parse.urlparse(value.rstrip("/"))
|
||||||
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||||
|
raise GiteaError("Gitea URL must use http:// or https:// with a hostname.")
|
||||||
|
if parsed.username or parsed.password:
|
||||||
|
raise GiteaError("Gitea URL must not include embedded credentials.")
|
||||||
|
return urllib.parse.urlunparse((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", "", ""))
|
||||||
|
|
||||||
|
|
||||||
class GiteaClient:
|
class GiteaClient:
|
||||||
def __init__(self, target: RepoTarget, token: str | None) -> None:
|
def __init__(self, target: RepoTarget, token: str | None, *, timeout: float = 30.0) -> None:
|
||||||
self.target = target
|
self.target = target
|
||||||
self.token = token
|
self.token = token
|
||||||
|
self.timeout = timeout
|
||||||
|
self._api_url = urllib.parse.urlparse(target.api_base)
|
||||||
|
self._connection: http.client.HTTPConnection | None = None
|
||||||
|
|
||||||
|
def __enter__(self) -> GiteaClient:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_exc_info: object) -> None:
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self._connection is not None:
|
||||||
|
self._connection.close()
|
||||||
|
self._connection = None
|
||||||
|
|
||||||
def request_json(
|
def request_json(
|
||||||
self,
|
self,
|
||||||
@@ -93,14 +115,13 @@ class GiteaClient:
|
|||||||
body: dict[str, Any] | None = None,
|
body: dict[str, Any] | None = None,
|
||||||
query: dict[str, Any] | None = None,
|
query: dict[str, Any] | None = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
url = f"{self.target.api_base}{path}"
|
request_target = self._request_target(path, query)
|
||||||
if query:
|
|
||||||
url = f"{url}?{urllib.parse.urlencode(query, doseq=True)}"
|
|
||||||
|
|
||||||
data = None
|
data = None
|
||||||
headers = {
|
headers = {
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
"User-Agent": "codex-gitea-maintenance",
|
"User-Agent": "codex-gitea-maintenance",
|
||||||
|
"Connection": "keep-alive",
|
||||||
}
|
}
|
||||||
if self.token:
|
if self.token:
|
||||||
headers["Authorization"] = f"token {self.token}"
|
headers["Authorization"] = f"token {self.token}"
|
||||||
@@ -108,18 +129,71 @@ class GiteaClient:
|
|||||||
data = json.dumps(body).encode("utf-8")
|
data = json.dumps(body).encode("utf-8")
|
||||||
headers["Content-Type"] = "application/json"
|
headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
method = method.upper()
|
||||||
|
safe_to_retry = method in {"GET", "HEAD", "OPTIONS"}
|
||||||
|
last_error: BaseException | None = None
|
||||||
|
for attempt in range(2 if safe_to_retry else 1):
|
||||||
|
try:
|
||||||
|
status, _reason, payload = self._request_once(method, request_target, data, headers)
|
||||||
|
break
|
||||||
|
except (OSError, http.client.HTTPException) as exc:
|
||||||
|
self.close()
|
||||||
|
last_error = exc
|
||||||
|
if attempt == 0 and safe_to_retry:
|
||||||
|
continue
|
||||||
|
raise GiteaError(f"{method} {self._display_url(request_target)} failed: {exc}") from exc
|
||||||
|
else:
|
||||||
|
raise GiteaError(f"{method} {self._display_url(request_target)} failed: {last_error}")
|
||||||
|
|
||||||
|
if status >= 400:
|
||||||
|
message = payload.decode("utf-8", errors="replace")
|
||||||
|
raise GiteaError(f"{method} {self._display_url(request_target)} failed with HTTP {status}: {message}")
|
||||||
|
if not payload:
|
||||||
|
return None
|
||||||
|
return json.loads(payload.decode("utf-8"))
|
||||||
|
|
||||||
|
def _connection_for_request(self) -> http.client.HTTPConnection:
|
||||||
|
if self._connection is not None:
|
||||||
|
return self._connection
|
||||||
|
host = self._api_url.hostname
|
||||||
|
if not host:
|
||||||
|
raise GiteaError("Gitea API URL is missing a hostname.")
|
||||||
|
if self._api_url.scheme == "https":
|
||||||
|
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":
|
||||||
|
self._connection = http.client.HTTPConnection(host, self._api_url.port, timeout=self.timeout)
|
||||||
|
else:
|
||||||
|
raise GiteaError("Gitea API URL must use http:// or https://.")
|
||||||
|
return self._connection
|
||||||
|
|
||||||
|
def _request_once(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
request_target: str,
|
||||||
|
data: bytes | None,
|
||||||
|
headers: dict[str, str],
|
||||||
|
) -> tuple[int, str, bytes]:
|
||||||
|
connection = self._connection_for_request()
|
||||||
|
connection.request(method, request_target, body=data, headers=headers)
|
||||||
|
response = connection.getresponse()
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(request, timeout=30) as response:
|
payload = response.read()
|
||||||
payload = response.read().decode("utf-8")
|
return response.status, response.reason, payload
|
||||||
if not payload:
|
finally:
|
||||||
return None
|
if response.will_close:
|
||||||
return json.loads(payload)
|
self.close()
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
message = exc.read().decode("utf-8", errors="replace")
|
def _request_target(self, path: str, query: dict[str, Any] | None) -> str:
|
||||||
raise GiteaError(f"{method} {url} failed with HTTP {exc.code}: {message}") from exc
|
base_path = self._api_url.path.rstrip("/")
|
||||||
except urllib.error.URLError as exc:
|
suffix = path if path.startswith("/") else f"/{path}"
|
||||||
raise GiteaError(f"{method} {url} failed: {exc.reason}") from exc
|
request_target = f"{base_path}{suffix}"
|
||||||
|
if query:
|
||||||
|
request_target = f"{request_target}?{urllib.parse.urlencode(query, doseq=True)}"
|
||||||
|
return request_target
|
||||||
|
|
||||||
|
def _display_url(self, request_target: str) -> str:
|
||||||
|
netloc = self._api_url.netloc
|
||||||
|
return urllib.parse.urlunparse((self._api_url.scheme, netloc, request_target, "", "", ""))
|
||||||
|
|
||||||
def paginate(
|
def paginate(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -15,11 +15,12 @@ BACKEND_HOST="${GOVOPLAN_BACKEND_HOST:-127.0.0.1}"
|
|||||||
BACKEND_PORT="${GOVOPLAN_BACKEND_PORT:-8000}"
|
BACKEND_PORT="${GOVOPLAN_BACKEND_PORT:-8000}"
|
||||||
FRONTEND_HOST="${GOVOPLAN_FRONTEND_HOST:-127.0.0.1}"
|
FRONTEND_HOST="${GOVOPLAN_FRONTEND_HOST:-127.0.0.1}"
|
||||||
FRONTEND_PORT="${GOVOPLAN_FRONTEND_PORT:-5173}"
|
FRONTEND_PORT="${GOVOPLAN_FRONTEND_PORT:-5173}"
|
||||||
OPEN_BROWSER="${OPEN_BROWSER:-1}"
|
OPEN_BROWSER="${GOVOPLAN_OPEN_BROWSER:-${OPEN_BROWSER:-0}}"
|
||||||
FRONTEND_FORCE_RELOAD="${GOVOPLAN_FRONTEND_FORCE_RELOAD:-1}"
|
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'
|
||||||
@@ -210,6 +219,7 @@ Frontend reload:
|
|||||||
Vite cache cleared: $FRONTEND_CLEAR_VITE_CACHE
|
Vite cache cleared: $FRONTEND_CLEAR_VITE_CACHE
|
||||||
Vite --force: $FRONTEND_FORCE_RELOAD
|
Vite --force: $FRONTEND_FORCE_RELOAD
|
||||||
Watch polling: $FRONTEND_USE_POLLING (${FRONTEND_POLLING_INTERVAL}ms)
|
Watch polling: $FRONTEND_USE_POLLING (${FRONTEND_POLLING_INTERVAL}ms)
|
||||||
|
Open browser: $OPEN_BROWSER
|
||||||
|
|
||||||
Press Ctrl+C to stop both processes.
|
Press Ctrl+C to stop both processes.
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -27,12 +27,14 @@ STOP_DEPENDENCIES_ON_EXIT="${GOVOPLAN_STOP_PROFILE_DEPENDENCIES_ON_EXIT:-0}"
|
|||||||
LOG_DIR="$META_ROOT/runtime/production-like/logs"
|
LOG_DIR="$META_ROOT/runtime/production-like/logs"
|
||||||
BACKEND_LOG="$LOG_DIR/backend.log"
|
BACKEND_LOG="$LOG_DIR/backend.log"
|
||||||
WORKER_LOG="$LOG_DIR/worker.log"
|
WORKER_LOG="$LOG_DIR/worker.log"
|
||||||
|
BEAT_LOG="$LOG_DIR/beat.log"
|
||||||
FRONTEND_LOG="$LOG_DIR/frontend.log"
|
FRONTEND_LOG="$LOG_DIR/frontend.log"
|
||||||
BACKEND_URL="http://$BACKEND_HOST:$BACKEND_PORT"
|
BACKEND_URL="http://$BACKEND_HOST:$BACKEND_PORT"
|
||||||
FRONTEND_URL="http://$FRONTEND_HOST:$FRONTEND_PORT"
|
FRONTEND_URL="http://$FRONTEND_HOST:$FRONTEND_PORT"
|
||||||
|
|
||||||
backend_pid=""
|
backend_pid=""
|
||||||
worker_pid=""
|
worker_pid=""
|
||||||
|
beat_pid=""
|
||||||
frontend_pid=""
|
frontend_pid=""
|
||||||
|
|
||||||
fail() {
|
fail() {
|
||||||
@@ -49,12 +51,12 @@ 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}}"
|
||||||
export CELERY_ENABLED="${CELERY_ENABLED:-true}"
|
export CELERY_ENABLED="${CELERY_ENABLED:-true}"
|
||||||
export CELERY_QUEUES="${CELERY_QUEUES:-send_email,append_sent,default}"
|
export CELERY_QUEUES="${CELERY_QUEUES:-send_email,append_sent,notifications,calendar,default}"
|
||||||
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}"
|
||||||
@@ -146,6 +148,9 @@ cleanup() {
|
|||||||
if [ -n "${worker_pid:-}" ] && kill -0 "$worker_pid" 2>/dev/null; then
|
if [ -n "${worker_pid:-}" ] && kill -0 "$worker_pid" 2>/dev/null; then
|
||||||
kill "$worker_pid" 2>/dev/null || true
|
kill "$worker_pid" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
if [ -n "${beat_pid:-}" ] && kill -0 "$beat_pid" 2>/dev/null; then
|
||||||
|
kill "$beat_pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
if [ "$STOP_DEPENDENCIES_ON_EXIT" = "1" ]; then
|
if [ "$STOP_DEPENDENCIES_ON_EXIT" = "1" ]; then
|
||||||
compose down >/dev/null 2>&1 || true
|
compose down >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
@@ -160,6 +165,7 @@ trap cleanup EXIT INT TERM
|
|||||||
mkdir -p "$LOG_DIR" "$FILE_STORAGE_LOCAL_ROOT"
|
mkdir -p "$LOG_DIR" "$FILE_STORAGE_LOCAL_ROOT"
|
||||||
: > "$BACKEND_LOG"
|
: > "$BACKEND_LOG"
|
||||||
: > "$WORKER_LOG"
|
: > "$WORKER_LOG"
|
||||||
|
: > "$BEAT_LOG"
|
||||||
: > "$FRONTEND_LOG"
|
: > "$FRONTEND_LOG"
|
||||||
|
|
||||||
port_is_free "$BACKEND_HOST" "$BACKEND_PORT" || fail "$BACKEND_URL is already in use"
|
port_is_free "$BACKEND_HOST" "$BACKEND_PORT" || fail "$BACKEND_URL is already in use"
|
||||||
@@ -200,6 +206,14 @@ printf 'Starting Celery worker for queues %s\n' "$CELERY_QUEUES"
|
|||||||
) >"$WORKER_LOG" 2>&1 &
|
) >"$WORKER_LOG" 2>&1 &
|
||||||
worker_pid="$!"
|
worker_pid="$!"
|
||||||
|
|
||||||
|
printf 'Starting Celery beat for durable recovery schedules\n'
|
||||||
|
(
|
||||||
|
cd "$ROOT"
|
||||||
|
"$PYTHON" -m celery -A govoplan_core.celery_app:celery beat \
|
||||||
|
--loglevel INFO
|
||||||
|
) >"$BEAT_LOG" 2>&1 &
|
||||||
|
beat_pid="$!"
|
||||||
|
|
||||||
printf 'Starting GovOPlaN WebUI at %s\n' "$FRONTEND_URL"
|
printf 'Starting GovOPlaN WebUI at %s\n' "$FRONTEND_URL"
|
||||||
(
|
(
|
||||||
cd "$WEBUI_ROOT"
|
cd "$WEBUI_ROOT"
|
||||||
@@ -235,10 +249,11 @@ Files: $FILE_STORAGE_LOCAL_ROOT
|
|||||||
Logs:
|
Logs:
|
||||||
Backend: $BACKEND_LOG
|
Backend: $BACKEND_LOG
|
||||||
Worker: $WORKER_LOG
|
Worker: $WORKER_LOG
|
||||||
|
Beat: $BEAT_LOG
|
||||||
WebUI: $FRONTEND_LOG
|
WebUI: $FRONTEND_LOG
|
||||||
|
|
||||||
Docker dependencies remain running after Ctrl+C unless GOVOPLAN_STOP_PROFILE_DEPENDENCIES_ON_EXIT=1.
|
Docker dependencies remain running after Ctrl+C unless GOVOPLAN_STOP_PROFILE_DEPENDENCIES_ON_EXIT=1.
|
||||||
Press Ctrl+C to stop API, worker, and WebUI.
|
Press Ctrl+C to stop API, worker, beat, and WebUI.
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
wait
|
wait
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ usage() {
|
|||||||
Usage: tools/launch/production-like-dev.sh <command> [--yes]
|
Usage: tools/launch/production-like-dev.sh <command> [--yes]
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
start Start the production-like API, worker, WebUI, PostgreSQL, and Redis profile.
|
start Start the production-like API, worker, scheduler, WebUI, PostgreSQL, and Redis profile.
|
||||||
stop Stop PostgreSQL and Redis profile containers.
|
stop Stop PostgreSQL and Redis profile containers.
|
||||||
status Show profile container status and validate the effective environment.
|
status Show profile container status and validate the effective environment.
|
||||||
seed Run migrations and seed development data against the profile database.
|
seed Run migrations and seed development data against the profile database.
|
||||||
@@ -27,7 +27,7 @@ Commands:
|
|||||||
validate-config Validate the profile environment only.
|
validate-config Validate the profile environment only.
|
||||||
|
|
||||||
The start command delegates to tools/launch/launch-production-like-dev.sh. Stop/reset
|
The start command delegates to tools/launch/launch-production-like-dev.sh. Stop/reset
|
||||||
operate on Docker dependencies; API/WebUI/worker processes started by the
|
operate on Docker dependencies; API/WebUI/worker/scheduler processes started by the
|
||||||
launcher are stopped with Ctrl+C in that launcher terminal.
|
launcher are stopped with Ctrl+C in that launcher terminal.
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
@@ -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}"
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|||||||
META_ROOT = Path(__file__).resolve().parents[2]
|
META_ROOT = Path(__file__).resolve().parents[2]
|
||||||
CORE_ROOT = Path(os.environ.get("GOVOPLAN_CORE_ROOT", META_ROOT.parent / "govoplan-core")).resolve()
|
CORE_ROOT = Path(os.environ.get("GOVOPLAN_CORE_ROOT", META_ROOT.parent / "govoplan-core")).resolve()
|
||||||
sys.path.insert(0, str(CORE_ROOT / "src"))
|
sys.path.insert(0, str(CORE_ROOT / "src"))
|
||||||
|
sys.path.insert(0, str(META_ROOT / "tools" / "release"))
|
||||||
|
|
||||||
from govoplan_core.core.modules import ModuleManifest # noqa: E402
|
from govoplan_core.core.modules import ModuleManifest # noqa: E402
|
||||||
from govoplan_core.server.registry import available_module_manifests # noqa: E402
|
from govoplan_core.server.registry import available_module_manifests # noqa: E402
|
||||||
|
from govoplan_release.version_alignment import selected_repository_version_issues # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
GITEA_BASE = "git+ssh://git@git.add-ideas.de/add-ideas"
|
GITEA_BASE = "git+ssh://git@git.add-ideas.de/add-ideas"
|
||||||
@@ -110,6 +112,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",
|
||||||
@@ -187,6 +198,19 @@ def main() -> int:
|
|||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
version = args.version.removeprefix("v")
|
version = args.version.removeprefix("v")
|
||||||
|
version_issues = selected_repository_version_issues(
|
||||||
|
repo_versions={
|
||||||
|
"govoplan-core": version,
|
||||||
|
**{module.repo: version for module in CATALOG_MODULES},
|
||||||
|
},
|
||||||
|
workspace=CORE_ROOT.parent,
|
||||||
|
)
|
||||||
|
if version_issues:
|
||||||
|
details = "; ".join(
|
||||||
|
f"{issue.repo}: {issue.source}={issue.actual!r}, expected {issue.expected!r} ({issue.message})"
|
||||||
|
for issue in version_issues
|
||||||
|
)
|
||||||
|
parser.error(f"version alignment gate failed: {details}")
|
||||||
tag = f"v{version}"
|
tag = f"v{version}"
|
||||||
generated_at = datetime.now(tz=UTC)
|
generated_at = datetime.now(tz=UTC)
|
||||||
sequence = args.sequence if args.sequence is not None else int(generated_at.strftime("%Y%m%d%H%M"))
|
sequence = args.sequence if args.sequence is not None else int(generated_at.strftime("%Y%m%d%H%M"))
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ the regenerated release lockfile.
|
|||||||
Options:
|
Options:
|
||||||
--npm <path> npm executable to use.
|
--npm <path> npm executable to use.
|
||||||
--core-root <path> govoplan-core checkout. Defaults to ../govoplan-core.
|
--core-root <path> govoplan-core checkout. Defaults to ../govoplan-core.
|
||||||
|
--local-git-repo <path>
|
||||||
|
Resolve that repository's release tag from its local Git
|
||||||
|
object store while preserving the published Git URL in
|
||||||
|
the lockfile. May be repeated.
|
||||||
-h, --help Show this help.
|
-h, --help Show this help.
|
||||||
USAGE
|
USAGE
|
||||||
}
|
}
|
||||||
@@ -32,6 +36,7 @@ CORE_ROOT="$(cd "$CORE_ROOT" && pwd)"
|
|||||||
WEBUI="$CORE_ROOT/webui"
|
WEBUI="$CORE_ROOT/webui"
|
||||||
NPM_BIN="${NPM:-}"
|
NPM_BIN="${NPM:-}"
|
||||||
NODE_BIN="${NODE:-}"
|
NODE_BIN="${NODE:-}"
|
||||||
|
LOCAL_GIT_REPOS=()
|
||||||
|
|
||||||
if [[ -z "$NPM_BIN" ]]; then
|
if [[ -z "$NPM_BIN" ]]; then
|
||||||
if [[ -x "/home/zemion/.nvm/versions/node/v22.22.3/bin/npm" ]]; then
|
if [[ -x "/home/zemion/.nvm/versions/node/v22.22.3/bin/npm" ]]; then
|
||||||
@@ -61,6 +66,11 @@ while [[ $# -gt 0 ]]; do
|
|||||||
WEBUI="$CORE_ROOT/webui"
|
WEBUI="$CORE_ROOT/webui"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--local-git-repo)
|
||||||
|
[[ $# -ge 2 ]] || fail "missing value for $1"
|
||||||
|
LOCAL_GIT_REPOS+=("$(cd "$2" && pwd)")
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage
|
usage
|
||||||
exit 0
|
exit 0
|
||||||
@@ -90,9 +100,27 @@ fi
|
|||||||
|
|
||||||
echo "Generating release lockfile from $WEBUI/package.release.json"
|
echo "Generating release lockfile from $WEBUI/package.release.json"
|
||||||
echo "Temporary workspace: $TMP_DIR"
|
echo "Temporary workspace: $TMP_DIR"
|
||||||
|
|
||||||
|
GIT_ENV=(env)
|
||||||
|
git_config_count=0
|
||||||
|
for local_repo in "${LOCAL_GIT_REPOS[@]}"; do
|
||||||
|
[[ -d "$local_repo/.git" ]] || fail "local Git release source is not a repository: $local_repo"
|
||||||
|
remote_url="$(git -C "$local_repo" remote get-url origin)"
|
||||||
|
remote_url="${remote_url#git+}"
|
||||||
|
if [[ "$remote_url" != *://* && "$remote_url" =~ ^([^@]+@)?([^:]+):(.+)$ ]]; then
|
||||||
|
remote_url="ssh://${BASH_REMATCH[1]}${BASH_REMATCH[2]}/${BASH_REMATCH[3]}"
|
||||||
|
fi
|
||||||
|
GIT_ENV+=(
|
||||||
|
"GIT_CONFIG_KEY_${git_config_count}=url.file://$local_repo/.insteadOf"
|
||||||
|
"GIT_CONFIG_VALUE_${git_config_count}=$remote_url"
|
||||||
|
)
|
||||||
|
git_config_count=$((git_config_count + 1))
|
||||||
|
done
|
||||||
|
GIT_ENV+=("GIT_CONFIG_COUNT=$git_config_count")
|
||||||
|
|
||||||
(
|
(
|
||||||
cd "$TMP_DIR"
|
cd "$TMP_DIR"
|
||||||
PATH="$(dirname "$NPM_BIN"):$PATH" "$NPM_BIN" install --package-lock-only --ignore-scripts
|
"${GIT_ENV[@]}" PATH="$(dirname "$NPM_BIN"):$PATH" "$NPM_BIN" install --package-lock-only --ignore-scripts
|
||||||
mapfile -t GIT_PACKAGES < <(
|
mapfile -t GIT_PACKAGES < <(
|
||||||
PATH="$(dirname "$NODE_BIN"):$PATH" "$NODE_BIN" <<'NODE'
|
PATH="$(dirname "$NODE_BIN"):$PATH" "$NODE_BIN" <<'NODE'
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
@@ -108,7 +136,7 @@ NODE
|
|||||||
)
|
)
|
||||||
if [[ "${#GIT_PACKAGES[@]}" -gt 0 ]]; then
|
if [[ "${#GIT_PACKAGES[@]}" -gt 0 ]]; then
|
||||||
echo "Refreshing git package lock entries: ${GIT_PACKAGES[*]}"
|
echo "Refreshing git package lock entries: ${GIT_PACKAGES[*]}"
|
||||||
PATH="$(dirname "$NPM_BIN"):$PATH" "$NPM_BIN" update --package-lock-only --ignore-scripts "${GIT_PACKAGES[@]}"
|
"${GIT_ENV[@]}" PATH="$(dirname "$NPM_BIN"):$PATH" "$NPM_BIN" update --package-lock-only --ignore-scripts "${GIT_PACKAGES[@]}"
|
||||||
fi
|
fi
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
112
tools/release/generate-release-sbom.py
Normal file
112
tools/release/generate-release-sbom.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate a CycloneDX SBOM from the resolved GovOPlaN release environment."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(META_ROOT / "tools" / "release"))
|
||||||
|
|
||||||
|
from govoplan_release.sbom import ( # noqa: E402
|
||||||
|
build_release_sbom,
|
||||||
|
parse_sbom_timestamp,
|
||||||
|
render_sbom,
|
||||||
|
sbom_sha256,
|
||||||
|
timestamp_from_source_date_epoch,
|
||||||
|
validate_sbom,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--python", default=sys.executable, help="Python executable for pip inspect.")
|
||||||
|
parser.add_argument("--core-root", type=Path, default=META_ROOT.parent / "govoplan-core")
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
type=Path,
|
||||||
|
default=META_ROOT / "runtime" / "release-artifacts" / "govoplan-sbom.cdx.json",
|
||||||
|
)
|
||||||
|
parser.add_argument("--pip-inspect", type=Path, help="Use an existing pip inspect report instead of executing pip.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--timestamp",
|
||||||
|
help="ISO-8601 build timestamp. Defaults to SOURCE_DATE_EPOCH, then the current time.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
core_root = args.core_root.expanduser().resolve()
|
||||||
|
product_version = _project_version(core_root / "pyproject.toml")
|
||||||
|
npm_lock_path = core_root / "webui" / "package-lock.release.json"
|
||||||
|
if not npm_lock_path.exists():
|
||||||
|
parser.error(f"release npm lockfile does not exist: {npm_lock_path}")
|
||||||
|
|
||||||
|
pip_inspect = _read_json(args.pip_inspect) if args.pip_inspect else _run_pip_inspect(args.python)
|
||||||
|
npm_lock = _read_json(npm_lock_path)
|
||||||
|
try:
|
||||||
|
timestamp = _resolved_timestamp(args.timestamp)
|
||||||
|
payload = build_release_sbom(
|
||||||
|
product_version=product_version,
|
||||||
|
pip_inspect=pip_inspect,
|
||||||
|
npm_lock=npm_lock,
|
||||||
|
timestamp=timestamp,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
parser.error(str(exc))
|
||||||
|
validate_sbom(payload)
|
||||||
|
|
||||||
|
output = args.output.expanduser().resolve()
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output.write_text(render_sbom(payload), encoding="utf-8")
|
||||||
|
print(f"Wrote {len(payload['components'])} components to {output}")
|
||||||
|
print(f"SHA-256: {sbom_sha256(payload)}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _run_pip_inspect(python: str) -> dict:
|
||||||
|
result = subprocess.run(
|
||||||
|
[python, "-m", "pip", "inspect", "--local"],
|
||||||
|
check=False,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise SystemExit(f"pip inspect failed: {result.stderr.strip()}")
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json(path: Path) -> dict:
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError(f"expected a JSON object in {path}")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _project_version(path: Path) -> str:
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
payload = tomllib.load(handle)
|
||||||
|
project = payload.get("project")
|
||||||
|
if not isinstance(project, dict) or not isinstance(project.get("version"), str):
|
||||||
|
raise ValueError(f"project.version is missing from {path}")
|
||||||
|
return project["version"]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolved_timestamp(explicit: str | None):
|
||||||
|
if explicit is not None:
|
||||||
|
return parse_sbom_timestamp(explicit)
|
||||||
|
source_date_epoch = os.environ.get("SOURCE_DATE_EPOCH")
|
||||||
|
if source_date_epoch is not None:
|
||||||
|
return timestamp_from_source_date_epoch(source_date_epoch)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
72
tools/release/govoplan_release/__init__.py
Normal file
72
tools/release/govoplan_release/__init__.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""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",
|
||||||
|
"tag_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
|
||||||
|
if name == "tag_repositories":
|
||||||
|
from .repository_tag import tag_repositories
|
||||||
|
|
||||||
|
return tag_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
|
||||||
378
tools/release/govoplan_release/catalog_entry_synthesis.py
Normal file
378
tools/release/govoplan_release/catalog_entry_synthesis.py
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
"""Synthesize initial catalog entries from a repository's package and manifest."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from contextlib import contextmanager
|
||||||
|
import importlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tomllib
|
||||||
|
from types import ModuleType
|
||||||
|
from typing import Iterator
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import ModuleManifest
|
||||||
|
from govoplan_core.core.versioning import version_satisfies_range
|
||||||
|
|
||||||
|
from .workspace import load_repository_specs, resolve_repo_path
|
||||||
|
|
||||||
|
|
||||||
|
_INSPECTION_CHILD = "GOVOPLAN_CATALOG_ENTRY_INSPECTION_CHILD"
|
||||||
|
_INSPECTION_MARKER = "GOVOPLAN_CATALOG_ENTRY_JSON="
|
||||||
|
|
||||||
|
|
||||||
|
def synthesize_repository_catalog_entries(
|
||||||
|
*,
|
||||||
|
repo: str,
|
||||||
|
version: str,
|
||||||
|
workspace: Path,
|
||||||
|
repository_base: str,
|
||||||
|
) -> tuple[dict[str, object], ...]:
|
||||||
|
"""Build install entries from tagged, preflighted local source metadata.
|
||||||
|
|
||||||
|
The caller owns source-tag and worktree provenance checks. This function
|
||||||
|
accepts no hand-maintained module catalog registry: distribution metadata
|
||||||
|
identifies the runtime entry point and the runtime ``ModuleManifest`` is
|
||||||
|
the authoritative dependency/interface/frontend description.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if os.getenv(_INSPECTION_CHILD) == "1":
|
||||||
|
return synthesize_repository_catalog_entries_in_process(
|
||||||
|
repo=repo,
|
||||||
|
version=version,
|
||||||
|
workspace=workspace,
|
||||||
|
repository_base=repository_base,
|
||||||
|
)
|
||||||
|
command = (
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"govoplan_release.catalog_entry_synthesis",
|
||||||
|
"--inspect-repo",
|
||||||
|
repo,
|
||||||
|
"--version",
|
||||||
|
version,
|
||||||
|
"--workspace",
|
||||||
|
str(workspace),
|
||||||
|
"--repository-base",
|
||||||
|
repository_base,
|
||||||
|
)
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment[_INSPECTION_CHILD] = "1"
|
||||||
|
release_root = str(Path(__file__).resolve().parents[1])
|
||||||
|
environment["PYTHONPATH"] = os.pathsep.join(
|
||||||
|
item for item in (release_root, environment.get("PYTHONPATH", "")) if item
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
command,
|
||||||
|
check=False,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
timeout=30,
|
||||||
|
env=environment,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
raise ValueError(f"Cannot synthesize {repo}: isolated manifest inspection timed out.") from exc
|
||||||
|
if result.returncode != 0:
|
||||||
|
detail = result.stderr.strip() or result.stdout.strip() or "unknown inspection error"
|
||||||
|
raise ValueError(f"Cannot synthesize {repo}: isolated manifest inspection failed: {detail}")
|
||||||
|
payload_line = next(
|
||||||
|
(line.removeprefix(_INSPECTION_MARKER) for line in reversed(result.stdout.splitlines()) if line.startswith(_INSPECTION_MARKER)),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if payload_line is None:
|
||||||
|
raise ValueError(f"Cannot synthesize {repo}: isolated manifest inspection returned no metadata.")
|
||||||
|
payload = json.loads(payload_line)
|
||||||
|
if not isinstance(payload, list) or not all(isinstance(item, dict) for item in payload):
|
||||||
|
raise ValueError(f"Cannot synthesize {repo}: isolated manifest inspection returned invalid metadata.")
|
||||||
|
return tuple(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def synthesize_repository_catalog_entries_in_process(
|
||||||
|
*,
|
||||||
|
repo: str,
|
||||||
|
version: str,
|
||||||
|
workspace: Path,
|
||||||
|
repository_base: str,
|
||||||
|
) -> tuple[dict[str, object], ...]:
|
||||||
|
specs = {item.name: item for item in load_repository_specs(include_website=False)}
|
||||||
|
spec = specs.get(repo)
|
||||||
|
if spec is None:
|
||||||
|
raise ValueError(f"Cannot synthesize {repo}: repository is not registered.")
|
||||||
|
root = resolve_repo_path(spec, workspace)
|
||||||
|
project = read_project_metadata(root / "pyproject.toml")
|
||||||
|
package = required_text(project, "name", source=f"{repo}/pyproject.toml")
|
||||||
|
project_version = required_text(project, "version", source=f"{repo}/pyproject.toml").removeprefix("v")
|
||||||
|
expected_version = version.removeprefix("v")
|
||||||
|
if project_version != expected_version:
|
||||||
|
raise ValueError(
|
||||||
|
f"Cannot synthesize {repo}: project version {project_version!r} does not match selected version {expected_version!r}."
|
||||||
|
)
|
||||||
|
description = optional_text(project.get("description"))
|
||||||
|
entry_points = module_entry_points(project, repo=repo)
|
||||||
|
entries: list[dict[str, object]] = []
|
||||||
|
for declared_module_id, target in sorted(entry_points.items()):
|
||||||
|
manifest = load_manifest(root=root, target=target, repo=repo)
|
||||||
|
if manifest.id != declared_module_id:
|
||||||
|
raise ValueError(
|
||||||
|
f"Cannot synthesize {repo}: entry point {declared_module_id!r} returns manifest {manifest.id!r}."
|
||||||
|
)
|
||||||
|
if manifest.version.removeprefix("v") != expected_version:
|
||||||
|
raise ValueError(
|
||||||
|
f"Cannot synthesize {repo}/{manifest.id}: manifest version {manifest.version!r} does not match {expected_version!r}."
|
||||||
|
)
|
||||||
|
entry = manifest_catalog_entry(
|
||||||
|
manifest=manifest,
|
||||||
|
repo=repo,
|
||||||
|
package=package,
|
||||||
|
version=expected_version,
|
||||||
|
description=description,
|
||||||
|
root=root,
|
||||||
|
repository_base=repository_base.rstrip("/"),
|
||||||
|
)
|
||||||
|
entries.append(entry)
|
||||||
|
return tuple(entries)
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_catalog_entry(
|
||||||
|
*,
|
||||||
|
manifest: ModuleManifest,
|
||||||
|
repo: str,
|
||||||
|
package: str,
|
||||||
|
version: str,
|
||||||
|
description: str | None,
|
||||||
|
root: Path,
|
||||||
|
repository_base: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
tag = f"v{version}"
|
||||||
|
entry: dict[str, object] = {
|
||||||
|
"module_id": manifest.id,
|
||||||
|
"name": manifest.name,
|
||||||
|
"version": version,
|
||||||
|
"action": "install",
|
||||||
|
"python_package": package,
|
||||||
|
"python_ref": f"{package} @ {repository_base}/{repo}.git@{tag}",
|
||||||
|
"license_features": [f"module.{manifest.id}"],
|
||||||
|
"tags": ["official"],
|
||||||
|
}
|
||||||
|
if description:
|
||||||
|
entry["description"] = description
|
||||||
|
if manifest.dependencies:
|
||||||
|
entry["dependencies"] = list(manifest.dependencies)
|
||||||
|
if manifest.optional_dependencies:
|
||||||
|
entry["optional_dependencies"] = list(manifest.optional_dependencies)
|
||||||
|
if manifest.provides_interfaces:
|
||||||
|
entry["provides_interfaces"] = [
|
||||||
|
{"name": item.name, "version": item.version}
|
||||||
|
for item in manifest.provides_interfaces
|
||||||
|
]
|
||||||
|
if manifest.requires_interfaces:
|
||||||
|
entry["requires_interfaces"] = [
|
||||||
|
{
|
||||||
|
"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 manifest.requires_interfaces
|
||||||
|
]
|
||||||
|
if manifest.frontend is not None and manifest.frontend.package_name:
|
||||||
|
validate_webui_package(root=root, package=manifest.frontend.package_name, version=version, repo=repo)
|
||||||
|
entry["webui_package"] = manifest.frontend.package_name
|
||||||
|
entry["webui_ref"] = f"{repository_base}/{repo}.git#{tag}"
|
||||||
|
if manifest.migration_spec is not None:
|
||||||
|
migration = manifest.migration_spec
|
||||||
|
entry["migration_safety"] = "requires_review"
|
||||||
|
entry["migration_notes"] = (
|
||||||
|
"Module owns database migrations; review release notes and migration output before activation."
|
||||||
|
)
|
||||||
|
if migration.migration_after:
|
||||||
|
entry["migration_after"] = list(migration.migration_after)
|
||||||
|
if migration.migration_before:
|
||||||
|
entry["migration_before"] = list(migration.migration_before)
|
||||||
|
if migration.migration_tasks:
|
||||||
|
entry["migration_tasks"] = [
|
||||||
|
{
|
||||||
|
"task_id": task.task_id,
|
||||||
|
"phase": task.phase,
|
||||||
|
"summary": task.summary,
|
||||||
|
"task_version": task.task_version,
|
||||||
|
"safety": task.safety,
|
||||||
|
"idempotent": task.idempotent,
|
||||||
|
**({"timeout_seconds": task.timeout_seconds} if task.timeout_seconds is not None else {}),
|
||||||
|
}
|
||||||
|
for task in migration.migration_tasks
|
||||||
|
]
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def validate_initial_entry_closure(
|
||||||
|
*,
|
||||||
|
catalog_modules: list[object],
|
||||||
|
initial_module_ids: set[str],
|
||||||
|
) -> None:
|
||||||
|
entries = [item for item in catalog_modules if isinstance(item, dict)]
|
||||||
|
by_id = {str(item.get("module_id") or ""): item for item in entries}
|
||||||
|
providers: dict[str, list[str]] = {}
|
||||||
|
for entry in entries:
|
||||||
|
for raw in entry.get("provides_interfaces") or ():
|
||||||
|
if isinstance(raw, dict) and isinstance(raw.get("name"), str) and isinstance(raw.get("version"), str):
|
||||||
|
providers.setdefault(raw["name"], []).append(raw["version"])
|
||||||
|
|
||||||
|
issues: list[str] = []
|
||||||
|
for module_id in sorted(initial_module_ids):
|
||||||
|
entry = by_id[module_id]
|
||||||
|
for dependency in entry.get("dependencies") or ():
|
||||||
|
if isinstance(dependency, str) and dependency not in by_id:
|
||||||
|
issues.append(f"{module_id} requires catalog module {dependency!r}")
|
||||||
|
for raw in entry.get("requires_interfaces") or ():
|
||||||
|
if not isinstance(raw, dict) or raw.get("optional") is True:
|
||||||
|
continue
|
||||||
|
name = raw.get("name")
|
||||||
|
if not isinstance(name, str):
|
||||||
|
continue
|
||||||
|
available = providers.get(name, [])
|
||||||
|
if not any(
|
||||||
|
version_satisfies_range(
|
||||||
|
provider_version,
|
||||||
|
version_min=raw.get("version_min") if isinstance(raw.get("version_min"), str) else None,
|
||||||
|
version_max_exclusive=raw.get("version_max_exclusive")
|
||||||
|
if isinstance(raw.get("version_max_exclusive"), str)
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
for provider_version in available
|
||||||
|
):
|
||||||
|
issues.append(f"{module_id} requires unavailable compatible interface {name!r}")
|
||||||
|
if issues:
|
||||||
|
raise ValueError("Initial catalog entry dependency closure failed: " + "; ".join(issues))
|
||||||
|
|
||||||
|
|
||||||
|
def read_project_metadata(path: Path) -> dict[str, object]:
|
||||||
|
if not path.exists():
|
||||||
|
raise ValueError(f"Cannot synthesize catalog entry: missing {path}.")
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
payload = tomllib.load(handle)
|
||||||
|
project = payload.get("project")
|
||||||
|
if not isinstance(project, dict):
|
||||||
|
raise ValueError(f"Cannot synthesize catalog entry: {path} has no [project] table.")
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
def module_entry_points(project: dict[str, object], *, repo: str) -> dict[str, str]:
|
||||||
|
groups = project.get("entry-points")
|
||||||
|
raw = groups.get("govoplan.modules") if isinstance(groups, dict) else None
|
||||||
|
if not isinstance(raw, dict) or not raw:
|
||||||
|
raise ValueError(f"Cannot synthesize {repo}: pyproject has no govoplan.modules entry point.")
|
||||||
|
result = {
|
||||||
|
str(module_id).strip(): str(target).strip()
|
||||||
|
for module_id, target in raw.items()
|
||||||
|
if str(module_id).strip() and str(target).strip()
|
||||||
|
}
|
||||||
|
if len(result) != len(raw):
|
||||||
|
raise ValueError(f"Cannot synthesize {repo}: govoplan.modules contains an empty id or target.")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def load_manifest(*, root: Path, target: str, repo: str) -> ModuleManifest:
|
||||||
|
module_name, separator, attribute = target.partition(":")
|
||||||
|
if not separator or not module_name or not attribute or "[" in attribute:
|
||||||
|
raise ValueError(f"Cannot synthesize {repo}: unsupported module entry point {target!r}.")
|
||||||
|
src = (root / "src").resolve()
|
||||||
|
expected_module = (src / Path(*module_name.split("."))).with_suffix(".py")
|
||||||
|
expected_package = src / Path(*module_name.split(".")) / "__init__.py"
|
||||||
|
expected = expected_module if expected_module.exists() else expected_package
|
||||||
|
if not expected.exists():
|
||||||
|
raise ValueError(f"Cannot synthesize {repo}: entry point source {expected_module} is missing.")
|
||||||
|
|
||||||
|
top_level = module_name.split(".", 1)[0]
|
||||||
|
with isolated_source_import(src=src, top_level=top_level):
|
||||||
|
try:
|
||||||
|
module = importlib.import_module(module_name)
|
||||||
|
except Exception as exc: # noqa: BLE001 - exact load failure is release evidence.
|
||||||
|
raise ValueError(f"Cannot synthesize {repo}: loading {target!r} failed: {exc}") from exc
|
||||||
|
origin = Path(str(getattr(module, "__file__", ""))).resolve()
|
||||||
|
if origin != expected.resolve():
|
||||||
|
raise ValueError(f"Cannot synthesize {repo}: entry point resolved outside selected source ({origin}).")
|
||||||
|
factory = getattr(module, attribute, None)
|
||||||
|
if not callable(factory):
|
||||||
|
raise ValueError(f"Cannot synthesize {repo}: entry point target {target!r} is not callable.")
|
||||||
|
manifest = factory()
|
||||||
|
if not isinstance(manifest, ModuleManifest):
|
||||||
|
raise ValueError(f"Cannot synthesize {repo}: entry point {target!r} did not return ModuleManifest.")
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def isolated_source_import(*, src: Path, top_level: str) -> Iterator[None]:
|
||||||
|
saved: dict[str, ModuleType] = {
|
||||||
|
name: module
|
||||||
|
for name, module in tuple(sys.modules.items())
|
||||||
|
if name == top_level or name.startswith(f"{top_level}.")
|
||||||
|
}
|
||||||
|
for name in saved:
|
||||||
|
sys.modules.pop(name, None)
|
||||||
|
sys.path.insert(0, str(src))
|
||||||
|
importlib.invalidate_caches()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
sys.path.remove(str(src))
|
||||||
|
for name in tuple(sys.modules):
|
||||||
|
if name == top_level or name.startswith(f"{top_level}."):
|
||||||
|
sys.modules.pop(name, None)
|
||||||
|
sys.modules.update(saved)
|
||||||
|
importlib.invalidate_caches()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_webui_package(*, root: Path, package: str, version: str, repo: str) -> None:
|
||||||
|
declarations: list[tuple[str, str]] = []
|
||||||
|
for path in (root / "package.json", root / "webui" / "package.json"):
|
||||||
|
if not path.exists():
|
||||||
|
continue
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if isinstance(payload, dict) and isinstance(payload.get("name"), str) and isinstance(payload.get("version"), str):
|
||||||
|
declarations.append((payload["name"], payload["version"]))
|
||||||
|
if (package, version) not in declarations:
|
||||||
|
raise ValueError(
|
||||||
|
f"Cannot synthesize {repo}: frontend manifest declares {package}@{version}, but package metadata does not."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def required_text(payload: dict[str, object], key: str, *, source: str) -> str:
|
||||||
|
value = optional_text(payload.get(key))
|
||||||
|
if value is None:
|
||||||
|
raise ValueError(f"Cannot synthesize catalog entry: {source} has no {key!r}.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def optional_text(value: object) -> str | None:
|
||||||
|
return value.strip() if isinstance(value, str) and value.strip() else None
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(add_help=False)
|
||||||
|
parser.add_argument("--inspect-repo", required=True)
|
||||||
|
parser.add_argument("--version", required=True)
|
||||||
|
parser.add_argument("--workspace", type=Path, required=True)
|
||||||
|
parser.add_argument("--repository-base", required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
entries = synthesize_repository_catalog_entries(
|
||||||
|
repo=args.inspect_repo,
|
||||||
|
version=args.version,
|
||||||
|
workspace=args.workspace.resolve(),
|
||||||
|
repository_base=args.repository_base,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
print(str(exc), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(_INSPECTION_MARKER + json.dumps(entries, sort_keys=True))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
450
tools/release/govoplan_release/contracts.py
Normal file
450
tools/release/govoplan_release/contracts.py
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
"""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 not candidate.exists():
|
||||||
|
top_level = module.split(".", 1)[0]
|
||||||
|
workspace = src_root.parent.parent
|
||||||
|
sibling_src = workspace / top_level.replace("_", "-") / "src"
|
||||||
|
candidate = sibling_src.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,
|
||||||
|
)
|
||||||
194
tools/release/govoplan_release/git_state.py
Normal file
194
tools/release/govoplan_release/git_state.py
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
"""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),
|
||||||
|
package_inits=read_package_init_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 read_package_init_versions(path: Path) -> tuple[str, ...]:
|
||||||
|
"""Read public runtime versions declared by top-level Python packages."""
|
||||||
|
|
||||||
|
src = path / "src"
|
||||||
|
if not src.exists():
|
||||||
|
return ()
|
||||||
|
versions: list[str] = []
|
||||||
|
for package_init in sorted(src.glob("*/__init__.py")):
|
||||||
|
text = package_init.read_text(encoding="utf-8")
|
||||||
|
match = re.search(r'(?m)^__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,
|
||||||
|
)
|
||||||
321
tools/release/govoplan_release/model.py
Normal file
321
tools/release/govoplan_release/model.py
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
"""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, ...] = ()
|
||||||
|
package_inits: 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)
|
||||||
|
or (self.package_inits[0] if self.package_inits 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),
|
||||||
|
)
|
||||||
465
tools/release/govoplan_release/publisher.py
Normal file
465
tools/release/govoplan_release/publisher.py
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
"""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 .module_directory import write_module_directory
|
||||||
|
from .selective_catalog import trusted_keys_from_keyring
|
||||||
|
from .source_provenance import catalog_source_selection, source_tag_provenance_issues
|
||||||
|
from .version_alignment import candidate_catalog_version_issues
|
||||||
|
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",
|
||||||
|
source_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"
|
||||||
|
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:
|
||||||
|
version_issues = candidate_catalog_version_issues(candidate_payload)
|
||||||
|
blockers.extend(
|
||||||
|
f"candidate version alignment: {issue.source}: {issue.actual!r}; "
|
||||||
|
f"expected {issue.expected!r} ({issue.message})"
|
||||||
|
for issue in version_issues
|
||||||
|
)
|
||||||
|
source_selection = catalog_source_selection(candidate_payload)
|
||||||
|
blockers.extend(
|
||||||
|
f"source provenance: {issue.describe()}"
|
||||||
|
for issue in source_selection.issues
|
||||||
|
)
|
||||||
|
source_issues = source_tag_provenance_issues(
|
||||||
|
repo_versions=source_selection.all_versions,
|
||||||
|
workspace=workspace,
|
||||||
|
remote=source_remote,
|
||||||
|
require_head_repos=source_selection.selected_versions,
|
||||||
|
expected_commits=source_selection.selected_commits,
|
||||||
|
expected_tag_objects=source_selection.selected_tag_objects,
|
||||||
|
)
|
||||||
|
blockers.extend(
|
||||||
|
f"source provenance: {issue.describe()}"
|
||||||
|
for issue in source_issues
|
||||||
|
)
|
||||||
|
candidate_keys = trusted_keys_from_keyring(keyring_payload if isinstance(keyring_payload, dict) else {})
|
||||||
|
target_keyring_payload = read_json(target_keyring) if target_keyring.exists() else None
|
||||||
|
publication_trust = trusted_keys_from_keyring(
|
||||||
|
target_keyring_payload if isinstance(target_keyring_payload, dict) else {}
|
||||||
|
)
|
||||||
|
if not publication_trust:
|
||||||
|
blockers.append(
|
||||||
|
"publication trust anchor is missing; bootstrap a trusted website keyring through a separate reviewed process"
|
||||||
|
)
|
||||||
|
for key_id in sorted(set(candidate_keys) & set(publication_trust)):
|
||||||
|
if candidate_keys[key_id] != publication_trust[key_id]:
|
||||||
|
blockers.append(f"candidate keyring changes the public key for trusted key id {key_id!r}")
|
||||||
|
if candidate_keyring_hash != target_keyring_hash_before:
|
||||||
|
release = candidate_payload.get("release") if isinstance(candidate_payload, dict) else None
|
||||||
|
signed_keyring_hash = release.get("keyring_sha256") if isinstance(release, dict) else None
|
||||||
|
if signed_keyring_hash != candidate_keyring_hash:
|
||||||
|
blockers.append(
|
||||||
|
"candidate keyring differs from the publication trust anchor without a matching signed keyring_sha256"
|
||||||
|
)
|
||||||
|
validation = validate_module_package_catalog(
|
||||||
|
candidate_catalog,
|
||||||
|
require_trusted=True,
|
||||||
|
approved_channels=(channel,),
|
||||||
|
trusted_keys=publication_trust,
|
||||||
|
)
|
||||||
|
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 derive the 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 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 target_modules.exists():
|
||||||
|
shutil.rmtree(target_modules)
|
||||||
|
write_module_directory(
|
||||||
|
catalog_payload=candidate_payload,
|
||||||
|
keyring_payload=keyring_payload,
|
||||||
|
output_root=target_catalog.parent.parent,
|
||||||
|
channel=channel,
|
||||||
|
public_base_url=catalog_public_base_url(candidate_payload),
|
||||||
|
)
|
||||||
|
|
||||||
|
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 catalog_public_base_url(payload: dict[str, object]) -> str:
|
||||||
|
release = payload.get("release")
|
||||||
|
catalog_url = release.get("catalog_url") if isinstance(release, dict) else None
|
||||||
|
if isinstance(catalog_url, str) and "/catalogs/" in catalog_url:
|
||||||
|
return catalog_url.split("/catalogs/", 1)[0]
|
||||||
|
return "https://govoplan.add-ideas.de"
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
478
tools/release/govoplan_release/repository_tag.py
Normal file
478
tools/release/govoplan_release/repository_tag.py
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
"""Preview, create, and publish immutable release tags for selected repositories."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from .git_state import collect_repository_snapshot, git_text
|
||||||
|
from .model import RepositorySnapshot
|
||||||
|
from .repository_push import command_text, compact_output
|
||||||
|
from .version_alignment import repository_version_issues, selected_release_webui_bundle_issues
|
||||||
|
from .workspace import META_ROOT, load_repository_specs, resolve_repo_path, resolve_workspace_root
|
||||||
|
|
||||||
|
|
||||||
|
_RELEASE_VERSION = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$")
|
||||||
|
|
||||||
|
|
||||||
|
def tag_repositories(
|
||||||
|
*,
|
||||||
|
repos: tuple[str, ...],
|
||||||
|
repo_versions: dict[str, str],
|
||||||
|
workspace_root: Path | str | None = None,
|
||||||
|
remote: str = "origin",
|
||||||
|
message: str | None = None,
|
||||||
|
apply: bool = False, # noqa: A002 - mirrors API field.
|
||||||
|
push: bool = False,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Create annotated tags and optionally publish branch and tag atomically.
|
||||||
|
|
||||||
|
A release tag is only created for a clean, aligned, non-behind worktree.
|
||||||
|
Both local and remote tags are resolved to commits before mutation so an
|
||||||
|
existing immutable tag can never be moved by this operation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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]] = []
|
||||||
|
bundle_issues_by_repo: dict[str, list[str]] = {}
|
||||||
|
for issue in selected_release_webui_bundle_issues(
|
||||||
|
repo_versions={repo: repo_versions.get(repo, "") for repo in selected},
|
||||||
|
workspace=workspace,
|
||||||
|
):
|
||||||
|
bundle_issues_by_repo.setdefault(issue.repo, []).append(
|
||||||
|
f"{issue.source}={issue.actual!r}, expected {issue.expected!r} ({issue.message})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if apply:
|
||||||
|
preflight = tag_repositories(
|
||||||
|
repos=selected,
|
||||||
|
repo_versions=repo_versions,
|
||||||
|
workspace_root=workspace,
|
||||||
|
remote=remote,
|
||||||
|
message=message,
|
||||||
|
apply=False,
|
||||||
|
push=push,
|
||||||
|
)
|
||||||
|
preflight_rows = preflight.get("repositories")
|
||||||
|
if isinstance(preflight_rows, list) and any(
|
||||||
|
isinstance(item, dict) and item.get("status") in {"blocked", "failed"}
|
||||||
|
for item in preflight_rows
|
||||||
|
):
|
||||||
|
blocked_rows = []
|
||||||
|
for item in preflight_rows:
|
||||||
|
if not isinstance(item, dict) or item.get("status") in {"blocked", "failed"}:
|
||||||
|
blocked_rows.append(item)
|
||||||
|
continue
|
||||||
|
blocked_rows.append(
|
||||||
|
{
|
||||||
|
**item,
|
||||||
|
"status": "skipped",
|
||||||
|
"detail": "preflight passed, but no release tag was changed because another selected repository is blocked",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "blocked",
|
||||||
|
"apply": True,
|
||||||
|
"push": push,
|
||||||
|
"remote": remote,
|
||||||
|
"detail": "batch preflight failed; no selected repository was mutated",
|
||||||
|
"repositories": blocked_rows,
|
||||||
|
}
|
||||||
|
elif selected:
|
||||||
|
manifest_gate_issue = manifest_shape_gate_issue(workspace)
|
||||||
|
if manifest_gate_issue:
|
||||||
|
return {
|
||||||
|
"status": "blocked",
|
||||||
|
"apply": False,
|
||||||
|
"push": push,
|
||||||
|
"remote": remote,
|
||||||
|
"detail": "release manifest validation failed",
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"repo": repo,
|
||||||
|
"status": "blocked",
|
||||||
|
"detail": manifest_gate_issue,
|
||||||
|
}
|
||||||
|
for repo in selected
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
for repo in selected:
|
||||||
|
version = normalize_version(repo_versions.get(repo))
|
||||||
|
tag = f"v{version}" if version else ""
|
||||||
|
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=tag or None, online=False)
|
||||||
|
path = resolve_repo_path(spec, workspace)
|
||||||
|
tag_message = message.strip() if message and message.strip() else f"Release {repo} {tag}"
|
||||||
|
create_command = ("git", "tag", "-a", tag, "-m", tag_message) if tag else ()
|
||||||
|
publish_command = (
|
||||||
|
"git",
|
||||||
|
"push",
|
||||||
|
"--atomic",
|
||||||
|
remote,
|
||||||
|
f"HEAD:refs/heads/{snapshot.branch or 'main'}",
|
||||||
|
f"refs/tags/{tag}",
|
||||||
|
) if tag else ()
|
||||||
|
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),
|
||||||
|
"version": version,
|
||||||
|
"tag": tag,
|
||||||
|
"remote": remote,
|
||||||
|
"message": tag_message,
|
||||||
|
"create_command": command_text(create_command) if create_command else "",
|
||||||
|
"publish_command": command_text(publish_command) if publish_command else "",
|
||||||
|
}
|
||||||
|
blocker = basic_blocker(snapshot=snapshot, version=version)
|
||||||
|
if blocker:
|
||||||
|
results.append({**row, "status": "blocked", "detail": blocker})
|
||||||
|
continue
|
||||||
|
if run(("git", "remote", "get-url", remote), cwd=path).returncode != 0:
|
||||||
|
results.append({**row, "status": "blocked", "detail": f"remote {remote!r} is not configured"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
alignment = repository_version_issues(path, expected_version=version)
|
||||||
|
if alignment:
|
||||||
|
detail = "; ".join(
|
||||||
|
f"{issue.source}={issue.actual!r}, expected {issue.expected!r}"
|
||||||
|
for issue in alignment
|
||||||
|
)
|
||||||
|
results.append({**row, "status": "blocked", "detail": f"version alignment gate failed: {detail}"})
|
||||||
|
continue
|
||||||
|
bundle_alignment = bundle_issues_by_repo.get(repo, ())
|
||||||
|
if bundle_alignment:
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
**row,
|
||||||
|
"status": "blocked",
|
||||||
|
"detail": "release WebUI composition gate failed: " + "; ".join(bundle_alignment),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
head_commit = git_text(path, "rev-parse", "HEAD")
|
||||||
|
local_commit = ref_commit(path, f"refs/tags/{tag}")
|
||||||
|
local_tag_object = git_text(path, "rev-parse", "--verify", f"refs/tags/{tag}") if local_commit else None
|
||||||
|
local_tag_type = git_text(path, "cat-file", "-t", f"refs/tags/{tag}") if local_commit else None
|
||||||
|
if local_commit and local_tag_type != "tag":
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
**row,
|
||||||
|
"status": "blocked",
|
||||||
|
"detail": f"local tag {tag} is not annotated; release tags must preserve an annotation object",
|
||||||
|
"local_tag_commit": local_commit,
|
||||||
|
"local_tag_type": local_tag_type,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if local_commit and local_commit != head_commit:
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
**row,
|
||||||
|
"status": "blocked",
|
||||||
|
"detail": f"local immutable tag {tag} points to {local_commit[:12]}, not HEAD {head_commit[:12]}",
|
||||||
|
"local_tag_commit": local_commit,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
remote_result = remote_tag_commit(path, remote=remote, tag=tag)
|
||||||
|
if remote_result.error:
|
||||||
|
results.append({**row, "status": "blocked", "detail": remote_result.error})
|
||||||
|
continue
|
||||||
|
if remote_result.commit and not remote_result.annotated:
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
**row,
|
||||||
|
"status": "blocked",
|
||||||
|
"detail": f"remote tag {tag} is not annotated; immutable release tags must preserve an annotation object",
|
||||||
|
"local_tag_commit": local_commit,
|
||||||
|
"remote_tag_commit": remote_result.commit,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if remote_result.commit and remote_result.commit != head_commit:
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
**row,
|
||||||
|
"status": "blocked",
|
||||||
|
"detail": f"remote immutable tag {tag} points to {remote_result.commit[:12]}, not HEAD {head_commit[:12]}",
|
||||||
|
"local_tag_commit": local_commit,
|
||||||
|
"remote_tag_commit": remote_result.commit,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if remote_result.tag_object and local_tag_object and remote_result.tag_object != local_tag_object:
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
**row,
|
||||||
|
"status": "blocked",
|
||||||
|
"detail": f"local and remote immutable tag objects differ for {tag}; reconcile the annotation without moving either tag",
|
||||||
|
"local_tag_commit": local_commit,
|
||||||
|
"remote_tag_commit": remote_result.commit,
|
||||||
|
"local_tag_object": local_tag_object,
|
||||||
|
"remote_tag_object": remote_result.tag_object,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
row.update(
|
||||||
|
{
|
||||||
|
"head_commit": head_commit,
|
||||||
|
"local_tag_commit": local_commit,
|
||||||
|
"remote_tag_commit": remote_result.commit,
|
||||||
|
"local_tag_object": local_tag_object,
|
||||||
|
"remote_tag_object": remote_result.tag_object,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if not apply:
|
||||||
|
detail = preview_detail(tag=tag, local_commit=local_commit, remote_commit=remote_result.commit, push=push)
|
||||||
|
status = "noop" if remote_result.commit or (local_commit and not push) else "planned"
|
||||||
|
results.append({**row, "status": status, "detail": detail})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if remote_result.commit:
|
||||||
|
if not local_commit:
|
||||||
|
fetch_result = run(("git", "fetch", remote, f"refs/tags/{tag}:refs/tags/{tag}"), cwd=path)
|
||||||
|
if fetch_result.returncode != 0:
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
**row,
|
||||||
|
"status": "failed",
|
||||||
|
"detail": f"remote tag {tag} exists at HEAD but could not be fetched locally",
|
||||||
|
"returncode": fetch_result.returncode,
|
||||||
|
"stdout": compact_output(fetch_result.stdout),
|
||||||
|
"stderr": compact_output(fetch_result.stderr),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
**row,
|
||||||
|
"status": "published",
|
||||||
|
"detail": f"immutable tag {tag} is already published at HEAD",
|
||||||
|
"after_local_tag_commit": head_commit,
|
||||||
|
"after_remote_tag_commit": head_commit,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
created = False
|
||||||
|
if not local_commit:
|
||||||
|
create_result = run(create_command, cwd=path)
|
||||||
|
if create_result.returncode != 0:
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
**row,
|
||||||
|
"status": "failed",
|
||||||
|
"detail": f"could not create annotated tag {tag}",
|
||||||
|
"returncode": create_result.returncode,
|
||||||
|
"stdout": compact_output(create_result.stdout),
|
||||||
|
"stderr": compact_output(create_result.stderr),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
created = True
|
||||||
|
|
||||||
|
if not push:
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
**row,
|
||||||
|
"status": "tagged" if created else "noop",
|
||||||
|
"detail": f"created annotated tag {tag} at HEAD" if created else f"annotated tag {tag} already exists at HEAD",
|
||||||
|
"after_local_tag_commit": head_commit,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
publish_result = run(publish_command, cwd=path)
|
||||||
|
if publish_result.returncode != 0:
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
**row,
|
||||||
|
"status": "failed",
|
||||||
|
"detail": f"created local tag {tag}, but atomic branch and tag publication failed" if created else f"atomic branch and tag publication failed for {tag}",
|
||||||
|
"returncode": publish_result.returncode,
|
||||||
|
"after_local_tag_commit": head_commit,
|
||||||
|
"stdout": compact_output(publish_result.stdout),
|
||||||
|
"stderr": compact_output(publish_result.stderr),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
after_local_object = git_text(path, "rev-parse", "--verify", f"refs/tags/{tag}")
|
||||||
|
after_remote = remote_tag_commit(path, remote=remote, tag=tag)
|
||||||
|
if (
|
||||||
|
after_remote.error
|
||||||
|
or not after_remote.annotated
|
||||||
|
or after_remote.commit != head_commit
|
||||||
|
or after_remote.tag_object != after_local_object
|
||||||
|
):
|
||||||
|
verification_detail = after_remote.error or "remote tag did not resolve to the published annotated tag object at HEAD"
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
**row,
|
||||||
|
"status": "failed",
|
||||||
|
"detail": f"Git push returned success, but the remote release-tag postcondition failed: {verification_detail}",
|
||||||
|
"returncode": publish_result.returncode,
|
||||||
|
"after_local_tag_commit": head_commit,
|
||||||
|
"after_local_tag_object": after_local_object,
|
||||||
|
"after_remote_tag_commit": after_remote.commit,
|
||||||
|
"after_remote_tag_object": after_remote.tag_object,
|
||||||
|
"stdout": compact_output(publish_result.stdout),
|
||||||
|
"stderr": compact_output(publish_result.stderr),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
**row,
|
||||||
|
"status": "published",
|
||||||
|
"detail": f"published branch {snapshot.branch} and immutable tag {tag} atomically to {remote}",
|
||||||
|
"returncode": publish_result.returncode,
|
||||||
|
"after_local_tag_commit": head_commit,
|
||||||
|
"after_remote_tag_commit": head_commit,
|
||||||
|
"after_local_tag_object": after_local_object,
|
||||||
|
"after_remote_tag_object": after_remote.tag_object,
|
||||||
|
"stdout": compact_output(publish_result.stdout),
|
||||||
|
"stderr": compact_output(publish_result.stderr),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if any(item["status"] in {"blocked", "failed"} for item in results):
|
||||||
|
status = "blocked" if not apply else "partial"
|
||||||
|
elif any(item["status"] == "published" for item in results):
|
||||||
|
status = "published"
|
||||||
|
elif any(item["status"] == "tagged" for item in results):
|
||||||
|
status = "tagged"
|
||||||
|
elif any(item["status"] == "planned" for item in results):
|
||||||
|
status = "planned"
|
||||||
|
else:
|
||||||
|
status = "noop"
|
||||||
|
return {"status": status, "apply": apply, "push": push, "remote": remote, "repositories": results}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_version(value: str | None) -> str:
|
||||||
|
normalized = (value or "").strip().removeprefix("v")
|
||||||
|
return normalized if _RELEASE_VERSION.fullmatch(normalized) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def basic_blocker(*, snapshot: RepositorySnapshot, version: str) -> str | None:
|
||||||
|
if not snapshot.exists or not snapshot.is_git:
|
||||||
|
return "repository is missing or is not a Git repository"
|
||||||
|
if snapshot.safe_directory_required:
|
||||||
|
return "Git safe.directory approval is required"
|
||||||
|
if not snapshot.has_head:
|
||||||
|
return "repository has no commit to tag"
|
||||||
|
if not snapshot.branch:
|
||||||
|
return "repository is not on a named branch"
|
||||||
|
if snapshot.behind:
|
||||||
|
return f"repository is behind {snapshot.upstream}"
|
||||||
|
if snapshot.dirty_entries:
|
||||||
|
return "worktree is not clean; commit or discard the reviewed changes before tagging"
|
||||||
|
if not version:
|
||||||
|
return "a valid target version is required"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_shape_gate_issue(workspace: Path) -> str | None:
|
||||||
|
command = (
|
||||||
|
sys.executable,
|
||||||
|
str(META_ROOT / "tools" / "checks" / "check-manifest-shapes.py"),
|
||||||
|
"--workspace-root",
|
||||||
|
str(workspace),
|
||||||
|
)
|
||||||
|
result = run(command, cwd=META_ROOT, env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"})
|
||||||
|
if result.returncode == 0:
|
||||||
|
return None
|
||||||
|
detail = compact_output(result.stderr) or compact_output(result.stdout)
|
||||||
|
return f"manifest/documentation release gate failed: {detail or 'unknown validation error'}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RemoteTagResult:
|
||||||
|
commit: str | None = None
|
||||||
|
tag_object: str | None = None
|
||||||
|
annotated: bool = False
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def ref_commit(path: Path, ref: str) -> str | None:
|
||||||
|
value = git_text(path, "rev-parse", "--verify", f"{ref}^{{commit}}")
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
def remote_tag_commit(
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
remote: str,
|
||||||
|
tag: str,
|
||||||
|
timeout: int = 15,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
) -> RemoteTagResult:
|
||||||
|
result = run(
|
||||||
|
("git", "ls-remote", "--tags", remote, f"refs/tags/{tag}", f"refs/tags/{tag}^{{}}"),
|
||||||
|
cwd=path,
|
||||||
|
timeout=timeout,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
detail = compact_output(result.stderr) or compact_output(result.stdout) or "unknown Git error"
|
||||||
|
return RemoteTagResult(error=f"could not inspect immutable tag {tag} on {remote}: {detail}")
|
||||||
|
refs = {}
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
commit, separator, ref = line.partition("\t")
|
||||||
|
if separator:
|
||||||
|
refs[ref] = commit
|
||||||
|
peeled = refs.get(f"refs/tags/{tag}^{{}}")
|
||||||
|
tag_object = refs.get(f"refs/tags/{tag}")
|
||||||
|
return RemoteTagResult(commit=peeled or tag_object, tag_object=tag_object, annotated=peeled is not None)
|
||||||
|
|
||||||
|
|
||||||
|
def preview_detail(*, tag: str, local_commit: str | None, remote_commit: str | None, push: bool) -> str:
|
||||||
|
if remote_commit:
|
||||||
|
return f"immutable tag {tag} is already published at HEAD"
|
||||||
|
if local_commit and push:
|
||||||
|
return f"existing local tag {tag} will be published with the current branch"
|
||||||
|
if local_commit:
|
||||||
|
return f"annotated tag {tag} already exists at HEAD"
|
||||||
|
if push:
|
||||||
|
return f"annotated tag {tag} will be created and published atomically with the current branch"
|
||||||
|
return f"annotated tag {tag} will be created at HEAD"
|
||||||
|
|
||||||
|
|
||||||
|
def run(
|
||||||
|
command: tuple[str, ...],
|
||||||
|
*,
|
||||||
|
cwd: Path,
|
||||||
|
timeout: int = 120,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
try:
|
||||||
|
return subprocess.run(
|
||||||
|
command,
|
||||||
|
cwd=cwd,
|
||||||
|
check=False,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
timeout=timeout,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
return subprocess.CompletedProcess(command, 124, exc.stdout or "", exc.stderr or "Git command timed out")
|
||||||
235
tools/release/govoplan_release/sbom.py
Normal file
235
tools/release/govoplan_release/sbom.py
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
"""CycloneDX SBOM construction from resolved Python and npm environments."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import quote, urlsplit
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
def build_release_sbom(
|
||||||
|
*,
|
||||||
|
product_version: str,
|
||||||
|
pip_inspect: dict[str, Any],
|
||||||
|
npm_lock: dict[str, Any],
|
||||||
|
timestamp: datetime | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
components = _deduplicate_components(
|
||||||
|
[
|
||||||
|
*python_components(pip_inspect),
|
||||||
|
*npm_components(npm_lock),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
root_ref = f"pkg:generic/govoplan@{quote(product_version, safe='') }"
|
||||||
|
resolved_timestamp = _normalized_timestamp(timestamp)
|
||||||
|
timestamp_text = resolved_timestamp.isoformat().replace("+00:00", "Z")
|
||||||
|
serial_seed = json.dumps(
|
||||||
|
{
|
||||||
|
"product_version": product_version,
|
||||||
|
"timestamp": timestamp_text,
|
||||||
|
"components": components,
|
||||||
|
},
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
serial = uuid.uuid5(uuid.NAMESPACE_URL, f"https://govoplan.add-ideas.de/sbom/{serial_seed}")
|
||||||
|
return {
|
||||||
|
"$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json",
|
||||||
|
"bomFormat": "CycloneDX",
|
||||||
|
"specVersion": "1.6",
|
||||||
|
"serialNumber": f"urn:uuid:{serial}",
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"timestamp": timestamp_text,
|
||||||
|
"lifecycles": [{"phase": "build"}],
|
||||||
|
"component": {
|
||||||
|
"type": "application",
|
||||||
|
"bom-ref": root_ref,
|
||||||
|
"group": "ADD ideas UG",
|
||||||
|
"name": "GovOPlaN",
|
||||||
|
"version": product_version,
|
||||||
|
"purl": root_ref,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"components": components,
|
||||||
|
"dependencies": [
|
||||||
|
{
|
||||||
|
"ref": root_ref,
|
||||||
|
"dependsOn": [component["bom-ref"] for component in components],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def python_components(report: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
if str(report.get("version")) != "1":
|
||||||
|
raise ValueError("unsupported pip inspect report version")
|
||||||
|
installed = report.get("installed")
|
||||||
|
if not isinstance(installed, list):
|
||||||
|
raise ValueError("pip inspect report is missing installed distributions")
|
||||||
|
|
||||||
|
components: list[dict[str, Any]] = []
|
||||||
|
for item in installed:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
metadata = item.get("metadata")
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
continue
|
||||||
|
name = metadata.get("name")
|
||||||
|
version = metadata.get("version")
|
||||||
|
if not isinstance(name, str) or not isinstance(version, str):
|
||||||
|
continue
|
||||||
|
normalized = name.lower().replace("_", "-")
|
||||||
|
purl = f"pkg:pypi/{quote(normalized, safe='')}@{quote(version, safe='')}"
|
||||||
|
component: dict[str, Any] = {
|
||||||
|
"type": "library",
|
||||||
|
"bom-ref": purl,
|
||||||
|
"name": name,
|
||||||
|
"version": version,
|
||||||
|
"purl": purl,
|
||||||
|
"scope": "required",
|
||||||
|
}
|
||||||
|
direct_url = item.get("direct_url")
|
||||||
|
if isinstance(direct_url, dict) and isinstance(direct_url.get("url"), str):
|
||||||
|
distribution_url = _public_distribution_url(direct_url["url"])
|
||||||
|
if distribution_url is not None:
|
||||||
|
component["externalReferences"] = [{"type": "distribution", "url": distribution_url}]
|
||||||
|
components.append(component)
|
||||||
|
return components
|
||||||
|
|
||||||
|
|
||||||
|
def npm_components(lock: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
packages = lock.get("packages")
|
||||||
|
if not isinstance(packages, dict):
|
||||||
|
raise ValueError("npm lockfile is missing the packages map")
|
||||||
|
|
||||||
|
components: list[dict[str, Any]] = []
|
||||||
|
for path, item in packages.items():
|
||||||
|
if not path or not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
name = item.get("name") or _npm_name_from_lock_path(str(path))
|
||||||
|
version = item.get("version")
|
||||||
|
if not isinstance(name, str) or not isinstance(version, str):
|
||||||
|
continue
|
||||||
|
purl = f"pkg:npm/{quote(name, safe='/')}@{quote(version, safe='')}"
|
||||||
|
component: dict[str, Any] = {
|
||||||
|
"type": "library",
|
||||||
|
"bom-ref": purl,
|
||||||
|
"name": name,
|
||||||
|
"version": version,
|
||||||
|
"purl": purl,
|
||||||
|
"scope": "optional" if item.get("dev") is True else "required",
|
||||||
|
}
|
||||||
|
hashes = _sri_hashes(item.get("integrity"))
|
||||||
|
if hashes:
|
||||||
|
component["hashes"] = hashes
|
||||||
|
resolved = item.get("resolved")
|
||||||
|
if isinstance(resolved, str):
|
||||||
|
distribution_url = _public_distribution_url(resolved)
|
||||||
|
if distribution_url is not None:
|
||||||
|
component["externalReferences"] = [{"type": "distribution", "url": distribution_url}]
|
||||||
|
components.append(component)
|
||||||
|
return components
|
||||||
|
|
||||||
|
|
||||||
|
def validate_sbom(payload: dict[str, Any]) -> None:
|
||||||
|
if payload.get("bomFormat") != "CycloneDX" or payload.get("specVersion") != "1.6":
|
||||||
|
raise ValueError("not a supported CycloneDX 1.6 SBOM")
|
||||||
|
metadata = payload.get("metadata")
|
||||||
|
if not isinstance(metadata, dict) or not isinstance(metadata.get("component"), dict):
|
||||||
|
raise ValueError("SBOM metadata component is missing")
|
||||||
|
components = payload.get("components")
|
||||||
|
if not isinstance(components, list) or not components:
|
||||||
|
raise ValueError("SBOM has no dependency components")
|
||||||
|
refs = [item.get("bom-ref") for item in components if isinstance(item, dict)]
|
||||||
|
if any(not isinstance(ref, str) or not ref for ref in refs):
|
||||||
|
raise ValueError("SBOM component is missing bom-ref")
|
||||||
|
if len(refs) != len(set(refs)):
|
||||||
|
raise ValueError("SBOM contains duplicate component references")
|
||||||
|
|
||||||
|
|
||||||
|
def sbom_sha256(payload: dict[str, Any]) -> str:
|
||||||
|
return hashlib.sha256(render_sbom(payload).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def render_sbom(payload: dict[str, Any]) -> str:
|
||||||
|
return json.dumps(payload, indent=2, sort_keys=True) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def timestamp_from_source_date_epoch(value: str) -> datetime:
|
||||||
|
"""Resolve the reproducible-build timestamp convention to UTC."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
epoch = int(value)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("SOURCE_DATE_EPOCH must be an integer Unix timestamp") from exc
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(epoch, tz=UTC)
|
||||||
|
except (OverflowError, OSError) as exc:
|
||||||
|
raise ValueError("SOURCE_DATE_EPOCH is outside the supported timestamp range") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def parse_sbom_timestamp(value: str) -> datetime:
|
||||||
|
"""Parse an explicit ISO-8601 SBOM timestamp and normalize it to UTC."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("timestamp must be an ISO-8601 value with a timezone") from exc
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise ValueError("timestamp must include a timezone")
|
||||||
|
return parsed.astimezone(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_timestamp(value: datetime | None) -> datetime:
|
||||||
|
resolved = value or datetime.now(tz=UTC)
|
||||||
|
if resolved.tzinfo is None:
|
||||||
|
raise ValueError("SBOM timestamp must include a timezone")
|
||||||
|
return resolved.astimezone(UTC).replace(microsecond=0)
|
||||||
|
|
||||||
|
|
||||||
|
def _deduplicate_components(components: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
unique = {component["bom-ref"]: component for component in components}
|
||||||
|
return [unique[key] for key in sorted(unique)]
|
||||||
|
|
||||||
|
|
||||||
|
def _npm_name_from_lock_path(path: str) -> str:
|
||||||
|
marker = "node_modules/"
|
||||||
|
if marker not in path:
|
||||||
|
return Path(path).name
|
||||||
|
return path.rsplit(marker, 1)[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def _sri_hashes(value: object) -> list[dict[str, str]]:
|
||||||
|
if not isinstance(value, str) or "-" not in value:
|
||||||
|
return []
|
||||||
|
algorithm, encoded = value.split("-", 1)
|
||||||
|
algorithm_name = {"sha256": "SHA-256", "sha384": "SHA-384", "sha512": "SHA-512"}.get(algorithm.lower())
|
||||||
|
if algorithm_name is None:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
content = base64.b64decode(encoded, validate=True).hex()
|
||||||
|
except ValueError:
|
||||||
|
return []
|
||||||
|
return [{"alg": algorithm_name, "content": content}]
|
||||||
|
|
||||||
|
|
||||||
|
def _public_distribution_url(value: str) -> str | None:
|
||||||
|
"""Keep stable public provenance without leaking local paths or credentials."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = urlsplit(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if parsed.scheme.lower() not in {"git", "git+https", "http", "https"}:
|
||||||
|
return None
|
||||||
|
if not parsed.hostname or parsed.username is not None or parsed.password is not None:
|
||||||
|
return None
|
||||||
|
if parsed.query:
|
||||||
|
return None
|
||||||
|
return value
|
||||||
546
tools/release/govoplan_release/selective_catalog.py
Normal file
546
tools/release/govoplan_release/selective_catalog.py
Normal file
@@ -0,0 +1,546 @@
|
|||||||
|
"""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 .catalog_entry_synthesis import (
|
||||||
|
synthesize_repository_catalog_entries,
|
||||||
|
validate_initial_entry_closure,
|
||||||
|
)
|
||||||
|
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 .source_provenance import (
|
||||||
|
catalog_source_selection,
|
||||||
|
selected_source_provenance,
|
||||||
|
source_tag_provenance_issues,
|
||||||
|
)
|
||||||
|
from .version_alignment import (
|
||||||
|
selected_release_webui_bundle_issues,
|
||||||
|
selected_repository_version_issues,
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
source_remote: str = "origin",
|
||||||
|
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)
|
||||||
|
enforce_selected_version_alignment(repo_versions=repo_versions, workspace=workspace)
|
||||||
|
enforce_selected_source_provenance(
|
||||||
|
repo_versions=repo_versions,
|
||||||
|
workspace=workspace,
|
||||||
|
remote=source_remote,
|
||||||
|
)
|
||||||
|
selected_provenance = selected_source_provenance(
|
||||||
|
repo_versions=repo_versions,
|
||||||
|
workspace=workspace,
|
||||||
|
)
|
||||||
|
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')}",
|
||||||
|
**selected_provenance[repo],
|
||||||
|
}
|
||||||
|
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("/"),
|
||||||
|
workspace=workspace,
|
||||||
|
)
|
||||||
|
enforce_complete_catalog_source_provenance(
|
||||||
|
payload=candidate,
|
||||||
|
workspace=workspace,
|
||||||
|
remote=source_remote,
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
keyring_payload = keyring_payload_for_candidate(
|
||||||
|
existing_keyring=web_root / "public" / "catalogs" / "v1" / "keyring.json",
|
||||||
|
signing_keys=parsed_keys,
|
||||||
|
generated_at=generated_at,
|
||||||
|
)
|
||||||
|
release["keyring_sha256"] = canonical_hash(keyring_payload)
|
||||||
|
|
||||||
|
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]
|
||||||
|
|
||||||
|
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_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 enforce_selected_version_alignment(*, repo_versions: dict[str, str], workspace: Path) -> None:
|
||||||
|
issues = (
|
||||||
|
*selected_repository_version_issues(
|
||||||
|
repo_versions=repo_versions,
|
||||||
|
workspace=workspace,
|
||||||
|
),
|
||||||
|
*selected_release_webui_bundle_issues(
|
||||||
|
repo_versions=repo_versions,
|
||||||
|
workspace=workspace,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
failures = [
|
||||||
|
f"{issue.repo}: {issue.source}={issue.actual!r}, expected {issue.expected!r} ({issue.message})"
|
||||||
|
for issue in issues
|
||||||
|
]
|
||||||
|
if failures:
|
||||||
|
raise ValueError("Version alignment gate failed: " + "; ".join(failures))
|
||||||
|
|
||||||
|
|
||||||
|
def enforce_selected_source_provenance(
|
||||||
|
*,
|
||||||
|
repo_versions: dict[str, str],
|
||||||
|
workspace: Path,
|
||||||
|
remote: str = "origin",
|
||||||
|
) -> None:
|
||||||
|
failures = source_tag_provenance_issues(
|
||||||
|
repo_versions=repo_versions,
|
||||||
|
workspace=workspace,
|
||||||
|
remote=remote,
|
||||||
|
require_head_repos=repo_versions,
|
||||||
|
)
|
||||||
|
if failures:
|
||||||
|
raise ValueError(
|
||||||
|
"Source tag provenance gate failed: "
|
||||||
|
+ "; ".join(issue.describe() for issue in failures)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def enforce_complete_catalog_source_provenance(
|
||||||
|
*,
|
||||||
|
payload: object,
|
||||||
|
workspace: Path,
|
||||||
|
remote: str = "origin",
|
||||||
|
) -> None:
|
||||||
|
selection = catalog_source_selection(payload)
|
||||||
|
failures = [*selection.issues]
|
||||||
|
failures.extend(
|
||||||
|
source_tag_provenance_issues(
|
||||||
|
repo_versions=selection.all_versions,
|
||||||
|
workspace=workspace,
|
||||||
|
remote=remote,
|
||||||
|
require_head_repos=selection.selected_versions,
|
||||||
|
expected_commits=selection.selected_commits,
|
||||||
|
expected_tag_objects=selection.selected_tag_objects,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if failures:
|
||||||
|
raise ValueError(
|
||||||
|
"Complete catalog source provenance gate failed: "
|
||||||
|
+ "; ".join(issue.describe() for issue in failures)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
workspace: Path,
|
||||||
|
) -> 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)
|
||||||
|
initial_module_ids: set[str] = set()
|
||||||
|
existing_module_ids = {
|
||||||
|
str(entry.get("module_id") or "")
|
||||||
|
for entry in modules
|
||||||
|
if isinstance(entry, dict)
|
||||||
|
}
|
||||||
|
for repo in missing:
|
||||||
|
entries = synthesize_repository_catalog_entries(
|
||||||
|
repo=repo,
|
||||||
|
version=repo_versions[repo],
|
||||||
|
workspace=workspace,
|
||||||
|
repository_base=repository_base,
|
||||||
|
)
|
||||||
|
for entry in entries:
|
||||||
|
module_id = str(entry["module_id"])
|
||||||
|
if module_id in existing_module_ids:
|
||||||
|
raise ValueError(
|
||||||
|
f"Cannot synthesize {repo}: module id {module_id!r} already belongs to another catalog entry."
|
||||||
|
)
|
||||||
|
modules.append(entry)
|
||||||
|
existing_module_ids.add(module_id)
|
||||||
|
initial_module_ids.add(module_id)
|
||||||
|
changes.append(
|
||||||
|
CatalogEntryChange(
|
||||||
|
repo=repo,
|
||||||
|
module_id=module_id,
|
||||||
|
field="catalog_entry",
|
||||||
|
before=None,
|
||||||
|
after=json.dumps(entry, sort_keys=True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
handled.add(repo)
|
||||||
|
if initial_module_ids:
|
||||||
|
validate_initial_entry_closure(
|
||||||
|
catalog_modules=modules,
|
||||||
|
initial_module_ids=initial_module_ids,
|
||||||
|
)
|
||||||
|
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)
|
||||||
322
tools/release/govoplan_release/selective_planner.py
Normal file
322
tools/release/govoplan_release/selective_planner.py
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
"""Selective release planning for independently versioned packages."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
import shlex
|
||||||
|
|
||||||
|
from .contracts import validate_contracts
|
||||||
|
from .model import (
|
||||||
|
CompatibilityIssue,
|
||||||
|
InterfaceProviderSnapshot,
|
||||||
|
InterfaceRequirementSnapshot,
|
||||||
|
ReleaseDashboard,
|
||||||
|
ReleasePlanStep,
|
||||||
|
ReleasePlanUnit,
|
||||||
|
RepositorySnapshot,
|
||||||
|
SelectiveReleasePlan,
|
||||||
|
ModuleContractSnapshot,
|
||||||
|
)
|
||||||
|
from .version_alignment import selected_release_webui_bundle_issues
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
units = apply_release_webui_bundle_gate(units, workspace=Path(dashboard.workspace_root))
|
||||||
|
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 apply_release_webui_bundle_gate(
|
||||||
|
units: tuple[ReleasePlanUnit, ...],
|
||||||
|
*,
|
||||||
|
workspace: Path,
|
||||||
|
) -> tuple[ReleasePlanUnit, ...]:
|
||||||
|
issues_by_repo: dict[str, list[str]] = {}
|
||||||
|
for issue in selected_release_webui_bundle_issues(
|
||||||
|
repo_versions={unit.repo: unit.target_version for unit in units},
|
||||||
|
workspace=workspace,
|
||||||
|
):
|
||||||
|
issues_by_repo.setdefault(issue.repo, []).append(
|
||||||
|
f"{issue.source}={issue.actual!r}, expected {issue.expected!r} ({issue.message})"
|
||||||
|
)
|
||||||
|
return tuple(
|
||||||
|
replace(
|
||||||
|
unit,
|
||||||
|
status="blocked",
|
||||||
|
blockers=(*unit.blockers, *issues_by_repo[unit.repo]),
|
||||||
|
)
|
||||||
|
if unit.repo in issues_by_repo
|
||||||
|
else unit
|
||||||
|
for unit in units
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
local_versions = tuple(
|
||||||
|
value
|
||||||
|
for value in (
|
||||||
|
repo.versions.pyproject,
|
||||||
|
repo.versions.package,
|
||||||
|
repo.versions.webui_package,
|
||||||
|
*repo.versions.manifests,
|
||||||
|
*repo.versions.package_inits,
|
||||||
|
)
|
||||||
|
if value
|
||||||
|
)
|
||||||
|
if len(set(local_versions)) > 1:
|
||||||
|
blockers.append("backend, manifest, and frontend version metadata is not aligned")
|
||||||
|
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] = []
|
||||||
|
snapshots = {repo.spec.name: repo for repo in dashboard.repositories}
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
snapshot = snapshots.get(unit.repo)
|
||||||
|
if unit.current_version != unit.target_version or (snapshot is not None and snapshot.dirty):
|
||||||
|
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-version-metadata",
|
||||||
|
title=f"Prepare version metadata before adding to {channel}",
|
||||||
|
detail=(
|
||||||
|
"Selected repositories have no source version metadata: "
|
||||||
|
+ ", ".join(unit.repo for unit in initial_units)
|
||||||
|
+ ". Initial catalog synthesis requires aligned pyproject, module manifest, and optional frontend versions."
|
||||||
|
),
|
||||||
|
cwd=dashboard.meta_root,
|
||||||
|
mutating=True,
|
||||||
|
status="needs-version-metadata",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
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. Preview and guarded apply/tag/push executors "
|
||||||
|
"are available in the Signed Website Catalog panel and the release-catalog CLI."
|
||||||
|
),
|
||||||
|
command=(
|
||||||
|
"tools/release/release-catalog.py publish-candidate "
|
||||||
|
f"--candidate-dir \"$CANDIDATE_DIR\" --channel {shlex.quote(channel)}"
|
||||||
|
),
|
||||||
|
cwd=dashboard.meta_root,
|
||||||
|
status="planned",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
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"
|
||||||
438
tools/release/govoplan_release/source_provenance.py
Normal file
438
tools/release/govoplan_release/source_provenance.py
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
"""Fail-closed provenance checks for catalog source release tags."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import tomllib
|
||||||
|
from typing import Iterable
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from .git_state import collect_repository_snapshot, git_text
|
||||||
|
from .repository_tag import RemoteTagResult, ref_commit, remote_tag_commit
|
||||||
|
from .version_alignment import repository_version_issues
|
||||||
|
from .workspace import load_repository_specs, resolve_repo_path
|
||||||
|
|
||||||
|
|
||||||
|
_CATALOG_PYTHON_REF = re.compile(
|
||||||
|
r"/(?P<repo>govoplan-[a-z0-9-]+)\.git@v(?P<version>[^\s;]+)$"
|
||||||
|
)
|
||||||
|
_VERSION = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SourceTagProvenanceIssue:
|
||||||
|
repo: str
|
||||||
|
tag: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
def describe(self) -> str:
|
||||||
|
return f"{self.repo} {self.tag}: {self.message}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CatalogSourceSelection:
|
||||||
|
all_versions: dict[str, str]
|
||||||
|
selected_versions: dict[str, str]
|
||||||
|
selected_commits: dict[str, str]
|
||||||
|
selected_tag_objects: dict[str, str]
|
||||||
|
issues: tuple[SourceTagProvenanceIssue, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_source_selection(payload: object) -> CatalogSourceSelection:
|
||||||
|
"""Extract all immutable Python source refs and the explicitly selected units."""
|
||||||
|
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return CatalogSourceSelection(
|
||||||
|
all_versions={},
|
||||||
|
selected_versions={},
|
||||||
|
selected_commits={},
|
||||||
|
selected_tag_objects={},
|
||||||
|
issues=(SourceTagProvenanceIssue("catalog", "", "catalog must be a JSON object"),),
|
||||||
|
)
|
||||||
|
|
||||||
|
versions: dict[str, str] = {}
|
||||||
|
issues: list[SourceTagProvenanceIssue] = []
|
||||||
|
entries: list[tuple[str, object]] = [("core_release", payload.get("core_release"))]
|
||||||
|
modules = payload.get("modules")
|
||||||
|
if isinstance(modules, list):
|
||||||
|
entries.extend((f"modules[{index}]", entry) for index, entry in enumerate(modules))
|
||||||
|
|
||||||
|
for source, raw_entry in entries:
|
||||||
|
if not isinstance(raw_entry, dict):
|
||||||
|
continue
|
||||||
|
python_ref = raw_entry.get("python_ref")
|
||||||
|
match = _CATALOG_PYTHON_REF.search(python_ref) if isinstance(python_ref, str) else None
|
||||||
|
if match is None:
|
||||||
|
continue # The catalog version-alignment gate reports malformed refs.
|
||||||
|
repo = match.group("repo")
|
||||||
|
version = match.group("version").removeprefix("v")
|
||||||
|
previous = versions.setdefault(repo, version)
|
||||||
|
if previous != version:
|
||||||
|
issues.append(
|
||||||
|
SourceTagProvenanceIssue(
|
||||||
|
repo,
|
||||||
|
f"v{version}",
|
||||||
|
f"{source} conflicts with the catalog's earlier v{previous} source ref",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
release = payload.get("release")
|
||||||
|
selected_units = release.get("selected_units") if isinstance(release, dict) else None
|
||||||
|
selected: dict[str, str] = {}
|
||||||
|
selected_commits: dict[str, str] = {}
|
||||||
|
selected_tag_objects: dict[str, str] = {}
|
||||||
|
if not isinstance(selected_units, list) or not selected_units:
|
||||||
|
issues.append(
|
||||||
|
SourceTagProvenanceIssue(
|
||||||
|
"catalog",
|
||||||
|
"",
|
||||||
|
"release.selected_units is missing or empty; selected source provenance cannot be established",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for unit in selected_units:
|
||||||
|
if not isinstance(unit, dict):
|
||||||
|
continue
|
||||||
|
repo = unit.get("repo")
|
||||||
|
version = unit.get("version")
|
||||||
|
if isinstance(repo, str) and isinstance(version, str):
|
||||||
|
selected[repo] = version.removeprefix("v")
|
||||||
|
commit = unit.get("commit_sha")
|
||||||
|
tag_object = unit.get("tag_object_sha")
|
||||||
|
if isinstance(commit, str) and re.fullmatch(r"[0-9a-fA-F]{40}|[0-9a-fA-F]{64}", commit):
|
||||||
|
selected_commits[repo] = commit.lower()
|
||||||
|
else:
|
||||||
|
issues.append(
|
||||||
|
SourceTagProvenanceIssue(repo, f"v{version.removeprefix('v')}", "selected unit has no valid commit_sha provenance")
|
||||||
|
)
|
||||||
|
if isinstance(tag_object, str) and re.fullmatch(r"[0-9a-fA-F]{40}|[0-9a-fA-F]{64}", tag_object):
|
||||||
|
selected_tag_objects[repo] = tag_object.lower()
|
||||||
|
else:
|
||||||
|
issues.append(
|
||||||
|
SourceTagProvenanceIssue(repo, f"v{version.removeprefix('v')}", "selected unit has no valid tag_object_sha provenance")
|
||||||
|
)
|
||||||
|
|
||||||
|
return CatalogSourceSelection(
|
||||||
|
all_versions=versions,
|
||||||
|
selected_versions=selected,
|
||||||
|
selected_commits=selected_commits,
|
||||||
|
selected_tag_objects=selected_tag_objects,
|
||||||
|
issues=tuple(issues),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def source_tag_provenance_issues(
|
||||||
|
*,
|
||||||
|
repo_versions: dict[str, str],
|
||||||
|
workspace: Path,
|
||||||
|
remote: str = "origin",
|
||||||
|
require_head_repos: Iterable[str] = (),
|
||||||
|
expected_commits: dict[str, str] | None = None,
|
||||||
|
expected_tag_objects: dict[str, str] | None = None,
|
||||||
|
) -> tuple[SourceTagProvenanceIssue, ...]:
|
||||||
|
"""Verify immutable local/remote tags and selected clean source HEADs.
|
||||||
|
|
||||||
|
Every requested ref must be an annotated tag in the local object store and
|
||||||
|
on the configured remote, and both tag objects must peel to the same commit.
|
||||||
|
Repositories in ``require_head_repos`` additionally have to be clean,
|
||||||
|
version-aligned worktrees whose requested tag peels to the current HEAD.
|
||||||
|
"""
|
||||||
|
|
||||||
|
remote = remote.strip() or "origin"
|
||||||
|
require_head = set(require_head_repos)
|
||||||
|
expected_commits = expected_commits or {}
|
||||||
|
expected_tag_objects = expected_tag_objects or {}
|
||||||
|
requested = sorted(repo_versions.items())
|
||||||
|
specs = {spec.name: spec for spec in load_repository_specs(include_website=False)}
|
||||||
|
issues: list[SourceTagProvenanceIssue] = []
|
||||||
|
|
||||||
|
for repo, requested_version in requested:
|
||||||
|
version = requested_version.removeprefix("v").strip()
|
||||||
|
tag = f"v{version}"
|
||||||
|
spec = specs.get(repo)
|
||||||
|
if spec is None:
|
||||||
|
issues.append(SourceTagProvenanceIssue(repo, tag, "repository is not registered in repositories.json"))
|
||||||
|
continue
|
||||||
|
if not _VERSION.fullmatch(version):
|
||||||
|
issues.append(SourceTagProvenanceIssue(repo, tag, "requested version is not a valid release version"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
path = resolve_repo_path(spec, workspace)
|
||||||
|
snapshot = collect_repository_snapshot(spec, workspace_root=workspace, target_tag=tag, online=False)
|
||||||
|
if not snapshot.exists or not snapshot.is_git:
|
||||||
|
issues.append(SourceTagProvenanceIssue(repo, tag, "local source repository is missing or is not a Git repository"))
|
||||||
|
continue
|
||||||
|
if snapshot.safe_directory_required:
|
||||||
|
issues.append(SourceTagProvenanceIssue(repo, tag, "Git safe.directory approval is required"))
|
||||||
|
continue
|
||||||
|
if not snapshot.has_head:
|
||||||
|
issues.append(SourceTagProvenanceIssue(repo, tag, "local source repository has no HEAD commit"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
head_commit = git_text(path, "rev-parse", "HEAD")
|
||||||
|
local_commit = ref_commit(path, f"refs/tags/{tag}")
|
||||||
|
local_object = git_text(path, "rev-parse", "--verify", f"refs/tags/{tag}") if local_commit else None
|
||||||
|
local_type = git_text(path, "cat-file", "-t", f"refs/tags/{tag}") if local_commit else None
|
||||||
|
if not local_commit:
|
||||||
|
issues.append(SourceTagProvenanceIssue(repo, tag, "annotated tag is missing from the local source repository"))
|
||||||
|
elif local_type != "tag":
|
||||||
|
issues.append(SourceTagProvenanceIssue(repo, tag, "local release tag is lightweight; an annotated tag is required"))
|
||||||
|
|
||||||
|
remote_url = git_text(path, "remote", "get-url", remote)
|
||||||
|
if remote_url == "":
|
||||||
|
issues.append(SourceTagProvenanceIssue(repo, tag, f"configured Git remote {remote!r} does not exist"))
|
||||||
|
remote_result = None
|
||||||
|
else:
|
||||||
|
remote_result = inspect_remote_tag(path=path, remote=remote, remote_url=remote_url, tag=tag)
|
||||||
|
if remote_result.error:
|
||||||
|
issues.append(SourceTagProvenanceIssue(repo, tag, remote_result.error))
|
||||||
|
elif not remote_result.commit:
|
||||||
|
issues.append(SourceTagProvenanceIssue(repo, tag, f"annotated tag is missing from remote {remote!r}"))
|
||||||
|
elif not remote_result.annotated:
|
||||||
|
issues.append(SourceTagProvenanceIssue(repo, tag, f"remote {remote!r} release tag is lightweight; an annotated tag is required"))
|
||||||
|
|
||||||
|
remote_commit = remote_result.commit if remote_result is not None and not remote_result.error else None
|
||||||
|
remote_annotated = remote_result.annotated if remote_result is not None else False
|
||||||
|
if local_commit and local_type == "tag" and remote_commit and remote_annotated and local_commit != remote_commit:
|
||||||
|
issues.append(
|
||||||
|
SourceTagProvenanceIssue(
|
||||||
|
repo,
|
||||||
|
tag,
|
||||||
|
f"local tag peels to {local_commit[:12]}, but remote {remote!r} peels to {remote_commit[:12]}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
local_object
|
||||||
|
and local_type == "tag"
|
||||||
|
and remote_result is not None
|
||||||
|
and remote_result.annotated
|
||||||
|
and remote_result.tag_object
|
||||||
|
and local_object != remote_result.tag_object
|
||||||
|
):
|
||||||
|
issues.append(
|
||||||
|
SourceTagProvenanceIssue(
|
||||||
|
repo,
|
||||||
|
tag,
|
||||||
|
"local and remote annotated tag objects differ; the release annotation provenance is inconsistent",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_commit = expected_commits.get(repo)
|
||||||
|
if expected_commit and local_commit and local_commit.lower() != expected_commit.lower():
|
||||||
|
issues.append(
|
||||||
|
SourceTagProvenanceIssue(
|
||||||
|
repo,
|
||||||
|
tag,
|
||||||
|
f"local tag no longer matches signed commit_sha {expected_commit}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
expected_object = expected_tag_objects.get(repo)
|
||||||
|
if expected_object and local_object and local_object.lower() != expected_object.lower():
|
||||||
|
issues.append(
|
||||||
|
SourceTagProvenanceIssue(
|
||||||
|
repo,
|
||||||
|
tag,
|
||||||
|
f"local tag no longer matches signed tag_object_sha {expected_object}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
issues.extend(tagged_source_version_issues(path=path, repo=repo, tag=tag, expected_version=version))
|
||||||
|
|
||||||
|
if repo in require_head:
|
||||||
|
if not snapshot.branch:
|
||||||
|
issues.append(SourceTagProvenanceIssue(repo, tag, "selected source HEAD is detached; a named branch is required"))
|
||||||
|
if snapshot.dirty_entries:
|
||||||
|
issues.append(SourceTagProvenanceIssue(repo, tag, "selected source worktree is not clean"))
|
||||||
|
if snapshot.behind:
|
||||||
|
issues.append(SourceTagProvenanceIssue(repo, tag, f"selected source branch is behind {snapshot.upstream}"))
|
||||||
|
for issue in repository_version_issues(path, expected_version=version):
|
||||||
|
issues.append(
|
||||||
|
SourceTagProvenanceIssue(
|
||||||
|
repo,
|
||||||
|
tag,
|
||||||
|
f"selected source version alignment failed: {issue.source}={issue.actual!r}, expected {issue.expected!r}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if local_commit and local_commit != head_commit:
|
||||||
|
issues.append(
|
||||||
|
SourceTagProvenanceIssue(
|
||||||
|
repo,
|
||||||
|
tag,
|
||||||
|
f"local tag peels to {local_commit[:12]}, not selected HEAD {head_commit[:12]}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if remote_commit and remote_commit != head_commit:
|
||||||
|
issues.append(
|
||||||
|
SourceTagProvenanceIssue(
|
||||||
|
repo,
|
||||||
|
tag,
|
||||||
|
f"remote tag peels to {remote_commit[:12]}, not selected HEAD {head_commit[:12]}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return tuple(_deduplicate(issues))
|
||||||
|
|
||||||
|
|
||||||
|
def selected_source_provenance(
|
||||||
|
*,
|
||||||
|
repo_versions: dict[str, str],
|
||||||
|
workspace: Path,
|
||||||
|
) -> dict[str, dict[str, str]]:
|
||||||
|
"""Return immutable source object identifiers after the provenance gate passes."""
|
||||||
|
|
||||||
|
specs = {spec.name: spec for spec in load_repository_specs(include_website=False)}
|
||||||
|
result: dict[str, dict[str, str]] = {}
|
||||||
|
for repo, requested_version in sorted(repo_versions.items()):
|
||||||
|
spec = specs[repo]
|
||||||
|
path = resolve_repo_path(spec, workspace)
|
||||||
|
tag = f"v{requested_version.removeprefix('v')}"
|
||||||
|
result[repo] = {
|
||||||
|
"commit_sha": git_text(path, "rev-parse", "--verify", f"refs/tags/{tag}^{{commit}}"),
|
||||||
|
"tag_object_sha": git_text(path, "rev-parse", "--verify", f"refs/tags/{tag}"),
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_remote_tag(*, path: Path, remote: str, remote_url: str, tag: str) -> RemoteTagResult:
|
||||||
|
"""Inspect refs over same-host HTTPS when possible, then fall back to the configured remote."""
|
||||||
|
|
||||||
|
environment = {
|
||||||
|
**os.environ,
|
||||||
|
"GIT_TERMINAL_PROMPT": "0",
|
||||||
|
"GIT_SSH_COMMAND": os.environ.get(
|
||||||
|
"GIT_SSH_COMMAND",
|
||||||
|
"ssh -o BatchMode=yes -o ConnectTimeout=8",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
inspection_remote = read_only_https_remote(remote_url)
|
||||||
|
if inspection_remote:
|
||||||
|
result = remote_tag_commit(
|
||||||
|
path,
|
||||||
|
remote=inspection_remote,
|
||||||
|
tag=tag,
|
||||||
|
timeout=5,
|
||||||
|
env=environment,
|
||||||
|
)
|
||||||
|
if not result.error:
|
||||||
|
return result
|
||||||
|
return remote_tag_commit(
|
||||||
|
path,
|
||||||
|
remote=remote,
|
||||||
|
tag=tag,
|
||||||
|
timeout=12,
|
||||||
|
env=environment,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def read_only_https_remote(remote_url: str) -> str | None:
|
||||||
|
"""Derive the equivalent same-host HTTPS Git URL for a conventional SSH remote."""
|
||||||
|
|
||||||
|
value = remote_url.strip()
|
||||||
|
if value.startswith(("https://", "http://")):
|
||||||
|
return value
|
||||||
|
if value.startswith("ssh://"):
|
||||||
|
parsed = urlsplit(value)
|
||||||
|
if parsed.hostname and parsed.port in {None, 22} and parsed.path:
|
||||||
|
return f"https://{parsed.hostname}{parsed.path}"
|
||||||
|
return None
|
||||||
|
match = re.fullmatch(r"(?:[^@/:]+@)?([^/:]+):(.+)", value)
|
||||||
|
if match:
|
||||||
|
return f"https://{match.group(1)}/{match.group(2).lstrip('/')}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def tagged_source_version_issues(
|
||||||
|
*,
|
||||||
|
path: Path,
|
||||||
|
repo: str,
|
||||||
|
tag: str,
|
||||||
|
expected_version: str,
|
||||||
|
) -> tuple[SourceTagProvenanceIssue, ...]:
|
||||||
|
"""Check version-bearing files from the tag without changing the worktree."""
|
||||||
|
|
||||||
|
if not ref_commit(path, f"refs/tags/{tag}"):
|
||||||
|
return ()
|
||||||
|
tree = _git(path, "ls-tree", "-r", "--name-only", tag)
|
||||||
|
if tree.returncode != 0:
|
||||||
|
return (SourceTagProvenanceIssue(repo, tag, "could not inspect the local tag's source tree"),)
|
||||||
|
files = set(tree.stdout.splitlines())
|
||||||
|
declarations: list[tuple[str, str | None]] = []
|
||||||
|
|
||||||
|
pyproject = _git_file(path, tag, "pyproject.toml")
|
||||||
|
if pyproject is None:
|
||||||
|
return (SourceTagProvenanceIssue(repo, tag, "tagged source has no pyproject.toml package metadata"),)
|
||||||
|
try:
|
||||||
|
parsed = tomllib.loads(pyproject)
|
||||||
|
project = parsed.get("project")
|
||||||
|
declarations.append(("pyproject.toml", project.get("version") if isinstance(project, dict) else None))
|
||||||
|
except tomllib.TOMLDecodeError:
|
||||||
|
declarations.append(("pyproject.toml", None))
|
||||||
|
|
||||||
|
for name in ("package.json", "webui/package.json", "webui/package.release.json"):
|
||||||
|
if name not in files:
|
||||||
|
continue
|
||||||
|
declarations.append((name, _json_version(_git_file(path, tag, name))))
|
||||||
|
|
||||||
|
for name in sorted(files):
|
||||||
|
if name.startswith("src/") and name.endswith("/backend/manifest.py"):
|
||||||
|
text = _git_file(path, tag, name) or ""
|
||||||
|
match = re.search(r'(?m)^\s*(?:version|MODULE_VERSION)\s*=\s*["\']([^"\']+)["\']', text)
|
||||||
|
if match:
|
||||||
|
declarations.append((name, match.group(1)))
|
||||||
|
|
||||||
|
return tuple(
|
||||||
|
SourceTagProvenanceIssue(
|
||||||
|
repo,
|
||||||
|
tag,
|
||||||
|
f"tagged {source} version is {actual!r}, expected {expected_version!r}",
|
||||||
|
)
|
||||||
|
for source, actual in declarations
|
||||||
|
if actual != expected_version
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_version(value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = json.loads(value)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
version = payload.get("version") if isinstance(payload, dict) else None
|
||||||
|
return version if isinstance(version, str) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _git_file(path: Path, tag: str, name: str) -> str | None:
|
||||||
|
result = _git(path, "show", f"{tag}:{name}")
|
||||||
|
return result.stdout if result.returncode == 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _git(path: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||||
|
try:
|
||||||
|
return subprocess.run(
|
||||||
|
("git", *args),
|
||||||
|
cwd=path,
|
||||||
|
check=False,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
return subprocess.CompletedProcess(("git", *args), 124, exc.stdout or "", exc.stderr or "Git command timed out")
|
||||||
|
|
||||||
|
|
||||||
|
def _deduplicate(issues: list[SourceTagProvenanceIssue]) -> list[SourceTagProvenanceIssue]:
|
||||||
|
result: list[SourceTagProvenanceIssue] = []
|
||||||
|
seen: set[tuple[str, str, str]] = set()
|
||||||
|
for issue in issues:
|
||||||
|
key = (issue.repo, issue.tag, issue.message)
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
result.append(issue)
|
||||||
|
return result
|
||||||
767
tools/release/govoplan_release/version_alignment.py
Normal file
767
tools/release/govoplan_release/version_alignment.py
Normal file
@@ -0,0 +1,767 @@
|
|||||||
|
"""Version-alignment checks for independently released GovOPlaN packages."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
from .git_state import collect_versions
|
||||||
|
from .workspace import load_repository_specs, resolve_repo_path
|
||||||
|
|
||||||
|
|
||||||
|
_PYTHON_RELEASE_REF = re.compile(
|
||||||
|
r"^(?P<package>govoplan-[a-z0-9-]+)\s+@\s+git\+ssh://.+/"
|
||||||
|
r"(?P<repo>govoplan-[a-z0-9-]+)\.git@v(?P<version>[^\s;]+)$"
|
||||||
|
)
|
||||||
|
_WEBUI_RELEASE_REF = re.compile(r"/(?P<repo>govoplan-[a-z0-9-]+)\.git#v(?P<version>[^#\s]+)$")
|
||||||
|
_CATALOG_PYTHON_REF = re.compile(r"/(?P<repo>govoplan-[a-z0-9-]+)\.git@v(?P<version>[^\s;]+)$")
|
||||||
|
_CATALOG_WEBUI_REF = re.compile(r"/(?P<repo>govoplan-[a-z0-9-]+)\.git#v(?P<version>[^#\s]+)$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class VersionAlignmentIssue:
|
||||||
|
repo: str
|
||||||
|
source: str
|
||||||
|
expected: str
|
||||||
|
actual: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
def repository_version_issues(
|
||||||
|
repo_path: Path,
|
||||||
|
*,
|
||||||
|
expected_version: str | None = None,
|
||||||
|
include_lockfiles: bool = True,
|
||||||
|
) -> tuple[VersionAlignmentIssue, ...]:
|
||||||
|
"""Compare every version-bearing file in one source repository."""
|
||||||
|
|
||||||
|
versions = collect_versions(repo_path)
|
||||||
|
declared = {
|
||||||
|
"pyproject.toml": versions.pyproject,
|
||||||
|
"package.json": versions.package,
|
||||||
|
"webui/package.json": versions.webui_package,
|
||||||
|
"webui/package.release.json": _json_version(repo_path / "webui" / "package.release.json")
|
||||||
|
if (repo_path / "webui" / "package.release.json").exists()
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
for index, version in enumerate(versions.manifests, start=1):
|
||||||
|
declared[f"module manifest {index}"] = version
|
||||||
|
for index, version in enumerate(versions.package_inits, start=1):
|
||||||
|
declared[f"package __init__.py {index}"] = version
|
||||||
|
|
||||||
|
canonical_source, canonical_version = next(
|
||||||
|
((source, version) for source, version in declared.items() if version),
|
||||||
|
(None, None),
|
||||||
|
)
|
||||||
|
repo = repo_path.name
|
||||||
|
if canonical_source is None or canonical_version is None:
|
||||||
|
if expected_version is None:
|
||||||
|
return ()
|
||||||
|
return (
|
||||||
|
VersionAlignmentIssue(
|
||||||
|
repo=repo,
|
||||||
|
source="repository",
|
||||||
|
expected=expected_version.removeprefix("v"),
|
||||||
|
actual="",
|
||||||
|
message="repository has no version metadata",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
issues = [
|
||||||
|
VersionAlignmentIssue(
|
||||||
|
repo=repo,
|
||||||
|
source=source,
|
||||||
|
expected=canonical_version,
|
||||||
|
actual=version,
|
||||||
|
message=f"{source} must match {canonical_source}",
|
||||||
|
)
|
||||||
|
for source, version in declared.items()
|
||||||
|
if version is not None and version != canonical_version
|
||||||
|
]
|
||||||
|
|
||||||
|
if expected_version is not None and canonical_version.removeprefix("v") != expected_version.removeprefix("v"):
|
||||||
|
issues.append(
|
||||||
|
VersionAlignmentIssue(
|
||||||
|
repo=repo,
|
||||||
|
source=canonical_source,
|
||||||
|
expected=expected_version.removeprefix("v"),
|
||||||
|
actual=canonical_version,
|
||||||
|
message="source version must match the requested release version",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if include_lockfiles:
|
||||||
|
issues.extend(
|
||||||
|
_lockfile_issues(
|
||||||
|
repo=repo,
|
||||||
|
package_path=repo_path / "package.json",
|
||||||
|
lock_path=repo_path / "package-lock.json",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
issues.extend(
|
||||||
|
_lockfile_issues(
|
||||||
|
repo=repo,
|
||||||
|
package_path=repo_path / "webui" / "package.json",
|
||||||
|
lock_path=repo_path / "webui" / "package-lock.json",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
issues.extend(
|
||||||
|
_lockfile_issues(
|
||||||
|
repo=repo,
|
||||||
|
package_path=repo_path / "webui" / "package.release.json",
|
||||||
|
lock_path=repo_path / "webui" / "package-lock.release.json",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(issues)
|
||||||
|
|
||||||
|
|
||||||
|
def selected_repository_version_issues(
|
||||||
|
*,
|
||||||
|
repo_versions: dict[str, str],
|
||||||
|
workspace: Path,
|
||||||
|
include_lockfiles: bool = True,
|
||||||
|
) -> tuple[VersionAlignmentIssue, ...]:
|
||||||
|
"""Validate registered selected worktrees against explicit release versions."""
|
||||||
|
|
||||||
|
specs = {spec.name: spec for spec in load_repository_specs(include_website=False)}
|
||||||
|
issues: list[VersionAlignmentIssue] = []
|
||||||
|
for repo, expected_version in sorted(repo_versions.items()):
|
||||||
|
spec = specs.get(repo)
|
||||||
|
if spec is None:
|
||||||
|
issues.append(
|
||||||
|
VersionAlignmentIssue(
|
||||||
|
repo=repo,
|
||||||
|
source="repository",
|
||||||
|
expected=expected_version.removeprefix("v"),
|
||||||
|
actual="",
|
||||||
|
message="repository is not registered",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
repo_path = resolve_repo_path(spec, workspace)
|
||||||
|
if not repo_path.exists():
|
||||||
|
issues.append(
|
||||||
|
VersionAlignmentIssue(
|
||||||
|
repo=repo,
|
||||||
|
source="repository",
|
||||||
|
expected=expected_version.removeprefix("v"),
|
||||||
|
actual="",
|
||||||
|
message="repository path is missing",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
issues.extend(
|
||||||
|
repository_version_issues(
|
||||||
|
repo_path,
|
||||||
|
expected_version=expected_version,
|
||||||
|
include_lockfiles=include_lockfiles,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(issues)
|
||||||
|
|
||||||
|
|
||||||
|
def selected_release_webui_bundle_issues(
|
||||||
|
*,
|
||||||
|
repo_versions: dict[str, str],
|
||||||
|
workspace: Path,
|
||||||
|
) -> tuple[VersionAlignmentIssue, ...]:
|
||||||
|
"""Require selected WebUI modules to match Core's pinned release bundle.
|
||||||
|
|
||||||
|
Selective releases leave unselected module pins untouched. A selected module
|
||||||
|
that exposes a WebUI package, however, must be the version that Core's
|
||||||
|
immutable release package input and lockfile will actually install.
|
||||||
|
"""
|
||||||
|
|
||||||
|
core_webui = workspace / "govoplan-core" / "webui"
|
||||||
|
release_package_path = core_webui / "package.release.json"
|
||||||
|
release_lock_path = core_webui / "package-lock.release.json"
|
||||||
|
release_package = _json_object(release_package_path) if release_package_path.exists() else {}
|
||||||
|
release_dependencies = release_package.get("dependencies")
|
||||||
|
if not isinstance(release_dependencies, dict):
|
||||||
|
release_dependencies = {}
|
||||||
|
|
||||||
|
release_lock = _json_object(release_lock_path) if release_lock_path.exists() else {}
|
||||||
|
lock_packages = release_lock.get("packages")
|
||||||
|
if not isinstance(lock_packages, dict):
|
||||||
|
lock_packages = {}
|
||||||
|
lock_root = lock_packages.get("")
|
||||||
|
lock_root_dependencies = lock_root.get("dependencies") if isinstance(lock_root, dict) else None
|
||||||
|
if not isinstance(lock_root_dependencies, dict):
|
||||||
|
lock_root_dependencies = {}
|
||||||
|
|
||||||
|
issues: list[VersionAlignmentIssue] = []
|
||||||
|
for repo, requested_version in sorted(repo_versions.items()):
|
||||||
|
if repo == "govoplan-core":
|
||||||
|
continue
|
||||||
|
module_package_path = workspace / repo / "webui" / "package.json"
|
||||||
|
if not module_package_path.exists():
|
||||||
|
continue
|
||||||
|
module_package = _json_object(module_package_path)
|
||||||
|
package_name = module_package.get("name")
|
||||||
|
if not isinstance(package_name, str) or not package_name:
|
||||||
|
issues.append(
|
||||||
|
VersionAlignmentIssue(
|
||||||
|
repo=repo,
|
||||||
|
source="webui/package.json:name",
|
||||||
|
expected="WebUI package name",
|
||||||
|
actual="",
|
||||||
|
message="selected WebUI module must declare its package name",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
version = requested_version.removeprefix("v")
|
||||||
|
release_ref = release_dependencies.get(package_name)
|
||||||
|
release_match = _CATALOG_WEBUI_REF.search(release_ref) if isinstance(release_ref, str) else None
|
||||||
|
actual_release = (
|
||||||
|
f"{release_match.group('repo')}@v{release_match.group('version').removeprefix('v')}"
|
||||||
|
if release_match is not None
|
||||||
|
else str(release_ref or "")
|
||||||
|
)
|
||||||
|
expected_release = f"{repo}@v{version}"
|
||||||
|
if (
|
||||||
|
release_match is None
|
||||||
|
or release_match.group("repo") != repo
|
||||||
|
or release_match.group("version").removeprefix("v") != version
|
||||||
|
):
|
||||||
|
issues.append(
|
||||||
|
VersionAlignmentIssue(
|
||||||
|
repo=repo,
|
||||||
|
source=f"govoplan-core/webui/package.release.json:dependencies.{package_name}",
|
||||||
|
expected=expected_release,
|
||||||
|
actual=actual_release,
|
||||||
|
message="Core release WebUI dependency must match the selected module version",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
locked_root_ref = lock_root_dependencies.get(package_name)
|
||||||
|
if locked_root_ref != release_ref:
|
||||||
|
issues.append(
|
||||||
|
VersionAlignmentIssue(
|
||||||
|
repo=repo,
|
||||||
|
source=f"govoplan-core/webui/package-lock.release.json:dependencies.{package_name}",
|
||||||
|
expected=str(release_ref or ""),
|
||||||
|
actual=str(locked_root_ref or ""),
|
||||||
|
message="Core release lock root dependency must match package.release.json",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
locked_package = lock_packages.get(f"node_modules/{package_name}")
|
||||||
|
locked_version = locked_package.get("version") if isinstance(locked_package, dict) else None
|
||||||
|
if locked_version != version:
|
||||||
|
issues.append(
|
||||||
|
VersionAlignmentIssue(
|
||||||
|
repo=repo,
|
||||||
|
source=f"govoplan-core/webui/package-lock.release.json:{package_name}",
|
||||||
|
expected=version,
|
||||||
|
actual=str(locked_version or ""),
|
||||||
|
message="Core release lock must resolve the selected module version",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_commit = _git_tag_commit(workspace / repo, f"v{version}")
|
||||||
|
resolved = locked_package.get("resolved") if isinstance(locked_package, dict) else None
|
||||||
|
resolved_commit = resolved.rsplit("#", 1)[-1] if isinstance(resolved, str) and "#" in resolved else None
|
||||||
|
if expected_commit is not None and resolved_commit != expected_commit:
|
||||||
|
issues.append(
|
||||||
|
VersionAlignmentIssue(
|
||||||
|
repo=repo,
|
||||||
|
source=f"govoplan-core/webui/package-lock.release.json:{package_name}:resolved",
|
||||||
|
expected=expected_commit,
|
||||||
|
actual=resolved_commit or "",
|
||||||
|
message="Core release lock commit must match the selected module tag",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(issues)
|
||||||
|
|
||||||
|
|
||||||
|
def release_composition_issues(meta_root: Path, *, core_root: Path) -> tuple[VersionAlignmentIssue, ...]:
|
||||||
|
"""Require release references to agree and point at installable artifacts."""
|
||||||
|
|
||||||
|
python_refs = _python_release_refs(meta_root / "requirements-release.txt")
|
||||||
|
webui_refs = _webui_release_refs(core_root / "webui" / "package.release.json")
|
||||||
|
issues: list[VersionAlignmentIssue] = []
|
||||||
|
|
||||||
|
for repo in sorted(set(python_refs) & set(webui_refs)):
|
||||||
|
python_version = python_refs[repo]
|
||||||
|
webui_version = webui_refs[repo]
|
||||||
|
if python_version == webui_version:
|
||||||
|
continue
|
||||||
|
issues.append(
|
||||||
|
VersionAlignmentIssue(
|
||||||
|
repo=repo,
|
||||||
|
source="webui/package.release.json",
|
||||||
|
expected=python_version,
|
||||||
|
actual=webui_version,
|
||||||
|
message="frontend release ref must match the backend release ref",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
workspace = meta_root.parent
|
||||||
|
for repo, version in sorted(python_refs.items()):
|
||||||
|
repo_path = workspace / repo
|
||||||
|
if repo_path.exists():
|
||||||
|
issues.extend(
|
||||||
|
_release_artifact_issues(
|
||||||
|
repo=repo,
|
||||||
|
repo_path=repo_path,
|
||||||
|
version=version,
|
||||||
|
artifact="pyproject.toml",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for repo, version in sorted(webui_refs.items()):
|
||||||
|
repo_path = workspace / repo
|
||||||
|
if repo_path.exists():
|
||||||
|
issues.extend(
|
||||||
|
_release_artifact_issues(
|
||||||
|
repo=repo,
|
||||||
|
repo_path=repo_path,
|
||||||
|
version=version,
|
||||||
|
artifact="webui/package.json",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(issues)
|
||||||
|
|
||||||
|
|
||||||
|
def _release_artifact_issues(
|
||||||
|
*,
|
||||||
|
repo: str,
|
||||||
|
repo_path: Path,
|
||||||
|
version: str,
|
||||||
|
artifact: str,
|
||||||
|
) -> list[VersionAlignmentIssue]:
|
||||||
|
tag = f"v{version.removeprefix('v')}"
|
||||||
|
payload = _git_tag_file(repo_path, tag, artifact)
|
||||||
|
if payload is None:
|
||||||
|
return [
|
||||||
|
VersionAlignmentIssue(
|
||||||
|
repo=repo,
|
||||||
|
source=f"{tag}:{artifact}",
|
||||||
|
expected="installable package metadata",
|
||||||
|
actual="missing or unreadable",
|
||||||
|
message="release tag must contain its declared package artifact",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
if artifact == "pyproject.toml":
|
||||||
|
parsed = tomllib.loads(payload)
|
||||||
|
project = parsed.get("project")
|
||||||
|
artifact_version = project.get("version") if isinstance(project, dict) else None
|
||||||
|
else:
|
||||||
|
parsed = json.loads(payload)
|
||||||
|
artifact_version = parsed.get("version") if isinstance(parsed, dict) else None
|
||||||
|
except (json.JSONDecodeError, tomllib.TOMLDecodeError):
|
||||||
|
artifact_version = None
|
||||||
|
if artifact_version == version.removeprefix("v"):
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
VersionAlignmentIssue(
|
||||||
|
repo=repo,
|
||||||
|
source=f"{tag}:{artifact}",
|
||||||
|
expected=version.removeprefix("v"),
|
||||||
|
actual=str(artifact_version or ""),
|
||||||
|
message="release artifact version must match its immutable tag",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def candidate_catalog_version_issues(payload: object) -> tuple[VersionAlignmentIssue, ...]:
|
||||||
|
"""Validate versions, refs, and selected-unit provenance inside a catalog."""
|
||||||
|
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return (
|
||||||
|
VersionAlignmentIssue(
|
||||||
|
repo="catalog",
|
||||||
|
source="catalog",
|
||||||
|
expected="object",
|
||||||
|
actual=type(payload).__name__,
|
||||||
|
message="candidate catalog must be a JSON object",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
issues: list[VersionAlignmentIssue] = []
|
||||||
|
represented: dict[str, str] = {}
|
||||||
|
core_release = payload.get("core_release")
|
||||||
|
if isinstance(core_release, dict):
|
||||||
|
issues.extend(_catalog_entry_issues(core_release, source="core_release", represented=represented))
|
||||||
|
else:
|
||||||
|
issues.append(_catalog_shape_issue("core_release", "candidate catalog has no core release"))
|
||||||
|
|
||||||
|
modules = payload.get("modules")
|
||||||
|
if not isinstance(modules, list):
|
||||||
|
issues.append(_catalog_shape_issue("modules", "candidate catalog has no modules list"))
|
||||||
|
else:
|
||||||
|
for index, entry in enumerate(modules):
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
issues.append(_catalog_shape_issue(f"modules[{index}]", "module entry must be an object"))
|
||||||
|
continue
|
||||||
|
issues.extend(
|
||||||
|
_catalog_entry_issues(
|
||||||
|
entry,
|
||||||
|
source=f"modules[{index}]",
|
||||||
|
represented=represented,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
release = payload.get("release")
|
||||||
|
if isinstance(release, dict):
|
||||||
|
issues.extend(_catalog_release_issues(release, represented=represented))
|
||||||
|
return tuple(issues)
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_entry_issues(
|
||||||
|
entry: dict[str, object],
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
represented: dict[str, str],
|
||||||
|
) -> list[VersionAlignmentIssue]:
|
||||||
|
issues: list[VersionAlignmentIssue] = []
|
||||||
|
version = entry.get("version")
|
||||||
|
normalized_version = version.removeprefix("v") if isinstance(version, str) and version else None
|
||||||
|
if normalized_version is None:
|
||||||
|
issues.append(_catalog_shape_issue(f"{source}.version", "catalog entry has no version"))
|
||||||
|
|
||||||
|
python_ref = entry.get("python_ref")
|
||||||
|
python_match = _CATALOG_PYTHON_REF.search(python_ref) if isinstance(python_ref, str) else None
|
||||||
|
if python_match is None:
|
||||||
|
issues.append(_catalog_shape_issue(f"{source}.python_ref", "Python ref must end in a version tag"))
|
||||||
|
repo = str(entry.get("python_package") or entry.get("module_id") or source)
|
||||||
|
else:
|
||||||
|
repo = python_match.group("repo")
|
||||||
|
ref_version = python_match.group("version").removeprefix("v")
|
||||||
|
if normalized_version is not None and ref_version != normalized_version:
|
||||||
|
issues.append(
|
||||||
|
VersionAlignmentIssue(
|
||||||
|
repo=repo,
|
||||||
|
source=f"{source}.python_ref",
|
||||||
|
expected=normalized_version,
|
||||||
|
actual=ref_version,
|
||||||
|
message="Python ref tag must match the catalog entry version",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if normalized_version is not None:
|
||||||
|
previous = represented.setdefault(repo, normalized_version)
|
||||||
|
if previous != normalized_version:
|
||||||
|
issues.append(
|
||||||
|
VersionAlignmentIssue(
|
||||||
|
repo=repo,
|
||||||
|
source=source,
|
||||||
|
expected=previous,
|
||||||
|
actual=normalized_version,
|
||||||
|
message="repository appears with conflicting catalog versions",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
webui_package = entry.get("webui_package")
|
||||||
|
webui_ref = entry.get("webui_ref")
|
||||||
|
if bool(webui_package) != bool(webui_ref):
|
||||||
|
issues.append(_catalog_shape_issue(f"{source}.webui_ref", "WebUI package and ref must be declared together"))
|
||||||
|
if isinstance(webui_ref, str):
|
||||||
|
webui_match = _CATALOG_WEBUI_REF.search(webui_ref)
|
||||||
|
if webui_match is None:
|
||||||
|
issues.append(_catalog_shape_issue(f"{source}.webui_ref", "WebUI ref must end in a version tag"))
|
||||||
|
else:
|
||||||
|
webui_repo = webui_match.group("repo")
|
||||||
|
webui_version = webui_match.group("version").removeprefix("v")
|
||||||
|
if python_match is not None and webui_repo != repo:
|
||||||
|
issues.append(
|
||||||
|
VersionAlignmentIssue(
|
||||||
|
repo=repo,
|
||||||
|
source=f"{source}.webui_ref",
|
||||||
|
expected=repo,
|
||||||
|
actual=webui_repo,
|
||||||
|
message="backend and WebUI refs must use the same repository",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if normalized_version is not None and webui_version != normalized_version:
|
||||||
|
issues.append(
|
||||||
|
VersionAlignmentIssue(
|
||||||
|
repo=repo,
|
||||||
|
source=f"{source}.webui_ref",
|
||||||
|
expected=normalized_version,
|
||||||
|
actual=webui_version,
|
||||||
|
message="WebUI ref tag must match the catalog entry version",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_release_issues(
|
||||||
|
release: dict[str, object],
|
||||||
|
*,
|
||||||
|
represented: dict[str, str],
|
||||||
|
) -> list[VersionAlignmentIssue]:
|
||||||
|
issues: list[VersionAlignmentIssue] = []
|
||||||
|
version = release.get("version")
|
||||||
|
tag = release.get("tag")
|
||||||
|
if isinstance(version, str) and isinstance(tag, str) and tag != f"v{version.removeprefix('v')}":
|
||||||
|
issues.append(
|
||||||
|
VersionAlignmentIssue(
|
||||||
|
repo="govoplan-core",
|
||||||
|
source="release.tag",
|
||||||
|
expected=f"v{version.removeprefix('v')}",
|
||||||
|
actual=tag,
|
||||||
|
message="release tag must match release version",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
selected_units = release.get("selected_units")
|
||||||
|
if selected_units is None:
|
||||||
|
return issues
|
||||||
|
if not isinstance(selected_units, list):
|
||||||
|
issues.append(_catalog_shape_issue("release.selected_units", "selected units must be a list"))
|
||||||
|
return issues
|
||||||
|
seen: set[str] = set()
|
||||||
|
for index, unit in enumerate(selected_units):
|
||||||
|
if not isinstance(unit, dict):
|
||||||
|
issues.append(_catalog_shape_issue(f"release.selected_units[{index}]", "selected unit must be an object"))
|
||||||
|
continue
|
||||||
|
repo = unit.get("repo")
|
||||||
|
unit_version = unit.get("version")
|
||||||
|
unit_tag = unit.get("tag")
|
||||||
|
if not isinstance(repo, str) or not isinstance(unit_version, str) or not isinstance(unit_tag, str):
|
||||||
|
issues.append(_catalog_shape_issue(f"release.selected_units[{index}]", "selected unit requires repo, version, and tag"))
|
||||||
|
continue
|
||||||
|
normalized_version = unit_version.removeprefix("v")
|
||||||
|
if repo in seen:
|
||||||
|
issues.append(_catalog_shape_issue(f"release.selected_units[{index}].repo", "selected repository is duplicated"))
|
||||||
|
seen.add(repo)
|
||||||
|
if unit_tag != f"v{normalized_version}":
|
||||||
|
issues.append(
|
||||||
|
VersionAlignmentIssue(
|
||||||
|
repo=repo,
|
||||||
|
source=f"release.selected_units[{index}].tag",
|
||||||
|
expected=f"v{normalized_version}",
|
||||||
|
actual=unit_tag,
|
||||||
|
message="selected-unit tag must match its version",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
represented_version = represented.get(repo)
|
||||||
|
if represented_version is None:
|
||||||
|
issues.append(
|
||||||
|
VersionAlignmentIssue(
|
||||||
|
repo=repo,
|
||||||
|
source=f"release.selected_units[{index}]",
|
||||||
|
expected=normalized_version,
|
||||||
|
actual="",
|
||||||
|
message="selected repository is not represented in the catalog",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif represented_version != normalized_version:
|
||||||
|
issues.append(
|
||||||
|
VersionAlignmentIssue(
|
||||||
|
repo=repo,
|
||||||
|
source=f"release.selected_units[{index}].version",
|
||||||
|
expected=represented_version,
|
||||||
|
actual=normalized_version,
|
||||||
|
message="selected-unit version must match the catalog entry",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_shape_issue(source: str, message: str) -> VersionAlignmentIssue:
|
||||||
|
return VersionAlignmentIssue(
|
||||||
|
repo="catalog",
|
||||||
|
source=source,
|
||||||
|
expected="valid value",
|
||||||
|
actual="",
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _lockfile_issues(*, repo: str, package_path: Path, lock_path: Path) -> list[VersionAlignmentIssue]:
|
||||||
|
if not package_path.exists() or not lock_path.exists():
|
||||||
|
return []
|
||||||
|
package_payload = _json_object(package_path)
|
||||||
|
lock_payload = _json_object(lock_path)
|
||||||
|
package_version = _payload_version(package_payload)
|
||||||
|
lock_version = _lockfile_root_version(lock_path)
|
||||||
|
issues: list[VersionAlignmentIssue] = []
|
||||||
|
if package_version is None or lock_version is None or package_version == lock_version:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
issues.append(
|
||||||
|
VersionAlignmentIssue(
|
||||||
|
repo=repo,
|
||||||
|
source=str(lock_path.relative_to(package_path.parents[1] if package_path.parent.name == "webui" else package_path.parent)),
|
||||||
|
expected=package_version,
|
||||||
|
actual=lock_version,
|
||||||
|
message=f"lockfile root version must match {package_path.name}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
lock_packages = lock_payload.get("packages")
|
||||||
|
lock_root = lock_packages.get("") if isinstance(lock_packages, dict) else None
|
||||||
|
if isinstance(lock_root, dict):
|
||||||
|
for group in ("dependencies", "devDependencies", "optionalDependencies", "peerDependencies"):
|
||||||
|
package_dependencies = package_payload.get(group) or {}
|
||||||
|
lock_dependencies = lock_root.get(group) or {}
|
||||||
|
if package_dependencies != lock_dependencies:
|
||||||
|
issues.append(
|
||||||
|
VersionAlignmentIssue(
|
||||||
|
repo=repo,
|
||||||
|
source=f"{lock_path.name}:{group}",
|
||||||
|
expected=json.dumps(package_dependencies, sort_keys=True),
|
||||||
|
actual=json.dumps(lock_dependencies, sort_keys=True),
|
||||||
|
message=f"lockfile root {group} must match {package_path.name}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
issues.extend(
|
||||||
|
_git_lock_resolution_issues(
|
||||||
|
repo=repo,
|
||||||
|
package_payload=package_payload,
|
||||||
|
lock_packages=lock_packages,
|
||||||
|
workspace=package_path.parents[2] if package_path.parent.name == "webui" else package_path.parent.parent,
|
||||||
|
lock_name=lock_path.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def _git_lock_resolution_issues(
|
||||||
|
*,
|
||||||
|
repo: str,
|
||||||
|
package_payload: dict[str, object],
|
||||||
|
lock_packages: dict[str, object],
|
||||||
|
workspace: Path,
|
||||||
|
lock_name: str,
|
||||||
|
) -> list[VersionAlignmentIssue]:
|
||||||
|
dependencies = package_payload.get("dependencies")
|
||||||
|
if not isinstance(dependencies, dict):
|
||||||
|
return []
|
||||||
|
issues: list[VersionAlignmentIssue] = []
|
||||||
|
for package_name, spec in dependencies.items():
|
||||||
|
if not isinstance(package_name, str) or not isinstance(spec, str):
|
||||||
|
continue
|
||||||
|
match = _CATALOG_WEBUI_REF.search(spec)
|
||||||
|
if match is None:
|
||||||
|
continue
|
||||||
|
source_repo = match.group("repo")
|
||||||
|
version = match.group("version").removeprefix("v")
|
||||||
|
locked = lock_packages.get(f"node_modules/{package_name}")
|
||||||
|
if not isinstance(locked, dict):
|
||||||
|
issues.append(
|
||||||
|
VersionAlignmentIssue(
|
||||||
|
repo=repo,
|
||||||
|
source=f"{lock_name}:{package_name}",
|
||||||
|
expected=version,
|
||||||
|
actual="",
|
||||||
|
message="release lock has no resolved GovOPlaN package",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
locked_version = locked.get("version")
|
||||||
|
if locked_version != version:
|
||||||
|
issues.append(
|
||||||
|
VersionAlignmentIssue(
|
||||||
|
repo=repo,
|
||||||
|
source=f"{lock_name}:{package_name}",
|
||||||
|
expected=version,
|
||||||
|
actual=str(locked_version or ""),
|
||||||
|
message="resolved package version must match its release ref",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
expected_commit = _git_tag_commit(workspace / source_repo, f"v{version}")
|
||||||
|
resolved = locked.get("resolved")
|
||||||
|
resolved_commit = resolved.rsplit("#", 1)[-1] if isinstance(resolved, str) and "#" in resolved else None
|
||||||
|
if expected_commit is None or resolved_commit != expected_commit:
|
||||||
|
issues.append(
|
||||||
|
VersionAlignmentIssue(
|
||||||
|
repo=repo,
|
||||||
|
source=f"{lock_name}:{package_name}:resolved",
|
||||||
|
expected=expected_commit or f"local tag v{version}",
|
||||||
|
actual=resolved_commit or "",
|
||||||
|
message="resolved package commit must match the local release tag",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def _git_tag_commit(repo_path: Path, tag: str) -> str | None:
|
||||||
|
if not (repo_path / ".git").exists():
|
||||||
|
return None
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", str(repo_path), "rev-list", "-n", "1", tag],
|
||||||
|
check=False,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
value = result.stdout.strip()
|
||||||
|
return value if result.returncode == 0 and value else None
|
||||||
|
|
||||||
|
|
||||||
|
def _git_tag_file(repo_path: Path, tag: str, relative_path: str) -> str | None:
|
||||||
|
if not (repo_path / ".git").exists():
|
||||||
|
return None
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", str(repo_path), "show", f"{tag}:{relative_path}"],
|
||||||
|
check=False,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
return result.stdout if result.returncode == 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _json_version(path: Path) -> str | None:
|
||||||
|
value = _payload_version(_json_object(path))
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _payload_version(payload: dict[str, object]) -> str | None:
|
||||||
|
value = payload.get("version")
|
||||||
|
return value if isinstance(value, str) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _json_object(path: Path) -> dict[str, object]:
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
return payload if isinstance(payload, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _lockfile_root_version(path: Path) -> str | None:
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
packages = payload.get("packages")
|
||||||
|
if isinstance(packages, dict):
|
||||||
|
root = packages.get("")
|
||||||
|
if isinstance(root, dict) and isinstance(root.get("version"), str):
|
||||||
|
return root["version"]
|
||||||
|
value = payload.get("version")
|
||||||
|
return value if isinstance(value, str) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _python_release_refs(path: Path) -> dict[str, str]:
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
refs: dict[str, str] = {}
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
match = _PYTHON_RELEASE_REF.match(line.strip())
|
||||||
|
if match is None:
|
||||||
|
continue
|
||||||
|
repo = match.group("repo")
|
||||||
|
refs[repo] = match.group("version")
|
||||||
|
return refs
|
||||||
|
|
||||||
|
|
||||||
|
def _webui_release_refs(path: Path) -> dict[str, str]:
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
dependencies = payload.get("dependencies") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(dependencies, dict):
|
||||||
|
return {}
|
||||||
|
refs: dict[str, str] = {}
|
||||||
|
for spec in dependencies.values():
|
||||||
|
if not isinstance(spec, str):
|
||||||
|
continue
|
||||||
|
match = _WEBUI_RELEASE_REF.search(spec)
|
||||||
|
if match is not None:
|
||||||
|
refs[match.group("repo")] = match.group("version")
|
||||||
|
return refs
|
||||||
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"
|
||||||
@@ -194,6 +194,7 @@ run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
GEN_ARGS=(
|
GEN_ARGS=(
|
||||||
|
env "GOVOPLAN_CORE_ROOT=$CORE_ROOT"
|
||||||
"$PYTHON" "$META_ROOT/tools/release/generate-release-catalog.py"
|
"$PYTHON" "$META_ROOT/tools/release/generate-release-catalog.py"
|
||||||
--version "$VERSION"
|
--version "$VERSION"
|
||||||
--channel "$CHANNEL"
|
--channel "$CHANNEL"
|
||||||
|
|||||||
@@ -52,28 +52,10 @@ Options:
|
|||||||
-h, --help Show this help.
|
-h, --help Show this help.
|
||||||
|
|
||||||
Repos:
|
Repos:
|
||||||
Installable release packages:
|
Core, every registered release/module repository listed below in this script,
|
||||||
govoplan-access
|
and the meta repository. Repositories with pyproject.toml are versioned and
|
||||||
govoplan-admin
|
alignment-gated dynamically; remaining roadmap/scaffold repositories are
|
||||||
govoplan-tenancy
|
committed and tagged without inventing package metadata.
|
||||||
govoplan-organizations
|
|
||||||
govoplan-identity
|
|
||||||
govoplan-idm
|
|
||||||
govoplan-policy
|
|
||||||
govoplan-audit
|
|
||||||
govoplan-dashboard
|
|
||||||
govoplan-files
|
|
||||||
govoplan-mail
|
|
||||||
govoplan-campaign
|
|
||||||
govoplan-calendar
|
|
||||||
govoplan-ops
|
|
||||||
govoplan-core
|
|
||||||
|
|
||||||
Roadmap/scaffold module repositories are tag-only until they have package
|
|
||||||
metadata: addresses, appointments, cases, connectors, dms, erp,
|
|
||||||
fit-connect, forms, identity-trust, ledger, notifications, payments,
|
|
||||||
portal, reporting, scheduling, search, tasks, templates, workflow, xoev,
|
|
||||||
xrechnung, and xta-osci.
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
tools/release/push-release-tag.sh
|
tools/release/push-release-tag.sh
|
||||||
@@ -121,12 +103,14 @@ PACKAGE_MODULE_REPOS=(
|
|||||||
"$PARENT/govoplan-organizations"
|
"$PARENT/govoplan-organizations"
|
||||||
"$PARENT/govoplan-permits"
|
"$PARENT/govoplan-permits"
|
||||||
"$PARENT/govoplan-policy"
|
"$PARENT/govoplan-policy"
|
||||||
|
"$PARENT/govoplan-poll"
|
||||||
"$PARENT/govoplan-procurement"
|
"$PARENT/govoplan-procurement"
|
||||||
"$PARENT/govoplan-records"
|
"$PARENT/govoplan-records"
|
||||||
"$PARENT/govoplan-resources"
|
"$PARENT/govoplan-resources"
|
||||||
"$PARENT/govoplan-risk-compliance"
|
"$PARENT/govoplan-risk-compliance"
|
||||||
"$PARENT/govoplan-tenancy"
|
"$PARENT/govoplan-tenancy"
|
||||||
"$PARENT/govoplan-transparency"
|
"$PARENT/govoplan-transparency"
|
||||||
|
"$PARENT/govoplan-evaluation"
|
||||||
)
|
)
|
||||||
TAG_ONLY_MODULE_REPOS=(
|
TAG_ONLY_MODULE_REPOS=(
|
||||||
"$PARENT/govoplan-addresses"
|
"$PARENT/govoplan-addresses"
|
||||||
@@ -134,6 +118,7 @@ TAG_ONLY_MODULE_REPOS=(
|
|||||||
"$PARENT/govoplan-cases"
|
"$PARENT/govoplan-cases"
|
||||||
"$PARENT/govoplan-connectors"
|
"$PARENT/govoplan-connectors"
|
||||||
"$PARENT/govoplan-dms"
|
"$PARENT/govoplan-dms"
|
||||||
|
"$PARENT/govoplan-dist-lists"
|
||||||
"$PARENT/govoplan-erp"
|
"$PARENT/govoplan-erp"
|
||||||
"$PARENT/govoplan-fit-connect"
|
"$PARENT/govoplan-fit-connect"
|
||||||
"$PARENT/govoplan-forms"
|
"$PARENT/govoplan-forms"
|
||||||
@@ -144,8 +129,10 @@ TAG_ONLY_MODULE_REPOS=(
|
|||||||
"$PARENT/govoplan-portal"
|
"$PARENT/govoplan-portal"
|
||||||
"$PARENT/govoplan-postbox"
|
"$PARENT/govoplan-postbox"
|
||||||
"$PARENT/govoplan-reporting"
|
"$PARENT/govoplan-reporting"
|
||||||
|
"$PARENT/govoplan-rest"
|
||||||
"$PARENT/govoplan-scheduling"
|
"$PARENT/govoplan-scheduling"
|
||||||
"$PARENT/govoplan-search"
|
"$PARENT/govoplan-search"
|
||||||
|
"$PARENT/govoplan-soap"
|
||||||
"$PARENT/govoplan-tasks"
|
"$PARENT/govoplan-tasks"
|
||||||
"$PARENT/govoplan-templates"
|
"$PARENT/govoplan-templates"
|
||||||
"$PARENT/govoplan-workflow"
|
"$PARENT/govoplan-workflow"
|
||||||
@@ -154,10 +141,12 @@ TAG_ONLY_MODULE_REPOS=(
|
|||||||
"$PARENT/govoplan-xta-osci"
|
"$PARENT/govoplan-xta-osci"
|
||||||
)
|
)
|
||||||
MODULE_REPOS=("${PACKAGE_MODULE_REPOS[@]}" "${TAG_ONLY_MODULE_REPOS[@]}")
|
MODULE_REPOS=("${PACKAGE_MODULE_REPOS[@]}" "${TAG_ONLY_MODULE_REPOS[@]}")
|
||||||
PACKAGE_REPOS=("${PACKAGE_MODULE_REPOS[@]}" "$ROOT")
|
SUPPORT_REPOS=("$META_ROOT")
|
||||||
|
PACKAGE_REPOS=()
|
||||||
REPOS=(
|
REPOS=(
|
||||||
"${MODULE_REPOS[@]}"
|
"${MODULE_REPOS[@]}"
|
||||||
"$ROOT"
|
"$ROOT"
|
||||||
|
"${SUPPORT_REPOS[@]}"
|
||||||
)
|
)
|
||||||
|
|
||||||
REMOTE="origin"
|
REMOTE="origin"
|
||||||
@@ -379,6 +368,7 @@ if project_name != "govoplan-core":
|
|||||||
peers["@govoplan/core-webui"] = f"^{new_version}"
|
peers["@govoplan/core-webui"] = f"^{new_version}"
|
||||||
path.write_text(json.dumps(data, indent=2) + "\n")
|
path.write_text(json.dumps(data, indent=2) + "\n")
|
||||||
PYCODE
|
PYCODE
|
||||||
|
synchronize_lockfile_root "$package_path" "${package_path%package.json}package-lock.json"
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ "$project_name" == "govoplan-core" && -f "$release_package_path" ]]; then
|
if [[ "$project_name" == "govoplan-core" && -f "$release_package_path" ]]; then
|
||||||
@@ -401,9 +391,59 @@ for name, spec in list(dependencies.items()):
|
|||||||
dependencies[name] = re.sub(r"#v\d+\.\d+\.\d+$", f"#v{new_version}", spec)
|
dependencies[name] = re.sub(r"#v\d+\.\d+\.\d+$", f"#v{new_version}", spec)
|
||||||
path.write_text(json.dumps(data, indent=2) + "\n")
|
path.write_text(json.dumps(data, indent=2) + "\n")
|
||||||
PYCODE
|
PYCODE
|
||||||
|
synchronize_lockfile_root "$release_package_path" "$repo/webui/package-lock.release.json"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
synchronize_lockfile_root() {
|
||||||
|
local package_path="$1"
|
||||||
|
local lock_path="$2"
|
||||||
|
[[ -f "$package_path" && -f "$lock_path" ]] || return 0
|
||||||
|
"$PYTHON" - "$package_path" "$lock_path" <<'PYCODE'
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
package_path = Path(sys.argv[1])
|
||||||
|
lock_path = Path(sys.argv[2])
|
||||||
|
package = json.loads(package_path.read_text())
|
||||||
|
lock = json.loads(lock_path.read_text())
|
||||||
|
version = package.get("version")
|
||||||
|
if not isinstance(version, str) or not version:
|
||||||
|
raise SystemExit(f"package version is missing from {package_path}")
|
||||||
|
lock["version"] = version
|
||||||
|
packages = lock.get("packages")
|
||||||
|
if isinstance(packages, dict) and isinstance(packages.get(""), dict):
|
||||||
|
packages[""]["version"] = version
|
||||||
|
lock_path.write_text(json.dumps(lock, indent=2) + "\n")
|
||||||
|
PYCODE
|
||||||
|
}
|
||||||
|
|
||||||
|
update_release_requirements() {
|
||||||
|
local version="$1"
|
||||||
|
"$PYTHON" - "$META_ROOT/requirements-release.txt" "$version" <<'PYCODE'
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
path = Path(sys.argv[1])
|
||||||
|
version = sys.argv[2]
|
||||||
|
text = path.read_text()
|
||||||
|
updated, count = re.subn(
|
||||||
|
r"(?m)^(govoplan-[a-z0-9-]+\s+@\s+git\+ssh://[^\s]+\.git)@v[^\s]+$",
|
||||||
|
rf"\1@v{version}",
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
if count == 0:
|
||||||
|
raise SystemExit(f"no tagged GovOPlaN requirements found in {path}")
|
||||||
|
path.write_text(updated)
|
||||||
|
PYCODE
|
||||||
|
}
|
||||||
|
|
||||||
update_version_files() {
|
update_version_files() {
|
||||||
local repo="$1"
|
local repo="$1"
|
||||||
local version="$2"
|
local version="$2"
|
||||||
@@ -427,7 +467,37 @@ generate_release_lock() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
[[ -x "$META_ROOT/tools/release/generate-release-lock.sh" ]] || fail "missing executable release lock generator: $META_ROOT/tools/release/generate-release-lock.sh"
|
[[ -x "$META_ROOT/tools/release/generate-release-lock.sh" ]] || fail "missing executable release lock generator: $META_ROOT/tools/release/generate-release-lock.sh"
|
||||||
run "$META_ROOT/tools/release/generate-release-lock.sh" --core-root "$ROOT" --npm "$NPM_BIN"
|
local command=("$META_ROOT/tools/release/generate-release-lock.sh" --core-root "$ROOT" --npm "$NPM_BIN")
|
||||||
|
local repo
|
||||||
|
for repo in "${MODULE_REPOS[@]}"; do
|
||||||
|
command+=(--local-git-repo "$repo")
|
||||||
|
done
|
||||||
|
run "${command[@]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_version_alignment_gate() {
|
||||||
|
local mode="${1:-full}"
|
||||||
|
local command=(
|
||||||
|
"$PYTHON"
|
||||||
|
"$META_ROOT/tools/checks/check-version-alignment.py"
|
||||||
|
--workspace-root "$PARENT"
|
||||||
|
--release-composition
|
||||||
|
)
|
||||||
|
if [[ "$mode" == "source" ]]; then
|
||||||
|
command+=(--source-metadata-only)
|
||||||
|
fi
|
||||||
|
local repo
|
||||||
|
for repo in "${PACKAGE_REPOS[@]}"; do
|
||||||
|
command+=(--repo-version "$(basename "$repo")=$TARGET_VERSION")
|
||||||
|
done
|
||||||
|
run "${command[@]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_manifest_shape_gate() {
|
||||||
|
run env PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
"$PYTHON" \
|
||||||
|
"$META_ROOT/tools/checks/check-manifest-shapes.py" \
|
||||||
|
--workspace-root "$PARENT"
|
||||||
}
|
}
|
||||||
|
|
||||||
run_migration_release_audit() {
|
run_migration_release_audit() {
|
||||||
@@ -694,6 +764,15 @@ for repo in "${REPOS[@]}"; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
EFFECTIVE_TAG_ONLY_REPOS=()
|
||||||
|
for repo in "${REPOS[@]}"; do
|
||||||
|
if [[ "${REPO_KINDS[$repo]}" == "package" ]]; then
|
||||||
|
PACKAGE_REPOS+=("$repo")
|
||||||
|
else
|
||||||
|
EFFECTIVE_TAG_ONLY_REPOS+=("$repo")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
if [[ -z "$TARGET_VERSION" ]]; then
|
if [[ -z "$TARGET_VERSION" ]]; then
|
||||||
BASE_VERSION="${OLD_VERSIONS[$ROOT]}"
|
BASE_VERSION="${OLD_VERSIONS[$ROOT]}"
|
||||||
for repo in "${PACKAGE_REPOS[@]}"; do
|
for repo in "${PACKAGE_REPOS[@]}"; do
|
||||||
@@ -744,9 +823,9 @@ echo " tag: $TAG"
|
|||||||
echo " remote: $REMOTE"
|
echo " remote: $REMOTE"
|
||||||
echo " commit message: $COMMIT_MESSAGE"
|
echo " commit message: $COMMIT_MESSAGE"
|
||||||
echo " package repos: ${#PACKAGE_REPOS[@]} versioned"
|
echo " package repos: ${#PACKAGE_REPOS[@]} versioned"
|
||||||
echo " tag-only module repos: ${#TAG_ONLY_MODULE_REPOS[@]} committed/tagged/pushed without package version files"
|
echo " tag-only/support repos: ${#EFFECTIVE_TAG_ONLY_REPOS[@]} committed/tagged/pushed without package version files"
|
||||||
if [[ "$GENERATE_RELEASE_LOCK" -eq 1 ]]; then
|
if [[ "$GENERATE_RELEASE_LOCK" -eq 1 ]]; then
|
||||||
echo " release lock: regenerate core webui/package-lock.release.json after module tags are pushed"
|
echo " release lock: resolve core webui/package-lock.release.json from local reviewed module tags"
|
||||||
else
|
else
|
||||||
echo " release lock: skipped"
|
echo " release lock: skipped"
|
||||||
fi
|
fi
|
||||||
@@ -769,6 +848,7 @@ for repo in "${REPOS[@]}"; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
run_migration_release_audit
|
run_migration_release_audit
|
||||||
|
run_manifest_shape_gate
|
||||||
|
|
||||||
confirm_release
|
confirm_release
|
||||||
|
|
||||||
@@ -780,9 +860,21 @@ for repo in "${PACKAGE_REPOS[@]}"; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
if [[ "$DRY_RUN" -eq 1 ]]; then
|
||||||
|
echo "Would update $META_ROOT/requirements-release.txt to $TAG"
|
||||||
|
else
|
||||||
|
update_release_requirements "$TARGET_VERSION"
|
||||||
|
fi
|
||||||
|
|
||||||
refresh_development_webui_lock
|
refresh_development_webui_lock
|
||||||
|
|
||||||
for repo in "${MODULE_REPOS[@]}"; do
|
# Source metadata, release refs, and lock roots must agree before creating any
|
||||||
|
# release commit or tag. A second gate below verifies the fully resolved release
|
||||||
|
# lock before any remote mutation.
|
||||||
|
run_version_alignment_gate source
|
||||||
|
|
||||||
|
PRE_CORE_REPOS=("${MODULE_REPOS[@]}" "${SUPPORT_REPOS[@]}")
|
||||||
|
for repo in "${PRE_CORE_REPOS[@]}"; do
|
||||||
run git -C "$repo" add -A
|
run git -C "$repo" add -A
|
||||||
|
|
||||||
if [[ "$DRY_RUN" -eq 0 ]]; then
|
if [[ "$DRY_RUN" -eq 0 ]]; then
|
||||||
@@ -806,12 +898,13 @@ for repo in "${MODULE_REPOS[@]}"; do
|
|||||||
run git -C "$repo" tag -a "$TAG" -m "$TAG_MESSAGE"
|
run git -C "$repo" tag -a "$TAG" -m "$TAG_MESSAGE"
|
||||||
done
|
done
|
||||||
|
|
||||||
for repo in "${MODULE_REPOS[@]}"; do
|
|
||||||
run git -C "$repo" push --atomic "$REMOTE" "HEAD:refs/heads/${BRANCHES[$repo]}" "refs/tags/$TAG"
|
|
||||||
done
|
|
||||||
|
|
||||||
generate_release_lock
|
generate_release_lock
|
||||||
|
|
||||||
|
# npm resolves the new module tags from the local repositories. Nothing has
|
||||||
|
# been pushed yet; a failed lock or alignment check leaves only local,
|
||||||
|
# recoverable commits/tags.
|
||||||
|
run_version_alignment_gate
|
||||||
|
|
||||||
run git -C "$ROOT" add -A
|
run git -C "$ROOT" add -A
|
||||||
|
|
||||||
if [[ "$DRY_RUN" -eq 0 ]]; then
|
if [[ "$DRY_RUN" -eq 0 ]]; then
|
||||||
@@ -822,6 +915,10 @@ fi
|
|||||||
|
|
||||||
run git -C "$ROOT" commit -m "$COMMIT_MESSAGE"
|
run git -C "$ROOT" commit -m "$COMMIT_MESSAGE"
|
||||||
run git -C "$ROOT" tag -a "$TAG" -m "$TAG_MESSAGE"
|
run git -C "$ROOT" tag -a "$TAG" -m "$TAG_MESSAGE"
|
||||||
|
|
||||||
|
for repo in "${PRE_CORE_REPOS[@]}"; do
|
||||||
|
run git -C "$repo" push --atomic "$REMOTE" "HEAD:refs/heads/${BRANCHES[$repo]}" "refs/tags/$TAG"
|
||||||
|
done
|
||||||
run git -C "$ROOT" push --atomic "$REMOTE" "HEAD:refs/heads/${BRANCHES[$ROOT]}" "refs/tags/$TAG"
|
run git -C "$ROOT" push --atomic "$REMOTE" "HEAD:refs/heads/${BRANCHES[$ROOT]}" "refs/tags/$TAG"
|
||||||
|
|
||||||
if [[ "$PUBLISH_WEB_CATALOG" -eq 1 ]]; then
|
if [[ "$PUBLISH_WEB_CATALOG" -eq 1 ]]; then
|
||||||
|
|||||||
218
tools/release/release-catalog.py
Normal file
218
tools/release/release-catalog.py
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
#!/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("--source-remote", default="origin", help="Configured Git remote containing immutable source tags.")
|
||||||
|
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("--source-remote", default="origin", help="Configured Git remote containing catalog source tags.")
|
||||||
|
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,
|
||||||
|
source_remote=args.source_remote,
|
||||||
|
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,
|
||||||
|
source_remote=args.source_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())
|
||||||
47
tools/release/release-console.py
Normal file
47
tools/release/release-console.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
#!/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 fragments are never sent in HTTP requests. The WebUI transfers
|
||||||
|
# this one-time bootstrap token to sessionStorage and clears the hash.
|
||||||
|
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,9 +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.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()
|
||||||
@@ -32,6 +31,7 @@ class SuggestedCommand:
|
|||||||
title: str
|
title: str
|
||||||
command: str
|
command: str
|
||||||
cwd: str
|
cwd: str
|
||||||
|
argv: tuple[str, ...] = ()
|
||||||
mutating: bool = False
|
mutating: bool = False
|
||||||
reason: str = ""
|
reason: str = ""
|
||||||
|
|
||||||
@@ -381,7 +381,7 @@ def repo_findings(repos: tuple[RepoState, ...], *, target_version: str | None, t
|
|||||||
findings.append(
|
findings.append(
|
||||||
Finding(
|
Finding(
|
||||||
check_id="repo-version-mismatch",
|
check_id="repo-version-mismatch",
|
||||||
severity="warning",
|
severity="blocker",
|
||||||
title=f"{repo.name} has inconsistent local version files",
|
title=f"{repo.name} has inconsistent local version files",
|
||||||
detail=", ".join(
|
detail=", ".join(
|
||||||
value
|
value
|
||||||
@@ -897,7 +897,12 @@ def run_suggested_command(item: SuggestedCommand, *, assume_yes: bool = False) -
|
|||||||
if confirmation not in {"y", "yes"}:
|
if confirmation not in {"y", "yes"}:
|
||||||
print("Skipped.")
|
print("Skipped.")
|
||||||
return None
|
return None
|
||||||
result = subprocess.run(item.command, shell=True, cwd=item.cwd) # noqa: S602 - only user-confirmed doctor commands are executed.
|
try:
|
||||||
|
argv = command_argv(item)
|
||||||
|
except ValueError as exc:
|
||||||
|
print(f"Cannot run command safely: {exc}")
|
||||||
|
return 2
|
||||||
|
result = subprocess.run(argv, cwd=item.cwd, check=False)
|
||||||
print(f"Command exited with {result.returncode}.")
|
print(f"Command exited with {result.returncode}.")
|
||||||
return result.returncode
|
return result.returncode
|
||||||
|
|
||||||
@@ -950,7 +955,7 @@ def run_safe_directory_workflow(report: DoctorReport) -> None:
|
|||||||
return
|
return
|
||||||
for repo in repos:
|
for repo in repos:
|
||||||
assert repo.safe_directory_command is not None
|
assert repo.safe_directory_command is not None
|
||||||
result = subprocess.run(repo.safe_directory_command, shell=True, cwd=ROOT) # noqa: S602 - generated git config command, user-confirmed.
|
result = subprocess.run(safe_directory_argv(Path(repo.path)), cwd=ROOT, check=False)
|
||||||
print(f"{repo.name}: command exited with {result.returncode}.")
|
print(f"{repo.name}: command exited with {result.returncode}.")
|
||||||
|
|
||||||
|
|
||||||
@@ -1020,9 +1025,8 @@ def commit_and_push_dirty_repositories(report: DoctorReport) -> None:
|
|||||||
|
|
||||||
def page_text(text: str) -> None:
|
def page_text(text: str) -> None:
|
||||||
if sys.stdout.isatty():
|
if sys.stdout.isatty():
|
||||||
pager = os.environ.get("PAGER", "less -R")
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(pager, input=text, shell=True, text=True, check=False) # noqa: S602 - user-configured pager.
|
result = subprocess.run(["less", "-R"], input=text, text=True, check=False)
|
||||||
if result.returncode != 127:
|
if result.returncode != 127:
|
||||||
return
|
return
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -1034,28 +1038,61 @@ def report_to_dict(report: DoctorReport) -> dict[str, Any]:
|
|||||||
return asdict(report)
|
return asdict(report)
|
||||||
|
|
||||||
|
|
||||||
def command(cwd: Path, title: str, value: str, *, mutating: bool = False, reason: str = "") -> SuggestedCommand:
|
def command(
|
||||||
return SuggestedCommand(title=title, command=value, cwd=str(cwd), mutating=mutating, reason=reason)
|
cwd: Path,
|
||||||
|
title: str,
|
||||||
|
value: str,
|
||||||
|
*,
|
||||||
|
mutating: bool = False,
|
||||||
|
reason: str = "",
|
||||||
|
argv: tuple[str, ...] = (),
|
||||||
|
) -> SuggestedCommand:
|
||||||
|
return SuggestedCommand(title=title, command=value, cwd=str(cwd), argv=argv, mutating=mutating, reason=reason)
|
||||||
|
|
||||||
|
|
||||||
|
def command_argv(item: SuggestedCommand) -> tuple[str, ...]:
|
||||||
|
if item.argv:
|
||||||
|
return item.argv
|
||||||
|
argv = tuple(shlex.split(item.command))
|
||||||
|
if not argv:
|
||||||
|
raise ValueError("empty command")
|
||||||
|
if any(_looks_like_shell_syntax(part) for part in argv):
|
||||||
|
raise ValueError("command contains shell syntax and needs an explicit argv definition")
|
||||||
|
return argv
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_shell_syntax(value: str) -> bool:
|
||||||
|
return any(marker in value for marker in ("|", "&&", "||", ";", "$(", "`", ">", "<"))
|
||||||
|
|
||||||
|
|
||||||
def release_tag_command(report: DoctorReport, *, dry_run: bool) -> SuggestedCommand:
|
def release_tag_command(report: DoctorReport, *, dry_run: bool) -> SuggestedCommand:
|
||||||
assert report.target_version is not None
|
assert report.target_version is not None
|
||||||
suffix = " --dry-run" if dry_run else ""
|
suffix = " --dry-run" if dry_run else ""
|
||||||
|
argv = (
|
||||||
|
"tools/release/push-release-tag.sh",
|
||||||
|
"--version",
|
||||||
|
report.target_version,
|
||||||
|
"--strict-migration-audit",
|
||||||
|
) + (("--dry-run",) if dry_run else ())
|
||||||
return command(
|
return command(
|
||||||
META_ROOT,
|
META_ROOT,
|
||||||
"Preview release tagging workflow" if dry_run else "Run release tagging workflow",
|
"Preview release tagging workflow" if dry_run else "Run release tagging workflow",
|
||||||
f"tools/release/push-release-tag.sh --version {shlex.quote(report.target_version)} --strict-migration-audit{suffix}",
|
f"tools/release/push-release-tag.sh --version {shlex.quote(report.target_version)} --strict-migration-audit{suffix}",
|
||||||
mutating=not dry_run,
|
mutating=not dry_run,
|
||||||
|
argv=argv,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def migration_audit_command(track: str, *, strict: bool = False) -> SuggestedCommand:
|
def migration_audit_command(track: str, *, strict: bool = False) -> SuggestedCommand:
|
||||||
extra = " --strict" if strict else ""
|
extra = " --strict" if strict else ""
|
||||||
return command(META_ROOT, f"Run {track} migration audit", f"{python_bin()} tools/release/release-migration-audit.py --track {track}{extra}")
|
argv = (python_bin_path(), "tools/release/release-migration-audit.py", "--track", track) + (("--strict",) if strict else ())
|
||||||
|
return command(META_ROOT, f"Run {track} migration audit", f"{python_bin()} tools/release/release-migration-audit.py --track {track}{extra}", argv=argv)
|
||||||
|
|
||||||
|
|
||||||
def publish_catalog_command(target_version: str | None) -> SuggestedCommand:
|
def publish_catalog_command(target_version: str | None) -> SuggestedCommand:
|
||||||
version = shlex.quote(target_version or "<x.y.z>")
|
version = shlex.quote(target_version or "<x.y.z>")
|
||||||
|
display_version = target_version or "<x.y.z>"
|
||||||
|
key_dir = Path.home() / ".config" / "govoplan" / "release-keys"
|
||||||
return command(
|
return command(
|
||||||
META_ROOT,
|
META_ROOT,
|
||||||
"Publish signed release catalog",
|
"Publish signed release catalog",
|
||||||
@@ -1063,12 +1100,24 @@ def publish_catalog_command(target_version: str | None) -> SuggestedCommand:
|
|||||||
f"tools/release/publish-release-catalog.sh --version {version} "
|
f"tools/release/publish-release-catalog.sh --version {version} "
|
||||||
"--catalog-signing-key \"release-key-1=$KEY_DIR/release-key-1.pem\" --build-web",
|
"--catalog-signing-key \"release-key-1=$KEY_DIR/release-key-1.pem\" --build-web",
|
||||||
mutating=True,
|
mutating=True,
|
||||||
|
argv=(
|
||||||
|
"tools/release/publish-release-catalog.sh",
|
||||||
|
"--version",
|
||||||
|
display_version,
|
||||||
|
"--catalog-signing-key",
|
||||||
|
f"release-key-1={key_dir / 'release-key-1.pem'}",
|
||||||
|
"--build-web",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def python_bin() -> str:
|
def python_bin() -> str:
|
||||||
|
return shlex.quote(python_bin_path())
|
||||||
|
|
||||||
|
|
||||||
|
def python_bin_path() -> str:
|
||||||
local = META_ROOT / ".venv" / "bin" / "python"
|
local = META_ROOT / ".venv" / "bin" / "python"
|
||||||
return shlex.quote(str(local if local.exists() else Path(sys.executable)))
|
return str(local if local.exists() else Path(sys.executable))
|
||||||
|
|
||||||
|
|
||||||
def normalize_version(value: str) -> str:
|
def normalize_version(value: str) -> str:
|
||||||
@@ -1152,12 +1201,16 @@ def safe_directory_command(path: Path) -> str:
|
|||||||
return "git config --global --add safe.directory " + shlex.quote(str(path.resolve()))
|
return "git config --global --add safe.directory " + shlex.quote(str(path.resolve()))
|
||||||
|
|
||||||
|
|
||||||
|
def safe_directory_argv(path: Path) -> list[str]:
|
||||||
|
return ["git", "config", "--global", "--add", "safe.directory", str(path.resolve())]
|
||||||
|
|
||||||
|
|
||||||
def check_url(url: str) -> dict[str, Any]:
|
def check_url(url: str) -> dict[str, Any]:
|
||||||
request = Request(url, method="HEAD")
|
|
||||||
try:
|
try:
|
||||||
with urlopen(request, timeout=8) as response: # noqa: S310 - operator-requested release availability check.
|
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."""
|
||||||
369
tools/release/server/app.py
Normal file
369
tools/release/server/app.py
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
"""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,
|
||||||
|
tag_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"
|
||||||
|
source_remote: str = "origin"
|
||||||
|
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"
|
||||||
|
source_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 = ""
|
||||||
|
|
||||||
|
|
||||||
|
class RepositoryTagRequest(BaseModel):
|
||||||
|
repos: list[str] = Field(default_factory=list)
|
||||||
|
repo_versions: dict[str, str] = Field(default_factory=dict)
|
||||||
|
remote: str = "origin"
|
||||||
|
message: str | None = None
|
||||||
|
apply: bool = False # noqa: A003 - API field mirrors CLI.
|
||||||
|
push: bool = False
|
||||||
|
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")
|
||||||
|
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")
|
||||||
|
try:
|
||||||
|
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,
|
||||||
|
source_remote=request.source_remote,
|
||||||
|
expires_days=request.expires_days,
|
||||||
|
sequence=request.sequence,
|
||||||
|
check_public=request.check_public,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
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,
|
||||||
|
source_remote=request.source_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)
|
||||||
|
|
||||||
|
@app.post("/api/repositories/tag")
|
||||||
|
def repository_tag(request: RepositoryTagRequest) -> 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.push and request.confirm != "PUBLISH":
|
||||||
|
raise HTTPException(status_code=400, detail="Release tag publication requires confirm=PUBLISH")
|
||||||
|
if request.apply and not request.push and request.confirm != "TAG":
|
||||||
|
raise HTTPException(status_code=400, detail="Release tag creation requires confirm=TAG")
|
||||||
|
result = tag_repositories(
|
||||||
|
repos=repos,
|
||||||
|
repo_versions=request.repo_versions,
|
||||||
|
message=request.message,
|
||||||
|
workspace_root=app.state.workspace_root,
|
||||||
|
remote=request.remote,
|
||||||
|
apply=request.apply,
|
||||||
|
push=request.push,
|
||||||
|
)
|
||||||
|
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
|
||||||
1884
tools/release/webui/index.html
Normal file
1884
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())
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user