Compare commits

...

18 Commits

Author SHA1 Message Date
17f8036bdc Harden release console token bootstrap
Some checks failed
Dependency Audit / dependency-audit (push) Failing after 14s
Security Audit / security-audit (push) Failing after 13s
2026-07-21 03:16:57 +02:00
fcc5885dcf Validate manifest module imports 2026-07-21 03:16:57 +02:00
0b1506f860 Cover live worktrees in security audits 2026-07-21 03:16:57 +02:00
295b49bb04 docs: add connected governance platform roadmap
Some checks failed
Dependency Audit / dependency-audit (push) Failing after 13s
Security Audit / security-audit (push) Failing after 13s
2026-07-20 20:48:41 +02:00
65cc099839 chore(meta): register current workspace modules
Some checks failed
Dependency Audit / dependency-audit (push) Failing after 15s
Security Audit / security-audit (push) Failing after 12s
2026-07-20 20:01:35 +02:00
1ce3d02239 fix(dev): make browser launch opt in 2026-07-20 20:01:27 +02:00
9805d085fa test(release): keep release tooling coverage with its owner 2026-07-20 20:01:19 +02:00
2e21397868 chore(checks): discover workspace module boundaries 2026-07-20 20:01:14 +02:00
4b217c7aba docs(ui): require central component reuse 2026-07-20 17:56:14 +02:00
8b80afbd60 feat(calendar): run scheduled outbox recovery
Some checks failed
Dependency Audit / dependency-audit (push) Failing after 18s
Security Audit / security-audit (push) Failing after 13s
2026-07-20 17:05:17 +02:00
8255665349 chore(gitea): add the user-story issue type 2026-07-20 17:03:23 +02:00
2fc3b66c07 docs: add capability and infrastructure fit assessment 2026-07-20 16:50:06 +02:00
9e7103f613 docs: define the interface pattern language 2026-07-20 16:49:30 +02:00
05ae81d641 intermediate commit
Some checks failed
Security Audit / security-audit (push) Failing after 11s
Security Audit Toolbox Update / toolbox-update (push) Failing after 4s
Dependency Audit / dependency-audit (push) Failing after 17s
2026-07-14 13:32:09 +02:00
dd796b4d3c Register poll and evaluation repositories
Some checks failed
Dependency Audit / dependency-audit (push) Failing after 15s
Security Audit / security-audit (push) Failing after 12s
2026-07-12 09:23:18 +02:00
27ea3c82d4 Reuse Gitea API connections in maintenance scripts
Some checks failed
Dependency Audit / dependency-audit (push) Failing after 25s
Security Audit / security-audit (push) Failing after 22s
2026-07-12 00:18:40 +02:00
342582645b Harden audit tooling and release helpers
Some checks failed
Dependency Audit / dependency-audit (push) Failing after 1m14s
Security Audit / security-audit (push) Failing after 31s
2026-07-11 18:49:36 +02:00
54ab1945ff Update release refs for v0.1.8 2026-07-11 17:22:53 +02:00
74 changed files with 12180 additions and 202 deletions

View File

@@ -12,7 +12,8 @@ ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,aud
CELERY_ENABLED=true
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
CORS_ORIGINS=https://govoplan.example.org
AUTH_COOKIE_SECURE=true

View File

@@ -42,8 +42,7 @@ jobs:
working-directory: govoplan
run: |
python -m venv .venv
.venv/bin/python -m pip install --upgrade pip
.venv/bin/python -m pip install -r requirements-release.txt
.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 'pip-audit>=2.9,<3'
- name: Install WebUI release dependencies
working-directory: govoplan

View File

@@ -38,8 +38,7 @@ jobs:
working-directory: govoplan
run: |
python -m venv .venv
.venv/bin/python -m pip install --upgrade pip
.venv/bin/python -m pip install -r requirements-release.txt
.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 '../govoplan-core[dev]'
- name: Install WebUI release dependencies with test scripts
working-directory: govoplan

View File

@@ -37,8 +37,7 @@ jobs:
working-directory: govoplan
run: |
python -m venv .venv
.venv/bin/python -m pip install --upgrade pip
.venv/bin/python -m pip install -r requirements-release.txt
.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 '../govoplan-core[dev]'
- name: Install WebUI release dependencies
working-directory: govoplan

View File

@@ -3,7 +3,6 @@ title = "GovOPlaN secret scanning"
[allowlist]
description = "Repository examples and documented development placeholders"
regexes = [
'''dev-multimailer-api-key''',
'''example(\.invalid|\.org|\.com)''',
'''postgresql(\+psycopg)?://[^:@/\s]+:\*\*\*@''',
'''<redacted>''',

View File

@@ -17,8 +17,7 @@ Create the whole-product development virtualenv in this meta repository:
```sh
python3 -m venv .venv
./.venv/bin/python -m pip install --upgrade pip
./.venv/bin/python -m pip install -r requirements-dev.txt
./.venv/bin/python tools/repo/sync-python-environment.py --requirements requirements-dev.txt --python ./.venv/bin/python --upgrade-pip
```
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
```
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:
```sh
@@ -55,6 +60,24 @@ Update generated repository type notes in all READMEs:
./tools/repo/update-repository-type-notes.py
```
Regenerate the human-readable repository link index:
```sh
./tools/repo/generate-repository-index.py
```
Synchronize the Python environment after package metadata changes:
```sh
./.venv/bin/python tools/repo/sync-python-environment.py --requirements requirements-dev.txt --python ./.venv/bin/python
```
Run the static cross-repository module contract check:
```sh
./tools/checks/check-contracts.sh
```
Run the consolidated focused verification suite:
```sh
@@ -76,6 +99,12 @@ Run installer rollback drills:
Release, catalog, Gitea, security-audit, and cross-repository maintenance
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
The repository root `.env.example` is the self-hosted operator template for a
@@ -89,7 +118,18 @@ such as `~/.config/gitea/gitea.env` and be passed with `--env-file`.
The repository categories are documented 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 first Campaign-centric capability and infrastructure fit assessment is in
`docs/CAPABILITY_AND_INFRASTRUCTURE_FIT.md`.
# GovOPlaN Docker

View File

@@ -80,6 +80,12 @@ cd /mnt/DATA/git/govoplan
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:
```bash

View File

@@ -16,7 +16,8 @@ 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
REDIS_URL=redis://127.0.0.1:56379/0
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
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

View File

@@ -1,7 +1,7 @@
# Production-Like Development Profile
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:
@@ -10,6 +10,7 @@ It provides:
- explicit `ENABLED_MODULES`
- local durable file storage under `runtime/production-like/files`
- 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:
@@ -37,7 +38,7 @@ password changes:
cp dev/production-like/.env.example dev/production-like/.env
```
The API and worker use:
The API, worker, and scheduler use:
```text
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
```
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:
```bash

View File

@@ -0,0 +1,349 @@
# GovOPlaN Capability and IT-Infrastructure Fit Assessment
## Assessment record
| Field | Value |
| --- | --- |
| Assessment ID | `campaign-reference-2026-07-20` |
| Schema | `govoplan.fit-assessment/0.1.0` |
| Assessed on | 2026-07-20 |
| Product snapshot | Untagged workspace at meta commit `05ae81d64195`; this is not a release artifact |
| 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 checkout contained unrelated uncommitted work when this assessment was
made. A dirty repository is recorded below, but local-only changes do not count
as an integrated release capability. Tests run against the dirty workspace are
useful implementation evidence and are labelled as such; they do not turn the
workspace into a reproducible release.
## 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.
It is not yet possible to call the current workspace a supported small-production
distribution. 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 a clean, pinned release candidate.
- **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`. Module versions come from the actual manifests. `Dirty`
means that repository had uncommitted changes at assessment time.
| Module | Repository commit | Manifest version | Dirty | Role in this assessment |
| --- | --- | ---: | :---: | --- |
| Core runtime | `govoplan-core@e6f7c45f0a95` | server `0.3.0` | yes | API, registry, migration orchestration, shared WebUI, sessions and kernel contracts |
| `tenancy` | `govoplan-tenancy@1dde03854733` | 0.1.8 | no | tenant context and lifecycle |
| `organizations` | `govoplan-organizations@45cda1a33f18` | 0.1.8 | no | organization model |
| `identity` | `govoplan-identity@7a1710af896f` | 0.1.8 | yes | normalized internal identity directory |
| `access` | `govoplan-access@ab07075a679b` | 0.1.8 | yes | local authentication, sessions, API keys and RBAC |
| `admin` | `govoplan-admin@c9e1fb287f50` | 0.1.8 | no | administration surfaces |
| `dashboard` | `govoplan-dashboard@a315a7c62b1d` | 0.1.8 | no | module-aware home surface |
| `policy` | `govoplan-policy@2511fbb5a864` | 0.1.8 | no | policy explanation/configuration boundary |
| `audit` | `govoplan-audit@5e4f84a789ea` | 0.1.8 | no | database audit records and retrying audit outbox |
| `campaigns` | `govoplan-campaign@2e593b7fa412` | 0.1.8 | yes | Campaign authoring, build, delivery control and reporting |
| `files` | `govoplan-files@f3210234d35a` | 0.1.8 | yes | managed files and Campaign attachments |
| `mail` | `govoplan-mail@b0337b2d261a` | 0.1.8 | yes | SMTP/IMAP profiles and transports |
| `calendar` | `govoplan-calendar@371a00aff51c` | 0.1.8 | yes | optional calendar; not needed by the Campaign pilot |
| `docs` | `govoplan-docs@f2ade1d62475` | 0.1.8 | yes | configured-system documentation |
| `ops` | `govoplan-ops@341773a4ff8a` | 0.1.8 | no | readiness and deployment-profile visibility |
No tagged release or configuration package is pinned. That is a reproducibility
gap, not a harmless omission. The static contract scan found 43 interface
contracts, 29 providers, and no contract errors in this workspace. This proves
the scanned manifest graph is internally consistent; it does not prove the
behavior of every repository in that graph.
`govoplan-addresses@f19350e65d76` (manifest 0.1.8, dirty) is an optional 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. | The pinned workspace is dirty and untagged; repeat on a clean release candidate. |
| 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 in this workspace. | Current Campaign UI/code also contains substantial unintegrated WIP; usability and release integration need separate acceptance. |
| 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. | A target-provider failure drill and operational alerting are still required; local Campaign audit-proof work is not release-integrated. |
| 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 storage/sync tests exist; durable external-write outbox, worker recovery and retention are local WIP in this snapshot. | Not required for the Campaign pilot and must not be treated as release-integrated yet. 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. | Build immutable, matching frontend/backend artifacts and supervise them; 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 and Calendar recovery schedule exist in local WIP. | 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 workspace is untagged and several key repositories are dirty.
- 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 dirty, untagged workspace cannot be reproduced or promoted safely.
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. Local Campaign and Calendar WIP can look complete in the checkout while
remaining absent from the remote release baseline.
6. Data classification, retention, recipient consent/legal basis and external
disclosure rules are not assessed for a concrete organization.
## Proof checks before promotion
1. Create a clean release candidate that pins every module/package and WebUI
artifact; rerun the contract, module-permutation and focused test gates.
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)
Checks run for this first slice against the 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
These checks are evidence for the rows above; they are not a substitute for the
clean-release and target-environment proof checks.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,65 @@
# 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.
- 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.

View File

@@ -140,6 +140,7 @@ Use one `type/*` label:
- `type/bug`
- `type/feature`
- `type/user-story`
- `type/task`
- `type/debt`
- `type/docs`

View File

@@ -0,0 +1,449 @@
# 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.
- 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
that do not apply without closing the gap or changing the relative order.
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.
- 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.
- Short field help sits with the label. 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.
## 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 `Scheduling requests` page heading owns one `Add` action.
- The left panel is shown for the creation view; it is not a second permanent
creation launcher beside the request list.
- `Basic information`, `Calendar integration`, `Candidate slots`, and
`Participants` are logical sections rendered with the central `Card`.
- 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.
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.

View 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 date: 2026-07-20.
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 `HEAD` is used as the integrated baseline. Dirty worktree changes are
not treated as delivered behavior. One material exception is called out in the
Campaign inventory: local WIP folds the tracked `recipient-data` surface into
`recipients`, but that change is not yet part of the baseline.
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` | Any campaign queue/review/send scope declared by the module | Monitor and intervene in campaign jobs | Monitoring/work queue | Pilot; P1 Campaign #74 |
| `/reports` | `govoplan-campaign` inline `ReportsPage` | `campaigns:report:read` | Reach campaign reporting; current component identifies itself as prepared for later functionality | Reporting | Pilot inventory; implementation maturity explicitly incomplete; P2 after campaign report pilot |
| `/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
tracked baseline still contains a dedicated `recipient-data` section. The dirty
local WIP redirects that path into `recipients` and removes it from the sidebar;
do not mark that consolidation delivered until
[Campaign #67](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/67)
is integrated or the issue state is corrected.
Campaign already consumes core primitives including `ModuleSubnav`, `Card`,
`PageTitle`, `Button`, `LoadingFrame`, `DismissibleAlert`, `FormField`,
`StatusBadge`, `MetricCard`, `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; integrate/reconcile #67 before migration claims |
| 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 and per-recipient values/files | Import/mapping plus list-detail editor | Personal data, validation, bulk activation, file links | #74; guided entry #35 |
| Recipient data (`RecipientDetailsPage` in tracked baseline) | Inspect/edit a dedicated recipient-data view | List-detail editor | Personal data and send eligibility | Local WIP removes this separate surface; #67 integration state must decide baseline |
| 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, partial effects, retries, evidence | Intervention vocabulary [#63](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/63); sync/async progress [#62](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/62) |
| 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 | Implement/verify [#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) together |
| Campaign report (`CampaignReportPage`) | Filter and inspect delivery outcomes | Reporting/list-detail | Partial, failed, skipped, SMTP/IMAP outcomes and retries | Filtering [#65](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/65), dependent on [Core #263](https://git.add-ideas.de/add-ideas/govoplan-core/issues/263) |
| 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 | Retry/pause/reconcile and system actor/effect evidence | #74, then #62 vocabulary |
| Top-level reports placeholder | Enter cross-campaign reports | Reporting | Component explicitly says functionality is prepared for later passes | Keep marked incomplete; do not infer parity with per-campaign 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 | Closed work and local WIP can differ from tracked baseline | Integrated, testable baseline before migration claims | Campaign #67 and tracker cleanup | Clean scoped diff, behavior tests, build, issue evidence | Must precede claiming Campaign migration complete |
| 2 | Campaign previews/details | Existing message/attachment overlays and known overflow/geometry issues | Stable header/body/footer, accessible long-content detail | Campaign #59 and #73 | Keyboard/focus/scroll behavior, long content, responsive and translated text | P1 ready |
| 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 | Synchronous/asynchronous behavior is not explicit enough | Pre-send mode/consequence plus durable leave/return progress, retry and reconciliation | Campaign #62 | Sync and async behavior, partial/failure/retry, reload/return tests | P1 ready after stage vocabulary |
| 5 | Campaign report filtering | Duplicate/inconsistent filter models are reported | One shared status/list/filter/count model | Campaign #65 and Core #263 | Query/filter behavior, keyboard, counts, large result set | P1 blocked on shared DataGrid direction |
| 6 | 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 |
| 7 | 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 |
| 8 | 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 |
| 9 | 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 |
| 10 | 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 |
| 11 | 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 rows 0 through 10. 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
View 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.

View File

@@ -0,0 +1,120 @@
# Module Contracts and Install Boundaries
## Why Some Changes Require `pip install`
GovOPlaN discovers runtime modules through Python package entry points in the
`govoplan.modules` group. Core reads those entry points with
`importlib.metadata.entry_points()`, imports the configured manifest factory, and
then builds the module registry from the returned `ModuleManifest`.
That means the Python environment must know that a package exists before core can
discover it.
In development, `requirements-dev.txt` installs modules with `-e
../govoplan-module`. With editable installs:
- normal Python source changes are picked up after the process reloads;
- manifest code changes are picked up after the process reloads;
- new imports inside an already installed package are picked up after reload.
`pip install` is still needed when package metadata changes:
- a repository was not installed in the environment before;
- a `pyproject.toml` entry point is added, renamed, or removed;
- package dependencies change;
- optional extras change;
- package names or import roots change;
- console scripts or other installed metadata change;
- release installs need a different tag, wheel, or source ref.
The same principle applies to WebUI packages: source changes are local during
development, but `package.json` dependency or export changes require an install
step so the consuming app sees the correct package metadata.
## Current Contract Mechanism
Modules already announce contracts through `ModuleManifest`:
- `dependencies`
- `optional_dependencies`
- `required_capabilities`
- `optional_capabilities`
- `provides_interfaces`
- `requires_interfaces`
- `capability_factories`
- permissions and role templates
- route factories, migrations, docs, lifecycle hooks, and frontend metadata
Core validates the active manifest graph when it builds the registry. Release
tooling can inspect those manifests to calculate compatibility and migration
impact.
## What Core Can and Cannot Pick Up Automatically
Core can pick up contract changes automatically after reload when the changed
module package is already installed and importable.
Core cannot discover a new module or new entry point that has not been installed
into the environment, because there is no distribution metadata to enumerate.
Core also cannot notify every module about a contract change at edit time by
itself. The runtime registry is built from installed/importable packages. The
right place for cross-module announcement is the meta repo tooling and CI:
- scan manifests across all repositories;
- build an impact graph from provided/required interfaces and capabilities;
- run affected module tests;
- post Gitea issue/release notes for affected modules;
- block releases when a required interface is missing or incompatible.
## Mitigation Strategy
Use three layers:
1. Editable development environment.
Keep `requirements-dev.txt` in the meta repo as the one workspace installer.
Source edits then need process reloads, not repeated full installs.
2. Versioned runtime contracts.
Keep adding and tightening `provides_interfaces`, `requires_interfaces`, and
capability protocols. Treat interface names and versions as public module
contracts.
3. Meta-level contract audit.
The meta repo statically reads `src/**/backend/manifest.py` files across all
repositories and validates provided/required interface ranges without
importing the packages. CI blocks missing or incompatible required
interfaces before release installs are attempted.
Run the static graph check with:
```sh
./tools/checks/check-contracts.sh
```
Use `--json` when the release console or another automation needs structured
provider/consumer impact data.
## Practical Rule
Do not run `pip install` for every code edit. Run it when package metadata or the
set of installed packages changes.
For normal development:
```sh
./.venv/bin/python tools/repo/sync-python-environment.py --requirements requirements-dev.txt --python ./.venv/bin/python
```
Then restart the server when Python code or manifests change. The development
launcher runs this helper automatically by default; set
`GOVOPLAN_AUTO_SYNC_PYTHON=0` to disable that preflight.
For release validation:
```sh
./.venv/bin/python tools/repo/sync-python-environment.py --requirements requirements-release.txt --python ./.venv/bin/python
```
That install is necessary because the release environment intentionally resolves
tagged package refs, not local editable source trees.

194
docs/RELEASE_CONSOLE.md Normal file
View File

@@ -0,0 +1,194 @@
# GovOPlaN Release Console
The release console is a local operator tool for planning and executing
GovOPlaN releases. It belongs to the `govoplan` meta repository because it works
across all local checkouts, release scripts, module manifests, migration audits,
catalog files, Git state, and signing keys.
The current implementation has a read-only dashboard plus guarded local
candidate/publish actions:
- inspect repositories from `repositories.json`
- show dirty, ahead, behind, missing, no-HEAD, and tag state
- show local package catalog and keyring state
- optionally run release/dev migration audits
- propose next actions without executing them
- compare local catalog/keyring JSON with the published channel and public
keyring when online checks are enabled
- configure target versions per release unit in the web UI
- select the repositories that should advance through checkboxes
- generate dry-run selective release plans for independently versioned packages
- generate signed catalog candidates for the selected release units
- preview/apply/push reviewed catalog candidates behind explicit confirmations
Start it from the meta repository:
```sh
./.venv/bin/python tools/release/release-console.py
```
The server binds to `127.0.0.1` by default and prints a URL containing a local
API token. Open that URL in a browser on the same machine.
The web UI starts with a repository table. Each repository can be checked
independently and assigned its own target version. Repositories without version
metadata remain visible so they can be planned as initial releases. `Build Plan`
shows the dry-run commands for the selected rows, and `Generate Candidate`
creates a signed catalog candidate that advances only selected repositories that
already have a catalog entry.
The release-control area above the repository table is read-only and is meant
to become the central release cockpit. It shows:
- local and published channel health
- catalog/keyring drift
- signature and trusted-key status
- published module versions and refs
- local checkout version drift against the catalog
- catalog-declared interface compatibility
The target-version control can generate the next major, minor, or subversion
from the current base version. Manual target input accepts explicit versions
such as `0.2.0` or `0.2.0-alpha1`, but requires the first three version numbers
to move forward.
Plain repository pushes are separate from catalog publication. `Preview Push`
shows the selected repository push commands. `Push Selected` requires `PUSH` in
the repository push confirmation field.
The catalog workflow panel can also operate on the same selected rows:
- `Generate` creates a signed candidate below `runtime/release-candidates/`.
- `Preview` validates a candidate and shows what would be copied into the
website repository.
- `Apply + Tag` requires `APPLY` in the confirmation field.
- `Push` requires `PUSH` in the confirmation field.
The default signing key is
`$HOME/.config/govoplan/release-keys/release-key-1.pem` when no signing key is
entered in the UI.
Generate a selective release plan from the terminal:
```sh
./.venv/bin/python tools/release/release-plan.py \
--repo govoplan-files \
--target-version 0.1.9 \
--channel stable \
--online
```
`--online` compares the local catalog/keyring with the published channel and
published keyring. Use `--remote-tags` only when the plan also needs to check
Git remotes for tag existence; that can be slower across the full repository
set.
Build a signed selective catalog candidate:
```sh
KEY_DIR="$HOME/.config/govoplan/release-keys"
./.venv/bin/python tools/release/release-catalog.py selective \
--repo-version govoplan-files=0.1.9 \
--channel stable \
--catalog-signing-key "release-key-1=$KEY_DIR/release-key-1.pem"
```
This writes candidate catalog/keyring files below `runtime/release-candidates/`,
generates the browsable module directory below `modules/`, validates the signed
candidate with the module installer validator, and reports whether the candidate
catalog/keyring match the currently published channel. It does not publish to
the website repository.
Regenerate the browsable module directory from an existing catalog/keyring:
```sh
./.venv/bin/python tools/release/release-catalog.py module-directory \
--catalog ../addideas-govoplan-website/public/catalogs/v1/channels/stable.json \
--keyring ../addideas-govoplan-website/public/catalogs/v1/keyring.json \
--output-dir runtime/module-directory-preview \
--channel stable
```
Preview publication of a reviewed candidate:
```sh
./.venv/bin/python tools/release/release-catalog.py publish-candidate \
--candidate-dir runtime/release-candidates/stable-YYYYMMDD-HHMMSS \
--channel stable
```
Apply the reviewed candidate into the website repository without pushing:
```sh
./.venv/bin/python tools/release/release-catalog.py publish-candidate \
--candidate-dir runtime/release-candidates/stable-YYYYMMDD-HHMMSS \
--channel stable \
--apply \
--commit \
--tag
```
Push is a separate explicit flag:
```sh
./.venv/bin/python tools/release/release-catalog.py publish-candidate \
--candidate-dir runtime/release-candidates/stable-YYYYMMDD-HHMMSS \
--channel stable \
--apply \
--commit \
--tag \
--push
```
Published channels are expected below the public catalog base URL:
- `https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json`
- `https://govoplan.add-ideas.de/catalogs/v1/keyring.json`
The console treats channels as release artifacts. A package can advance without
forcing every repository to the same tag, but channel publication must preserve
the unchanged package versions, validate interface compatibility, sign the
updated catalog, and keep the published keyring healthy.
## Published Module Directory
The target release repository is an online, browsable module directory. The
catalog remains the machine-readable channel entry point, but the published
space should also expose a tree that operators and installations can inspect:
- `/catalogs/v1/channels/<channel>.json` describes the active channel state.
- `/catalogs/v1/keyring.json` publishes trusted release signing keys.
- `/catalogs/v1/modules/<module>/<version>/manifest.json` describes one
published module version, including repository refs, package refs, contracts,
compatibility windows, signatures, and available artifacts.
- `/catalogs/v1/modules/<module>/index.json` lists available versions for
one module.
- `/catalogs/v1/modules/index.json` lists all published modules.
Gitea tags/releases remain the source release anchors. The public GovOPlaN
catalog directory becomes the installation-facing release repository that points
to those anchors and carries structured compatibility information.
Selective catalog candidates now include this directory tree. Publishing a
candidate copies the channel catalog, keyring, and `modules/` tree into the
website repository together.
Current limitation: selective catalog generation updates existing catalog
entries. Initial catalog entry synthesis for brand-new modules is still a
separate backend slice.
## Direction
The console should grow in slices:
1. Read-only dashboard and next-action suggestions.
2. Release plan builder that writes explicit JSON plans.
3. Dry-run executor that shows exact commands and expected file changes.
4. Apply executor for commit, tag, push, artifact build, signing, and catalog
publication.
5. Compatibility planner for manifest contracts and version ranges.
6. Install/test workflow that validates a release from tags or catalog entries.
All mutation must stay explicit: plan first, dry run second, apply only after a
clear confirmation.

89
docs/REPOSITORY_INDEX.md Normal file
View 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) |

View File

@@ -40,6 +40,11 @@ The wrapper tags the toolbox image by a fingerprint of the Dockerfile and
the existing local image instead of reinstalling all tools. The stable alias is
`govoplan/security-audit:local` unless `SECURITY_AUDIT_IMAGE` is set.
Semgrep is installed separately in the toolbox image because current Semgrep
packages pin Click to an affected `8.1.x` line. The image upgrades Click after
Semgrep installation and fails during build if Semgrep no longer starts with the
patched Click version.
Force a cached rebuild:
```bash
@@ -65,6 +70,13 @@ tools/checks/security-audit/run.sh --mode quick --scope current --update --build
- `ci`: quick plus Semgrep public registry rulesets, Trivy, pip-audit, npm audit.
- `full`: ci plus OSV-Scanner, jscpd, Radon, and Xenon.
Bandit and Ruff security reports are split by source kind. Production code under
`src/` is written to `bandit.json` and `ruff-security.json` and controls strict
mode. Test code under `tests/` is still scanned for visibility, but its findings
are written separately to `bandit-tests.json` and `ruff-security-tests.json` so
fixture passwords, assertions, and temporary paths do not hide the production
baseline.
## Gating
The initial Gitea workflow runs in report-only mode:
@@ -115,6 +127,13 @@ Keep active implementation status in issues instead of committing generated
audit reports. `audit-reports/` is ignored; quote the report directory and the
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
The regular `Security Audit` workflow reuses the fingerprinted toolbox image
@@ -133,6 +152,8 @@ tools first:
cd /mnt/DATA/git/govoplan
python -m venv .venv
./.venv/bin/python -m pip install -r requirements-audit.txt
./.venv/bin/python -m pip install 'semgrep>=1.140,<2'
./.venv/bin/python -m pip install --upgrade --no-deps 'click>=8.3.3'
```
Then install the non-Python tools (`gitleaks`, `trivy`, `osv-scanner`, `jscpd`)

View File

@@ -0,0 +1,968 @@
{
"$schema": "./capability-fit.schema.json",
"schema_version": "0.1.0",
"assessment_id": "campaign-reference-2026-07-20",
"assessed_at": "2026-07-20",
"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": "workspace_snapshot",
"ref": "workspace-2026-07-20",
"meta_commit": "05ae81d64195",
"reproducible": false,
"configuration_packages": [],
"notes": [
"The snapshot is untagged.",
"Dirty repositories are recorded and local-only work is not treated as release-integrated."
]
},
"composition": [
{
"module_id": "core",
"repository": "govoplan-core",
"commit": "e6f7c45f0a95",
"manifest_version": "server-0.3.0",
"enabled": true,
"dirty": true,
"role": "API, registry, migrations, sessions, kernel contracts and shared WebUI"
},
{
"module_id": "tenancy",
"repository": "govoplan-tenancy",
"commit": "1dde03854733",
"manifest_version": "0.1.8",
"enabled": true,
"dirty": false,
"role": "Tenant context and lifecycle"
},
{
"module_id": "organizations",
"repository": "govoplan-organizations",
"commit": "45cda1a33f18",
"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": true,
"role": "Normalized internal identity directory"
},
{
"module_id": "access",
"repository": "govoplan-access",
"commit": "ab07075a679b",
"manifest_version": "0.1.8",
"enabled": true,
"dirty": true,
"role": "Local authentication, sessions, API keys and RBAC"
},
{
"module_id": "admin",
"repository": "govoplan-admin",
"commit": "c9e1fb287f50",
"manifest_version": "0.1.8",
"enabled": true,
"dirty": false,
"role": "Administration surfaces"
},
{
"module_id": "dashboard",
"repository": "govoplan-dashboard",
"commit": "a315a7c62b1d",
"manifest_version": "0.1.8",
"enabled": true,
"dirty": false,
"role": "Module-aware home surface"
},
{
"module_id": "policy",
"repository": "govoplan-policy",
"commit": "2511fbb5a864",
"manifest_version": "0.1.8",
"enabled": true,
"dirty": false,
"role": "Policy explanation and configuration boundary"
},
{
"module_id": "audit",
"repository": "govoplan-audit",
"commit": "5e4f84a789ea",
"manifest_version": "0.1.8",
"enabled": true,
"dirty": false,
"role": "Database audit records and retrying audit outbox"
},
{
"module_id": "campaigns",
"repository": "govoplan-campaign",
"commit": "2e593b7fa412",
"manifest_version": "0.1.8",
"enabled": true,
"dirty": true,
"role": "Campaign authoring, build, delivery control and reporting"
},
{
"module_id": "files",
"repository": "govoplan-files",
"commit": "f3210234d35a",
"manifest_version": "0.1.8",
"enabled": true,
"dirty": true,
"role": "Managed files and Campaign attachments"
},
{
"module_id": "mail",
"repository": "govoplan-mail",
"commit": "b0337b2d261a",
"manifest_version": "0.1.8",
"enabled": true,
"dirty": true,
"role": "SMTP and IMAP profiles and transports"
},
{
"module_id": "calendar",
"repository": "govoplan-calendar",
"commit": "371a00aff51c",
"manifest_version": "0.1.8",
"enabled": true,
"dirty": true,
"role": "Optional calendar outside the Campaign pilot minimum"
},
{
"module_id": "docs",
"repository": "govoplan-docs",
"commit": "f2ade1d62475",
"manifest_version": "0.1.8",
"enabled": true,
"dirty": true,
"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": "f19350e65d76",
"manifest_version": "0.1.8",
"enabled": false,
"dirty": true,
"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 contracts, 29 providers, no issues"
}
],
"conditions": [
"Repeat checks on a clean pinned release candidate."
],
"gaps": [
"The current snapshot is dirty and untagged."
],
"risks": [
"A working module graph can still be unreproducible."
],
"recommendation": "Use the minimal Campaign composition and pin all artifacts before promotion.",
"proof_check": "Run contract, migration, API and WebUI module-permutation gates on the release candidate."
},
{
"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": "current_workspace",
"locator": "govoplan-campaign/tests",
"note": "14 tests passed"
}
],
"conditions": [
"This verifies implementation paths, not target-provider delivery."
],
"gaps": [
"Substantial Campaign UI/code WIP is not release-integrated."
],
"risks": [
"Local WIP can make the checkout look more complete than the release baseline."
],
"recommendation": "Integrate and retest Campaign work before usability or production 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": [
"Build immutable matching artifacts for promotion."
],
"gaps": [
"No production image or service bundle is supplied by the profile."
],
"risks": [
"Editable source processes are unsuitable as a production artifact."
],
"recommendation": "Package and supervise WebUI/API from one release candidate.",
"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": "current_workspace",
"locator": "govoplan-core/tests/test_calendar_outbox_worker.py"
}
],
"conditions": [
"Relevant Calendar work is local WIP."
],
"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 dirty, untagged workspace cannot be reproduced as a release.",
"impact": "Uncertain deployed behavior and unsafe promotion or rollback.",
"treatment": "Integrate scoped work and create a clean pinned release candidate.",
"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 a clean release candidate, target mail proof, monitoring and coherent restore drill as production gates."
],
"proof_checks": [
"Pin and build a clean matching backend/WebUI release candidate and rerun cross-repository gates.",
"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."
]
}

View 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" }
}
}
}
}

View File

@@ -11,6 +11,12 @@
"description": "New user-visible behavior or platform capability.",
"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",
"color": "1d76db",
@@ -149,12 +155,24 @@
"description": "GovOPlaN Dms module behavior or integration.",
"exclusive": false
},
{
"name": "module/dist-lists",
"color": "0e8a16",
"description": "GovOPlaN Distribution Lists module behavior or integration.",
"exclusive": false
},
{
"name": "module/erp",
"color": "fef2c0",
"description": "GovOPlaN Erp module behavior or integration.",
"exclusive": false
},
{
"name": "module/evaluation",
"color": "bfdadc",
"description": "GovOPlaN Evaluation module behavior or integration.",
"exclusive": false
},
{
"name": "module/files",
"color": "006b75",
@@ -227,12 +245,24 @@
"description": "GovOPlaN Payments module behavior or integration.",
"exclusive": false
},
{
"name": "module/permits",
"color": "fbca04",
"description": "GovOPlaN Permits module behavior or integration.",
"exclusive": false
},
{
"name": "module/policy",
"color": "d93f0b",
"description": "GovOPlaN Policy module behavior or integration.",
"exclusive": false
},
{
"name": "module/poll",
"color": "e4e669",
"description": "GovOPlaN Poll module behavior or integration.",
"exclusive": false
},
{
"name": "module/portal",
"color": "1d76db",

View File

@@ -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-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-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-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-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"},
@@ -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-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-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-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"},

View File

@@ -1,4 +1,5 @@
bandit>=1.8,<2
click>=8.3.3
filelock>=3.20.3
idna>=3.15
pip>=26.1.2
@@ -6,5 +7,4 @@ pip-audit>=2.9,<3
python-multipart>=0.0.31
radon>=6,<7
ruff>=0.14,<1
semgrep>=1.140,<2
xenon>=0.9,<1

View File

@@ -7,15 +7,22 @@
-e ../govoplan-tenancy
-e ../govoplan-organizations
-e ../govoplan-identity
-e ../govoplan-idm
-e ../govoplan-access
-e ../govoplan-admin
-e ../govoplan-policy
-e ../govoplan-audit
-e ../govoplan-dashboard
-e ../govoplan-addresses
-e ../govoplan-dist-lists
-e ../govoplan-files
-e ../govoplan-mail
-e ../govoplan-campaign
-e ../govoplan-calendar
-e ../govoplan-poll
-e ../govoplan-scheduling
-e ../govoplan-notifications
-e ../govoplan-evaluation
-e ../govoplan-docs
-e ../govoplan-ops
httpx==0.28.1
@@ -25,3 +32,4 @@ idna>=3.15
pip>=26.1.2
pip-audit>=2.9,<3
python-multipart>=0.0.31
ruff>=0.14,<1

View File

@@ -2,18 +2,23 @@
# Update GOVOPLAN_RELEASE_TAG together with pyproject/package versions when
# cutting a release.
../govoplan-core[server]
govoplan-tenancy @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-tenancy.git@v0.1.6
govoplan-organizations @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git@v0.1.6
govoplan-identity @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-identity.git@v0.1.6
govoplan-idm @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git@v0.1.6
govoplan-access @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git@v0.1.6
govoplan-admin @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git@v0.1.6
govoplan-policy @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git@v0.1.6
govoplan-audit @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git@v0.1.6
govoplan-dashboard @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git@v0.1.6
govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.6
govoplan-mail @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git@v0.1.6
govoplan-campaign @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git@v0.1.6
govoplan-calendar @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git@v0.1.6
govoplan-docs @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git@v0.1.6
govoplan-ops @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.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.8
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.8
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.8
govoplan-policy @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git@v0.1.8
govoplan-audit @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git@v0.1.8
govoplan-dashboard @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git@v0.1.8
govoplan-addresses @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-addresses.git@v0.1.8
govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.8
govoplan-mail @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git@v0.1.8
govoplan-campaign @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git@v0.1.8
govoplan-calendar @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git@v0.1.8
govoplan-poll @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-poll.git@v0.1.8
govoplan-scheduling @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-scheduling.git@v0.1.8
govoplan-notifications @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-notifications.git@v0.1.8
govoplan-evaluation @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-evaluation.git@v0.1.8
govoplan-docs @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git@v0.1.8
govoplan-ops @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git@v0.1.8

View 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()

View 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()

View File

@@ -0,0 +1,191 @@
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_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()

View 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()

View 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())

View 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" "$@"

View File

@@ -33,59 +33,14 @@ PY
"$PYTHON" - <<'PY'
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 sys
root = pathlib.Path.cwd()
scan_roots = [
root / "src",
root / "tests",
root.parent / "govoplan-access" / "src",
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",
scan_roots = [root / "tests"] + [
repo / "src"
for repo in sorted(root.parent.glob("govoplan*"))
if (repo / "src").is_dir()
]
deprecated_tokens = {
"HTTP_422_UNPROCESSABLE_ENTITY": "Use HTTP_422_UNPROCESSABLE_CONTENT; the numeric status remains 422.",

View File

@@ -27,26 +27,25 @@ unset npm_config_tmp NPM_CONFIG_TMP
cd "$ROOT"
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'
import ast
import pathlib
import sys
repos_root = pathlib.Path("/mnt/DATA/git")
roots = [
pathlib.Path("/mnt/DATA/git/govoplan-core/src"),
pathlib.Path("/mnt/DATA/git/govoplan-access/src"),
pathlib.Path("/mnt/DATA/git/govoplan-admin/src"),
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"),
repo / "src"
for repo in sorted(repos_root.glob("govoplan*"))
if (repo / "src").is_dir()
]
roots.extend(
repo / "tests"
for repo in sorted(repos_root.glob("govoplan*"))
if (repo / "tests").is_dir()
)
errors = []
count = 0
@@ -67,7 +66,7 @@ if errors:
print(f"AST syntax check passed for {count} Python files")
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"
"$PYTHON" -m unittest tests.test_module_system
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-mail/tests

View 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())

View File

@@ -18,6 +18,7 @@ if [[ ! -x "$NPM" ]]; then
fi
cd "$ROOT"
"$PYTHON" "$META_ROOT/tools/checks/check-contracts.py" --no-impact
"$PYTHON" -m unittest tests.test_module_system tests.test_access_contracts
"$PYTHON" "$META_ROOT/tools/checks/check_dependency_boundaries.py"

View File

@@ -92,6 +92,7 @@ install_cloned_webui_dependencies() {
}
run_step "Validate release refs and installed package metadata"
"$PYTHON" "$META_ROOT/tools/checks/check-contracts.py" --no-impact
"$PYTHON" - <<'PY'
from __future__ import annotations

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
MODE="${SECURITY_AUDIT_MODE:-ci}"
SCOPE="${SECURITY_AUDIT_SCOPE:-current}"
REPORTS_DIR="${SECURITY_AUDIT_REPORTS_DIR:-$ROOT/audit-reports}"
@@ -95,10 +95,26 @@ if [[ "${#REPOS[@]}" -eq 0 ]]; then
exit 1
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_PROD_ROOTS=()
declare -a PY_TEST_ROOTS=()
for repo in "${REPOS[@]}"; do
[[ -d "$repo/src" ]] && PY_ROOTS+=("$repo/src")
[[ -d "$repo/tests" ]] && PY_ROOTS+=("$repo/tests")
if [[ -d "$repo/src" ]]; then
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
safe_name() {
@@ -164,25 +180,41 @@ run_semgrep() {
fi
semgrep scan \
--metrics=off \
--no-git-ignore \
--sarif \
--output "$report" \
--exclude .git \
--exclude node_modules \
--exclude .venv \
--exclude dist \
--exclude build \
--exclude runtime \
--exclude audit-reports \
--exclude '**/.*-test-build/**' \
"${config_args[@]}" \
"${REPOS[@]}"
}
run_bandit() {
[[ "${#PY_ROOTS[@]}" -gt 0 ]] || return 0
bandit -r "${PY_ROOTS[@]}" -f json -o "$REPORTS_DIR/bandit.json"
local status=0
if [[ "${#PY_PROD_ROOTS[@]}" -gt 0 ]]; then
bandit -r "${PY_PROD_ROOTS[@]}" -f json -o "$REPORTS_DIR/bandit.json" || status=1
fi
if [[ "${#PY_TEST_ROOTS[@]}" -gt 0 ]]; then
bandit -r "${PY_TEST_ROOTS[@]}" -f json -o "$REPORTS_DIR/bandit-tests.json" || true
fi
return "$status"
}
run_ruff_security() {
[[ "${#PY_ROOTS[@]}" -gt 0 ]] || return 0
ruff check --select S --output-format json "${PY_ROOTS[@]}" > "$REPORTS_DIR/ruff-security.json"
local status=0
if [[ "${#PY_PROD_ROOTS[@]}" -gt 0 ]]; then
ruff check --select S --output-format json "${PY_PROD_ROOTS[@]}" > "$REPORTS_DIR/ruff-security.json" || status=1
fi
if [[ "${#PY_TEST_ROOTS[@]}" -gt 0 ]]; then
ruff check --select S --output-format json "${PY_TEST_ROOTS[@]}" > "$REPORTS_DIR/ruff-security-tests.json" || true
fi
return "$status"
}
run_gitleaks() {
@@ -194,7 +226,16 @@ run_gitleaks() {
gitleaks git \
--config "$ROOT/.gitleaks.toml" \
--report-format json \
--report-path "$REPORTS_DIR/gitleaks-$name.json" \
--report-path "$REPORTS_DIR/gitleaks-history-$name.json" \
--no-banner \
"$repo" || status=1
# 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" || status=1
else
@@ -233,7 +274,9 @@ run_pip_audit_manifests() {
[[ -f "$repo/requirements.txt" ]] || continue
local name
name="$(safe_name "$repo")"
pip-audit -r "$repo/requirements.txt" --progress-spinner off --format json --output "$REPORTS_DIR/pip-audit-$name.json" || status=1
# 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") || status=1
done
return "$status"
}
@@ -255,7 +298,11 @@ run_osv_scanner() {
local name
name="$(safe_name "$repo")"
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
osv-scanner scan -r \
--allow-no-lockfiles \
--format json \
--output-file "$REPORTS_DIR/osv-scanner-$name.json" \
"$repo" || status=1
else
osv-scanner -r --format json --output "$REPORTS_DIR/osv-scanner-$name.json" "$repo" || status=1
fi
@@ -264,12 +311,39 @@ run_osv_scanner() {
}
run_jscpd() {
local ignored_paths
ignored_paths=(
"**/.git/**"
"**/node_modules/**"
"**/.venv/**"
"**/dist/**"
"**/build/**"
"**/audit-reports/**"
"**/package-lock.json"
"**/package-lock.release.json"
"**/package.json"
"package.json"
"**/generatedTranslations.ts"
"**/docs/gitea-labels.json"
"**/*.md"
"**/*.md:*"
"*.md"
"*.md:*"
"**/*.svg"
"*.svg"
".gitea/workflows/*.yml"
"**/.gitea/workflows/*.yml"
"**/backend/schema/*.schema.json"
)
local ignored_path_pattern
ignored_path_pattern="$(printf '%s,' "${ignored_paths[@]}")"
ignored_path_pattern="${ignored_path_pattern%,}"
jscpd \
--silent \
--min-tokens 80 \
--reporters json \
--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[@]}"
}

View File

@@ -11,48 +11,89 @@ from pathlib import Path
META_ROOT = Path(__file__).resolve().parents[2]
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 = {
"core": CORE_ROOT / "src" / "govoplan_core",
"access": REPOS_ROOT / "govoplan-access" / "src" / "govoplan_access",
"admin": REPOS_ROOT / "govoplan-admin" / "src" / "govoplan_admin",
"tenancy": REPOS_ROOT / "govoplan-tenancy" / "src" / "govoplan_tenancy",
"organizations": REPOS_ROOT / "govoplan-organizations" / "src" / "govoplan_organizations",
"identity": REPOS_ROOT / "govoplan-identity" / "src" / "govoplan_identity",
"policy": REPOS_ROOT / "govoplan-policy" / "src" / "govoplan_policy",
"audit": REPOS_ROOT / "govoplan-audit" / "src" / "govoplan_audit",
"dashboard": REPOS_ROOT / "govoplan-dashboard" / "src" / "govoplan_dashboard",
"files": REPOS_ROOT / "govoplan-files" / "src" / "govoplan_files",
"mail": REPOS_ROOT / "govoplan-mail" / "src" / "govoplan_mail",
"campaign": REPOS_ROOT / "govoplan-campaign" / "src" / "govoplan_campaign",
}
PREFIXES = {
"core": "govoplan_core",
"access": "govoplan_access",
"admin": "govoplan_admin",
"tenancy": "govoplan_tenancy",
"organizations": "govoplan_organizations",
"identity": "govoplan_identity",
"policy": "govoplan_policy",
"audit": "govoplan_audit",
"dashboard": "govoplan_dashboard",
"files": "govoplan_files",
"mail": "govoplan_mail",
"campaign": "govoplan_campaign",
}
FEATURE_OWNERS = ("files", "mail", "campaign")
PLATFORM_OWNERS = ("admin", "tenancy", "organizations", "identity", "policy", "audit", "dashboard")
WEBUI_REPOS = {
"core": CORE_ROOT / "webui",
"files": REPOS_ROOT / "govoplan-files" / "webui",
"mail": REPOS_ROOT / "govoplan-mail" / "webui",
"campaign": REPOS_ROOT / "govoplan-campaign" / "webui",
}
WEBUI_PACKAGES = {
"core": "@govoplan/core-webui",
"files": "@govoplan/files-webui",
"mail": "@govoplan/mail-webui",
"campaign": "@govoplan/campaign-webui",
}
def _discover_python_repos(
repos_root: Path = REPOS_ROOT,
) -> tuple[dict[str, Path], dict[str, str], tuple[str, ...]]:
repos: dict[str, Path] = {}
prefixes: dict[str, str] = {}
errors: list[str] = []
for repo_root in sorted(repos_root.glob("govoplan*")):
source_root = repo_root / "src"
if not source_root.is_dir():
continue
packages = sorted(
path
for path in source_root.glob("govoplan_*")
if path.is_dir() and (path / "__init__.py").is_file()
)
if len(packages) != 1:
discovered = ", ".join(path.name for path in packages) or "none"
errors.append(
f"{repo_root.name}: expected exactly one importable govoplan_* package "
f"under {source_root}, found {len(packages)} ({discovered})"
)
continue
owner = repo_root.name
prefix = packages[0].name
duplicate_owner = next(
(candidate_owner for candidate_owner, candidate_prefix in prefixes.items() if candidate_prefix == prefix),
None,
)
if duplicate_owner is not None:
errors.append(
f"{owner}: import package {prefix!r} is already owned by {duplicate_owner}; "
"Python package ownership must be unique"
)
continue
repos[owner] = packages[0]
prefixes[owner] = prefix
return repos, prefixes, tuple(errors)
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(
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_./-]+)?[\"']"
@@ -123,18 +164,7 @@ def _violations() -> list[Violation]:
imported_owner = _import_owner(imported)
if imported_owner is None or imported_owner == owner:
continue
if owner == "core" 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 == "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":
if imported_owner != CORE_OWNER and not _allowed(owner, path, imported_owner):
violations.append(Violation("python", owner, path, lineno, imported, imported_owner))
violations.extend(_webui_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))
if imported_owner is None or imported_owner == owner:
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))
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:
continue
lineno = text.count("\n", 0, match.start()) + 1
if owner == "core" and imported_owner in FEATURE_OWNERS:
violations.append(Violation("webui-source", owner, path, lineno, package_name, imported_owner))
elif owner in FEATURE_OWNERS and imported_owner in FEATURE_OWNERS:
if imported_owner != CORE_OWNER:
violations.append(Violation("webui-source", owner, path, lineno, package_name, imported_owner))
return violations
@@ -220,9 +253,21 @@ def _webui_violations() -> list[Violation]:
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()
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
print("Dependency boundary violations:")

View File

@@ -34,6 +34,8 @@ COPY requirements-audit.txt /tmp/requirements-audit.txt
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 "semgrep>=1.140,<2" \
&& python -m pip install --no-cache-dir --upgrade --no-deps "click>=8.3.3" \
&& npm install -g jscpd \
&& semgrep --version \
&& bandit --version \

View 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__")

View File

@@ -4,14 +4,13 @@
from __future__ import annotations
import dataclasses
import http.client
import json
import os
import pathlib
import re
import subprocess
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
@@ -31,7 +30,7 @@ class RepoTarget:
@property
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:
@@ -80,10 +79,33 @@ def quote_path(value: str) -> str:
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:
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.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(
self,
@@ -93,14 +115,13 @@ class GiteaClient:
body: dict[str, Any] | None = None,
query: dict[str, Any] | None = None,
) -> Any:
url = f"{self.target.api_base}{path}"
if query:
url = f"{url}?{urllib.parse.urlencode(query, doseq=True)}"
request_target = self._request_target(path, query)
data = None
headers = {
"Accept": "application/json",
"User-Agent": "codex-gitea-maintenance",
"Connection": "keep-alive",
}
if self.token:
headers["Authorization"] = f"token {self.token}"
@@ -108,18 +129,71 @@ class GiteaClient:
data = json.dumps(body).encode("utf-8")
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:
with urllib.request.urlopen(request, timeout=30) as response:
payload = response.read().decode("utf-8")
if not payload:
return None
return json.loads(payload)
except urllib.error.HTTPError as exc:
message = exc.read().decode("utf-8", errors="replace")
raise GiteaError(f"{method} {url} failed with HTTP {exc.code}: {message}") from exc
except urllib.error.URLError as exc:
raise GiteaError(f"{method} {url} failed: {exc.reason}") from exc
payload = response.read()
return response.status, response.reason, payload
finally:
if response.will_close:
self.close()
def _request_target(self, path: str, query: dict[str, Any] | None) -> str:
base_path = self._api_url.path.rstrip("/")
suffix = path if path.startswith("/") else f"/{path}"
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(
self,

View File

@@ -15,11 +15,12 @@ BACKEND_HOST="${GOVOPLAN_BACKEND_HOST:-127.0.0.1}"
BACKEND_PORT="${GOVOPLAN_BACKEND_PORT:-8000}"
FRONTEND_HOST="${GOVOPLAN_FRONTEND_HOST:-127.0.0.1}"
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_CLEAR_VITE_CACHE="${GOVOPLAN_FRONTEND_CLEAR_VITE_CACHE:-1}"
FRONTEND_USE_POLLING="${GOVOPLAN_FRONTEND_USE_POLLING:-1}"
FRONTEND_POLLING_INTERVAL="${GOVOPLAN_FRONTEND_POLLING_INTERVAL:-500}"
AUTO_SYNC_PYTHON="${GOVOPLAN_AUTO_SYNC_PYTHON:-1}"
DEV_DATABASE_BACKEND="${GOVOPLAN_DEV_DATABASE_BACKEND:-postgres}"
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"
[ -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"
"$PYTHON" - <<'PY'
@@ -210,6 +219,7 @@ Frontend reload:
Vite cache cleared: $FRONTEND_CLEAR_VITE_CACHE
Vite --force: $FRONTEND_FORCE_RELOAD
Watch polling: $FRONTEND_USE_POLLING (${FRONTEND_POLLING_INTERVAL}ms)
Open browser: $OPEN_BROWSER
Press Ctrl+C to stop both processes.
EOF

View File

@@ -27,12 +27,14 @@ STOP_DEPENDENCIES_ON_EXIT="${GOVOPLAN_STOP_PROFILE_DEPENDENCIES_ON_EXIT:-0}"
LOG_DIR="$META_ROOT/runtime/production-like/logs"
BACKEND_LOG="$LOG_DIR/backend.log"
WORKER_LOG="$LOG_DIR/worker.log"
BEAT_LOG="$LOG_DIR/beat.log"
FRONTEND_LOG="$LOG_DIR/frontend.log"
BACKEND_URL="http://$BACKEND_HOST:$BACKEND_PORT"
FRONTEND_URL="http://$FRONTEND_HOST:$FRONTEND_PORT"
backend_pid=""
worker_pid=""
beat_pid=""
frontend_pid=""
fail() {
@@ -49,12 +51,12 @@ set +a
export APP_ENV="${APP_ENV:-staging}"
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 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 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_LOCAL_ROOT="${FILE_STORAGE_LOCAL_ROOT:-$META_ROOT/runtime/production-like/files}"
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
kill "$worker_pid" 2>/dev/null || true
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
compose down >/dev/null 2>&1 || true
fi
@@ -160,6 +165,7 @@ trap cleanup EXIT INT TERM
mkdir -p "$LOG_DIR" "$FILE_STORAGE_LOCAL_ROOT"
: > "$BACKEND_LOG"
: > "$WORKER_LOG"
: > "$BEAT_LOG"
: > "$FRONTEND_LOG"
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_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"
(
cd "$WEBUI_ROOT"
@@ -235,10 +249,11 @@ Files: $FILE_STORAGE_LOCAL_ROOT
Logs:
Backend: $BACKEND_LOG
Worker: $WORKER_LOG
Beat: $BEAT_LOG
WebUI: $FRONTEND_LOG
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
wait

View File

@@ -19,7 +19,7 @@ usage() {
Usage: tools/launch/production-like-dev.sh <command> [--yes]
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.
status Show profile container status and validate the effective environment.
seed Run migrations and seed development data against the profile database.
@@ -27,7 +27,7 @@ Commands:
validate-config Validate the profile environment only.
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.
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 REDIS_URL="${REDIS_URL:-${GOVOPLAN_PRODUCTION_LIKE_REDIS_URL:-redis://127.0.0.1:56379/0}}"
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_LOCAL_ROOT="${FILE_STORAGE_LOCAL_ROOT:-$META_ROOT/runtime/production-like/files}"
export DEV_AUTO_MIGRATE_ENABLED="${DEV_AUTO_MIGRATE_ENABLED:-false}"

View File

@@ -110,6 +110,15 @@ CATALOG_MODULES = (
tags=("official", "platform-module"),
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(
module_id="files",
repo="govoplan-files",

View File

@@ -0,0 +1,67 @@
"""Shared release tooling for the local GovOPlaN release console."""
from __future__ import annotations
from .model import ReleaseDashboard, ReleasePlan, RepositorySnapshot, RepositorySpec
__all__ = [
"ReleaseDashboard",
"ReleasePlan",
"RepositorySnapshot",
"RepositorySpec",
"build_dashboard",
"build_release_intelligence",
"build_release_plan",
"build_selective_catalog_candidate",
"build_selective_release_plan",
"publish_catalog_candidate",
"prepare_repositories",
"push_repositories",
"sync_repositories",
]
def __getattr__(name: str):
value = _load_lazy_export(name)
globals()[name] = value
return value
def _load_lazy_export(name: str):
if name == "build_dashboard":
from .dashboard import build_dashboard
return build_dashboard
if name == "build_release_intelligence":
from .release_intelligence import build_release_intelligence
return build_release_intelligence
if name == "build_release_plan":
from .planner import build_release_plan
return build_release_plan
if name == "build_selective_catalog_candidate":
from .selective_catalog import build_selective_catalog_candidate
return build_selective_catalog_candidate
if name == "build_selective_release_plan":
from .selective_planner import build_selective_release_plan
return build_selective_release_plan
if name == "publish_catalog_candidate":
from .publisher import publish_catalog_candidate
return publish_catalog_candidate
if name == "prepare_repositories":
from .repository_prepare import prepare_repositories
return prepare_repositories
if name == "push_repositories":
from .repository_push import push_repositories
return push_repositories
if name == "sync_repositories":
from .repository_sync import sync_repositories
return sync_repositories
raise AttributeError(name)

View 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

View File

@@ -0,0 +1,445 @@
"""Manifest contract extraction and validation for release planning.
The static scanner intentionally parses manifest source files with ``ast``
instead of importing module packages. That keeps contract checks usable before
``pip install`` has been rerun for a changed ``pyproject.toml`` or entry point.
"""
from __future__ import annotations
import ast
import re
from pathlib import Path
from .model import (
CompatibilityIssue,
InterfaceProviderSnapshot,
InterfaceRequirementSnapshot,
ModuleContractSnapshot,
RepositorySnapshot,
)
def collect_contracts(repositories: tuple[RepositorySnapshot, ...]) -> tuple[ModuleContractSnapshot, ...]:
contracts: list[ModuleContractSnapshot] = []
for repo in repositories:
if not repo.exists or not repo.is_git:
continue
root = Path(repo.absolute_path)
for manifest in sorted(root.glob("src/**/backend/manifest.py")):
contract = parse_manifest_contract(manifest, repo_name=repo.spec.name)
if contract is not None:
contracts.append(contract)
return tuple(contracts)
def validate_contracts(contracts: tuple[ModuleContractSnapshot, ...]) -> tuple[CompatibilityIssue, ...]:
"""Validate the statically collected module interface graph.
This mirrors the runtime interface closure validation in
``govoplan_core.core.registry`` but reports all issues at once for CI and
release planning.
"""
issues: list[CompatibilityIssue] = []
providers: dict[str, list[tuple[ModuleContractSnapshot, InterfaceProviderSnapshot]]] = {}
module_ids: dict[str, list[ModuleContractSnapshot]] = {}
for contract in contracts:
module_ids.setdefault(contract.module_id, []).append(contract)
local_provider_names: set[str] = set()
local_requirement_names: set[str] = set()
for provider in contract.provides_interfaces:
if provider.name in local_provider_names:
issues.append(
CompatibilityIssue(
severity="blocker",
code="duplicate_provided_interface",
message=f"{contract.module_id} provides interface {provider.name!r} more than once.",
repo=contract.repo,
module_id=contract.module_id,
interface=provider.name,
)
)
local_provider_names.add(provider.name)
if not valid_interface_name(provider.name):
issues.append(
CompatibilityIssue(
severity="blocker",
code="invalid_provided_interface_name",
message=f"{contract.module_id} provides invalid interface name {provider.name!r}.",
repo=contract.repo,
module_id=contract.module_id,
interface=provider.name,
)
)
if not provider.version.strip():
issues.append(
CompatibilityIssue(
severity="blocker",
code="missing_provided_interface_version",
message=f"{contract.module_id} provides interface {provider.name!r} without a version.",
repo=contract.repo,
module_id=contract.module_id,
interface=provider.name,
)
)
providers.setdefault(provider.name, []).append((contract, provider))
for requirement in contract.requires_interfaces:
if requirement.name in local_requirement_names:
issues.append(
CompatibilityIssue(
severity="blocker",
code="duplicate_required_interface",
message=f"{contract.module_id} requires interface {requirement.name!r} more than once.",
repo=contract.repo,
module_id=contract.module_id,
interface=requirement.name,
)
)
local_requirement_names.add(requirement.name)
if not valid_interface_name(requirement.name):
issues.append(
CompatibilityIssue(
severity="blocker",
code="invalid_required_interface_name",
message=f"{contract.module_id} requires invalid interface name {requirement.name!r}.",
repo=contract.repo,
module_id=contract.module_id,
interface=requirement.name,
)
)
if requirement.version_min is not None and not requirement.version_min.strip():
issues.append(
CompatibilityIssue(
severity="blocker",
code="blank_required_interface_minimum",
message=f"{contract.module_id} has a blank minimum version for {requirement.name!r}.",
repo=contract.repo,
module_id=contract.module_id,
interface=requirement.name,
)
)
if requirement.version_max_exclusive is not None and not requirement.version_max_exclusive.strip():
issues.append(
CompatibilityIssue(
severity="blocker",
code="blank_required_interface_maximum",
message=f"{contract.module_id} has a blank maximum version for {requirement.name!r}.",
repo=contract.repo,
module_id=contract.module_id,
interface=requirement.name,
)
)
if not version_range_is_valid(
version_min=requirement.version_min,
version_max_exclusive=requirement.version_max_exclusive,
):
issues.append(
CompatibilityIssue(
severity="blocker",
code="invalid_required_interface_range",
message=(
f"{contract.module_id} requires {requirement.name!r} with invalid range "
f"{format_version_range(version_min=requirement.version_min, version_max_exclusive=requirement.version_max_exclusive)}."
),
repo=contract.repo,
module_id=contract.module_id,
interface=requirement.name,
)
)
for module_id, matches in sorted(module_ids.items()):
if len(matches) > 1:
repos = ", ".join(sorted(contract.repo for contract in matches))
for contract in matches:
issues.append(
CompatibilityIssue(
severity="blocker",
code="duplicate_module_id",
message=f"Module id {module_id!r} is declared by multiple repositories: {repos}.",
repo=contract.repo,
module_id=module_id,
)
)
for contract in contracts:
for requirement in contract.requires_interfaces:
available = providers.get(requirement.name, [])
matching = [
(provider_contract, provider)
for provider_contract, provider in available
if version_satisfies_range(
provider.version,
version_min=requirement.version_min,
version_max_exclusive=requirement.version_max_exclusive,
)
]
if matching:
continue
version_range = format_version_range(
version_min=requirement.version_min,
version_max_exclusive=requirement.version_max_exclusive,
)
if not available:
if requirement.optional:
continue
issues.append(
CompatibilityIssue(
severity="blocker",
code="required_interface_missing",
message=f"{contract.module_id} requires unavailable interface {requirement.name!r} ({version_range}).",
repo=contract.repo,
module_id=contract.module_id,
interface=requirement.name,
)
)
continue
available_versions = ", ".join(
f"{provider_contract.module_id}@{provider.version}" for provider_contract, provider in available
)
issues.append(
CompatibilityIssue(
severity="warning" if requirement.optional else "blocker",
code="interface_version_mismatch",
message=(
f"{contract.module_id} requires interface {requirement.name!r} ({version_range}) "
f"but available providers are {available_versions}."
),
repo=contract.repo,
module_id=contract.module_id,
interface=requirement.name,
)
)
return tuple(sorted(issues, key=issue_sort_key))
def interface_impact_map(
contracts: tuple[ModuleContractSnapshot, ...],
) -> dict[str, tuple[tuple[ModuleContractSnapshot, InterfaceRequirementSnapshot], ...]]:
consumers: dict[str, list[tuple[ModuleContractSnapshot, InterfaceRequirementSnapshot]]] = {}
for contract in contracts:
for requirement in contract.requires_interfaces:
consumers.setdefault(requirement.name, []).append((contract, requirement))
return {name: tuple(items) for name, items in sorted(consumers.items())}
def provided_interface_map(
contracts: tuple[ModuleContractSnapshot, ...],
) -> dict[str, tuple[tuple[ModuleContractSnapshot, InterfaceProviderSnapshot], ...]]:
providers: dict[str, list[tuple[ModuleContractSnapshot, InterfaceProviderSnapshot]]] = {}
for contract in contracts:
for provider in contract.provides_interfaces:
providers.setdefault(provider.name, []).append((contract, provider))
return {name: tuple(items) for name, items in sorted(providers.items())}
def parse_manifest_contract(path: Path, *, repo_name: str) -> ModuleContractSnapshot | None:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
constants = imported_string_constants(tree, path)
constants.update(module_constants(tree, initial=constants))
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
if call_name(node.func) != "ModuleManifest":
continue
keywords = {keyword.arg: keyword.value for keyword in node.keywords if keyword.arg}
module_id = value_as_string(keywords.get("id"), constants)
if module_id is None:
continue
return ModuleContractSnapshot(
repo=repo_name,
module_id=module_id,
module_version=value_as_string(keywords.get("version"), constants),
manifest_path=str(path),
provides_interfaces=parse_providers(keywords.get("provides_interfaces"), constants),
requires_interfaces=parse_requirements(keywords.get("requires_interfaces"), constants),
)
return None
def parse_providers(node: ast.AST | None, constants: dict[str, str]) -> tuple[InterfaceProviderSnapshot, ...]:
providers: list[InterfaceProviderSnapshot] = []
for call in interface_calls(node, "ModuleInterfaceProvider"):
keywords = {keyword.arg: keyword.value for keyword in call.keywords if keyword.arg}
name = value_as_string(keywords.get("name"), constants)
version = value_as_string(keywords.get("version"), constants)
if name and version:
providers.append(InterfaceProviderSnapshot(name=name, version=version))
return tuple(providers)
def parse_requirements(node: ast.AST | None, constants: dict[str, str]) -> tuple[InterfaceRequirementSnapshot, ...]:
requirements: list[InterfaceRequirementSnapshot] = []
for call in interface_calls(node, "ModuleInterfaceRequirement"):
keywords = {keyword.arg: keyword.value for keyword in call.keywords if keyword.arg}
name = value_as_string(keywords.get("name"), constants)
if not name:
continue
requirements.append(
InterfaceRequirementSnapshot(
name=name,
version_min=value_as_string(keywords.get("version_min"), constants),
version_max_exclusive=value_as_string(keywords.get("version_max_exclusive"), constants),
optional=value_as_bool(keywords.get("optional")),
)
)
return tuple(requirements)
def interface_calls(node: ast.AST | None, expected_name: str) -> tuple[ast.Call, ...]:
if node is None:
return ()
return tuple(
child
for child in ast.walk(node)
if isinstance(child, ast.Call) and call_name(child.func) == expected_name
)
def imported_string_constants(tree: ast.Module, path: Path) -> dict[str, str]:
constants: dict[str, str] = {}
for node in tree.body:
if not isinstance(node, ast.ImportFrom):
continue
source = import_source_path(path, module=node.module, level=node.level)
if source is None or not source.exists():
continue
try:
imported = module_constants(ast.parse(source.read_text(encoding="utf-8"), filename=str(source)))
except SyntaxError:
continue
for alias in node.names:
if alias.name in imported:
constants[alias.asname or alias.name] = imported[alias.name]
return constants
def import_source_path(path: Path, *, module: str | None, level: int) -> Path | None:
if level:
base = path.parent
for _ in range(level - 1):
base = base.parent
if module:
base = base.joinpath(*module.split("."))
candidate = base.with_suffix(".py")
else:
src_root = next((parent for parent in path.parents if parent.name == "src"), None)
if src_root is None or module is None:
return None
candidate = src_root.joinpath(*module.split(".")).with_suffix(".py")
if candidate.exists():
return candidate
init = candidate.with_suffix("") / "__init__.py"
return init if init.exists() else candidate
def module_constants(tree: ast.Module, *, initial: dict[str, str] | None = None) -> dict[str, str]:
constants: dict[str, str] = dict(initial or {})
for node in tree.body:
if isinstance(node, ast.Assign):
value = node.value
for target in node.targets:
if isinstance(target, ast.Name):
resolved = value_as_string(value, constants)
if resolved is not None:
constants[target.id] = resolved
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
resolved = value_as_string(node.value, constants)
if resolved is not None:
constants[node.target.id] = resolved
return constants
def value_as_string(node: ast.AST | None, constants: dict[str, str]) -> str | None:
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
if isinstance(node, ast.Name):
return constants.get(node.id)
return None
def value_as_bool(node: ast.AST | None) -> bool:
return bool(node.value) if isinstance(node, ast.Constant) and isinstance(node.value, bool) else False
def call_name(node: ast.AST) -> str:
if isinstance(node, ast.Name):
return node.id
if isinstance(node, ast.Attribute):
return node.attr
return ""
def issue_sort_key(issue: CompatibilityIssue) -> tuple[int, str, str, str, str]:
severity_order = {"blocker": 0, "warning": 1, "info": 2}
return (
severity_order.get(issue.severity, 9),
issue.repo or "",
issue.module_id or "",
issue.interface or "",
issue.code,
)
def valid_interface_name(value: str) -> bool:
return bool(re.match(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$", value))
def version_satisfies_range(
version: str,
*,
version_min: str | None = None,
version_max_exclusive: str | None = None,
) -> bool:
if version_min is not None and compare_versions(version, version_min) < 0:
return False
if version_max_exclusive is not None and compare_versions(version, version_max_exclusive) >= 0:
return False
return True
def version_range_is_valid(
*,
version_min: str | None = None,
version_max_exclusive: str | None = None,
) -> bool:
if version_min is None or version_max_exclusive is None:
return True
return compare_versions(version_min, version_max_exclusive) < 0
def format_version_range(
*,
version_min: str | None = None,
version_max_exclusive: str | None = None,
) -> str:
parts: list[str] = []
if version_min is not None:
parts.append(f">= {version_min}")
if version_max_exclusive is not None:
parts.append(f"< {version_max_exclusive}")
return ", ".join(parts) if parts else "any version"
def compare_versions(left: str, right: str) -> int:
left_parts = version_tuple(left)
right_parts = version_tuple(right)
max_length = max(len(left_parts), len(right_parts))
padded_left = left_parts + (0,) * (max_length - len(left_parts))
padded_right = right_parts + (0,) * (max_length - len(right_parts))
if padded_left < padded_right:
return -1
if padded_left > padded_right:
return 1
return 0
def version_tuple(value: str) -> tuple[int, ...]:
match = re.match(r"^\s*v?(\d+(?:\.\d+)*)", value)
if not match:
return (0,)
return tuple(int(part) for part in match.group(1).split("."))

View 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,
)

View File

@@ -0,0 +1,178 @@
"""Read-only Git and version metadata collection."""
from __future__ import annotations
import json
from pathlib import Path
import re
import subprocess
import tomllib
from .model import RepositorySnapshot, RepositorySpec, VersionSnapshot
from .workspace import resolve_repo_path
def collect_repository_snapshot(
spec: RepositorySpec,
*,
workspace_root: Path,
target_tag: str | None,
online: bool = False,
) -> RepositorySnapshot:
path = resolve_repo_path(spec, workspace_root)
if not path.exists():
return RepositorySnapshot(spec=spec, absolute_path=str(path), exists=False, is_git=False, errors=("repository directory is missing",))
if not (path / ".git").exists():
return RepositorySnapshot(spec=spec, absolute_path=str(path), exists=True, is_git=False, errors=("not a git repository",))
errors: list[str] = []
status_result = git(path, "status", "--porcelain", timeout=10)
if status_result.returncode != 0:
message = git_error(status_result)
if is_dubious_ownership_error(message):
return RepositorySnapshot(
spec=spec,
absolute_path=str(path),
exists=True,
is_git=True,
safe_directory_required=True,
safe_directory_command=safe_directory_command(path),
errors=(compact_git_error(message),),
)
return RepositorySnapshot(
spec=spec,
absolute_path=str(path),
exists=True,
is_git=True,
errors=(compact_git_error(message),),
)
branch = git_text(path, "branch", "--show-current")
has_head = git_success(path, "rev-parse", "--verify", "HEAD")
head = git_text(path, "rev-parse", "--short", "HEAD") if has_head else None
upstream = git_text(path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}") if has_head else None
ahead: int | None = None
behind: int | None = None
if upstream:
counts = git_text(path, "rev-list", "--left-right", "--count", "@{upstream}...HEAD")
parts = counts.split()
if len(parts) == 2 and all(part.isdigit() for part in parts):
behind = int(parts[0])
ahead = int(parts[1])
local_target_tag_exists: bool | None = None
remote_target_tag_exists: bool | None = None
if target_tag:
local_target_tag_exists = git_success(path, "rev-parse", "-q", "--verify", f"refs/tags/{target_tag}")
if online:
remote_target_tag_exists = git_success(path, "ls-remote", "--exit-code", "--tags", "origin", f"refs/tags/{target_tag}", timeout=8)
try:
versions = collect_versions(path)
except Exception as exc: # noqa: BLE001 - surfaced in the dashboard.
errors.append(str(exc))
versions = VersionSnapshot()
return RepositorySnapshot(
spec=spec,
absolute_path=str(path),
exists=True,
is_git=True,
has_head=has_head,
head=head,
branch=branch or None,
upstream=upstream or None,
ahead=ahead,
behind=behind,
dirty_entries=tuple(line for line in status_result.stdout.splitlines() if line.strip()),
versions=versions,
local_target_tag_exists=local_target_tag_exists,
remote_target_tag_exists=remote_target_tag_exists,
remote_checked=online,
errors=tuple(errors),
)
def collect_versions(path: Path) -> VersionSnapshot:
return VersionSnapshot(
pyproject=read_pyproject_version(path),
package=read_json_version(path / "package.json"),
webui_package=read_json_version(path / "webui" / "package.json"),
manifests=read_manifest_versions(path),
)
def read_pyproject_version(path: Path) -> str | None:
pyproject = path / "pyproject.toml"
if not pyproject.exists():
return None
with pyproject.open("rb") as handle:
payload = tomllib.load(handle)
project = payload.get("project")
if isinstance(project, dict) and isinstance(project.get("version"), str):
return project["version"]
return None
def read_json_version(path: Path) -> str | None:
if not path.exists():
return None
payload = json.loads(path.read_text(encoding="utf-8"))
version = payload.get("version") if isinstance(payload, dict) else None
return version if isinstance(version, str) else None
def read_manifest_versions(path: Path) -> tuple[str, ...]:
src = path / "src"
if not src.exists():
return ()
versions: list[str] = []
for manifest in sorted(src.glob("**/backend/manifest.py")):
text = manifest.read_text(encoding="utf-8")
match = re.search(r'(?m)^\s*version\s*=\s*["\']([^"\']+)["\']', text)
if match is None:
match = re.search(r'(?m)^MODULE_VERSION\s*=\s*["\']([^"\']+)["\']', text)
if match is not None:
versions.append(match.group(1))
return tuple(versions)
def git_text(path: Path, *args: str, timeout: int = 10) -> str:
result = git(path, *args, timeout=timeout)
return result.stdout.strip() if result.returncode == 0 else ""
def git_success(path: Path, *args: str, timeout: int = 10) -> bool:
return git(path, *args, timeout=timeout).returncode == 0
def git(path: Path, *args: str, timeout: int = 10) -> subprocess.CompletedProcess[str]:
try:
return subprocess.run(
["git", *args],
cwd=path,
check=False,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout,
)
except subprocess.TimeoutExpired as exc:
return subprocess.CompletedProcess(["git", *args], 124, exc.stdout or "", exc.stderr or "git command timed out")
def git_error(result: subprocess.CompletedProcess[str]) -> str:
return "\n".join(part for part in (result.stderr.strip(), result.stdout.strip()) if part)
def is_dubious_ownership_error(value: str) -> bool:
return "detected dubious ownership in repository" in value or "safe.directory" in value
def compact_git_error(value: str) -> str:
lines = [line.strip() for line in value.splitlines() if line.strip()]
return lines[0] if lines else "git command failed"
def safe_directory_command(path: Path) -> str:
return f"git config --global --add safe.directory {path}"

View 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(),
)

View 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,
)

View File

@@ -0,0 +1,314 @@
"""Data models for GovOPlaN release tooling.
The models intentionally stay dataclass-based so both the CLI and the local web
console can use them without depending on a web framework or Pydantic.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, is_dataclass
from pathlib import Path
from typing import Any
@dataclass(frozen=True, slots=True)
class RepositorySpec:
name: str
category: str
subtype: str
remote: str
path: str
@dataclass(frozen=True, slots=True)
class VersionSnapshot:
pyproject: str | None = None
package: str | None = None
webui_package: str | None = None
manifests: tuple[str, ...] = ()
@property
def primary(self) -> str | None:
return self.pyproject or self.package or self.webui_package or (self.manifests[0] if self.manifests else None)
@dataclass(frozen=True, slots=True)
class RepositorySnapshot:
spec: RepositorySpec
absolute_path: str
exists: bool
is_git: bool
has_head: bool = False
head: str | None = None
branch: str | None = None
upstream: str | None = None
ahead: int | None = None
behind: int | None = None
dirty_entries: tuple[str, ...] = ()
versions: VersionSnapshot = VersionSnapshot()
local_target_tag_exists: bool | None = None
remote_target_tag_exists: bool | None = None
remote_checked: bool = False
safe_directory_required: bool = False
safe_directory_command: str | None = None
errors: tuple[str, ...] = ()
@property
def dirty(self) -> bool:
return bool(self.dirty_entries)
@dataclass(frozen=True, slots=True)
class CatalogSnapshot:
channel: str
website_path: str
catalog_path: str
catalog_exists: bool
keyring_path: str
keyring_exists: bool
catalog_version: str | None = None
core_release_version: str | None = None
module_count: int = 0
signature_count: int = 0
key_count: int = 0
generated_at: str | None = None
expires_at: str | None = None
catalog_error: str | None = None
keyring_error: str | None = None
local_catalog_hash: str | None = None
local_keyring_hash: str | None = None
public_catalog_url: str | None = None
public_keyring_url: str | None = None
public_checked: bool = False
public_catalog_exists: bool | None = None
public_keyring_exists: bool | None = None
public_core_release_version: str | None = None
public_module_count: int | None = None
public_signature_count: int | None = None
public_key_count: int | None = None
public_catalog_hash: str | None = None
public_keyring_hash: str | None = None
public_catalog_error: str | None = None
public_keyring_error: str | None = None
catalog_matches_public: bool | None = None
keyring_matches_public: bool | None = None
@dataclass(frozen=True, slots=True)
class MigrationTrackSnapshot:
track: str
ok: bool
ran: bool
graph_errors: tuple[str, ...] = ()
strict_errors: tuple[str, ...] = ()
error: str | None = None
@dataclass(frozen=True, slots=True)
class DashboardSummary:
repository_count: int
missing_count: int
dirty_count: int
ahead_count: int
behind_count: int
no_head_count: int
error_count: int
safe_directory_count: int
local_target_tag_missing_count: int
status: str
@dataclass(frozen=True, slots=True)
class ReleaseDashboard:
generated_at: str
meta_root: str
workspace_root: str
target_version: str | None
target_tag: str | None
online: bool
include_migrations: bool
summary: DashboardSummary
repositories: tuple[RepositorySnapshot, ...]
catalog: CatalogSnapshot
migrations: tuple[MigrationTrackSnapshot, ...] = ()
contracts: tuple[ModuleContractSnapshot, ...] = ()
@dataclass(frozen=True, slots=True)
class PlanAction:
id: str
title: str
detail: str
command: str | None = None
cwd: str | None = None
mutating: bool = False
severity: str = "info"
repo: str | None = None
@dataclass(frozen=True, slots=True)
class ReleasePlan:
generated_at: str
target_version: str | None
target_tag: str | None
status: str
actions: tuple[PlanAction, ...]
@dataclass(frozen=True, slots=True)
class InterfaceProviderSnapshot:
name: str
version: str
@dataclass(frozen=True, slots=True)
class InterfaceRequirementSnapshot:
name: str
version_min: str | None = None
version_max_exclusive: str | None = None
optional: bool = False
@dataclass(frozen=True, slots=True)
class ModuleContractSnapshot:
repo: str
module_id: str
module_version: str | None
manifest_path: str
provides_interfaces: tuple[InterfaceProviderSnapshot, ...] = ()
requires_interfaces: tuple[InterfaceRequirementSnapshot, ...] = ()
@dataclass(frozen=True, slots=True)
class ReleasePlanUnit:
repo: str
category: str
subtype: str
branch: str | None
current_version: str | None
target_version: str
target_tag: str
status: str
blockers: tuple[str, ...] = ()
warnings: tuple[str, ...] = ()
provides_interfaces: tuple[InterfaceProviderSnapshot, ...] = ()
requires_interfaces: tuple[InterfaceRequirementSnapshot, ...] = ()
@dataclass(frozen=True, slots=True)
class CompatibilityIssue:
severity: str
code: str
message: str
repo: str | None = None
module_id: str | None = None
interface: str | None = None
@dataclass(frozen=True, slots=True)
class ReleasePlanStep:
id: str
title: str
detail: str
command: str | None = None
cwd: str | None = None
mutating: bool = False
repo: str | None = None
status: str = "planned"
@dataclass(frozen=True, slots=True)
class SelectiveReleasePlan:
generated_at: str
target_channel: str
status: str
units: tuple[ReleasePlanUnit, ...]
compatibility: tuple[CompatibilityIssue, ...]
dry_run_steps: tuple[ReleasePlanStep, ...]
notes: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class CatalogEntryChange:
repo: str
module_id: str | None
field: str
before: str | None
after: str | None
@dataclass(frozen=True, slots=True)
class SelectiveCatalogCandidate:
generated_at: str
channel: str
status: str
candidate_dir: str
catalog_path: str
keyring_path: str
summary_path: str | None
source_catalog: str
public_catalog_url: str
public_keyring_url: str
sequence: int
signature_count: int
key_count: int
candidate_catalog_hash: str
candidate_keyring_hash: str
published_catalog_hash: str | None
published_keyring_hash: str | None
candidate_matches_published_catalog: bool | None
candidate_matches_published_keyring: bool | None
validation_valid: bool
validation_error: str | None = None
validation_warnings: tuple[str, ...] = ()
changes: tuple[CatalogEntryChange, ...] = ()
notes: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class CatalogPublishStep:
id: str
title: str
detail: str
command: str | None = None
mutating: bool = False
status: str = "planned"
@dataclass(frozen=True, slots=True)
class CatalogPublishResult:
generated_at: str
channel: str
status: str
applied: bool
candidate_dir: str
candidate_catalog_path: str
candidate_keyring_path: str
web_root: str
target_catalog_path: str
target_keyring_path: str
branch: str | None
tag_name: str | None
validation_valid: bool
validation_error: str | None = None
validation_warnings: tuple[str, ...] = ()
candidate_catalog_hash: str | None = None
candidate_keyring_hash: str | None = None
target_catalog_hash_before: str | None = None
target_keyring_hash_before: str | None = None
catalog_changed: bool = False
keyring_changed: bool = False
steps: tuple[CatalogPublishStep, ...] = ()
notes: tuple[str, ...] = ()
def to_jsonable(value: Any) -> Any:
if is_dataclass(value):
return {key: to_jsonable(item) for key, item in asdict(value).items()}
if isinstance(value, tuple | list):
return [to_jsonable(item) for item in value]
if isinstance(value, dict):
return {str(key): to_jsonable(item) for key, item in value.items()}
if isinstance(value, Path):
return str(value)
return value

View 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")

View 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),
)

View File

@@ -0,0 +1,409 @@
"""Publish reviewed release catalog candidates into the website repository."""
from __future__ import annotations
from datetime import UTC, datetime
import json
from pathlib import Path
import shlex
import shutil
import subprocess
from govoplan_core.core.module_package_catalog import validate_module_package_catalog
from .catalog import canonical_hash
from .model import CatalogPublishResult, CatalogPublishStep
from .selective_catalog import trusted_keys_from_keyring
from .workspace import DEFAULT_WORKSPACE_ROOT, resolve_workspace_root, website_root
def publish_catalog_candidate(
*,
candidate_dir: Path | str,
workspace_root: Path | str | None = None,
web_root: Path | str | None = None,
channel: str = "stable",
apply: bool = False, # noqa: A002 - CLI uses --apply.
build_web: bool = False,
commit: bool = False,
tag: bool = False,
push: bool = False,
remote: str = "origin",
branch: str | None = None,
npm: str = "npm",
tag_name: str | None = None,
allow_dirty_website: bool = False,
) -> CatalogPublishResult:
workspace = resolve_workspace_root(workspace_root or DEFAULT_WORKSPACE_ROOT)
candidate_root = Path(candidate_dir).expanduser()
if not candidate_root.is_absolute():
candidate_root = Path.cwd() / candidate_root
resolved_web_root = Path(web_root).expanduser().resolve() if web_root is not None else website_root(workspace)
candidate_catalog = candidate_root / "channels" / f"{channel}.json"
candidate_keyring = candidate_root / "keyring.json"
candidate_modules = candidate_root / "modules"
target_catalog = resolved_web_root / "public" / "catalogs" / "v1" / "channels" / f"{channel}.json"
target_keyring = resolved_web_root / "public" / "catalogs" / "v1" / "keyring.json"
target_modules = resolved_web_root / "public" / "catalogs" / "v1" / "modules"
steps: list[CatalogPublishStep] = []
notes: list[str] = []
blockers: list[str] = []
if not candidate_catalog.exists():
blockers.append(f"candidate catalog is missing: {candidate_catalog}")
if not candidate_keyring.exists():
blockers.append(f"candidate keyring is missing: {candidate_keyring}")
if not resolved_web_root.exists():
blockers.append(f"website root is missing: {resolved_web_root}")
if push:
commit = True
if tag:
commit = True
candidate_payload = read_json(candidate_catalog) if candidate_catalog.exists() else None
keyring_payload = read_json(candidate_keyring) if candidate_keyring.exists() else None
candidate_catalog_hash = canonical_hash(candidate_payload) if candidate_payload is not None else None
candidate_keyring_hash = canonical_hash(keyring_payload) if keyring_payload is not None else None
target_catalog_hash_before = file_json_hash(target_catalog)
target_keyring_hash_before = file_json_hash(target_keyring)
validation_valid = False
validation_error: str | None = None
validation_warnings: tuple[str, ...] = ()
if candidate_payload is not None and keyring_payload is not None:
validation = validate_module_package_catalog(
candidate_catalog,
require_trusted=True,
approved_channels=(channel,),
trusted_keys=trusted_keys_from_keyring(keyring_payload if isinstance(keyring_payload, dict) else {}),
)
validation_valid = validation.get("valid") is True
validation_error = str(validation["error"]) if validation.get("error") else None
validation_warnings = tuple(str(item) for item in validation.get("warnings") or ())
if not validation_valid:
blockers.append(validation_error or "candidate catalog validation failed")
catalog_changed = candidate_catalog_hash != target_catalog_hash_before
keyring_changed = candidate_keyring_hash != target_keyring_hash_before
if not catalog_changed and not keyring_changed:
notes.append("Candidate catalog and keyring already match the website repository.")
if apply or commit or tag or push:
if not (resolved_web_root / ".git").exists():
blockers.append(f"website root is not a Git repository: {resolved_web_root}")
if not allow_dirty_website and website_dirty(resolved_web_root):
blockers.append("website repository has uncommitted changes")
effective_branch = branch or git_text(resolved_web_root, "branch", "--show-current") or None
effective_tag_name = tag_name or default_tag_name(candidate_payload, channel=channel)
if (tag or push) and not effective_tag_name:
blockers.append("could not infer catalog tag name; pass --tag-name")
if (commit or push) and not effective_branch:
blockers.append("could not infer website branch; pass --branch")
if tag and effective_tag_name and git_success(resolved_web_root, "rev-parse", "--quiet", "--verify", f"refs/tags/{effective_tag_name}"):
blockers.append(f"website tag already exists: {effective_tag_name}")
steps.append(
CatalogPublishStep(
id="validate",
title="Validate candidate catalog",
detail="Validate signature, approved channel, freshness, interface closure, and keyring trust.",
status="done" if validation_valid else "blocked",
)
)
steps.append(
CatalogPublishStep(
id="copy",
title="Copy candidate catalog, keyring, and module directory",
detail=f"{candidate_root} -> {resolved_web_root / 'public' / 'catalogs' / 'v1'}",
mutating=True,
status="planned" if not apply else "blocked" if blockers else "done",
)
)
if not candidate_modules.exists():
notes.append("Candidate has no module-directory tree; only catalog and keyring will be copied.")
if build_web:
steps.append(
CatalogPublishStep(
id="build-web",
title="Build website",
detail="Run the website build after copying catalog files.",
command=shlex.join([npm, "--prefix", str(resolved_web_root), "run", "build"]),
mutating=True,
status="planned" if not apply else "blocked" if blockers else "pending",
)
)
if commit:
steps.append(
CatalogPublishStep(
id="commit",
title="Commit website catalog update",
detail="Commit generated catalog/keyring files in the website repository.",
command=shlex.join(["git", "-C", str(resolved_web_root), "commit", "-m", commit_message(candidate_payload, channel=channel)]),
mutating=True,
status="planned" if not apply else "blocked" if blockers else "pending",
)
)
if tag:
steps.append(
CatalogPublishStep(
id="tag",
title=f"Tag website catalog publication {effective_tag_name}",
detail="Create an annotated website publication tag.",
command=shlex.join(["git", "-C", str(resolved_web_root), "tag", "-a", effective_tag_name, "-m", commit_message(candidate_payload, channel=channel)]),
mutating=True,
status="planned" if not apply else "blocked" if blockers else "pending",
)
)
if push:
steps.append(
CatalogPublishStep(
id="push",
title="Push website catalog publication",
detail="Push website branch and tag if a tag was requested.",
command=push_command(resolved_web_root, remote=remote, branch=effective_branch, tag_name=effective_tag_name if tag else None),
mutating=True,
status="planned" if not apply else "blocked" if blockers else "pending",
)
)
if blockers:
return result(
status="blocked",
applied=False,
candidate_root=candidate_root,
candidate_catalog=candidate_catalog,
candidate_keyring=candidate_keyring,
resolved_web_root=resolved_web_root,
target_catalog=target_catalog,
target_keyring=target_keyring,
channel=channel,
branch=effective_branch,
tag_name=effective_tag_name,
validation_valid=validation_valid,
validation_error=validation_error,
validation_warnings=validation_warnings,
candidate_catalog_hash=candidate_catalog_hash,
candidate_keyring_hash=candidate_keyring_hash,
target_catalog_hash_before=target_catalog_hash_before,
target_keyring_hash_before=target_keyring_hash_before,
catalog_changed=catalog_changed,
keyring_changed=keyring_changed,
steps=tuple(steps),
notes=tuple([*notes, *blockers]),
)
if not apply:
notes.append("Dry run only. Pass --apply to copy files; pass --commit/--tag/--push for Git publication steps.")
return result(
status="ready",
applied=False,
candidate_root=candidate_root,
candidate_catalog=candidate_catalog,
candidate_keyring=candidate_keyring,
resolved_web_root=resolved_web_root,
target_catalog=target_catalog,
target_keyring=target_keyring,
channel=channel,
branch=effective_branch,
tag_name=effective_tag_name,
validation_valid=validation_valid,
validation_error=validation_error,
validation_warnings=validation_warnings,
candidate_catalog_hash=candidate_catalog_hash,
candidate_keyring_hash=candidate_keyring_hash,
target_catalog_hash_before=target_catalog_hash_before,
target_keyring_hash_before=target_keyring_hash_before,
catalog_changed=catalog_changed,
keyring_changed=keyring_changed,
steps=tuple(steps),
notes=tuple(notes),
)
target_catalog.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(candidate_catalog, target_catalog)
shutil.copy2(candidate_keyring, target_keyring)
if candidate_modules.exists():
if target_modules.exists():
shutil.rmtree(target_modules)
shutil.copytree(candidate_modules, target_modules)
completed_steps = [replace_status(step, "done") if step.id == "copy" else step for step in steps]
if build_web:
run_checked([npm, "--prefix", str(resolved_web_root), "run", "build"], cwd=resolved_web_root)
completed_steps = [replace_status(step, "done") if step.id == "build-web" else step for step in completed_steps]
if commit:
add_paths = ["git", "-C", str(resolved_web_root), "add", str(target_catalog), str(target_keyring)]
if target_modules.exists():
add_paths.append(str(target_modules))
run_checked(add_paths, cwd=resolved_web_root)
if git_success(resolved_web_root, "diff", "--cached", "--quiet"):
notes.append("No website catalog changes were staged for commit.")
completed_steps = [replace_status(step, "skipped") if step.id == "commit" else step for step in completed_steps]
else:
run_checked(["git", "-C", str(resolved_web_root), "commit", "-m", commit_message(candidate_payload, channel=channel)], cwd=resolved_web_root)
completed_steps = [replace_status(step, "done") if step.id == "commit" else step for step in completed_steps]
if tag and effective_tag_name:
run_checked(["git", "-C", str(resolved_web_root), "tag", "-a", effective_tag_name, "-m", commit_message(candidate_payload, channel=channel)], cwd=resolved_web_root)
completed_steps = [replace_status(step, "done") if step.id == "tag" else step for step in completed_steps]
if push and effective_branch:
push_args = ["git", "-C", str(resolved_web_root), "push"]
if tag and effective_tag_name:
push_args.extend(["--atomic", remote, f"HEAD:refs/heads/{effective_branch}", f"refs/tags/{effective_tag_name}"])
else:
push_args.extend([remote, f"HEAD:refs/heads/{effective_branch}"])
run_checked(push_args, cwd=resolved_web_root)
completed_steps = [replace_status(step, "done") if step.id == "push" else step for step in completed_steps]
return result(
status="published" if push else "applied",
applied=True,
candidate_root=candidate_root,
candidate_catalog=candidate_catalog,
candidate_keyring=candidate_keyring,
resolved_web_root=resolved_web_root,
target_catalog=target_catalog,
target_keyring=target_keyring,
channel=channel,
branch=effective_branch,
tag_name=effective_tag_name,
validation_valid=validation_valid,
validation_error=validation_error,
validation_warnings=validation_warnings,
candidate_catalog_hash=candidate_catalog_hash,
candidate_keyring_hash=candidate_keyring_hash,
target_catalog_hash_before=target_catalog_hash_before,
target_keyring_hash_before=target_keyring_hash_before,
catalog_changed=catalog_changed,
keyring_changed=keyring_changed,
steps=tuple(completed_steps),
notes=tuple(notes),
)
def result(
*,
status: str,
applied: bool,
candidate_root: Path,
candidate_catalog: Path,
candidate_keyring: Path,
resolved_web_root: Path,
target_catalog: Path,
target_keyring: Path,
channel: str,
branch: str | None,
tag_name: str | None,
validation_valid: bool,
validation_error: str | None,
validation_warnings: tuple[str, ...],
candidate_catalog_hash: str | None,
candidate_keyring_hash: str | None,
target_catalog_hash_before: str | None,
target_keyring_hash_before: str | None,
catalog_changed: bool,
keyring_changed: bool,
steps: tuple[CatalogPublishStep, ...],
notes: tuple[str, ...],
) -> CatalogPublishResult:
return CatalogPublishResult(
generated_at=datetime.now(tz=UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
channel=channel,
status=status,
applied=applied,
candidate_dir=str(candidate_root),
candidate_catalog_path=str(candidate_catalog),
candidate_keyring_path=str(candidate_keyring),
web_root=str(resolved_web_root),
target_catalog_path=str(target_catalog),
target_keyring_path=str(target_keyring),
branch=branch,
tag_name=tag_name,
validation_valid=validation_valid,
validation_error=validation_error,
validation_warnings=validation_warnings,
candidate_catalog_hash=candidate_catalog_hash,
candidate_keyring_hash=candidate_keyring_hash,
target_catalog_hash_before=target_catalog_hash_before,
target_keyring_hash_before=target_keyring_hash_before,
catalog_changed=catalog_changed,
keyring_changed=keyring_changed,
steps=steps,
notes=notes,
)
def read_json(path: Path) -> object:
return json.loads(path.read_text(encoding="utf-8"))
def file_json_hash(path: Path) -> str | None:
if not path.exists():
return None
return canonical_hash(read_json(path))
def website_dirty(path: Path) -> bool:
if not (path / ".git").exists():
return False
return bool(git_text(path, "status", "--porcelain"))
def default_tag_name(payload: object, *, channel: str) -> str | None:
if not isinstance(payload, dict):
return None
sequence = payload.get("sequence")
release = payload.get("release")
selected_units = release.get("selected_units") if isinstance(release, dict) else None
if isinstance(selected_units, list) and len(selected_units) == 1 and isinstance(selected_units[0], dict):
repo = str(selected_units[0].get("repo") or "catalog")
version = str(selected_units[0].get("version") or "").removeprefix("v")
if version:
return f"catalog-{channel}-{repo}-v{version}"
return f"catalog-{channel}-{sequence}" if sequence is not None else None
def commit_message(payload: object, *, channel: str) -> str:
if isinstance(payload, dict):
release = payload.get("release")
selected_units = release.get("selected_units") if isinstance(release, dict) else None
if isinstance(selected_units, list) and selected_units:
units = ", ".join(
f"{item.get('repo')} v{str(item.get('version') or '').removeprefix('v')}"
for item in selected_units
if isinstance(item, dict)
)
if units:
return f"Publish {channel} catalog for {units}"
return f"Publish {channel} catalog"
def push_command(web_root: Path, *, remote: str, branch: str | None, tag_name: str | None) -> str:
if tag_name:
return shlex.join(["git", "-C", str(web_root), "push", "--atomic", remote, f"HEAD:refs/heads/{branch or '<branch>'}", f"refs/tags/{tag_name}"])
return shlex.join(["git", "-C", str(web_root), "push", remote, f"HEAD:refs/heads/{branch or '<branch>'}"])
def replace_status(step: CatalogPublishStep, status: str) -> CatalogPublishStep:
return CatalogPublishStep(
id=step.id,
title=step.title,
detail=step.detail,
command=step.command,
mutating=step.mutating,
status=status,
)
def git_text(path: Path, *args: str) -> str:
result = subprocess.run(["git", *args], cwd=path, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
return result.stdout.strip() if result.returncode == 0 else ""
def git_success(path: Path, *args: str) -> bool:
return subprocess.run(["git", *args], cwd=path, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE).returncode == 0
def run_checked(args: list[str], *, cwd: Path) -> None:
subprocess.run(args, cwd=cwd, check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)

View 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")

View 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")

View 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 ..."

View 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")

View File

@@ -0,0 +1,407 @@
"""Selective release catalog candidate generation."""
from __future__ import annotations
import base64
from datetime import UTC, datetime, timedelta
import json
from pathlib import Path
import re
from typing import Any
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from govoplan_core.core.module_package_catalog import validate_module_package_catalog
from .catalog import DEFAULT_PUBLIC_BASE_URL, canonical_hash, fetch_json
from .contracts import collect_contracts
from .git_state import collect_repository_snapshot
from .model import CatalogEntryChange, SelectiveCatalogCandidate
from .module_directory import write_module_directory
from .workspace import load_repository_specs, resolve_workspace_root, website_root
GITEA_BASE = "git+ssh://git@git.add-ideas.de/add-ideas"
def build_selective_catalog_candidate(
*,
repo_versions: dict[str, str],
channel: str = "stable",
workspace_root: Path | str | None = None,
output_dir: Path | str | None = None,
base_catalog: Path | str | None = None,
signing_keys: tuple[str, ...] = (),
public_base_url: str = DEFAULT_PUBLIC_BASE_URL,
repository_base: str = GITEA_BASE,
expires_days: int = 90,
sequence: int | None = None,
check_public: bool = True,
write_summary: bool = True,
) -> SelectiveCatalogCandidate:
if not repo_versions:
raise ValueError("At least one repo version must be provided.")
parsed_keys = tuple(parse_signing_key(value) for value in signing_keys)
if not parsed_keys:
raise ValueError("At least one signing key is required.")
workspace = resolve_workspace_root(workspace_root)
web_root = website_root(workspace)
source_catalog = resolve_base_catalog(base_catalog=base_catalog, web_root=web_root, channel=channel)
payload = read_catalog(source_catalog)
if not isinstance(payload, dict):
raise ValueError("Selective catalog updates require object-style catalogs.")
generated_at = datetime.now(tz=UTC)
resolved_sequence = sequence or next_sequence(payload, generated_at=generated_at)
candidate = json.loads(json.dumps(payload))
candidate["channel"] = channel
candidate["sequence"] = resolved_sequence
candidate["generated_at"] = json_datetime(generated_at)
candidate["expires_at"] = json_datetime(generated_at + timedelta(days=expires_days))
release = candidate.get("release")
if not isinstance(release, dict):
release = {}
release["catalog_url"] = f"{public_base_url.rstrip('/')}/catalogs/v1/channels/{channel}.json"
release["keyring_url"] = f"{public_base_url.rstrip('/')}/catalogs/v1/keyring.json"
release["selected_units"] = [{"repo": repo, "version": version, "tag": f"v{version.removeprefix('v')}"} for repo, version in sorted(repo_versions.items())]
candidate["release"] = release
repo_contracts = contracts_by_repo(workspace)
changes = apply_repo_updates(
candidate,
repo_versions=repo_versions,
repo_contracts=repo_contracts,
repository_base=repository_base.rstrip("/"),
)
candidate.pop("signature", None)
candidate.pop("signatures", None)
candidate["signatures"] = [signature(candidate, key_id=key_id, private_key=private_key) for key_id, private_key in parsed_keys]
output_root = resolve_output_dir(output_dir=output_dir, channel=channel, generated_at=generated_at)
catalog_path = output_root / "channels" / f"{channel}.json"
keyring_path = output_root / "keyring.json"
summary_path = output_root / "summary.json" if write_summary else None
catalog_path.parent.mkdir(parents=True, exist_ok=True)
catalog_path.write_text(json.dumps(candidate, indent=2, sort_keys=True) + "\n", encoding="utf-8")
keyring_payload = keyring_payload_for_candidate(
existing_keyring=web_root / "public" / "catalogs" / "v1" / "keyring.json",
signing_keys=parsed_keys,
generated_at=generated_at,
)
keyring_path.write_text(json.dumps(keyring_payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
module_directory_files = write_module_directory(
catalog_payload=candidate,
keyring_payload=keyring_payload,
output_root=output_root,
channel=channel,
public_base_url=public_base_url,
)
trusted_keys = trusted_keys_from_keyring(keyring_payload)
validation = validate_module_package_catalog(
catalog_path,
require_trusted=True,
approved_channels=(channel,),
trusted_keys=trusted_keys,
)
validation_warnings = tuple(str(item) for item in validation.get("warnings") or ())
validation_error = validation.get("error")
candidate_catalog_hash = canonical_hash(candidate)
candidate_keyring_hash = canonical_hash(keyring_payload)
public_catalog_url = f"{public_base_url.rstrip('/')}/catalogs/v1/channels/{channel}.json"
public_keyring_url = f"{public_base_url.rstrip('/')}/catalogs/v1/keyring.json"
published_catalog_hash: str | None = None
published_keyring_hash: str | None = None
if check_public:
published_catalog = fetch_json(public_catalog_url)
if published_catalog["ok"]:
published_catalog_hash = canonical_hash(published_catalog["payload"])
published_keyring = fetch_json(public_keyring_url)
if published_keyring["ok"]:
published_keyring_hash = canonical_hash(published_keyring["payload"])
result = SelectiveCatalogCandidate(
generated_at=json_datetime(generated_at),
channel=channel,
status="ready" if validation.get("valid") else "blocked",
candidate_dir=str(output_root),
catalog_path=str(catalog_path),
keyring_path=str(keyring_path),
summary_path=str(summary_path) if summary_path is not None else None,
source_catalog=str(source_catalog),
public_catalog_url=public_catalog_url,
public_keyring_url=public_keyring_url,
sequence=resolved_sequence,
signature_count=len(candidate["signatures"]) if isinstance(candidate.get("signatures"), list) else 0,
key_count=len(keyring_payload.get("keys", ())) if isinstance(keyring_payload.get("keys"), list) else 0,
candidate_catalog_hash=candidate_catalog_hash,
candidate_keyring_hash=candidate_keyring_hash,
published_catalog_hash=published_catalog_hash,
published_keyring_hash=published_keyring_hash,
candidate_matches_published_catalog=compare_hash(candidate_catalog_hash, published_catalog_hash),
candidate_matches_published_keyring=compare_hash(candidate_keyring_hash, published_keyring_hash),
validation_valid=validation.get("valid") is True,
validation_error=str(validation_error) if validation_error else None,
validation_warnings=validation_warnings,
changes=tuple(changes),
notes=(
"Candidate catalog preserves unchanged entries from the source catalog.",
f"Generated {len(module_directory_files)} module-directory file(s).",
"Publishing is intentionally separate from candidate generation.",
),
)
if summary_path is not None:
summary_path.write_text(json.dumps(dataclass_payload(result), indent=2, sort_keys=True) + "\n", encoding="utf-8")
return result
def resolve_base_catalog(*, base_catalog: Path | str | None, web_root: Path, channel: str) -> Path | str:
if base_catalog is not None:
value = str(base_catalog)
return value if value.startswith(("http://", "https://")) else Path(value).expanduser()
local = web_root / "public" / "catalogs" / "v1" / "channels" / f"{channel}.json"
if local.exists():
return local
return f"{DEFAULT_PUBLIC_BASE_URL}/catalogs/v1/channels/{channel}.json"
def read_catalog(source: Path | str) -> object:
if isinstance(source, str) and source.startswith(("http://", "https://")):
payload = fetch_json(source)
if not payload["ok"]:
raise ValueError(f"Could not fetch catalog {source}: {payload['error']}")
return payload["payload"]
return json.loads(Path(source).read_text(encoding="utf-8"))
def next_sequence(payload: dict[str, Any], *, generated_at: datetime) -> int:
timestamp_sequence = int(generated_at.strftime("%Y%m%d%H%M"))
try:
current = int(payload.get("sequence") or 0)
except (TypeError, ValueError):
current = 0
return max(current + 1, timestamp_sequence)
def contracts_by_repo(workspace: Path) -> dict[str, Any]:
specs = load_repository_specs(include_website=False)
snapshots = tuple(
collect_repository_snapshot(spec, workspace_root=workspace, target_tag=None, online=False)
for spec in specs
)
return {contract.repo: contract for contract in collect_contracts(snapshots)}
def apply_repo_updates(
payload: dict[str, Any],
*,
repo_versions: dict[str, str],
repo_contracts: dict[str, Any],
repository_base: str,
) -> list[CatalogEntryChange]:
changes: list[CatalogEntryChange] = []
modules = payload.get("modules")
if not isinstance(modules, list):
raise ValueError("Catalog payload has no modules list.")
handled: set[str] = set()
if "govoplan-core" in repo_versions:
version = repo_versions["govoplan-core"].removeprefix("v")
tag = f"v{version}"
core_release = payload.get("core_release")
if not isinstance(core_release, dict):
raise ValueError("Catalog payload has no core_release object.")
changes.extend(update_field(core_release, repo="govoplan-core", module_id=None, field="version", value=version))
changes.extend(update_field(core_release, repo="govoplan-core", module_id=None, field="python_ref", value=f"govoplan-core[server] @ {repository_base}/govoplan-core.git@{tag}"))
changes.extend(update_field(core_release, repo="govoplan-core", module_id=None, field="webui_ref", value=f"{repository_base}/govoplan-core.git#{tag}"))
release = payload.get("release")
if isinstance(release, dict) and isinstance(release.get("version"), str):
changes.extend(update_field(release, repo="govoplan-core", module_id=None, field="version", value=version))
changes.extend(update_field(release, repo="govoplan-core", module_id=None, field="tag", value=tag))
handled.add("govoplan-core")
for entry in modules:
if not isinstance(entry, dict):
continue
repo = module_entry_repo(entry)
if repo not in repo_versions:
continue
version = repo_versions[repo].removeprefix("v")
tag = f"v{version}"
module_id = str(entry.get("module_id") or "")
package = str(entry.get("python_package") or repo)
changes.extend(update_field(entry, repo=repo, module_id=module_id, field="version", value=version))
changes.extend(update_field(entry, repo=repo, module_id=module_id, field="python_ref", value=f"{package} @ {repository_base}/{repo}.git@{tag}"))
if entry.get("webui_package"):
changes.extend(update_field(entry, repo=repo, module_id=module_id, field="webui_ref", value=f"{repository_base}/{repo}.git#{tag}"))
contract = repo_contracts.get(repo)
if contract is not None:
provider_payload = [{"name": item.name, "version": item.version} for item in contract.provides_interfaces]
requirement_payload = [
{
"name": item.name,
**({"version_min": item.version_min} if item.version_min else {}),
**({"version_max_exclusive": item.version_max_exclusive} if item.version_max_exclusive else {}),
"optional": item.optional,
}
for item in contract.requires_interfaces
]
if provider_payload:
changes.extend(update_json_field(entry, repo=repo, module_id=module_id, field="provides_interfaces", value=provider_payload))
if requirement_payload:
changes.extend(update_json_field(entry, repo=repo, module_id=module_id, field="requires_interfaces", value=requirement_payload))
handled.add(repo)
missing = sorted(set(repo_versions) - handled)
if missing:
raise ValueError(f"Selected repositories are not represented in the catalog: {', '.join(missing)}")
return changes
def module_entry_repo(entry: dict[str, Any]) -> str | None:
for field in ("python_ref", "webui_ref"):
value = entry.get(field)
if isinstance(value, str):
match = re.search(r"/([^/@#]+)[.]git(?:[@#]|$)", value)
if match:
return match.group(1)
package = entry.get("python_package")
return str(package).split("[", 1)[0] if isinstance(package, str) and package.startswith("govoplan-") else None
def update_field(target: dict[str, Any], *, repo: str, module_id: str | None, field: str, value: str) -> list[CatalogEntryChange]:
before = target.get(field)
before_text = before if isinstance(before, str) else None
if before_text == value:
return []
target[field] = value
return [CatalogEntryChange(repo=repo, module_id=module_id, field=field, before=before_text, after=value)]
def update_json_field(target: dict[str, Any], *, repo: str, module_id: str | None, field: str, value: list[dict[str, object]]) -> list[CatalogEntryChange]:
before = target.get(field)
if before == value:
return []
target[field] = value
return [
CatalogEntryChange(
repo=repo,
module_id=module_id,
field=field,
before=json.dumps(before, sort_keys=True) if before is not None else None,
after=json.dumps(value, sort_keys=True),
)
]
def parse_signing_key(value: str) -> tuple[str, Ed25519PrivateKey]:
key_id, separator, path_text = value.partition("=")
if not separator or not key_id.strip() or not path_text.strip():
raise ValueError("--catalog-signing-key must use KEY_ID=/path/to/private.pem")
path = Path(path_text).expanduser()
private_key = serialization.load_pem_private_key(path.read_bytes(), password=None)
if not isinstance(private_key, Ed25519PrivateKey):
raise ValueError(f"Catalog signing key must be an Ed25519 private key: {path}")
return key_id.strip(), private_key
def signature(payload: dict[str, Any], *, key_id: str, private_key: Ed25519PrivateKey) -> dict[str, str]:
signature_payload = dict(payload)
signature_payload.pop("signature", None)
signature_payload.pop("signatures", None)
return {
"algorithm": "ed25519",
"key_id": key_id,
"value": base64.b64encode(private_key.sign(canonical_bytes(signature_payload))).decode("ascii"),
}
def keyring_payload_for_candidate(
*,
existing_keyring: Path,
signing_keys: tuple[tuple[str, Ed25519PrivateKey], ...],
generated_at: datetime,
) -> dict[str, Any]:
expected = {key_id: public_key_base64(private_key) for key_id, private_key in signing_keys}
if existing_keyring.exists():
payload = json.loads(existing_keyring.read_text(encoding="utf-8"))
keys = payload.get("keys") if isinstance(payload, dict) else None
if isinstance(keys, list):
existing = {
str(item.get("key_id")): str(item.get("public_key") or item.get("public_key_base64"))
for item in keys
if isinstance(item, dict) and item.get("key_id")
}
if all(existing.get(key_id) == public_key for key_id, public_key in expected.items()):
return payload
return {
"keyring_version": "1",
"purpose": "govoplan module package catalog signatures",
"generated_at": json_datetime(generated_at),
"keys": [
{
"key_id": key_id,
"status": "active",
"public_key": public_key_base64(private_key),
"not_before": generated_at.date().isoformat() + "T00:00:00Z",
}
for key_id, private_key in signing_keys
],
}
def trusted_keys_from_keyring(payload: dict[str, Any]) -> dict[str, str]:
keys = payload.get("keys")
if not isinstance(keys, list):
return {}
result: dict[str, str] = {}
for item in keys:
if not isinstance(item, dict):
continue
status = str(item.get("status") or "active").lower()
if status in {"revoked", "disabled", "retired"}:
continue
key_id = str(item.get("key_id") or "")
public_key = str(item.get("public_key") or item.get("public_key_base64") or "")
if key_id and public_key:
result[key_id] = public_key
return result
def public_key_base64(private_key: Ed25519PrivateKey) -> str:
public_bytes = private_key.public_key().public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
return base64.b64encode(public_bytes).decode("ascii")
def canonical_bytes(payload: object) -> bytes:
return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
def compare_hash(candidate_hash: str | None, published_hash: str | None) -> bool | None:
if candidate_hash is None or published_hash is None:
return None
return candidate_hash == published_hash
def resolve_output_dir(*, output_dir: Path | str | None, channel: str, generated_at: datetime) -> Path:
if output_dir is not None:
return Path(output_dir).expanduser()
stamp = generated_at.strftime("%Y%m%d-%H%M%S")
return Path.cwd() / "runtime" / "release-candidates" / f"{channel}-{stamp}"
def json_datetime(value: datetime) -> str:
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
def dataclass_payload(value: object) -> dict[str, object]:
from dataclasses import asdict
return asdict(value)

View File

@@ -0,0 +1,271 @@
"""Selective release planning for independently versioned packages."""
from __future__ import annotations
from datetime import UTC, datetime
import shlex
from .contracts import validate_contracts
from .model import (
CompatibilityIssue,
InterfaceProviderSnapshot,
InterfaceRequirementSnapshot,
ReleaseDashboard,
ReleasePlanStep,
ReleasePlanUnit,
RepositorySnapshot,
SelectiveReleasePlan,
ModuleContractSnapshot,
)
def build_selective_release_plan(
dashboard: ReleaseDashboard,
*,
selected_repos: tuple[str, ...] = (),
target_version: str | None = None,
repo_versions: dict[str, str] | None = None,
channel: str = "stable",
) -> SelectiveReleasePlan:
repo_versions = repo_versions or {}
repositories = selected_repositories(dashboard, selected_repos=selected_repos)
contracts_by_repo = {contract.repo: contract for contract in dashboard.contracts}
units = tuple(
build_unit(repo, target_version=repo_versions.get(repo.spec.name) or target_version, contracts=contracts_by_repo.get(repo.spec.name))
for repo in repositories
)
compatibility = compatibility_issues(dashboard)
steps = dry_run_steps(units=units, dashboard=dashboard, channel=channel)
notes = release_notes(dashboard)
status = plan_status(units=units, compatibility=compatibility)
return SelectiveReleasePlan(
generated_at=datetime.now(tz=UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
target_channel=channel,
status=status,
units=units,
compatibility=compatibility,
dry_run_steps=steps,
notes=notes,
)
def selected_repositories(dashboard: ReleaseDashboard, *, selected_repos: tuple[str, ...]) -> tuple[RepositorySnapshot, ...]:
if selected_repos:
wanted = set(selected_repos)
return tuple(repo for repo in dashboard.repositories if repo.spec.name in wanted)
return tuple(repo for repo in dashboard.repositories if release_candidate(repo))
def release_candidate(repo: RepositorySnapshot) -> bool:
return repo.dirty or bool(repo.ahead) or (repo.exists and repo.is_git and not repo.has_head)
def build_unit(repo: RepositorySnapshot, *, target_version: str | None, contracts: ModuleContractSnapshot | None) -> ReleasePlanUnit:
current_version = repo.versions.primary
resolved_target = (target_version or current_version or "0.1.0").removeprefix("v")
target_tag = f"v{resolved_target}"
blockers: list[str] = []
warnings: list[str] = []
if not repo.exists:
blockers.append("repository is missing locally")
elif not repo.is_git:
blockers.append("path is not a Git repository")
elif repo.safe_directory_required:
blockers.append("Git safe.directory approval is required")
elif repo.behind:
blockers.append(f"repository is behind {repo.upstream}")
if repo.exists and repo.is_git and not repo.has_head:
blockers.append("repository has no initial commit")
if repo.dirty:
warnings.append("uncommitted changes will need review before release")
if repo.ahead:
warnings.append("unpushed commits should be pushed before catalog publication")
if current_version is None:
warnings.append("no local version metadata found; initial release needs version metadata and catalog entry synthesis")
if current_version and current_version == resolved_target and repo.local_target_tag_exists:
warnings.append(f"local tag {target_tag} already exists")
provides: tuple[InterfaceProviderSnapshot, ...] = ()
requires: tuple[InterfaceRequirementSnapshot, ...] = ()
if contracts is not None:
provides = contracts.provides_interfaces
requires = contracts.requires_interfaces
return ReleasePlanUnit(
repo=repo.spec.name,
category=repo.spec.category,
subtype=repo.spec.subtype,
branch=repo.branch,
current_version=current_version,
target_version=resolved_target,
target_tag=target_tag,
status="blocked" if blockers else "attention" if warnings else "ready",
blockers=tuple(blockers),
warnings=tuple(warnings),
provides_interfaces=provides,
requires_interfaces=requires,
)
def compatibility_issues(dashboard: ReleaseDashboard) -> tuple[CompatibilityIssue, ...]:
return validate_contracts(dashboard.contracts)
def dry_run_steps(*, units: tuple[ReleasePlanUnit, ...], dashboard: ReleaseDashboard, channel: str) -> tuple[ReleasePlanStep, ...]:
steps: list[ReleasePlanStep] = []
for unit in units:
steps.append(
ReleasePlanStep(
id=f"{unit.repo}:preflight",
title=f"Inspect {unit.repo}",
detail="Verify local branch, dirty state, current version, and target tag before release.",
command="git status --short --branch && git tag --list " + shlex.quote(unit.target_tag),
cwd=f"{dashboard.workspace_root}/{unit.repo}",
repo=unit.repo,
)
)
if unit.current_version != unit.target_version:
steps.append(
ReleasePlanStep(
id=f"{unit.repo}:version",
title=f"Update {unit.repo} version metadata",
detail=f"Prepare version files and manifest metadata for {unit.target_version}.",
command=None,
cwd=f"{dashboard.workspace_root}/{unit.repo}",
mutating=True,
repo=unit.repo,
status="needs-executor",
)
)
steps.append(
ReleasePlanStep(
id=f"{unit.repo}:commit",
title=f"Commit {unit.repo} release changes",
detail="Commit reviewed changes for this release unit only.",
command=f"git add -A && git commit -m {shlex.quote(f'Release {unit.repo} {unit.target_tag}')}",
cwd=f"{dashboard.workspace_root}/{unit.repo}",
mutating=True,
repo=unit.repo,
)
)
steps.append(
ReleasePlanStep(
id=f"{unit.repo}:tag",
title=f"Create {unit.repo} tag {unit.target_tag}",
detail="Create an annotated tag for this package release.",
command=f"git tag -a {shlex.quote(unit.target_tag)} -m {shlex.quote(f'Release {unit.repo} {unit.target_tag}')}",
cwd=f"{dashboard.workspace_root}/{unit.repo}",
mutating=True,
repo=unit.repo,
)
)
steps.append(
ReleasePlanStep(
id=f"{unit.repo}:push",
title=f"Push {unit.repo} release",
detail="Push the branch and release tag for this package only.",
command=f"git push --atomic origin HEAD:refs/heads/{shlex.quote(unit.branch or 'main')} refs/tags/{shlex.quote(unit.target_tag)}",
cwd=f"{dashboard.workspace_root}/{unit.repo}",
mutating=True,
repo=unit.repo,
)
)
if units:
initial_units = tuple(unit for unit in units if unit.current_version is None)
repo_version_args = " ".join(
f"--repo-version {shlex.quote(unit.repo + '=' + unit.target_version)}"
for unit in units
)
if initial_units:
steps.append(
ReleasePlanStep(
id="catalog:initial-entry-synthesis",
title=f"Prepare initial {channel} catalog entries",
detail=(
"Selected initial-release repositories are not guaranteed to exist in the channel catalog yet: "
+ ", ".join(unit.repo for unit in initial_units)
+ ". The current selective catalog writer updates existing entries only."
),
cwd=dashboard.meta_root,
mutating=True,
status="needs-catalog-entry-synthesis",
)
)
else:
steps.append(
ReleasePlanStep(
id="catalog:selective-generator",
title=f"Update {channel} release channel catalog",
detail=(
"Generate a signed candidate catalog that preserves unchanged package versions "
"and updates only selected release units."
),
command=(
"KEY_DIR=\"$HOME/.config/govoplan/release-keys\" "
f"tools/release/release-catalog.py selective --channel {shlex.quote(channel)} "
f"{repo_version_args} "
"--catalog-signing-key \"release-key-1=$KEY_DIR/release-key-1.pem\""
),
cwd=dashboard.meta_root,
mutating=True,
status="planned",
)
)
steps.append(
ReleasePlanStep(
id="catalog:validate-sign-publish",
title=f"Validate, sign, and publish {channel}",
detail="The candidate writer validates and signs locally. Publishing to the website repo remains a separate apply step.",
command="Review the candidate under runtime/release-candidates before publishing it to the website repository.",
cwd=dashboard.meta_root,
status="needs-publish-executor",
)
)
return tuple(steps)
def release_notes(dashboard: ReleaseDashboard) -> tuple[str, ...]:
notes = [
"Release units are independent; selecting one package should not force every repository to a shared tag.",
"Channel publication must preserve unchanged package versions and only advance selected release units.",
"Compatibility is checked from currently discovered manifest interface providers and requirements.",
]
if dashboard.catalog.public_checked:
notes.append("Local catalog/keyring hashes are compared with the published channel and keyring.")
return tuple(notes)
def plan_status(*, units: tuple[ReleasePlanUnit, ...], compatibility: tuple[CompatibilityIssue, ...]) -> str:
if any(unit.blockers for unit in units) or any(issue.severity == "blocker" for issue in compatibility):
return "blocked"
if units or compatibility:
return "attention"
return "ready"
def version_satisfies(version: str, requirement: InterfaceRequirementSnapshot) -> bool:
parsed = parse_version(version)
if parsed is None:
return False
if requirement.version_min and (minimum := parse_version(requirement.version_min)) is not None and parsed < minimum:
return False
if requirement.version_max_exclusive and (maximum := parse_version(requirement.version_max_exclusive)) is not None and parsed >= maximum:
return False
return True
def parse_version(value: str) -> tuple[int, ...] | None:
parts = value.split(".")
if not parts or any(not part.isdigit() for part in parts):
return None
return tuple(int(part) for part in parts)
def format_requirement(requirement: InterfaceRequirementSnapshot) -> str:
parts: list[str] = []
if requirement.version_min:
parts.append(f">={requirement.version_min}")
if requirement.version_max_exclusive:
parts.append(f"<{requirement.version_max_exclusive}")
return ", ".join(parts) if parts else "any version"

View 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"

View File

@@ -0,0 +1,214 @@
#!/usr/bin/env python3
"""Build GovOPlaN release catalog candidates."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from govoplan_release.module_directory import write_module_directory
from govoplan_release.model import to_jsonable
from govoplan_release.publisher import publish_catalog_candidate
from govoplan_release.selective_catalog import build_selective_catalog_candidate
from govoplan_release.workspace import DEFAULT_WORKSPACE_ROOT
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
selective = subparsers.add_parser("selective", help="Build a signed selective channel catalog candidate.")
selective.add_argument("--workspace-root", type=Path, default=DEFAULT_WORKSPACE_ROOT)
selective.add_argument("--channel", default="stable")
selective.add_argument("--repo-version", action="append", default=[], metavar="REPO=VERSION", required=True)
selective.add_argument("--catalog-signing-key", action="append", default=[], metavar="KEY_ID=PRIVATE_KEY", required=True)
selective.add_argument("--base-catalog", help="Catalog path or URL to use as the source. Defaults to local channel, then public channel.")
selective.add_argument("--output-dir", type=Path, help="Candidate output directory. Defaults to runtime/release-candidates/<channel>-<timestamp>.")
selective.add_argument("--public-base-url", default="https://govoplan.add-ideas.de")
selective.add_argument("--repository-base", default="git+ssh://git@git.add-ideas.de/add-ideas")
selective.add_argument("--expires-days", type=int, default=90)
selective.add_argument("--sequence", type=int)
selective.add_argument("--skip-public-check", action="store_true")
selective.add_argument("--json", action="store_true", help="Print machine-readable summary JSON.")
publish = subparsers.add_parser("publish-candidate", help="Publish a reviewed catalog candidate into the website repo.")
publish.add_argument("--candidate-dir", type=Path, required=True)
publish.add_argument("--workspace-root", type=Path, default=DEFAULT_WORKSPACE_ROOT)
publish.add_argument("--web-root", type=Path)
publish.add_argument("--channel", default="stable")
publish.add_argument("--apply", action="store_true", help="Copy candidate files into the website repo. Without this, only preview.")
publish.add_argument("--build-web", action="store_true")
publish.add_argument("--commit", action="store_true")
publish.add_argument("--tag", action="store_true", help="Create a website catalog publication tag. Implies --commit.")
publish.add_argument("--push", action="store_true", help="Push the website branch and tag. Implies --commit.")
publish.add_argument("--remote", default="origin")
publish.add_argument("--branch")
publish.add_argument("--tag-name")
publish.add_argument("--npm", default="npm")
publish.add_argument("--allow-dirty-website", action="store_true")
publish.add_argument("--json", action="store_true", help="Print machine-readable summary JSON.")
directory = subparsers.add_parser("module-directory", help="Generate browsable module-directory files from a catalog and keyring.")
directory.add_argument("--catalog", type=Path, required=True)
directory.add_argument("--keyring", type=Path, required=True)
directory.add_argument("--output-dir", type=Path, required=True)
directory.add_argument("--channel", default="stable")
directory.add_argument("--public-base-url", default="https://govoplan.add-ideas.de")
directory.add_argument("--json", action="store_true", help="Print machine-readable summary JSON.")
args = parser.parse_args()
if args.command == "selective":
result = build_selective_catalog_candidate(
repo_versions=parse_repo_versions(tuple(args.repo_version)),
channel=args.channel,
workspace_root=args.workspace_root,
output_dir=args.output_dir,
base_catalog=args.base_catalog,
signing_keys=tuple(args.catalog_signing_key),
public_base_url=args.public_base_url,
repository_base=args.repository_base,
expires_days=args.expires_days,
sequence=args.sequence,
check_public=not args.skip_public_check,
)
payload = to_jsonable(result)
if args.json:
print(json.dumps(payload, indent=2, sort_keys=True))
else:
print_text_summary(payload)
return 0 if result.status == "ready" else 1
if args.command == "publish-candidate":
result = publish_catalog_candidate(
candidate_dir=args.candidate_dir,
workspace_root=args.workspace_root,
web_root=args.web_root,
channel=args.channel,
apply=args.apply,
build_web=args.build_web,
commit=args.commit,
tag=args.tag,
push=args.push,
remote=args.remote,
branch=args.branch,
npm=args.npm,
tag_name=args.tag_name,
allow_dirty_website=args.allow_dirty_website,
)
payload = to_jsonable(result)
if args.json:
print(json.dumps(payload, indent=2, sort_keys=True))
else:
print_publish_summary(payload)
return 0 if result.status in {"ready", "applied", "published"} else 1
if args.command == "module-directory":
catalog_payload = json.loads(args.catalog.read_text(encoding="utf-8"))
keyring_payload = json.loads(args.keyring.read_text(encoding="utf-8"))
files = write_module_directory(
catalog_payload=catalog_payload,
keyring_payload=keyring_payload,
output_root=args.output_dir,
channel=args.channel,
public_base_url=args.public_base_url,
)
payload = {"status": "generated", "output_dir": str(args.output_dir), "files": [str(path) for path in files]}
if args.json:
print(json.dumps(payload, indent=2, sort_keys=True))
else:
print("Module directory")
print(f" output_dir: {args.output_dir}")
print(f" files: {len(files)}")
for path in files[:20]:
print(f" {path}")
if len(files) > 20:
print(f" ... {len(files) - 20} more")
return 0
return 2
def parse_repo_versions(values: tuple[str, ...]) -> dict[str, str]:
result: dict[str, str] = {}
for value in values:
if "=" not in value:
raise SystemExit(f"--repo-version must use REPO=VERSION: {value}")
repo, version = value.split("=", 1)
repo = repo.strip()
version = version.strip().removeprefix("v")
if not repo or not version:
raise SystemExit(f"--repo-version must use REPO=VERSION: {value}")
result[repo] = version
return result
def print_text_summary(payload: dict[str, object]) -> None:
print("Selective catalog candidate")
for key in (
"status",
"channel",
"sequence",
"catalog_path",
"keyring_path",
"summary_path",
"validation_valid",
"validation_error",
"candidate_matches_published_catalog",
"candidate_matches_published_keyring",
):
print(f" {key}: {payload.get(key)}")
changes = payload.get("changes")
if isinstance(changes, list):
print(f" changes: {len(changes)}")
for item in changes[:20]:
if not isinstance(item, dict):
continue
print(
" "
f"{item.get('repo')}:{item.get('module_id') or '-'} "
f"{item.get('field')} {item.get('before')!r} -> {item.get('after')!r}"
)
if len(changes) > 20:
print(f" ... {len(changes) - 20} more")
warnings = payload.get("validation_warnings")
if isinstance(warnings, list) and warnings:
print(" validation warnings:")
for warning in warnings:
print(f" {warning}")
def print_publish_summary(payload: dict[str, object]) -> None:
print("Catalog publish candidate")
for key in (
"status",
"applied",
"channel",
"candidate_dir",
"web_root",
"target_catalog_path",
"target_keyring_path",
"branch",
"tag_name",
"validation_valid",
"validation_error",
"catalog_changed",
"keyring_changed",
):
print(f" {key}: {payload.get(key)}")
steps = payload.get("steps")
if isinstance(steps, list):
print(" steps:")
for item in steps:
if not isinstance(item, dict):
continue
print(f" [{item.get('status')}] {item.get('title')}")
command = item.get("command")
if command:
print(f" {command}")
notes = payload.get("notes")
if isinstance(notes, list) and notes:
print(" notes:")
for note in notes:
print(f" {note}")
if __name__ == "__main__":
raise SystemExit(main())

View 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())

View File

@@ -15,9 +15,8 @@ import subprocess
import sys
import tomllib
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]
ROOT = Path(os.environ.get("GOVOPLAN_CORE_ROOT", META_ROOT.parent / "govoplan-core")).resolve()
@@ -32,6 +31,7 @@ class SuggestedCommand:
title: str
command: str
cwd: str
argv: tuple[str, ...] = ()
mutating: bool = False
reason: str = ""
@@ -897,7 +897,12 @@ def run_suggested_command(item: SuggestedCommand, *, assume_yes: bool = False) -
if confirmation not in {"y", "yes"}:
print("Skipped.")
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}.")
return result.returncode
@@ -950,7 +955,7 @@ def run_safe_directory_workflow(report: DoctorReport) -> None:
return
for repo in repos:
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}.")
@@ -1020,9 +1025,8 @@ def commit_and_push_dirty_repositories(report: DoctorReport) -> None:
def page_text(text: str) -> None:
if sys.stdout.isatty():
pager = os.environ.get("PAGER", "less -R")
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:
return
except OSError:
@@ -1034,28 +1038,61 @@ def report_to_dict(report: DoctorReport) -> dict[str, Any]:
return asdict(report)
def command(cwd: Path, title: str, value: str, *, mutating: bool = False, reason: str = "") -> SuggestedCommand:
return SuggestedCommand(title=title, command=value, cwd=str(cwd), mutating=mutating, reason=reason)
def command(
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:
assert report.target_version is not None
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(
META_ROOT,
"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}",
mutating=not dry_run,
argv=argv,
)
def migration_audit_command(track: str, *, strict: bool = False) -> SuggestedCommand:
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:
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(
META_ROOT,
"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} "
"--catalog-signing-key \"release-key-1=$KEY_DIR/release-key-1.pem\" --build-web",
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:
return shlex.quote(python_bin_path())
def python_bin_path() -> str:
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:
@@ -1152,12 +1201,16 @@ def safe_directory_command(path: Path) -> str:
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]:
request = Request(url, method="HEAD")
try:
with urlopen(request, timeout=8) as response: # noqa: S310 - operator-requested release availability check.
return {"ok": 200 <= response.status < 400, "status": response.status, "url": url}
except URLError as exc:
checked_url = validate_http_url(url)
response = fetch_http(checked_url, timeout=8, method="HEAD")
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}

View 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())

View File

@@ -0,0 +1 @@
"""Local release console server package."""

331
tools/release/server/app.py Normal file
View File

@@ -0,0 +1,331 @@
"""FastAPI app for the local GovOPlaN release console."""
from __future__ import annotations
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse
from pydantic import BaseModel, Field
from govoplan_release import (
build_dashboard,
build_release_intelligence,
build_release_plan,
build_selective_catalog_candidate,
build_selective_release_plan,
publish_catalog_candidate,
prepare_repositories,
push_repositories,
sync_repositories,
)
from govoplan_release.model import to_jsonable
from govoplan_release.workspace import DEFAULT_WORKSPACE_ROOT, META_ROOT
class CatalogCandidateRequest(BaseModel):
repo_versions: dict[str, str] = Field(default_factory=dict)
repos: list[str] = Field(default_factory=list)
target_version: str | None = None
channel: str = "stable"
signing_keys: list[str] = Field(default_factory=list)
output_dir: str | None = None
base_catalog: str | None = None
expires_days: int = 90
sequence: int | None = None
public_base_url: str = "https://govoplan.add-ideas.de"
repository_base: str = "git+ssh://git@git.add-ideas.de/add-ideas"
check_public: bool = True
class PublishCandidateRequest(BaseModel):
candidate_dir: str
channel: str = "stable"
web_root: str | None = None
apply: bool = False # noqa: A003 - API field mirrors CLI.
build_web: bool = False
commit: bool = False
tag: bool = False
push: bool = False
remote: str = "origin"
branch: str | None = None
tag_name: str | None = None
npm: str = "npm"
allow_dirty_website: bool = False
confirm: str = ""
class RepositoryPushRequest(BaseModel):
repos: list[str] = Field(default_factory=list)
remote: str = "origin"
apply: bool = False # noqa: A003 - API field mirrors CLI.
confirm: str = ""
class RepositorySyncRequest(BaseModel):
repos: list[str] = Field(default_factory=list)
remote: str = "origin"
apply: bool = False # noqa: A003 - API field mirrors CLI.
confirm: str = ""
class RepositoryPrepareRequest(BaseModel):
repos: list[str] = Field(default_factory=list)
repo_versions: dict[str, str] = Field(default_factory=dict)
message: str | None = None
apply: bool = False # noqa: A003 - API field mirrors CLI.
confirm: str = ""
def create_app(*, workspace_root: Path = DEFAULT_WORKSPACE_ROOT, token: str | None = None) -> FastAPI:
app = FastAPI(title="GovOPlaN Release Console", version="0.1.0")
app.state.workspace_root = workspace_root
app.state.token = token
@app.middleware("http")
async def require_token(request: Request, call_next): # type: ignore[no-untyped-def]
if token and request.url.path.startswith("/api/"):
provided = request.headers.get("x-release-console-token")
if provided != token:
return JSONResponse({"detail": "release console token required"}, status_code=401)
return await call_next(request)
@app.get("/", response_class=HTMLResponse)
def index() -> HTMLResponse:
index_path = Path(__file__).resolve().parents[1] / "webui" / "index.html"
if not index_path.exists():
raise HTTPException(status_code=500, detail="release console UI is missing")
return HTMLResponse(index_path.read_text(encoding="utf-8"), headers={"Cache-Control": "no-store"})
@app.get("/api/health")
def health() -> dict[str, object]:
return {
"ok": True,
"workspace_root": str(app.state.workspace_root),
"token_required": bool(app.state.token),
}
@app.get("/api/dashboard")
def dashboard(
target_version: str | None = None,
online: bool = False,
remote_tags: bool = False,
public_catalog: bool = True,
include_migrations: bool = False,
include_website: bool = False,
channel: str = "stable",
) -> dict[str, object]:
snapshot = build_dashboard(
workspace_root=app.state.workspace_root,
target_version=target_version,
online=online,
check_remote_tags=online or remote_tags,
check_public_catalog=public_catalog,
include_migrations=include_migrations,
include_website=include_website,
channel=channel,
)
return to_jsonable(snapshot)
@app.get("/api/plan")
def plan(
target_version: str | None = None,
online: bool = False,
remote_tags: bool = False,
public_catalog: bool = True,
include_migrations: bool = False,
channel: str = "stable",
) -> dict[str, object]:
snapshot = build_dashboard(
workspace_root=app.state.workspace_root,
target_version=target_version,
online=online,
check_remote_tags=online or remote_tags,
check_public_catalog=public_catalog,
include_migrations=include_migrations,
channel=channel,
)
release_plan = build_release_plan(snapshot)
return to_jsonable(release_plan)
@app.get("/api/release-intelligence")
def release_intelligence(
channel: str = "stable",
public_catalog: bool = True,
) -> dict[str, object]:
intelligence = build_release_intelligence(
workspace_root=app.state.workspace_root,
channel=channel,
prefer_public=public_catalog,
)
return to_jsonable(intelligence)
@app.get("/api/selective-plan")
def selective_plan(
repos: str | None = None,
target_version: str | None = None,
repo_versions: str | None = None,
online: bool = False,
remote_tags: bool = False,
public_catalog: bool = True,
include_migrations: bool = False,
channel: str = "stable",
) -> dict[str, object]:
snapshot = build_dashboard(
workspace_root=app.state.workspace_root,
target_version=target_version,
online=online,
check_remote_tags=online or remote_tags,
check_public_catalog=public_catalog,
include_migrations=include_migrations,
channel=channel,
)
release_plan = build_selective_release_plan(
snapshot,
selected_repos=parse_csv(repos),
target_version=target_version,
repo_versions=parse_repo_versions(repo_versions),
channel=channel,
)
return to_jsonable(release_plan)
@app.get("/api/catalog-candidates")
def catalog_candidates() -> dict[str, object]:
return {"candidates": list_catalog_candidates()}
@app.post("/api/catalog-candidates")
def catalog_candidate(request: CatalogCandidateRequest) -> dict[str, object]:
repo_versions = dict(request.repo_versions)
if not repo_versions and request.repos and request.target_version:
repo_versions = {repo: request.target_version for repo in request.repos}
if not repo_versions:
raise HTTPException(status_code=400, detail="repo_versions or repos with target_version is required")
signing_keys = tuple(request.signing_keys or default_signing_keys())
if not signing_keys:
raise HTTPException(status_code=400, detail="No signing key supplied and default release key was not found")
candidate = build_selective_catalog_candidate(
repo_versions=repo_versions,
channel=request.channel,
workspace_root=app.state.workspace_root,
output_dir=request.output_dir,
base_catalog=request.base_catalog,
signing_keys=signing_keys,
public_base_url=request.public_base_url,
repository_base=request.repository_base,
expires_days=request.expires_days,
sequence=request.sequence,
check_public=request.check_public,
)
return to_jsonable(candidate)
@app.post("/api/catalog-candidates/publish")
def publish_candidate(request: PublishCandidateRequest) -> dict[str, object]:
if request.push and request.confirm != "PUSH":
raise HTTPException(status_code=400, detail="Push requires confirm=PUSH")
if (request.apply or request.commit or request.tag or request.build_web) and not request.push and request.confirm != "APPLY":
raise HTTPException(status_code=400, detail="Apply/commit/tag/build requires confirm=APPLY")
result = publish_catalog_candidate(
candidate_dir=request.candidate_dir,
workspace_root=app.state.workspace_root,
web_root=request.web_root,
channel=request.channel,
apply=request.apply,
build_web=request.build_web,
commit=request.commit,
tag=request.tag,
push=request.push,
remote=request.remote,
branch=request.branch,
tag_name=request.tag_name,
npm=request.npm,
allow_dirty_website=request.allow_dirty_website,
)
return to_jsonable(result)
@app.post("/api/repositories/push")
def repository_push(request: RepositoryPushRequest) -> dict[str, object]:
repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip()))
if not repos:
raise HTTPException(status_code=400, detail="At least one repository must be selected")
if request.apply and request.confirm != "PUSH":
raise HTTPException(status_code=400, detail="Repository push requires confirm=PUSH")
result = push_repositories(repos=repos, workspace_root=app.state.workspace_root, remote=request.remote, apply=request.apply)
return to_jsonable(result)
@app.post("/api/repositories/sync")
def repository_sync(request: RepositorySyncRequest) -> dict[str, object]:
repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip()))
if not repos:
raise HTTPException(status_code=400, detail="At least one repository must be selected")
if request.apply and request.confirm != "SYNC":
raise HTTPException(status_code=400, detail="Repository sync requires confirm=SYNC")
result = sync_repositories(repos=repos, workspace_root=app.state.workspace_root, remote=request.remote, apply=request.apply)
return to_jsonable(result)
@app.post("/api/repositories/prepare")
def repository_prepare(request: RepositoryPrepareRequest) -> dict[str, object]:
repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip()))
if not repos:
raise HTTPException(status_code=400, detail="At least one repository must be selected")
if request.apply and request.confirm != "COMMIT":
raise HTTPException(status_code=400, detail="Repository prepare requires confirm=COMMIT")
result = prepare_repositories(
repos=repos,
repo_versions=request.repo_versions,
message=request.message,
workspace_root=app.state.workspace_root,
apply=request.apply,
)
return to_jsonable(result)
return app
def parse_csv(value: str | None) -> tuple[str, ...]:
if not value:
return ()
return tuple(item.strip() for item in value.split(",") if item.strip())
def parse_repo_versions(value: str | None) -> dict[str, str]:
if not value:
return {}
result: dict[str, str] = {}
for item in value.split(","):
if ":" not in item:
continue
repo, version = item.split(":", 1)
repo = repo.strip()
version = version.strip()
if repo and version:
result[repo] = version
return result
def default_signing_keys() -> tuple[str, ...]:
key_path = Path.home() / ".config" / "govoplan" / "release-keys" / "release-key-1.pem"
if not key_path.exists():
return ()
return (f"release-key-1={key_path}",)
def list_catalog_candidates() -> list[dict[str, object]]:
root = META_ROOT / "runtime" / "release-candidates"
if not root.exists():
return []
candidates: list[dict[str, object]] = []
for path in sorted((item for item in root.iterdir() if item.is_dir()), reverse=True):
summary = path / "summary.json"
if summary.exists():
try:
import json
payload = json.loads(summary.read_text(encoding="utf-8"))
if isinstance(payload, dict):
payload.setdefault("candidate_dir", str(path))
candidates.append(payload)
continue
except json.JSONDecodeError:
pass
candidates.append({"candidate_dir": str(path), "status": "unknown"})
return candidates

File diff suppressed because it is too large Load Diff

View 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())

View File

@@ -0,0 +1,479 @@
#!/usr/bin/env python3
"""Synchronize a GovOPlaN Python environment when package metadata changes."""
from __future__ import annotations
import argparse
from dataclasses import dataclass
import hashlib
import json
import os
from pathlib import Path
import shlex
import subprocess
import sys
from typing import Any
META_ROOT = Path(__file__).resolve().parents[2]
STAMP_VERSION = 1
@dataclass(frozen=True, slots=True)
class RequirementEntry:
line: str
install_args: tuple[str, ...]
is_local: bool
key: str
target_path: str | None = None
pyproject: str | None = None
def as_dict(self) -> dict[str, object]:
payload: dict[str, object] = {
"line": self.line,
"install_args": list(self.install_args),
"is_local": self.is_local,
"key": self.key,
}
if self.target_path:
payload["target_path"] = self.target_path
if self.pyproject:
payload["pyproject"] = self.pyproject
return payload
@dataclass(frozen=True, slots=True)
class InstallPlan:
mode: str
reason: str
commands: tuple[tuple[str, ...], ...]
warnings: tuple[str, ...] = ()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--requirements",
type=Path,
default=META_ROOT / "requirements-dev.txt",
help="Requirements file to install. Defaults to requirements-dev.txt.",
)
parser.add_argument(
"--python",
default=sys.executable,
help="Python executable to use for pip. Defaults to the current interpreter.",
)
parser.add_argument(
"--stamp",
type=Path,
default=None,
help="Stamp file used to remember the last synchronized metadata digest.",
)
parser.add_argument("--force", action="store_true", help="Run pip even when the environment stamp is current.")
parser.add_argument("--check", action="store_true", help="Exit 1 if synchronization would be required.")
parser.add_argument("--dry-run", action="store_true", help="Print what would run without changing the environment.")
parser.add_argument("--upgrade-pip", action="store_true", help="Upgrade pip before installing requirements.")
args = parser.parse_args()
requirements = args.requirements.expanduser().resolve()
if not requirements.exists():
print(f"Requirements file not found: {requirements}", file=sys.stderr)
return 2
python = normalize_python_command(args.python)
stamp = (args.stamp or default_stamp_path(python, requirements)).expanduser().resolve()
local_requirements = local_requirement_entries(requirements)
fingerprint = build_fingerprint(requirements=requirements, python=python, local_requirements=local_requirements)
previous = load_stamp(stamp)
current = previous.get("digest") == fingerprint["digest"]
if current and not args.force:
payload = stamp_payload(
requirements=requirements,
python=python,
fingerprint=fingerprint,
entries=parse_requirement_entries(requirements),
)
if stamp_needs_refresh(previous, payload):
if args.dry_run:
print(f"Python environment is current for {requirements.name}. Stamp metadata would be refreshed.")
print("No pip command required; the environment stamp would be refreshed.")
return 0
if args.check:
print(f"Python environment is current for {requirements.name}.")
return 0
write_stamp(stamp, payload)
print(f"Python environment is current for {requirements.name}. Stamp metadata refreshed.")
return 0
print(f"Python environment is current for {requirements.name}.")
return 0
print(f"Python environment metadata is stale for {requirements.name}.")
print(f"Tracked inputs: {len(fingerprint['inputs'])}")
if args.check:
return 1
plan = build_install_plan(
previous=previous,
fingerprint=fingerprint,
requirements=requirements,
python=python,
local_requirements=local_requirements,
force=args.force,
)
print(f"{plan.mode}: {plan.reason}")
for warning in plan.warnings:
print(f"warning: {warning}", file=sys.stderr)
commands: list[tuple[str, ...]] = []
if args.upgrade_pip:
commands.append((python, "-m", "pip", "install", "--upgrade", "pip"))
commands.extend(plan.commands)
if args.dry_run:
for command in commands:
print("+ " + format_command(command))
if not commands:
print("No pip command required; the environment stamp would be refreshed.")
return 0
for command in commands:
subprocess.run(command, check=True)
write_stamp(
stamp,
stamp_payload(
requirements=requirements,
python=python,
fingerprint=fingerprint,
entries=parse_requirement_entries(requirements),
),
)
print(f"Python environment synchronized. Stamp written to {stamp}.")
return 0
def default_stamp_path(python: str, requirements: Path) -> Path:
executable = Path(python)
if executable.name.startswith("python") and executable.parent.name == "bin":
return executable.parent.parent / f".govoplan-sync-{requirements.stem}.json"
return META_ROOT / "runtime" / f".govoplan-sync-{requirements.stem}.json"
def normalize_python_command(value: str) -> str:
if os.sep not in value:
return value
path = Path(value).expanduser()
if not path.is_absolute():
path = Path.cwd() / path
return str(path)
def stamp_payload(
*,
requirements: Path,
python: str,
fingerprint: dict[str, Any],
entries: tuple[RequirementEntry, ...],
) -> dict[str, object]:
return {
"version": STAMP_VERSION,
"digest": fingerprint["digest"],
"requirements": str(requirements),
"python": python,
"inputs": fingerprint["inputs"],
"requirements_entries": [entry.as_dict() for entry in entries],
}
def stamp_needs_refresh(previous: dict[str, Any], payload: dict[str, object]) -> bool:
return any(previous.get(key) != value for key, value in payload.items())
def build_fingerprint(*, requirements: Path, python: str, local_requirements: tuple[RequirementEntry, ...]) -> dict[str, Any]:
hasher = hashlib.sha256()
inputs: list[dict[str, str]] = []
add_text(hasher, "stamp-version", str(STAMP_VERSION))
add_text(hasher, "python", python)
add_file(hasher, inputs, requirements)
for project in local_pyprojects(local_requirements):
add_file(hasher, inputs, project)
return {"digest": hasher.hexdigest(), "inputs": inputs}
def build_install_plan(
*,
previous: dict[str, Any],
fingerprint: dict[str, Any],
requirements: Path,
python: str,
local_requirements: tuple[RequirementEntry, ...],
force: bool,
) -> InstallPlan:
full_command = (python, "-m", "pip", "install", "-r", str(requirements))
if force:
return InstallPlan("Full Python environment sync", "--force was requested.", (full_command,))
if previous.get("version") != STAMP_VERSION:
return InstallPlan("Full Python environment sync", "the environment stamp is missing or uses an older format.", (full_command,))
if previous.get("python") != python:
return InstallPlan("Full Python environment sync", "the Python executable changed.", (full_command,))
previous_inputs = inputs_by_path(previous.get("inputs"))
current_inputs = inputs_by_path(fingerprint.get("inputs"))
if not previous_inputs:
return InstallPlan("Full Python environment sync", "there is no previous input fingerprint.", (full_command,))
requirements_key = str(requirements)
changed_paths = {
path
for path, digest in current_inputs.items()
if previous_inputs.get(path) != digest
}
removed_paths = set(previous_inputs) - set(current_inputs)
stale_requirements = requirements_key in changed_paths or requirements_key in removed_paths
commands: list[tuple[str, ...]] = []
warnings: list[str] = []
if stale_requirements:
delta = requirement_delta_plan(previous.get("requirements_entries"), parse_requirement_entries(requirements))
if delta is None:
return InstallPlan(
"Full Python environment sync",
"requirements changed in a way that needs the full resolver.",
(full_command,),
)
for install_args in delta.commands:
commands.append((python, "-m", "pip", "install", *install_args))
warnings.extend(delta.warnings)
changed_paths.discard(requirements_key)
removed_paths.discard(requirements_key)
local_by_pyproject = {
entry.pyproject: entry
for entry in local_requirements
if entry.pyproject
}
for path in sorted(changed_paths):
entry = local_by_pyproject.get(path)
if entry is None:
return InstallPlan(
"Full Python environment sync",
f"tracked input changed outside a known local project: {path}",
(full_command,),
)
commands.append((python, "-m", "pip", "install", *entry.install_args))
if removed_paths:
return InstallPlan(
"Full Python environment sync",
"tracked input files disappeared; using the full requirements installer.",
(full_command,),
)
deduped_commands = tuple(dict.fromkeys(commands))
if deduped_commands:
return InstallPlan(
"Selective Python environment sync",
f"installing {len(deduped_commands)} stale local requirement(s).",
deduped_commands,
tuple(warnings),
)
return InstallPlan(
"Stamp-only Python environment sync",
"requirements metadata changed without a pip-installable requirement change.",
(),
tuple(warnings),
)
def requirement_delta_plan(previous_entries: object, current_entries: tuple[RequirementEntry, ...]) -> InstallPlan | None:
parsed_previous = previous_requirement_entries(previous_entries)
if parsed_previous is None:
return None
previous_by_key = {entry.key: entry for entry in parsed_previous}
current_by_key = {entry.key: entry for entry in current_entries}
changed_current = [
entry
for key, entry in current_by_key.items()
if previous_by_key.get(key) is None or previous_by_key[key].install_args != entry.install_args or previous_by_key[key].line != entry.line
]
removed_keys = set(previous_by_key) - set(current_by_key)
if any(not entry.is_local or not entry.pyproject for entry in changed_current):
return None
warnings = []
if removed_keys:
warnings.append("Removed requirements are not uninstalled automatically; pip install -r had the same limitation.")
commands = tuple((entry.install_args) for entry in changed_current)
return InstallPlan(
"Requirement delta",
"requirements file changed only in local editable requirements.",
commands,
tuple(warnings),
)
def previous_requirement_entries(value: object) -> tuple[RequirementEntry, ...] | None:
if not isinstance(value, list):
return None
entries: list[RequirementEntry] = []
for item in value:
if not isinstance(item, dict):
return None
raw_install_args = item.get("install_args")
if not isinstance(raw_install_args, list) or not all(isinstance(arg, str) for arg in raw_install_args):
return None
line = item.get("line")
key = item.get("key")
if not isinstance(line, str) or not isinstance(key, str):
return None
entries.append(
RequirementEntry(
line=line,
install_args=tuple(raw_install_args),
is_local=bool(item.get("is_local")),
key=key,
target_path=item.get("target_path") if isinstance(item.get("target_path"), str) else None,
pyproject=item.get("pyproject") if isinstance(item.get("pyproject"), str) else None,
)
)
return tuple(entries)
def inputs_by_path(value: object) -> dict[str, str]:
if not isinstance(value, list):
return {}
inputs: dict[str, str] = {}
for item in value:
if not isinstance(item, dict):
continue
path = item.get("path")
digest = item.get("sha256")
if isinstance(path, str) and isinstance(digest, str):
inputs[path] = digest
return inputs
def local_requirement_entries(requirements: Path) -> tuple[RequirementEntry, ...]:
return tuple(entry for entry in parse_requirement_entries(requirements) if entry.is_local and entry.pyproject)
def parse_requirement_entries(requirements: Path) -> tuple[RequirementEntry, ...]:
entries: list[RequirementEntry] = []
for raw_line in requirements.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or line.startswith(("-r ", "--requirement")):
continue
editable = editable_target(line)
target = editable or local_path_target(line)
if target is None:
entries.append(RequirementEntry(line=line, install_args=(line,), is_local=False, key=f"requirement:{line}"))
continue
path = resolve_requirement_path(requirements.parent, target)
if path is None:
entries.append(RequirementEntry(line=line, install_args=(line,), is_local=False, key=f"requirement:{line}"))
continue
pyproject = path / "pyproject.toml"
resolved_pyproject = str(pyproject.resolve()) if pyproject.exists() else None
install_target = absolute_local_target(requirements.parent, target)
install_args = ("-e", install_target) if editable is not None else (install_target,)
key = f"local:{resolved_pyproject or path.resolve()}"
entries.append(
RequirementEntry(
line=line,
install_args=install_args,
is_local=True,
key=key,
target_path=str(path.resolve()),
pyproject=resolved_pyproject,
)
)
return tuple(entries)
def local_pyprojects(local_requirements: tuple[RequirementEntry, ...]) -> tuple[Path, ...]:
projects = {Path(entry.pyproject) for entry in local_requirements if entry.pyproject}
return tuple(sorted(projects))
def editable_target(line: str) -> str | None:
if line.startswith("-e "):
return line[3:].strip()
if line.startswith("--editable "):
return line.removeprefix("--editable ").strip()
if line.startswith("--editable="):
return line.removeprefix("--editable=").strip()
return None
def local_path_target(line: str) -> str | None:
if line.startswith(("./", "../", "/")):
return line
return None
def resolve_requirement_path(base: Path, target: str) -> Path | None:
cleaned, _suffix = split_local_target(target)
if "://" in cleaned or cleaned.startswith("git+"):
return None
if not cleaned:
return None
return (base / cleaned).resolve()
def absolute_local_target(base: Path, target: str) -> str:
path_part, suffix = split_local_target(target)
return f"{(base / path_part).resolve()}{suffix}"
def split_local_target(target: str) -> tuple[str, str]:
cleaned = target.split("#", 1)[0].strip()
fragment = target[len(cleaned) :].strip()
extras = ""
if "[" in cleaned:
cleaned, extra_part = cleaned.split("[", 1)
extras = f"[{extra_part}"
return cleaned.strip(), f"{extras}{fragment}"
def add_file(hasher: hashlib._Hash, inputs: list[dict[str, str]], path: Path) -> None:
data = path.read_bytes()
digest = hashlib.sha256(data).hexdigest()
add_text(hasher, str(path), digest)
inputs.append({"path": str(path), "sha256": digest})
def add_text(hasher: hashlib._Hash, key: str, value: str) -> None:
hasher.update(key.encode("utf-8"))
hasher.update(b"\0")
hasher.update(value.encode("utf-8"))
hasher.update(b"\0")
def load_stamp(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return payload if isinstance(payload, dict) else {}
def write_stamp(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def format_command(command: tuple[str, ...]) -> str:
return " ".join(shlex.quote(item) for item in command)
if __name__ == "__main__":
raise SystemExit(main())