Harden source release preparation and record verified security follow-up
This commit is contained in:
@@ -361,6 +361,15 @@ and WebUI API proxy traffic across API replicas. The WebUI and API services do
|
||||
not publish host ports. HAProxy has no Docker socket and discovers only the
|
||||
bounded replica slots rendered into `load-balancer.cfg`.
|
||||
|
||||
New installation specifications default to HAProxy `3.2.23-alpine`, pinned to
|
||||
registry index `sha256:6343ce34a132a5dceaa24767d739df2bd519f8f7c1079ae39e4821334e8eb42e`.
|
||||
This patch remains on HAProxy 3.2 LTS and Alpine 3.24.1. Loading an existing
|
||||
specification preserves its explicit image; it does not perform an upgrade.
|
||||
The [runtime remediation evidence](../security/RUNTIME_IMAGE_REMEDIATION_2026-09-08.md)
|
||||
records both architecture scans and the pending binary/configuration, runtime,
|
||||
inventory and final-image checks. The source default is not a release approval:
|
||||
runtime publication remains held in Meta #52.
|
||||
|
||||
Replica counts are desired state:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -263,11 +263,198 @@ python tools/release/generate-developer-meta-package.py
|
||||
python tools/release/generate-developer-meta-package.py --check
|
||||
```
|
||||
|
||||
The direct generator is a development synchronization tool, not a receipt-gated
|
||||
release executor. For release preparation, use the guarded out-of-run stage below.
|
||||
|
||||
`push-release-tag.sh` performs this synchronization before release commits and
|
||||
tags. The meta-package is for editable/developer setup and composition tests. It
|
||||
does not enable modules, apply migrations, provision services, or establish
|
||||
backup and recovery evidence.
|
||||
|
||||
### Shared source-tag contract and Meta composition
|
||||
|
||||
The shared version collector names Meta's real
|
||||
`packages/govoplan-meta/pyproject.toml` separately from root `pyproject.toml`.
|
||||
Only the registered `govoplan` system/meta repository with nested project name
|
||||
`govoplan` receives this contract. Missing, unknown, or misidentified metadata
|
||||
does not become a versionless exception. Version alignment compares the complete
|
||||
nested file with the canonical operator-tool generator output: its version must
|
||||
match Core, and dependencies and `full` composition must match the reviewed
|
||||
requirements and workspace package versions. Validation never executes a
|
||||
generator from a selected checkout. The shared trusted manifest checker can
|
||||
load reviewed application manifests; these checks are not a code sandbox.
|
||||
|
||||
Meta's complete generated file is recognized by shared version-mutation discovery,
|
||||
but the generic durable version executor deliberately cannot write it. A durable
|
||||
run freezes the release console's own Meta checkout as trusted runtime code;
|
||||
changing it in place would invalidate that run. The planner therefore places Meta
|
||||
after Core and exposes only non-executable support preparation/publication steps,
|
||||
not misleading automatic Meta version, commit, tag, or push actions. A missing or
|
||||
different Core target produces an actionable preparation prerequisite.
|
||||
|
||||
Prepare Core and the intended module inputs first, commit their reviewed state,
|
||||
then stop active durable runs for the target workspace. Use trusted operator tools
|
||||
against a separate registered, private source checkout, never the running operator
|
||||
Meta directory. Preview outside any selected source checkout, for example:
|
||||
|
||||
```sh
|
||||
python tools/release/prepare-developer-meta-package.py \
|
||||
--workspace /private/release-workspace --target-version X.Y.Z \
|
||||
> /private/operator/meta-preview.json
|
||||
python tools/release/prepare-developer-meta-package.py \
|
||||
--workspace /private/release-workspace --target-version X.Y.Z \
|
||||
--receipt /private/operator/meta-preview.json --apply --confirm-out-of-run
|
||||
```
|
||||
|
||||
The explicit confirmation attests that no durable run is active for that target
|
||||
workspace; the helper does not discover or stop other processes. Preview/apply
|
||||
requires registered clean main sources, matching origins and live-main ancestry,
|
||||
Core already aligned at the target, the exact nested identity, and no existing or
|
||||
unverifiable target Meta tag. Frozen receipts cover source HEADs/filesystem
|
||||
identities, release requirements, every discovered registered full-composition
|
||||
pyproject, the trusted generator snapshot, and the resulting full-file hash.
|
||||
Inputs are limited to 128 selected files, 2 MiB per file and 16 MiB aggregate.
|
||||
The canonical generator renders copied bounded data in a temporary directory;
|
||||
no generator from the selected checkout executes. Changed receipts block before
|
||||
the file effect. Apply writes only `packages/govoplan-meta/pyproject.toml`, then
|
||||
rechecks the other sources and exact output. A write or post-check failure that
|
||||
may have changed the file reports `needs-reconciliation` and leaves that bounded
|
||||
delta for explicit review; it never retries, rolls back, commits or publishes.
|
||||
|
||||
Review the complete generated composition and manually commit the resulting file.
|
||||
Complete matching Core publication before guarded Meta source tagging/publication,
|
||||
then start a fresh durable run from reviewed, clean, published operator tooling.
|
||||
Hot self-updating durable Meta release execution remains explicitly unsupported;
|
||||
this out-of-run preparation is the existing developer-meta support contract.
|
||||
|
||||
For every `tag_repositories` batch, strict checks apply to every selected
|
||||
repository before any tag creation, fetch, or push: registered checkout and
|
||||
origin/push URL, a clean `main` tracking `origin/main`, live remote-main ancestry,
|
||||
exact frozen HEADs, and immutable annotated local/remote tag objects. Git metadata
|
||||
must remain inside the operator's trusted workspace. Missing local knowledge of
|
||||
live remote main is a blocker; fetch and review it separately. Unknown repositories
|
||||
and non-registered remote aliases fail closed. If selected, Meta runs last.
|
||||
For Meta only, the matching annotated Core release must exist before its effect; Core may be
|
||||
an earlier selected repository, or an already tagged dependency. Publication
|
||||
requires that Core's exact tag and main commit are already remote.
|
||||
|
||||
Non-Meta selections do not acquire Meta's composition or Core-tag prerequisite.
|
||||
Local module-candidate tags still work before Core's final release lock or tag.
|
||||
The existing Core WebUI bundle gate still applies to module publication and
|
||||
batches selecting Core: relevant Core release-package and release-lock inputs
|
||||
must be operator-owned regular files, at most 16 MiB each, and their identities
|
||||
and content hashes are frozen before preflight and rechecked before every effect.
|
||||
When Core is unselected, this does not require its checkout to be clean or tagged;
|
||||
reviewed pending composition inputs retain their previous meaning. Backend-only
|
||||
selections never read irrelevant Core WebUI files.
|
||||
|
||||
Before even read-only Git commands, source ancestry must be owned by root or the
|
||||
current operator and must not be group/world writable. A sticky shared ancestor
|
||||
such as `/tmp` is permitted only above an owned, protected child; the workspace
|
||||
and checkouts receive no writable-directory exception. The current operator must
|
||||
own source inputs and actual Git/worktree/common metadata, which must be regular
|
||||
files/directories, non-symlinked, and non-writable by other users. Metadata walks
|
||||
are bounded to 500,000 entries and 128 levels, and tracked inputs to 100,000 paths
|
||||
and 16 MiB of listing text. Read-only Git targets, object alternates/grafts and
|
||||
hidden/sparse/unmerged index entries are blocked. Frozen receipts include actual
|
||||
checkout/Git directory paths, devices, inodes, owners and modes, so replacing Git
|
||||
metadata with the same HEAD is still detected. All selected version/composition
|
||||
inputs must be tracked, including root and WebUI package/lock metadata, discovered
|
||||
module manifests and package initializers, and Meta's nested package and release
|
||||
requirements; ignored working files cannot supply declarations absent from a tag.
|
||||
No chmod, ownership repair or
|
||||
global Git trust change is performed. A shared writable workspace must first be
|
||||
recreated or reviewed in the operator's protected release area by an explicitly
|
||||
authorized preparation workflow.
|
||||
|
||||
Preview is read-only. Local-tag mode creates only pinned annotated tags (or
|
||||
retrieves an identical published annotation); it does not publish main or tags.
|
||||
Publish mode atomically pushes the frozen main commit and annotation object,
|
||||
without force, retagging, fallback, or automatic retry. The complete source
|
||||
receipt is rechecked before every effect and afterward; remote main and the
|
||||
exact annotated tag must both match, not merely the Git exit status. Changes
|
||||
after preflight stop the remaining batch. Atomicity is per repository, not
|
||||
across repositories: earlier successful publications and a newly created local
|
||||
tag can remain after a later failure. Inspect reported receipts and obtain a new
|
||||
review before retrying; do not move immutable tags.
|
||||
|
||||
Whole-batch revalidation deliberately repeats source and live-remote checks around
|
||||
each repository effect; the number of checks can grow quadratically with batch
|
||||
size. Plan release time accordingly rather than bypassing trust checks. The
|
||||
shared internal preflight is read-only and exposes no legacy mutation path.
|
||||
The fixture suite covers Meta and non-Meta preview/local-tag/
|
||||
publication using temporary local bare remotes, including stale compositions,
|
||||
unsafe origins, divergent branches, damaged tag identity, changed receipts and
|
||||
false publication success. This is local tooling evidence, not a real release
|
||||
publication or production permission check.
|
||||
|
||||
Deutsch: Die gemeinsamen Helfer erkennen ausschließlich das registrierte
|
||||
Meta-Repository mit dem echten Paket `packages/govoplan-meta/pyproject.toml`
|
||||
(Projektname `govoplan`). Version und vollständige Zusammensetzung müssen dem
|
||||
kanonischen Generator, Core und den geprüften Anforderungen entsprechen; der
|
||||
Generator stammt niemals aus dem ausgewählten Checkout. Der gemeinsame
|
||||
Manifestprüfer kann geprüften Anwendungscode laden und ist keine Sandbox.
|
||||
Die gemeinsame Änderungsplanung erkennt die vollständig generierte Paketdatei,
|
||||
aber der dauerhafte Versionsausführer darf Meta nicht selbst verändern: sein
|
||||
eingefrorener Lauf bindet den Meta-Checkout als vertrauenswürdigen Programmstand.
|
||||
Meta erscheint deshalb nach Core ausschließlich mit nicht automatisch ausführbaren
|
||||
Vorbereitungs-/Veröffentlichungsschritten. Zuerst Core und Modulquellen vorbereiten
|
||||
und geprüft committen; bei abweichender Core-Zielversion nennt der Plan diese
|
||||
Voraussetzung ausdrücklich. Aktive dauerhafte Läufe des Ziel-Workspaces beenden.
|
||||
Mit `prepare-developer-meta-package.py` zunächst eine Vorschau außerhalb der
|
||||
Quell-Checkouts speichern, dann deren JSON über `--receipt` zusammen mit `--apply`
|
||||
und `--confirm-out-of-run` bestätigen. Das Ziel muss ein separater registrierter
|
||||
privater Checkout sein, niemals das laufende Operator-Meta. Die Bestätigung ist
|
||||
eine Betreibererklärung; der Helfer sucht oder beendet keine fremden Prozesse.
|
||||
Quell-HEADs, Pfadidentitäten, Anforderungen, alle registrierten vollständigen
|
||||
Paket-Eingaben, der vertrauenswürdige Generator und der vollständige Ausgabehash
|
||||
werden eingefroren. Es gelten höchstens 128 Quelldateien, 2 MiB je Datei und
|
||||
16 MiB insgesamt. Core muss bereits vollständig zur Zielversion passen; vorhandene
|
||||
oder nicht verifizierbare Meta-Zieltags sperren die Vorbereitung. Geänderte
|
||||
Nachweise stoppen vor dem Schreiben. Ausschließlich die verschachtelte Paketdatei
|
||||
wird vollständig generiert und danach geprüft; ein Fehler nach dem Schreiben
|
||||
meldet `needs-reconciliation` und erfordert die manuelle Prüfung dieser begrenzten
|
||||
Änderung, ohne automatisches Zurücksetzen. Kein automatischer
|
||||
Commit, Push oder Wiederholungsversuch findet statt. Zusammensetzung prüfen,
|
||||
manuell committen, Core zuerst veröffentlichen, dann die geschützte Meta-Tag-Route
|
||||
verwenden und einen neuen dauerhaften Lauf starten. Eine Selbstaktualisierung
|
||||
des aktiven dauerhaften Meta-Laufs bleibt ausdrücklich nicht unterstützt.
|
||||
Für jeden Tag-Stapel, auch ohne Meta, gelten
|
||||
Vertrauens-, Origin-, saubere Main- und Live-Abstammungsprüfungen für die gesamte
|
||||
Auswahl vor jeder Änderung. Unbekannte Repositories und nicht registrierte
|
||||
Remote-Aliase sind gesperrt. Nur bei ausgewähltem Meta gelten zusätzlich dessen
|
||||
Zusammensetzungsprüfung und der passende annotierte Core-Tag als Voraussetzung;
|
||||
Meta folgt zuletzt. Für Metas Veröffentlichung müssen Core-Tag und Main-Commit
|
||||
bereits auf dem Remote vorliegen. Lokale Modul-Kandidatentags bleiben vor Cores
|
||||
abschließendem Release-Lock und Tag möglich. Die vorhandene Core-WebUI-Bundleprüfung
|
||||
bleibt bei Modulveröffentlichung und Core-Auswahl erhalten. Relevante Core-Paket-
|
||||
und Lockdateien müssen eigene reguläre Dateien mit höchstens je 16 MiB sein;
|
||||
Identität und Inhaltshash werden eingefroren und vor jeder Aktion erneut geprüft.
|
||||
Nicht ausgewähltes Core benötigt dafür weder einen sauberen Checkout noch einen
|
||||
Tag. Reine Backend-Auswahlen lesen keine irrelevanten Core-WebUI-Dateien.
|
||||
Vor Git-Aufrufen werden Eigentümer, Schreibrechte, sichere
|
||||
Pfadabstammung und echte Git-/Worktree-Metadaten geprüft; veränderbare gemeinsame
|
||||
Verzeichnisse, fremde Eigentümer, Alternates, Grafts und versteckte Indexeinträge
|
||||
sind gesperrt. Ein Sticky-Bit-Vorfahr wie `/tmp` ist nur oberhalb eines eigenen
|
||||
geschützten Unterverzeichnisses zulässig. Es erfolgen weder Rechtereparaturen
|
||||
noch globale Git-Vertrauensänderungen. Ausgewählte Versions- und Zusammensetzungs-
|
||||
dateien müssen versioniert sein: Paket-/Lockdateien, Modulmanifeste und
|
||||
Paketinitialisierer sowie Metas verschachteltes Paket und Release-Anforderungen.
|
||||
Ignorierte Arbeitsdateien dürfen keine vom Tag abweichenden Angaben liefern.
|
||||
Die Vorschau schreibt nichts, lokale Tags veröffentlichen nichts,
|
||||
und die Veröffentlichung überträgt Main und den exakten annotierten Tag atomar
|
||||
je Repository. Unmittelbar vor und nach den Aktionen werden die eingefrorenen
|
||||
Quellnachweise erneut geprüft, einschließlich entferntem Main und Tag-Objekt.
|
||||
Bei Änderungen oder Fehlern stoppt der Rest des Stapels ohne automatischen
|
||||
Wiederholungsversuch. Frühere Veröffentlichungen und neu erzeugte lokale Tags
|
||||
können bestehen bleiben: vor einem neuen Versuch Nachweise prüfen und erneut
|
||||
freigeben, niemals unveränderliche Tags verschieben. Die vollständigen Quell- und
|
||||
Live-Remote-Prüfungen werden um jede Aktion wiederholt; bei großen Stapeln kann
|
||||
deren Anzahl quadratisch wachsen. Diese konservativen Prüfkosten gehören zur
|
||||
Release-Planung. Der interne Vorprüfer ist ausschließlich lesend und besitzt
|
||||
keinen alten Änderungspfad. Tests für Auswahlen mit und ohne Meta verwenden
|
||||
nur temporäre lokale Remotes und ersetzen keine echte Veröffentlichungsprüfung.
|
||||
|
||||
If the tag-triggered developer meta-package job fails before publication, rerun
|
||||
`publish-developer-meta-package.yml` with the existing protected version. The
|
||||
manual path validates that tag against `main`, checks out its exact commit, and
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# Runtime image remediation follow-up — 8 September 2026
|
||||
|
||||
Canonical tracking: [Meta #52](https://git.add-ideas.de/GovOPlaN/govoplan/issues/52)
|
||||
and [website #9](https://git.add-ideas.de/add-ideas/addideas-govoplan-website/issues/9).
|
||||
This addendum supplements the [original audit](RUNTIME_IMAGE_AUDIT_2026-09-08.md);
|
||||
it does not replace that historical baseline or lift either publication or
|
||||
deployment gate. The immutable 0.1.45 release and `catalog-v0.1.45` are unchanged.
|
||||
|
||||
## Source change and candidate decisions
|
||||
|
||||
New installer specifications now use
|
||||
`haproxy:3.2.23-alpine@sha256:6343ce34a132a5dceaa24767d739df2bd519f8f7c1079ae39e4821334e8eb42e`.
|
||||
This is a patch update from 3.2.21 within the supported
|
||||
[3.2 LTS branch](https://www.haproxy.org/), keeping Alpine 3.24.1.
|
||||
The [publisher's exact build source](https://github.com/docker-library/haproxy/blob/7a5c202cde713867a737033dca56e7a211a8b8df/3.2/alpine/Dockerfile)
|
||||
and scanned image configuration retain the non-root `haproxy` user,
|
||||
`/usr/local/etc/haproxy/haproxy.cfg`, entrypoint and graceful-stop signal.
|
||||
The [upstream changelog](https://www.haproxy.org/download/3.2/src/CHANGELOG)
|
||||
includes HTTP parsing, TLS and memory-safety fixes in 3.2.22/3.2.23.
|
||||
Existing specifications retain their explicit image, including an older pin;
|
||||
this source change does not update a running installation.
|
||||
|
||||
| Candidate | OS | C / H / M / L / Unknown, per architecture | Disposition |
|
||||
| --- | --- | --- | --- |
|
||||
| HAProxy `3.2.23-alpine` | Alpine 3.24.1 | 0 / 0 / 0 / 0 / 0 | Installer source default updated; binary/runtime checks pending |
|
||||
| nginx-unprivileged `1.30.4-alpine` | Alpine 3.24.1 | 0 / 0 / 0 / 0 / 0 | Candidate only; compatibility and website OS upgrade review pending |
|
||||
| Caddy `2.11.4-alpine` | Alpine 3.23.5 | 1 / 38 / 41 / 12 / 23 | Not selected; all 39 C/H records have recorded fixes |
|
||||
| Node `24-alpine` (24.20.0) | Alpine 3.24.1 | 0 / 6 / 11 / 12 / 0 | Not selected; major change and six fixable HIGH records |
|
||||
|
||||
Each row was scanned separately for **linux/amd64 and linux/arm64**, with the
|
||||
same counts on both. Counts are package-vulnerability records, not distinct
|
||||
CVEs or proven exploits. The [machine-readable evidence](runtime-image-candidates-2026-09-08.json)
|
||||
contains exact index, platform-manifest, config and report digests, inventory
|
||||
counts, scanner bounds and decisions. It is audit data, not an accepted release
|
||||
manifest or an installer input.
|
||||
|
||||
nginx's candidate reference is
|
||||
`docker.io/nginxinc/nginx-unprivileged:1.30.4-alpine@sha256:442753882674b49ae2c1de83ed67896131c0777f56df5005e356e62bc3f7e7ce`.
|
||||
It inventories 70 Alpine packages including nginx/NJS, uses UID 101 and exposes
|
||||
8080. The [upstream stable release](https://nginx.org/en/download.html) and
|
||||
[security advisories](https://nginx.org/en/security_advisories.html) include the
|
||||
1.30.4 fixes. The publisher retains its
|
||||
[unprivileged port and temporary-path contract](https://github.com/nginx/docker-nginx-unprivileged).
|
||||
For the website, this changes nginx 1.27.5 to 1.30.4, NJS 0.8.10 to 1.0.1 and
|
||||
Alpine 3.21.3 to 3.24.1. These changes are explicit review items; the website
|
||||
Dockerfile has not been changed. The GovOPlaN Web image still requires an
|
||||
explicit verified `NGINX_IMAGE` build argument.
|
||||
|
||||
Caddy's candidate reference is
|
||||
`docker.io/library/caddy:2.11.4-alpine@sha256:5f5c8640aae01df9654968d946d8f1a56c497f1dd5c5cda4cf95ab7c14d58648`.
|
||||
Although this is the current
|
||||
[official image line](https://raw.githubusercontent.com/docker-library/official-images/master/library/caddy),
|
||||
its inventory still includes Go 1.26.3, `x/crypto` 0.52.0, `x/net` 0.55.0,
|
||||
`x/text` 0.37.0 and gRPC 1.81.0. Recorded fixes include Go 1.26.6,
|
||||
`x/crypto` 0.55.0, `x/net` 0.56.0, `x/text` 0.39.0 and gRPC 1.83.1; Alpine
|
||||
findings also remain in c-ares, curl/libcurl and OpenSSL. The CRITICAL
|
||||
`CVE-2026-56854` concerns `x/crypto/ssh` source-address enforcement. A module
|
||||
record alone does not establish that this binary exposes that SSH path; exact
|
||||
binary symbol/reachability analysis is still required for a disposition.
|
||||
|
||||
The [official Node image catalogue](https://raw.githubusercontent.com/docker-library/official-images/master/library/node)
|
||||
still maps Node 22 Alpine to 22.23.2 and the previously scanned digest. Node 24's
|
||||
candidate is
|
||||
`docker.io/library/node:24-alpine@sha256:e67514e5d0f6c46656005e1b693b2ec9d52e80b641307de684d4a015ba7a4eaf`.
|
||||
Its HIGH records remain in two OpenSSL packages and npm dependencies
|
||||
`brace-expansion`, `ip-address` and `tar`; fixing the earlier critical tar
|
||||
record alone is insufficient. The website builder stays on Node 22 pending
|
||||
a reviewed build-tool remedy and a final builder scan.
|
||||
|
||||
## Method, verification and retained evidence
|
||||
|
||||
The existing Trivy 0.74.0 executable was rehashed against the previously verified
|
||||
archive member: `d89bcc6510a267f11b773398cbf1be5520ce39f9e8b6633178c4487f05b7d791`.
|
||||
The same schema-2 vulnerability database was used, updated
|
||||
`2026-09-07T19:06:01.154199452Z`. No tool installation or database refresh occurred.
|
||||
Index bytes matched both the registry digest header and Docker Hub publisher
|
||||
metadata; both platform-manifest byte hashes matched the index. All eight
|
||||
registry-only scans completed successfully with validated JSON, `--list-all-pkgs`,
|
||||
`--scanners vuln`, an eight-minute/2GB image bound, an empty Docker configuration
|
||||
and no inherited credentials. Exit zero means execution succeeded. Private
|
||||
temporary paths and in-memory artifact cache isolated this follow-up from the
|
||||
earlier scanner's artifact cache; its vulnerability database was read only.
|
||||
|
||||
Raw reports, logs, manifests, publisher metadata and the scanner script are in
|
||||
`/home/zemion/.cache/govoplan-runtime-remediation.qfDSWHJh/`:
|
||||
|
||||
- `summary.json` SHA-256: `0d44390408ab35270e4430516f77bf11aa7877334eff2ef19e11e9a863fe5c56`.
|
||||
- `frozen-images.json` SHA-256: `d0cb156f4a88998531ec55ab950067a3f1350ded648f07650c463af101dad467`.
|
||||
- `scan_successors.py` SHA-256: `f64c69e06efc2ad7b5a657a25aa73f9ff586e3685737d525456bcecbe3ab5f07`.
|
||||
|
||||
Local retention is not permanent artifact hosting; preserve this evidence with
|
||||
the eventual reviewed release. The JSON evidence records compressed registry
|
||||
layer sizes; these are not expanded filesystem limits or final GovOPlaN sizes.
|
||||
|
||||
Installer regression checks cover the new generated image pin, legacy
|
||||
specification fallback, preserved explicit images, generated topology and
|
||||
configuration: `python -I -m unittest discover -s tests -p
|
||||
test_deployment_installer.py` ran 45 tests successfully with one skip because
|
||||
Core was not importable in that isolated test environment. The skipped Core
|
||||
startup-configuration integration was subsequently rerun in the shared development
|
||||
environment with Core available: all 45 installer tests passed with no skips,
|
||||
including generated-environment startup validation. This is configuration
|
||||
validation, not execution of the candidate image.
|
||||
Both repositories passed `git diff --check`; the audit JSON and all eight
|
||||
report hashes were checked against the retained evidence.
|
||||
**Docker, Podman and HAProxy executables are unavailable on
|
||||
this host**, so no image or HAProxy configuration was executed and no daemon was
|
||||
installed. Publisher metadata and installer tests support the scoped source
|
||||
patch; they do not establish binary or deployed compatibility.
|
||||
|
||||
## Gates that remain open
|
||||
|
||||
- Validate `haproxy -c` on generated local, existing-proxy and managed-ingress
|
||||
configurations using the exact pinned image and target architectures. Run
|
||||
bounded isolated checks without live mounts, secrets, privilege or external
|
||||
network access. Then verify DNS discovery, readiness, forwarded headers,
|
||||
replica routing and graceful termination in the intended runtime.
|
||||
- Test the nginx candidate with both the website configuration and GovOPlaN
|
||||
WebUI entrypoint/proxy configuration, including UID 101, writable temporary
|
||||
paths, health paths, cache headers and static catalog bytes. Approve the
|
||||
website nginx/NJS/Alpine version changes before changing its Dockerfile.
|
||||
- Resolve Caddy, Node build-tool and all unchanged baseline dependencies with
|
||||
updated publisher images or narrow reviewed applicability evidence. No
|
||||
severity-wide exceptions or custom third-party rebuilds were introduced.
|
||||
- Close the original source-built/static inventory gaps. HAProxy's 24-package
|
||||
OS inventory still omits the source-built HAProxy executable. Node's npm
|
||||
inventory still omits the Node executable/stdlib. Garage, CPython, Redis,
|
||||
PostgreSQL and QEMU gaps are unchanged. Alpine 3.24 EOL metadata is still
|
||||
missing from this scanner; zero findings is not complete coverage.
|
||||
- Scan **final built** API/Web/website layers and the selected managed
|
||||
dependencies on both architectures, then perform migration, worker,
|
||||
readiness and ingress smoke checks. Record failure and unknown states.
|
||||
Secrets, misconfiguration and image signature policy need separate checks.
|
||||
- Obtain the website deployment host/operator and rebuild/restart authority,
|
||||
preserving the exact immutable catalog/keyring/module-directory bytes and
|
||||
verifying fresh public responses after an authorized rollout.
|
||||
|
||||
No images were built, executed, published or deployed; no running service,
|
||||
release tag, signed manifest, CI image input or live infrastructure was changed.
|
||||
@@ -0,0 +1,219 @@
|
||||
# Security and performance follow-up — 8 September 2026
|
||||
|
||||
This follows the [original review](SECURITY_PERFORMANCE_REVIEW_2026-09-08.md)
|
||||
and its post-release issue reconciliation. It describes new source work after
|
||||
the frozen 0.1.45 release; it does not change published tags, packages, signed
|
||||
catalogs or deployed images. Gitea remains the canonical state log.
|
||||
|
||||
## Implemented source slices
|
||||
|
||||
- [Core #297](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/297):
|
||||
a shared disposable-process runner enforces wall/CPU/address-space/input/output
|
||||
limits, bounded stderr, process-group cleanup and non-queuing per-process
|
||||
admission. A private binary codec bounds decoding before allocating a full
|
||||
object graph and preserves explicitly supported data types without pickle.
|
||||
Read the owning Core `docs/BOUNDED_PROCESS_CONTRACT.md` before adding callers.
|
||||
- Connectors XLSX parsing, Templates rendering, Files ZIP/TAR inspection and
|
||||
extraction, and Dataflow reference previews/development execution now use
|
||||
that boundary. Existing authorization, sessions, provider credentials,
|
||||
idempotency and persistence remain in the parent. No unprotected inline
|
||||
fallback is used. Each module contributes static EN/DE user/admin limits and
|
||||
operational consequences through its manifest.
|
||||
- Files snapshots authorized sources inside shared admission, validates private
|
||||
staged members, and acknowledges each persisted member before decoding the
|
||||
next. Numeric progress remains available. The acknowledgement is event-driven,
|
||||
not a fixed sleep per member. Reads allocate by validated actual file size,
|
||||
not by the configured ceiling. Failures reap children, clear private staging
|
||||
and retain the existing transaction/blob cleanup and explicit retry behavior.
|
||||
- Dataflow's normal reference preview formerly bypassed the backend wrapper;
|
||||
it now enters the worker too. Nested source configurations cannot collide
|
||||
merely because subflows reuse node IDs. Combined reference-source data is
|
||||
checked before creating further columnar copies, while individual providers
|
||||
retain their own authorized-read bounds. Staging/production still require
|
||||
DuckDB; this change does not replace that separate backend.
|
||||
- [Access #22](https://git.add-ideas.de/GovOPlaN/govoplan-access/issues/22):
|
||||
current-password change, session/CSRF rotation, cross-tenant session and human
|
||||
API-key revocation, and optional administrator-assisted recovery. Recovery
|
||||
codes are hashed, single-use, expire after 15 minutes, require a current local
|
||||
System owner and explicit identity verification, and recheck current account,
|
||||
membership, tenant and issuer authority at redemption. A password change also
|
||||
invalidates outstanding codes issued by that account for other people. Audit
|
||||
evidence and validation/error responses do not contain passwords or codes.
|
||||
External-provider and service-account rules remain separate.
|
||||
- The Access UI provides first-login/required change, self-service change,
|
||||
policy-aware sign-in help, public code redemption and eligible owner issuance.
|
||||
Core consumes an optional lazy auth-action capability rather than importing
|
||||
Access internals. The required-action gate fails closed if its UI is missing.
|
||||
- [Workflow Engine #3](https://git.add-ideas.de/GovOPlaN/govoplan-workflow-engine/issues/3):
|
||||
full-history lists batch pinned revisions, while new summary and bounded
|
||||
step/event endpoints preserve authorization and explicit pagination. Existing
|
||||
full-history responses are not silently truncated. Exact inbox total semantics
|
||||
are retained and their counting cost is documented.
|
||||
- [Meta #55](https://git.add-ideas.de/GovOPlaN/govoplan/issues/55):
|
||||
shared version/planning helpers recognize the existing nested developer
|
||||
package, not invented root metadata. All tag batches enforce trusted private
|
||||
source ownership, registered origins and clean main/upstream state. Meta
|
||||
batches additionally require exact composition and matching Core evidence.
|
||||
Whole-batch preflight,
|
||||
frozen source receipts, annotated immutable tags, object-pinned atomic
|
||||
publication and post-effect remote checks are covered with temporary local
|
||||
repositories. Hidden Git index flags, unsafe ancestry, alternates and changed
|
||||
Git-directory identities are rejected. Selected version/composition metadata
|
||||
must be tracked, so ignored files cannot describe bytes absent from a tag.
|
||||
Applicable unselected Core WebUI inputs have bounded, frozen read receipts;
|
||||
backend-only releases do not read them. The existing local module-candidate
|
||||
exception remains intact. The weaker legacy mutation path was removed.
|
||||
Canonical whole-package preview and receipt-bound apply now cover Meta's
|
||||
version preparation too. Core must already match the target. Preparation
|
||||
requires a separate trusted checkout, explicit out-of-run confirmation and
|
||||
unchanged source/tooling receipts; it cannot rewrite the running operator.
|
||||
Plans place Meta after Core and explain the manual preparation/publication
|
||||
steps instead of promising a durable self-update. Ambiguous partial writes
|
||||
require reconciliation, without automatic rollback or retry.
|
||||
- [Runtime-image follow-up](RUNTIME_IMAGE_REMEDIATION_2026-09-08.md): eight new
|
||||
registry-only scans cover four exact candidates on amd64 and arm64. New
|
||||
installer specifications select the patched same-line HAProxy digest;
|
||||
existing specifications retain their explicit image. Other candidates and
|
||||
unresolved inventory/deployment gates remain visible, not blanket-approved.
|
||||
|
||||
## Verification record
|
||||
|
||||
Targeted checks include actual child execution, catastrophic regex CPU,
|
||||
aggregate memory exhaustion, TAR extension metadata, noisy output, malformed
|
||||
transport/staging data, Unicode allocation limits, cancellation/callback
|
||||
failures, descendant cleanup, rollback and explicit retries. Local mixed-owner
|
||||
composition tests completed nine real children, rejected six overlapping
|
||||
requests as busy, observed at most one unreaped child and recovered all slots.
|
||||
This is local admission evidence, not a target deployment load certification.
|
||||
|
||||
Workflow fixtures serialize 40 different pinned revisions with five SQL reads;
|
||||
summary lists use one query for 40 ordinary rows. Exact inbox totals for
|
||||
40/400/4,000 candidates used one query, with measured local costs approximately
|
||||
0.011/0.057/0.492 seconds. These are fixture measurements, not production SLOs.
|
||||
|
||||
The broader Core API smoke suite exposed three stale campaign assertions.
|
||||
All three failures were reproduced against the unchanged private frozen 0.1.45
|
||||
sources. Updated fixtures verify recipient-summary projection, detailed payload
|
||||
separation and explicit fenced recovery of a confirmed stopped runtime; observing
|
||||
SENDING alone must not make a claim recoverable. All 76 smoke tests then passed.
|
||||
No production Campaign behavior was changed to satisfy these tests.
|
||||
|
||||
The final release-tool suite passed 279 tests and 68 subtests, including
|
||||
temporary local remotes and adversarial source/tag/receipt changes. Rechecking
|
||||
the whole batch before effects is deliberately conservative: its repeated
|
||||
filesystem/Git/remote work grows quadratically with batch size. It is not a
|
||||
new unattended publication path or permission to execute unreviewed source.
|
||||
|
||||
Strict interface inventory now reports no unclassified endpoints and exact
|
||||
contextual help for all 133 high-risk controls. Seventeen password browser cases
|
||||
include actual F1 help from the restricted screen, empty workspace scopes,
|
||||
EN/DE layouts, Unicode boundaries and no credential values in help URLs.
|
||||
The initial production bundle remains within the unchanged limits (512,036
|
||||
raw bytes and 162,415 gzip bytes; 1,713 gzip bytes below its ceiling), with
|
||||
46 optional descriptors and no eager optional-module imports.
|
||||
|
||||
The focused checker now includes the new Core process, mixed-owner admission,
|
||||
Access password, Templates and Files worker tests, the repaired campaign smoke
|
||||
cases, and browser-side auth/password transport contracts. The full focused run
|
||||
passed, including 63 production module/build permutations and all 230 browser
|
||||
cases. Its two opt-in Datasources PostgreSQL cases were skipped in that run
|
||||
and subsequently passed against the isolated real database described below.
|
||||
The final Meta preparation gate was added after that full run and verified
|
||||
with the owning release-tool suite and the focused release-gate command.
|
||||
Manifest validation passed for all 72 modules. The full focused log is
|
||||
`/mnt/DATA/tmp/govoplan-security-followup-20260908-focused.log`.
|
||||
|
||||
The first follow-up quick audit captured an unchanged 79-repository snapshot
|
||||
in `/mnt/DATA/tmp/govoplan-security-followup-quick-20260908-7s8Sgh/`.
|
||||
All four required scanners completed, with zero missing/execution reports;
|
||||
all 168 report checksums and 163 machine-readable reports were validated.
|
||||
Gitleaks found no secrets in all 79 histories and 79 worktrees. Local Semgrep
|
||||
rules reported zero findings. Production Bandit reported 65 low and four medium
|
||||
warnings, and production Ruff retained 54 warnings. The two added Bandit
|
||||
warnings identify the new Core subprocess import and invocation: trusted
|
||||
server-owned arguments, no shell, and the documented resource/process boundary
|
||||
were reviewed; warnings remain visible. This is report-only evidence, not a
|
||||
warning-free audit or a penetration test. A final snapshot follows the
|
||||
cross-module declaration/contextual-help corrections and release-tool checks.
|
||||
|
||||
That final audit completed on 8 September, 05:59:46–06:02:18 UTC, in
|
||||
`/mnt/DATA/tmp/govoplan-security-final-quick-20260908-vAwQIh/`. All 79 start/end
|
||||
source fingerprints were identical; all four scanners completed, all 168
|
||||
registered report checksums matched, and all 163 JSON/SARIF reports parsed.
|
||||
There were no missing reports or scanner execution errors. Semgrep and both
|
||||
Gitleaks scopes again reported zero findings. Production counts were unchanged
|
||||
from the first follow-up: Bandit 65 low/four medium and Ruff 54. Test-only
|
||||
counts were Bandit 140 low/34 medium and Ruff 136. A separate frozen scan of
|
||||
all ten changed Meta release/deployment Python files reported seven low Bandit
|
||||
and four Ruff S603 warnings, with no execution errors. Its four argv-only
|
||||
subprocess sites were reviewed; the preparation additions introduced no new
|
||||
warnings. No findings were hidden or severity-wide exceptions added.
|
||||
The audit manifest SHA-256 is
|
||||
`a997b786239cd11443cb665d5f9041a968cc38f9d49171e68bb868bf2bd73310`;
|
||||
its report-checksum list SHA-256 is
|
||||
`dc590ca5b0a4e445019a05536d410226088d67b40d61cd7657bdef4a4eae56d8`.
|
||||
|
||||
The audit includes the eight committed feature/website source changes and the
|
||||
final uncommitted Meta source. Only this evidence document was updated after
|
||||
the source freeze ended; the final Meta commit and remote publication are
|
||||
recorded in the linked Gitea issues, not inferred from local audit completion.
|
||||
|
||||
Fresh dependency audits are retained in
|
||||
`/mnt/DATA/tmp/govoplan-dependency-final-20260908-d24LEK/`: all four full npm
|
||||
lockfile audits (Core WebUI, Mail root/WebUI and website) report zero known
|
||||
vulnerabilities. Installed Python auditing covers 137 distributions with zero
|
||||
known vulnerabilities; 51 local GovOPlaN distributions lack PyPI advisory
|
||||
coverage. Core's 46 linked packages are likewise not claimed covered by public
|
||||
registry advisories. All 12 dependency-file hashes and the installed inventory
|
||||
were unchanged. No packages were installed or automatically fixed.
|
||||
|
||||
Managed PostgreSQL 16.15 fixtures used private Unix sockets, synthetic roles
|
||||
and databases, no TCP listener, per-case schemas and bounded SQL/lock waits.
|
||||
Both previously skipped Datasources races passed. Twenty-one existing Access
|
||||
password HTTP tests and four additional races passed on PostgreSQL: single-use
|
||||
redemption, stale-session/password replacement, competing issuance, and issuer
|
||||
password revocation during redemption. Four release/development migration checks
|
||||
also passed for Access and Workflow, including credential preservation and
|
||||
idempotent indexes. The four races are now owning opt-in Access regressions;
|
||||
see `govoplan-access/docs/PASSWORD_RECOVERY_POSTGRES_TESTS.md`. These local
|
||||
database checks do not certify a deployment, fleet load or external recovery
|
||||
handover. With both explicit PostgreSQL test URLs enabled, the full Access suite
|
||||
passed 122 tests and 18 subtests, and the full Datasources suite passed 57 tests,
|
||||
without skips. Access retained 12 existing SQLite datetime-adapter warnings in
|
||||
its separate SQLite migration cases. Both temporary PostgreSQL fixtures were
|
||||
stopped and independently verified: no server process, private socket,
|
||||
generated schema or synthetic cluster remains. Scripts, logs and shutdown
|
||||
receipts are retained under `/home/zemion/.cache/govoplan-pg-security-20260908.RAUitg/`
|
||||
and `/home/zemion/.cache/govoplan-pg-promoted-20260908.JZcIKL/`.
|
||||
|
||||
## Adoption and remaining gates
|
||||
|
||||
1. `AUTH_LOCAL_PASSWORD_RECOVERY_ENABLED` remains **false** by default. The
|
||||
existing flag is still advisory until an operator explicitly adopts and
|
||||
enables the complete recovery policy. Confirm who verifies identity and how
|
||||
the one-time code is handed over; automated email recovery is not enabled.
|
||||
Test first-login, lost-password, code expiry and administrator availability
|
||||
in the target environment before enforcement.
|
||||
2. Access migration `e9a2c5f8b1d4` adds recovery evidence; Workflow migration
|
||||
`9e6b3f8a2c7d` adds summary-pagination indexes. Use normal backed-up upgrade
|
||||
procedures and account for index-build cost. No manual live migration or
|
||||
server restart was performed during this work. The user's existing devserver
|
||||
has automatic reload, so live schema state must not be assumed unchanged.
|
||||
3. Release preparation must assign new source/package versions and require a
|
||||
Core version containing the new worker/auth contracts in the affected module
|
||||
metadata, including matching WebUI assets. The old immutable release must
|
||||
not be relabelled or treated as containing these APIs.
|
||||
4. Resource limits are not an arbitrary-code, filesystem or network sandbox.
|
||||
Admission is per API/worker process, not fleet-wide. Validate Linux/cgroup
|
||||
memory, disk quotas, process counts, cancellation and legitimate large-file
|
||||
workloads on the intended runtime before increasing concurrency. Core #297
|
||||
retains this target-evidence follow-up.
|
||||
5. Meta #52 and website #9 retain runtime-image/publication/deployment holds.
|
||||
Docker/Podman/HAProxy executables are unavailable here. Final built images,
|
||||
binary/source inventories, ingress behavior, migration/readiness/worker
|
||||
smoke checks and the website's target/operator authority remain outstanding.
|
||||
Zero findings in a detected package inventory is not full image coverage.
|
||||
|
||||
No real messages, IMAP appends, password resets, provider operations or deployment
|
||||
actions were used as test fixtures. Development tests use temporary databases,
|
||||
private temporary files, mock transports and managed test-browser servers.
|
||||
@@ -199,3 +199,9 @@ they do not change its immutable tags or published package bytes.
|
||||
These are unreleased follow-up source changes, not a new runtime release or
|
||||
deployment. The runtime-image hold under Meta #52 remains in force; the
|
||||
historical peer-dependency workaround still needs its separate review.
|
||||
|
||||
Further implementation and adoption gates are tracked in the
|
||||
[security follow-up](SECURITY_FOLLOWUP_2026-09-08.md), including disposable
|
||||
parsing/execution workers, opt-in password recovery, workflow read projections
|
||||
and the newer runtime-image evidence. The original scanner counts above remain
|
||||
historical and are not silently replaced by later test results.
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"purpose": "Audit evidence only; not a release manifest or active installer configuration.",
|
||||
"observed_at": "2026-09-08T03:56:47.666808+00:00",
|
||||
"runtime_publication_held": true,
|
||||
"website_deployment_held": true,
|
||||
"scan_execution_complete": true,
|
||||
"coverage_complete": false,
|
||||
"scanner": {
|
||||
"name": "Trivy",
|
||||
"version": "0.74.0",
|
||||
"binary_sha256": "d89bcc6510a267f11b773398cbf1be5520ce39f9e8b6633178c4487f05b7d791",
|
||||
"database_metadata": {
|
||||
"Version": 2,
|
||||
"NextUpdate": "2026-09-08T19:06:01.154199291Z",
|
||||
"UpdatedAt": "2026-09-07T19:06:01.154199452Z",
|
||||
"DownloadedAt": "2026-09-07T23:30:53.198039224Z"
|
||||
},
|
||||
"source": "remote",
|
||||
"scanners": [
|
||||
"vuln"
|
||||
],
|
||||
"list_all_packages": true,
|
||||
"images_executed": false,
|
||||
"existing_docker_credentials_used": false,
|
||||
"timeout": "8m",
|
||||
"maximum_image_size": "2GB"
|
||||
},
|
||||
"evidence": {
|
||||
"private_directory": "/home/zemion/.cache/govoplan-runtime-remediation.qfDSWHJh",
|
||||
"summary_sha256": "0d44390408ab35270e4430516f77bf11aa7877334eff2ef19e11e9a863fe5c56",
|
||||
"frozen_images_sha256": "d0cb156f4a88998531ec55ab950067a3f1350ded648f07650c463af101dad467",
|
||||
"scanner_script_sha256": "f64c69e06efc2ad7b5a657a25aa73f9ff586e3685737d525456bcecbe3ab5f07"
|
||||
},
|
||||
"candidates": [
|
||||
{
|
||||
"name": "haproxy",
|
||||
"image": "docker.io/library/haproxy:3.2.23-alpine@sha256:6343ce34a132a5dceaa24767d739df2bd519f8f7c1079ae39e4821334e8eb42e",
|
||||
"disposition": "source_default_updated_binary_runtime_verification_pending",
|
||||
"platforms": [
|
||||
{
|
||||
"platform": "linux/amd64",
|
||||
"manifest_digest": "sha256:0666a2c2f41d341084ed2da85392b48cdcd766adfa28231f31305724ed5c6ea5",
|
||||
"config_digest": "sha256:9621d75e50a8f26d3738ae3cdbf15e98c6aa5cd48e6baca0699492de502156f5",
|
||||
"compressed_layer_bytes": 20516844,
|
||||
"scan_exit_code": 0,
|
||||
"os": {
|
||||
"Family": "alpine",
|
||||
"Name": "3.24.1"
|
||||
},
|
||||
"inventory": [
|
||||
{
|
||||
"type": "alpine",
|
||||
"packages": 24
|
||||
}
|
||||
],
|
||||
"counts": {
|
||||
"CRITICAL": 0,
|
||||
"HIGH": 0,
|
||||
"MEDIUM": 0,
|
||||
"LOW": 0,
|
||||
"UNKNOWN": 0
|
||||
},
|
||||
"fixable_high_critical": 0,
|
||||
"unique_cves": 0,
|
||||
"report_sha256": "40cfc3db74e29f09723f38698660ccdd50ac935c8d6e1e75c6e0555b3e9361fb",
|
||||
"log_sha256": "46881f695780f89c037d5b0b4dc0ded9ea4e459077c7658d80b786efc5754084"
|
||||
},
|
||||
{
|
||||
"platform": "linux/arm64",
|
||||
"manifest_digest": "sha256:cd20b9dc6b4713956a2a043997001a1948167d345e7c8d5bd5ff2e667166651f",
|
||||
"config_digest": "sha256:19796bff8905d4a46c9463c576203b20cac8984d41b7b01ea9cbff55e8422c34",
|
||||
"compressed_layer_bytes": 20970772,
|
||||
"scan_exit_code": 0,
|
||||
"os": {
|
||||
"Family": "alpine",
|
||||
"Name": "3.24.1"
|
||||
},
|
||||
"inventory": [
|
||||
{
|
||||
"type": "alpine",
|
||||
"packages": 24
|
||||
}
|
||||
],
|
||||
"counts": {
|
||||
"CRITICAL": 0,
|
||||
"HIGH": 0,
|
||||
"MEDIUM": 0,
|
||||
"LOW": 0,
|
||||
"UNKNOWN": 0
|
||||
},
|
||||
"fixable_high_critical": 0,
|
||||
"unique_cves": 0,
|
||||
"report_sha256": "0e1326c58abf358d592fa55c94333228ce1094edf4e489fcc4ece6e32d3320f6",
|
||||
"log_sha256": "397c2fbf1044cf50733d68b4b9cc4eb68211d96f1f302d5e9f8af0d11fb0de1c"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "nginx-stable",
|
||||
"image": "docker.io/nginxinc/nginx-unprivileged:1.30.4-alpine@sha256:442753882674b49ae2c1de83ed67896131c0777f56df5005e356e62bc3f7e7ce",
|
||||
"disposition": "candidate_pending_compatibility",
|
||||
"platforms": [
|
||||
{
|
||||
"platform": "linux/amd64",
|
||||
"manifest_digest": "sha256:b8c179cd3c2ae222a873dd59fbae240fadc03836cae5198afc9e9c19919c3880",
|
||||
"config_digest": "sha256:8b5953dae38d27a76bca22373bb920fd6ce8d9d7da21578d2926e678002de8a0",
|
||||
"compressed_layer_bytes": 25526590,
|
||||
"scan_exit_code": 0,
|
||||
"os": {
|
||||
"Family": "alpine",
|
||||
"Name": "3.24.1"
|
||||
},
|
||||
"inventory": [
|
||||
{
|
||||
"type": "alpine",
|
||||
"packages": 70
|
||||
}
|
||||
],
|
||||
"counts": {
|
||||
"CRITICAL": 0,
|
||||
"HIGH": 0,
|
||||
"MEDIUM": 0,
|
||||
"LOW": 0,
|
||||
"UNKNOWN": 0
|
||||
},
|
||||
"fixable_high_critical": 0,
|
||||
"unique_cves": 0,
|
||||
"report_sha256": "3ad164dae3cdf891b12e41451b8a8e65a5e1688824a7c01d848e1274363e6bf9",
|
||||
"log_sha256": "0f99ff3d9b4396de48655bf8299df30c14ba0c579f480d37baa7d2f6a4c11f1d"
|
||||
},
|
||||
{
|
||||
"platform": "linux/arm64",
|
||||
"manifest_digest": "sha256:b6742a0cbd749add25346658991c3da06a8e38796949df120fed78db8c512576",
|
||||
"config_digest": "sha256:4d8b10f5d2ff99e7aa5f161930d8a4693a86a26f288ec8c0d4cd47f2ef5af179",
|
||||
"compressed_layer_bytes": 25892370,
|
||||
"scan_exit_code": 0,
|
||||
"os": {
|
||||
"Family": "alpine",
|
||||
"Name": "3.24.1"
|
||||
},
|
||||
"inventory": [
|
||||
{
|
||||
"type": "alpine",
|
||||
"packages": 70
|
||||
}
|
||||
],
|
||||
"counts": {
|
||||
"CRITICAL": 0,
|
||||
"HIGH": 0,
|
||||
"MEDIUM": 0,
|
||||
"LOW": 0,
|
||||
"UNKNOWN": 0
|
||||
},
|
||||
"fixable_high_critical": 0,
|
||||
"unique_cves": 0,
|
||||
"report_sha256": "506fdeb97525ed8e4d9ae538c42bab7aa2217f662a7135f0f12d16d20410c7a6",
|
||||
"log_sha256": "c41ef9ea091d00f18c7a097404672591bee85e7477ae17d8f5ca771d8e42b2bb"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "caddy",
|
||||
"image": "docker.io/library/caddy:2.11.4-alpine@sha256:5f5c8640aae01df9654968d946d8f1a56c497f1dd5c5cda4cf95ab7c14d58648",
|
||||
"disposition": "not_selected_remaining_fixable_findings",
|
||||
"platforms": [
|
||||
{
|
||||
"platform": "linux/amd64",
|
||||
"manifest_digest": "sha256:98eb57d882ccd5213d1688764db10c1ca2c58a1ca3a6717a3411ad798f7a423a",
|
||||
"config_digest": "sha256:af555904a0961945f16bb323a501457b13a4f7e9bde969b145b97da80b38ecbe",
|
||||
"compressed_layer_bytes": 23907283,
|
||||
"scan_exit_code": 0,
|
||||
"os": {
|
||||
"Family": "alpine",
|
||||
"Name": "3.23.5"
|
||||
},
|
||||
"inventory": [
|
||||
{
|
||||
"type": "alpine",
|
||||
"packages": 32
|
||||
},
|
||||
{
|
||||
"type": "gobinary",
|
||||
"packages": 146
|
||||
}
|
||||
],
|
||||
"counts": {
|
||||
"CRITICAL": 1,
|
||||
"HIGH": 38,
|
||||
"MEDIUM": 41,
|
||||
"LOW": 12,
|
||||
"UNKNOWN": 23
|
||||
},
|
||||
"fixable_high_critical": 39,
|
||||
"unique_cves": 68,
|
||||
"report_sha256": "e483352a1d5b9b97950dcf92e4dfcf4600f5b09306af3d0640b10ca229a07779",
|
||||
"log_sha256": "9cd599d6dd8c101b421fdeb57036cec1f69f815d765a8bf1f850ec60061036f4"
|
||||
},
|
||||
{
|
||||
"platform": "linux/arm64",
|
||||
"manifest_digest": "sha256:1172d4213087d3fc30bafc7ff2c2896180eb0c41ff7f75f315568fb36cabdcba",
|
||||
"config_digest": "sha256:6b08c1b9858ca9a7d99c1da13c3695081e0e604c6cf214ca26a7ce0e2c4fd9b4",
|
||||
"compressed_layer_bytes": 22722712,
|
||||
"scan_exit_code": 0,
|
||||
"os": {
|
||||
"Family": "alpine",
|
||||
"Name": "3.23.5"
|
||||
},
|
||||
"inventory": [
|
||||
{
|
||||
"type": "alpine",
|
||||
"packages": 32
|
||||
},
|
||||
{
|
||||
"type": "gobinary",
|
||||
"packages": 146
|
||||
}
|
||||
],
|
||||
"counts": {
|
||||
"CRITICAL": 1,
|
||||
"HIGH": 38,
|
||||
"MEDIUM": 41,
|
||||
"LOW": 12,
|
||||
"UNKNOWN": 23
|
||||
},
|
||||
"fixable_high_critical": 39,
|
||||
"unique_cves": 68,
|
||||
"report_sha256": "20e04d820e3b27d57575ea177e523e23109fd83344cdf57e184a4d2fad27e803",
|
||||
"log_sha256": "a1d9c37aa7948c137db64040d95e5930c5859a8acd71ac5bc41ff168e2b270c5"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "node-lts",
|
||||
"image": "docker.io/library/node:24-alpine@sha256:e67514e5d0f6c46656005e1b693b2ec9d52e80b641307de684d4a015ba7a4eaf",
|
||||
"disposition": "not_selected_remaining_fixable_findings",
|
||||
"platforms": [
|
||||
{
|
||||
"platform": "linux/amd64",
|
||||
"manifest_digest": "sha256:4caaaf42195bcd6f6f3559a413b20cb8f8ad089e231ee874cf7701643966689f",
|
||||
"config_digest": "sha256:ee289c69ed1ac50a5a042112ea97f132800e2dd53e832da27784f00e45b3289c",
|
||||
"compressed_layer_bytes": 58486244,
|
||||
"scan_exit_code": 0,
|
||||
"os": {
|
||||
"Family": "alpine",
|
||||
"Name": "3.24.1"
|
||||
},
|
||||
"inventory": [
|
||||
{
|
||||
"type": "alpine",
|
||||
"packages": 18
|
||||
},
|
||||
{
|
||||
"type": "node-pkg",
|
||||
"packages": 146
|
||||
}
|
||||
],
|
||||
"counts": {
|
||||
"CRITICAL": 0,
|
||||
"HIGH": 6,
|
||||
"MEDIUM": 11,
|
||||
"LOW": 12,
|
||||
"UNKNOWN": 0
|
||||
},
|
||||
"fixable_high_critical": 6,
|
||||
"unique_cves": 19,
|
||||
"report_sha256": "c927d995dde700c92027f6328dc6c273f0f1445cd8154301480757cb962e02b2",
|
||||
"log_sha256": "c7dc1900cbe39c9f91e228b2770bcbd394ec2914d2b3879478295fc7f8f77ab3"
|
||||
},
|
||||
{
|
||||
"platform": "linux/arm64",
|
||||
"manifest_digest": "sha256:d3724e44ee368606d753e0027eb8d2a94fc1f275e5d9e4620178a12edb655f5f",
|
||||
"config_digest": "sha256:722cc1507731edf58a4c0bc3e29553c44ce5774d0849242c1b237abc79926a0a",
|
||||
"compressed_layer_bytes": 58935654,
|
||||
"scan_exit_code": 0,
|
||||
"os": {
|
||||
"Family": "alpine",
|
||||
"Name": "3.24.1"
|
||||
},
|
||||
"inventory": [
|
||||
{
|
||||
"type": "alpine",
|
||||
"packages": 18
|
||||
},
|
||||
{
|
||||
"type": "node-pkg",
|
||||
"packages": 146
|
||||
}
|
||||
],
|
||||
"counts": {
|
||||
"CRITICAL": 0,
|
||||
"HIGH": 6,
|
||||
"MEDIUM": 11,
|
||||
"LOW": 12,
|
||||
"UNKNOWN": 0
|
||||
},
|
||||
"fixable_high_critical": 6,
|
||||
"unique_cves": 19,
|
||||
"report_sha256": "e5f7799761f23cefc36929381b4186eeb3afb46a2a559c789d12a7eea5dc30d0",
|
||||
"log_sha256": "fd24671f0da4d3b20dc8bcc2718531e6f6e19155d49bed15958965e21dd10813"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -780,6 +780,11 @@ class DeploymentInstallerTests(unittest.TestCase):
|
||||
self.assertIn("redis", compose["services"])
|
||||
self.assertIn("worker", compose["services"])
|
||||
self.assertIn("load-balancer", compose["services"])
|
||||
self.assertEqual(
|
||||
"haproxy:3.2.23-alpine@sha256:"
|
||||
"6343ce34a132a5dceaa24767d739df2bd519f8f7c1079ae39e4821334e8eb42e",
|
||||
compose["services"]["load-balancer"]["image"],
|
||||
)
|
||||
self.assertNotIn("test-mail", compose["services"])
|
||||
self.assertEqual(
|
||||
["127.0.0.1:8080:8080"],
|
||||
@@ -1145,8 +1150,26 @@ class DeploymentInstallerTests(unittest.TestCase):
|
||||
self.assertEqual(1, parsed.replicas.web)
|
||||
self.assertEqual(1, parsed.replicas.worker)
|
||||
self.assertEqual("managed", parsed.components.load_balancer.mode)
|
||||
self.assertEqual(
|
||||
default_spec().components.load_balancer.image,
|
||||
parsed.components.load_balancer.image,
|
||||
)
|
||||
self.assertEqual("local", parsed.ingress.mode)
|
||||
|
||||
def test_load_balancer_patch_does_not_rewrite_an_existing_image(self) -> None:
|
||||
for image in (
|
||||
"haproxy:3.2.21-alpine",
|
||||
"registry.example.test/haproxy@sha256:" + "a" * 64,
|
||||
):
|
||||
with self.subTest(image=image):
|
||||
saved = default_spec(load_balancer_image=image).to_dict()
|
||||
|
||||
restored = parse_spec(json.loads(json.dumps(saved)))
|
||||
compose = render_compose(restored)
|
||||
|
||||
self.assertEqual(image, restored.components.load_balancer.image)
|
||||
self.assertEqual(image, compose["services"]["load-balancer"]["image"])
|
||||
|
||||
def test_compose_contains_no_secret_values(self) -> None:
|
||||
spec = default_spec()
|
||||
values = initial_secrets(spec)
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Local mixed-owner admission/recovery evidence, not production capacity certification.
|
||||
|
||||
All inputs are synthetic and held in memory. The spawn observer temporarily
|
||||
holds the admitted parent's handshake so the other owners encounter the same
|
||||
occupied slot deterministically. Children perform real XLSX, template and
|
||||
Dataflow work; there is no mocked process execution, database, or live service.
|
||||
The non-queuing gate promises retryable rejection, not scheduler fairness.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from io import BytesIO
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
from govoplan_connectors.backend.tabular_adapters import (
|
||||
parse_managed_tabular_content,
|
||||
)
|
||||
from govoplan_core.core.templates import TemplateRenderRequest
|
||||
from govoplan_core.security import bounded_process
|
||||
from govoplan_core.security.bounded_process import ProcessBudgetError
|
||||
from govoplan_core.settings import settings
|
||||
from govoplan_dataflow.backend.backends import execute_typed_graph
|
||||
from govoplan_dataflow.backend.schemas import (
|
||||
GraphEdge,
|
||||
GraphNode,
|
||||
GraphPosition,
|
||||
PipelineGraph,
|
||||
)
|
||||
from govoplan_templates.backend.rendering import _render_payload
|
||||
except ImportError as exc:
|
||||
raise unittest.SkipTest(
|
||||
"Mixed-owner isolation requires the optional module test environment."
|
||||
) from exc
|
||||
|
||||
|
||||
class IsolatedWorkCompositionTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
workbook = Workbook()
|
||||
workbook.active.append(["name"])
|
||||
workbook.active.append(["Ada"])
|
||||
stream = BytesIO()
|
||||
workbook.save(stream)
|
||||
workbook.close()
|
||||
self.workbook = stream.getvalue()
|
||||
self.graph = PipelineGraph(
|
||||
nodes=[
|
||||
GraphNode(
|
||||
id="source",
|
||||
type="source.inline",
|
||||
label="Source",
|
||||
position=GraphPosition(x=0, y=0),
|
||||
config={"source_name": "records", "rows": [{"name": "Ada"}]},
|
||||
),
|
||||
GraphNode(
|
||||
id="output",
|
||||
type="output",
|
||||
label="Output",
|
||||
position=GraphPosition(x=100, y=0),
|
||||
config={},
|
||||
),
|
||||
],
|
||||
edges=[GraphEdge(id="edge", source="source", target="output")],
|
||||
)
|
||||
|
||||
def xlsx(self):
|
||||
rows, sheet = parse_managed_tabular_content(
|
||||
self.workbook,
|
||||
filename="synthetic.xlsx",
|
||||
content_type=None,
|
||||
delimiter=",",
|
||||
sheet_name=None,
|
||||
)
|
||||
self.assertEqual(rows, ({"name": "Ada"},))
|
||||
self.assertEqual(sheet, "Sheet")
|
||||
return "xlsx"
|
||||
|
||||
def templates(self):
|
||||
payload, content_type, _pages = _render_payload(
|
||||
SimpleNamespace(name="Synthetic template"),
|
||||
SimpleNamespace(
|
||||
content_text="Hello {{item.name}}",
|
||||
content_html=None,
|
||||
template_type="letter",
|
||||
layout={},
|
||||
output_profiles=[],
|
||||
),
|
||||
request=TemplateRenderRequest(
|
||||
template_id="synthetic", output_format="text"
|
||||
),
|
||||
items=({"name": "Ada"},),
|
||||
)
|
||||
self.assertEqual(payload, b"Hello Ada")
|
||||
self.assertEqual(content_type, "text/plain; charset=utf-8")
|
||||
return "templates"
|
||||
|
||||
def dataflow(self):
|
||||
result = execute_typed_graph(self.graph, backend="reference")
|
||||
self.assertEqual(result.rows, [{"name": "Ada"}])
|
||||
return "dataflow"
|
||||
|
||||
def test_one_shared_slot_rejects_other_owners_and_all_retries_recover(self):
|
||||
owners = {
|
||||
"xlsx": self.xlsx,
|
||||
"templates": self.templates,
|
||||
"dataflow": self.dataflow,
|
||||
}
|
||||
processes = []
|
||||
modules = []
|
||||
hold_next = False
|
||||
entered = threading.Event()
|
||||
release = threading.Event()
|
||||
observer_lock = threading.Lock()
|
||||
maximum_unreaped = 0
|
||||
observer_timeouts = 0
|
||||
original_popen = bounded_process.subprocess.Popen
|
||||
|
||||
def observe_spawn(*args, **kwargs):
|
||||
nonlocal hold_next, maximum_unreaped, observer_timeouts
|
||||
process = original_popen(*args, **kwargs)
|
||||
with observer_lock:
|
||||
processes.append(process)
|
||||
modules.append(args[0][5])
|
||||
maximum_unreaped = max(
|
||||
maximum_unreaped, sum(item.returncode is None for item in processes)
|
||||
)
|
||||
should_hold = hold_next
|
||||
hold_next = False
|
||||
if should_hold:
|
||||
entered.set()
|
||||
if not release.wait(8):
|
||||
# Return control so the real runner's normal timeout and
|
||||
# process-group cleanup still own this child on test error.
|
||||
observer_timeouts += 1
|
||||
return process
|
||||
|
||||
def rejected(operation):
|
||||
try:
|
||||
operation()
|
||||
except Exception as exc:
|
||||
cause = exc
|
||||
while cause is not None and not isinstance(cause, ProcessBudgetError):
|
||||
cause = cause.__cause__
|
||||
self.assertIsInstance(cause, ProcessBudgetError)
|
||||
self.assertEqual(cause.code, "busy")
|
||||
return "busy"
|
||||
self.fail(
|
||||
"A different module admitted work while the shared slot was occupied."
|
||||
)
|
||||
|
||||
started = time.monotonic()
|
||||
busy_count = 0
|
||||
try:
|
||||
with (
|
||||
patch.object(settings, "isolated_process_concurrency", 1),
|
||||
patch.object(bounded_process.subprocess, "Popen", observe_spawn),
|
||||
ThreadPoolExecutor(max_workers=3) as executor,
|
||||
):
|
||||
for owner, operation in owners.items():
|
||||
with self.subTest(admitted_owner=owner):
|
||||
entered.clear()
|
||||
release.clear()
|
||||
hold_next = True
|
||||
holder = executor.submit(operation)
|
||||
try:
|
||||
self.assertTrue(
|
||||
entered.wait(5),
|
||||
"The admitted operation never spawned its real child.",
|
||||
)
|
||||
children_before = len(processes)
|
||||
others = [
|
||||
work for label, work in owners.items() if label != owner
|
||||
]
|
||||
denied = [
|
||||
executor.submit(rejected, work) for work in others
|
||||
]
|
||||
self.assertEqual(
|
||||
[future.result(timeout=5) for future in denied],
|
||||
["busy", "busy"],
|
||||
)
|
||||
busy_count += len(denied)
|
||||
self.assertEqual(len(processes), children_before)
|
||||
finally:
|
||||
release.set()
|
||||
self.assertEqual(holder.result(timeout=15), owner)
|
||||
self.assertEqual(bounded_process._active, 0)
|
||||
# Every rejected owner is retried through its real API.
|
||||
# Each must complete after the previous holder releases.
|
||||
for other in others:
|
||||
other()
|
||||
self.assertEqual(bounded_process._active, 0)
|
||||
finally:
|
||||
release.set()
|
||||
for process in processes:
|
||||
self.assertIsNotNone(process.returncode, "Worker was not reaped.")
|
||||
self.assertTrue(
|
||||
all(
|
||||
stream.closed
|
||||
for stream in (process.stdin, process.stdout, process.stderr)
|
||||
)
|
||||
)
|
||||
with self.assertRaises(ChildProcessError):
|
||||
os.waitpid(process.pid, os.WNOHANG)
|
||||
self.assertEqual(maximum_unreaped, 1)
|
||||
self.assertEqual(observer_timeouts, 0)
|
||||
self.assertEqual(len(processes), 9)
|
||||
self.assertEqual(busy_count, 6)
|
||||
self.assertEqual(
|
||||
set(modules),
|
||||
{
|
||||
"govoplan_connectors.backend.tabular_adapters",
|
||||
"govoplan_templates.backend.rendering",
|
||||
"govoplan_dataflow.backend.backends.reference",
|
||||
},
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"local_composition": {
|
||||
"successful_children": len(processes),
|
||||
"busy_rejections": busy_count,
|
||||
"maximum_unreaped_children": maximum_unreaped,
|
||||
"all_children_reaped": True,
|
||||
"seconds": round(time.monotonic() - started, 3),
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -96,6 +96,18 @@ class PlatformInterfaceInventoryTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, "tracking_issue"):
|
||||
inventory._load_endpoint_declarations(path)
|
||||
|
||||
def test_bounded_workflow_read_apis_have_explicit_headless_declarations(self) -> None:
|
||||
declarations = inventory._load_endpoint_declarations(inventory.DEFAULT_ENDPOINT_DECLARATIONS)
|
||||
for path in (
|
||||
"/workflow/instances/summaries", "/workflow/instances/{}/summary",
|
||||
"/workflow/instances/{}/steps", "/workflow/instances/{}/events",
|
||||
):
|
||||
with self.subTest(path=path):
|
||||
entry = declarations[("govoplan-workflow-engine", "GET", path)]
|
||||
self.assertEqual("intentionally_headless", entry["category"])
|
||||
self.assertIn("current-authorized", entry["rationale"])
|
||||
self.assertIn("workflow.instance-history", entry["rationale"])
|
||||
|
||||
def test_inventory_reports_unclassified_and_stale_endpoint_declarations(
|
||||
self,
|
||||
) -> None:
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from contextlib import redirect_stdout
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import shutil
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "tools/release"))
|
||||
|
||||
from govoplan_release import meta_preparation # noqa: E402
|
||||
from govoplan_release.git_state import collect_repository_snapshot # noqa: E402
|
||||
from govoplan_release.meta_preparation import ( # noqa: E402
|
||||
MetaPreparationError,
|
||||
prepare_developer_meta_package,
|
||||
)
|
||||
from govoplan_release.model import RepositorySpec # noqa: E402
|
||||
from govoplan_release.selective_planner import build_selective_release_plan # noqa: E402
|
||||
from govoplan_release.version_metadata import ( # noqa: E402
|
||||
VersionMetadataError,
|
||||
apply_version_metadata_mutations,
|
||||
version_metadata_mutations,
|
||||
)
|
||||
import test_release_meta_source_tag as meta_fixture # noqa: E402
|
||||
from test_release_plan_guidance import dashboard # noqa: E402
|
||||
from test_release_repository_tag import create_release_repo, git, git_text # noqa: E402
|
||||
|
||||
|
||||
class MetaPreparationTests(unittest.TestCase):
|
||||
synchronize = meta_fixture.MetaSourceTagTests.synchronize
|
||||
|
||||
def setUp(self):
|
||||
meta_fixture.MetaSourceTagTests.setUp(self)
|
||||
self.operator = self.root / "operator"
|
||||
self.generator = (
|
||||
self.operator / "tools/release/generate-developer-meta-package.py"
|
||||
)
|
||||
self.generator.parent.mkdir(parents=True)
|
||||
shutil.copyfile(
|
||||
ROOT / "tools/release/generate-developer-meta-package.py", self.generator
|
||||
)
|
||||
self.enterContext(patch.object(meta_preparation, "META_ROOT", self.operator))
|
||||
|
||||
def prepare_core(self):
|
||||
apply_version_metadata_mutations(self.core, target_version="0.1.11")
|
||||
git(self.core, "add", ".")
|
||||
git(self.core, "commit", "-m", "Prepared synthetic Core target")
|
||||
|
||||
def preview(self, **kwargs):
|
||||
return prepare_developer_meta_package(
|
||||
repo_path=self.meta, target_version="0.1.11", **kwargs
|
||||
)
|
||||
|
||||
def apply(self, preview):
|
||||
return self.preview(
|
||||
apply=True, expected_receipt=preview["receipt"], confirm_out_of_run=True
|
||||
)
|
||||
|
||||
def test_full_canonical_preview_apply_and_shared_mutation_discovery(self):
|
||||
extra, remote = create_release_repo(
|
||||
root=self.root,
|
||||
workspace=self.workspace,
|
||||
name="govoplan-workflow-engine",
|
||||
version="0.2.3",
|
||||
)
|
||||
self.specs.append(
|
||||
{
|
||||
"name": extra.name,
|
||||
"path": extra.name,
|
||||
"category": "module",
|
||||
"subtype": "",
|
||||
"remote": str(remote),
|
||||
}
|
||||
)
|
||||
self.registry.write_text(json.dumps({"repositories": self.specs}))
|
||||
self.prepare_core()
|
||||
before = self.package.read_bytes()
|
||||
preview = self.preview()
|
||||
self.assertEqual("planned", preview["status"])
|
||||
self.assertEqual(before, self.package.read_bytes())
|
||||
mutations = version_metadata_mutations(self.meta, target_version="0.1.11")
|
||||
self.assertEqual([meta_preparation.PACKAGE], [item.path for item in mutations])
|
||||
self.assertIn(b"govoplan-workflow-engine==0.2.3", mutations[0].after)
|
||||
self.assertIn(b"govoplan-core==0.1.11", mutations[0].after)
|
||||
result = self.apply(preview)
|
||||
self.assertEqual("prepared", result["status"])
|
||||
self.assertEqual(mutations[0].after, self.package.read_bytes())
|
||||
self.assertEqual(
|
||||
self.render(workspace=self.workspace, requirements=self.requirements),
|
||||
self.package.read_text(),
|
||||
)
|
||||
self.assertEqual(
|
||||
f"M {meta_preparation.PACKAGE}",
|
||||
git_text(self.meta, "status", "--porcelain"),
|
||||
)
|
||||
self.assertFalse(git_text(self.meta, "tag", "--list"))
|
||||
|
||||
def test_core_target_must_already_be_prepared(self):
|
||||
before = self.package.read_bytes()
|
||||
with self.assertRaisesRegex(MetaPreparationError, "Prepare and commit Core"):
|
||||
self.preview()
|
||||
self.assertEqual(before, self.package.read_bytes())
|
||||
|
||||
def test_changed_requirements_receipt_blocks_before_any_output(self):
|
||||
self.prepare_core()
|
||||
preview = self.preview()
|
||||
before = self.package.read_bytes()
|
||||
self.requirements.write_text(
|
||||
self.requirements.read_text() + "# reviewed different inputs\n"
|
||||
)
|
||||
git(self.meta, "add", ".")
|
||||
git(self.meta, "commit", "-m", "Changed synthetic requirements")
|
||||
with self.assertRaisesRegex(MetaPreparationError, "changed since"):
|
||||
self.apply(preview)
|
||||
self.assertEqual(before, self.package.read_bytes())
|
||||
|
||||
def test_source_change_immediately_before_effect_is_rechecked(self):
|
||||
self.prepare_core()
|
||||
preview = self.preview()
|
||||
before = self.package.read_bytes()
|
||||
original = meta_preparation.preview_meta_mutation
|
||||
|
||||
def changed(**kwargs):
|
||||
result = original(**kwargs)
|
||||
self.requirements.write_text(
|
||||
self.requirements.read_text() + "# concurrent change\n"
|
||||
)
|
||||
git(self.meta, "add", ".")
|
||||
git(self.meta, "commit", "-m", "Concurrent synthetic change")
|
||||
return result
|
||||
|
||||
with patch.object(
|
||||
meta_preparation, "preview_meta_mutation", side_effect=changed
|
||||
):
|
||||
with self.assertRaisesRegex(MetaPreparationError, "changed before"):
|
||||
self.apply(preview)
|
||||
self.assertEqual(before, self.package.read_bytes())
|
||||
|
||||
def test_core_full_package_and_operator_generator_are_receipt_bound(self):
|
||||
self.prepare_core()
|
||||
for path, repository in (
|
||||
(self.core / "pyproject.toml", self.core),
|
||||
(self.access / "pyproject.toml", self.access),
|
||||
(self.generator, None),
|
||||
):
|
||||
with self.subTest(input=path.name, repo=str(repository)):
|
||||
preview = self.preview()
|
||||
before = self.package.read_bytes()
|
||||
path.write_text(path.read_text() + "\n# changed frozen input\n")
|
||||
if repository is not None:
|
||||
git(repository, "add", ".")
|
||||
git(
|
||||
repository,
|
||||
"commit",
|
||||
"-m",
|
||||
"Changed synthetic composition input",
|
||||
)
|
||||
with self.assertRaisesRegex(MetaPreparationError, "changed since"):
|
||||
self.apply(preview)
|
||||
self.assertEqual(before, self.package.read_bytes())
|
||||
|
||||
def test_post_write_source_change_is_reported_without_retry_or_rollback(self):
|
||||
from govoplan_release import version_metadata
|
||||
|
||||
self.prepare_core()
|
||||
preview = self.preview()
|
||||
original = version_metadata._atomic_write
|
||||
|
||||
def changed(path, payload):
|
||||
original(path, payload)
|
||||
self.requirements.write_text(
|
||||
self.requirements.read_text() + "# concurrent after write\n"
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
version_metadata, "_atomic_write", side_effect=changed
|
||||
) as writer:
|
||||
with self.assertRaisesRegex(
|
||||
meta_preparation.MetaPreparationAmbiguous, "write/post-check failed"
|
||||
):
|
||||
self.apply(preview)
|
||||
self.assertEqual(1, writer.call_count)
|
||||
self.assertIn('version = "0.1.11"', self.package.read_text())
|
||||
self.assertIn("# concurrent after write", self.requirements.read_text())
|
||||
|
||||
def test_write_failure_after_replace_requires_reconciliation(self):
|
||||
from govoplan_release import version_metadata
|
||||
|
||||
self.prepare_core()
|
||||
preview = self.preview()
|
||||
original = version_metadata._atomic_write
|
||||
|
||||
def partial(path, payload):
|
||||
original(path, payload)
|
||||
raise OSError("Synthetic directory fsync failure after replacement")
|
||||
|
||||
with patch.object(version_metadata, "_atomic_write", side_effect=partial) as writer:
|
||||
with self.assertRaisesRegex(meta_preparation.MetaPreparationAmbiguous, "may have been written"):
|
||||
self.apply(preview)
|
||||
self.assertEqual(1, writer.call_count)
|
||||
self.assertIn('version = "0.1.11"', self.package.read_text())
|
||||
|
||||
def test_cli_requires_reviewed_receipt_and_explicit_out_of_run_confirmation(self):
|
||||
self.prepare_core()
|
||||
main = runpy.run_path(
|
||||
str(ROOT / "tools/release/prepare-developer-meta-package.py")
|
||||
)["main"]
|
||||
arguments = [
|
||||
"prepare-developer-meta-package.py",
|
||||
"--workspace",
|
||||
str(self.workspace),
|
||||
"--target-version",
|
||||
"0.1.11",
|
||||
]
|
||||
output = io.StringIO()
|
||||
with patch.object(sys, "argv", arguments), redirect_stdout(output):
|
||||
self.assertEqual(0, main())
|
||||
preview = self.root / "meta-preview.json"
|
||||
preview.write_text(output.getvalue())
|
||||
with (
|
||||
patch.object(sys, "argv", [*arguments, "--apply"]),
|
||||
redirect_stdout(io.StringIO()),
|
||||
):
|
||||
self.assertEqual(1, main())
|
||||
with (
|
||||
patch.object(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
*arguments,
|
||||
"--apply",
|
||||
"--receipt",
|
||||
str(preview),
|
||||
"--confirm-out-of-run",
|
||||
],
|
||||
),
|
||||
redirect_stdout(io.StringIO()),
|
||||
):
|
||||
self.assertEqual(0, main())
|
||||
|
||||
def test_unknown_full_input_and_unsafe_operator_tooling_fail_closed(self):
|
||||
self.prepare_core()
|
||||
unknown = self.workspace / "govoplan-unknown/pyproject.toml"
|
||||
unknown.parent.mkdir()
|
||||
unknown.write_text('[project]\nname="govoplan-unknown"\nversion="1.0.0"\n')
|
||||
with self.assertRaisesRegex(MetaPreparationError, "unregistered"):
|
||||
self.preview()
|
||||
unknown.unlink()
|
||||
self.generator.chmod(0o666)
|
||||
with self.assertRaisesRegex(MetaPreparationError, "owned, bounded regular"):
|
||||
self.preview()
|
||||
|
||||
def test_wrong_nested_identity_and_existing_immutable_tag_fail_closed(self):
|
||||
self.prepare_core()
|
||||
original = self.package.read_text()
|
||||
self.package.write_text(
|
||||
original.replace('name = "govoplan"', 'name = "not-govoplan"')
|
||||
)
|
||||
git(self.meta, "add", ".")
|
||||
git(self.meta, "commit", "-m", "Wrong synthetic package identity")
|
||||
with self.assertRaisesRegex(MetaPreparationError, "identity"):
|
||||
self.preview()
|
||||
self.package.write_text(original)
|
||||
git(self.meta, "add", ".")
|
||||
git(self.meta, "commit", "-m", "Restore synthetic package identity")
|
||||
git(self.meta, "tag", "-a", "v0.1.11", "-m", "Immutable target")
|
||||
with self.assertRaisesRegex(MetaPreparationError, "target Meta tag"):
|
||||
self.preview()
|
||||
|
||||
def test_no_generic_durable_self_mutation_or_running_tooling_target(self):
|
||||
self.prepare_core()
|
||||
preview = self.preview()
|
||||
with self.assertRaisesRegex(VersionMetadataError, "outside durable runs"):
|
||||
apply_version_metadata_mutations(self.meta, target_version="0.1.11")
|
||||
with self.assertRaisesRegex(MetaPreparationError, "confirm"):
|
||||
self.preview(apply=True, expected_receipt=preview["receipt"])
|
||||
with patch.object(meta_preparation, "META_ROOT", self.meta):
|
||||
with self.assertRaisesRegex(MetaPreparationError, "running operator"):
|
||||
self.preview()
|
||||
|
||||
def test_next_version_plan_is_actionable_core_first_without_meta_executor(self):
|
||||
snapshots = tuple(
|
||||
collect_repository_snapshot(
|
||||
RepositorySpec(**spec),
|
||||
workspace_root=self.workspace,
|
||||
target_tag="v0.1.11",
|
||||
)
|
||||
for spec in self.specs[:2]
|
||||
)
|
||||
source = replace(
|
||||
dashboard(workspace=self.workspace, version=self.version),
|
||||
repositories=snapshots,
|
||||
)
|
||||
plan = build_selective_release_plan(
|
||||
source,
|
||||
selected_repos=("govoplan", "govoplan-core"),
|
||||
target_version="0.1.11",
|
||||
)
|
||||
self.assertEqual(
|
||||
["govoplan-core", "govoplan"], [unit.repo for unit in plan.units]
|
||||
)
|
||||
findings = [
|
||||
finding for finding in plan.gate_findings if finding.repo == "govoplan"
|
||||
]
|
||||
self.assertEqual(
|
||||
["developer_meta_core_preparation_required"],
|
||||
[finding.code for finding in findings],
|
||||
)
|
||||
self.assertIn("prepare-developer-meta-package.py", findings[0].remediation)
|
||||
meta_steps = [step for step in plan.dry_run_steps if step.repo == "govoplan"]
|
||||
self.assertEqual(
|
||||
["govoplan:prepare-support", "govoplan:publish-support"],
|
||||
[step.id for step in meta_steps],
|
||||
)
|
||||
self.assertTrue(all(step.status == "needs-executor" for step in meta_steps))
|
||||
self.prepare_core()
|
||||
prepared = build_selective_release_plan(
|
||||
source, selected_repos=("govoplan",), target_version="0.1.11"
|
||||
)
|
||||
self.assertEqual("developer_meta_out_of_run", prepared.gate_findings[0].code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,569 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "tools/release"))
|
||||
|
||||
from govoplan_release import source_tag_batch as meta_source_tag, workspace # noqa: E402
|
||||
from govoplan_release.git_state import collect_versions # noqa: E402
|
||||
from govoplan_release.model import RepositorySnapshot, RepositorySpec, VersionSnapshot # noqa: E402
|
||||
from govoplan_release.repository_tag import tag_repositories # noqa: E402
|
||||
from govoplan_release.selective_planner import build_unit # noqa: E402
|
||||
from govoplan_release.version_alignment import repository_version_issues # noqa: E402
|
||||
from test_release_repository_tag import ( # noqa: E402
|
||||
add_scoped_workflow_manifest,
|
||||
create_release_repo,
|
||||
git,
|
||||
git_text,
|
||||
ref_exists,
|
||||
)
|
||||
|
||||
|
||||
class MetaSourceTagTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = self.enterContext(
|
||||
tempfile.TemporaryDirectory(prefix="meta-release-tests-")
|
||||
)
|
||||
self.root = Path(self.temporary)
|
||||
self.workspace = self.root / "workspace"
|
||||
self.workspace.mkdir()
|
||||
self.version = "0.1.10"
|
||||
self.core, self.core_remote = create_release_repo(
|
||||
root=self.root,
|
||||
workspace=self.workspace,
|
||||
name="govoplan-core",
|
||||
version=self.version,
|
||||
)
|
||||
self.access, self.access_remote = create_release_repo(
|
||||
root=self.root,
|
||||
workspace=self.workspace,
|
||||
name="govoplan-access",
|
||||
version=self.version,
|
||||
)
|
||||
add_scoped_workflow_manifest(self.access)
|
||||
self.meta = self.workspace / "govoplan"
|
||||
self.meta_remote = self.root / "govoplan.git"
|
||||
git(self.root, "init", "--bare", str(self.meta_remote))
|
||||
git(self.workspace, "init", "-b", "main", str(self.meta))
|
||||
git(self.meta, "config", "user.name", "Meta Release Fixture")
|
||||
git(self.meta, "config", "user.email", "release@example.invalid")
|
||||
self.package = self.meta / "packages/govoplan-meta/pyproject.toml"
|
||||
self.package.parent.mkdir(parents=True)
|
||||
self.requirements = self.meta / "requirements-release.txt"
|
||||
self.requirements.write_text(
|
||||
"../govoplan-core\ngovoplan-access @ git+ssh://git@example.invalid/GovOPlaN/govoplan-access.git@v0.1.10\n"
|
||||
)
|
||||
self.render = runpy.run_path(
|
||||
str(ROOT / "tools/release/generate-developer-meta-package.py")
|
||||
)["render"]
|
||||
self.synchronize()
|
||||
git(self.meta, "add", ".")
|
||||
git(self.meta, "commit", "-m", "Nested developer package")
|
||||
git(self.meta, "remote", "add", "origin", str(self.meta_remote))
|
||||
git(self.meta, "push", "-u", "origin", "main")
|
||||
self.specs = [
|
||||
{
|
||||
"name": name,
|
||||
"category": "system" if subtype else "module",
|
||||
"subtype": subtype,
|
||||
"path": name,
|
||||
"remote": str(remote),
|
||||
}
|
||||
for name, subtype, remote in (
|
||||
("govoplan", "meta", self.meta_remote),
|
||||
("govoplan-core", "kernel", self.core_remote),
|
||||
("govoplan-access", "", self.access_remote),
|
||||
)
|
||||
]
|
||||
self.registry = self.root / "repositories.json"
|
||||
self.registry.write_text(json.dumps({"repositories": self.specs}))
|
||||
self.enterContext(patch.object(workspace, "REPOSITORIES_FILE", self.registry))
|
||||
|
||||
def synchronize(self):
|
||||
self.package.write_text(
|
||||
self.render(workspace=self.workspace, requirements=self.requirements)
|
||||
)
|
||||
|
||||
def commit_meta(self):
|
||||
git(self.meta, "add", ".")
|
||||
git(self.meta, "commit", "-m", "Changed synthetic metadata")
|
||||
|
||||
def tag(
|
||||
self,
|
||||
*,
|
||||
repos=("govoplan", "govoplan-core"),
|
||||
apply=False,
|
||||
push=False,
|
||||
**overrides,
|
||||
):
|
||||
return tag_repositories(
|
||||
repos=repos,
|
||||
repo_versions={repo: self.version for repo in repos},
|
||||
workspace_root=self.workspace,
|
||||
apply=apply,
|
||||
push=push,
|
||||
**overrides,
|
||||
)
|
||||
|
||||
def assert_no_tags(self):
|
||||
for repo in (
|
||||
self.meta,
|
||||
self.meta_remote,
|
||||
self.core,
|
||||
self.core_remote,
|
||||
self.access,
|
||||
self.access_remote,
|
||||
):
|
||||
self.assertFalse(ref_exists(repo, "refs/tags/v0.1.10"), str(repo))
|
||||
|
||||
def test_explicit_nested_version_collection_and_alignment_without_root_package(
|
||||
self,
|
||||
):
|
||||
versions = collect_versions(self.meta)
|
||||
self.assertIsNone(versions.pyproject)
|
||||
self.assertEqual(self.version, versions.developer_meta)
|
||||
self.assertEqual(self.version, versions.primary)
|
||||
self.assertFalse((self.meta / "pyproject.toml").exists())
|
||||
self.assertEqual(
|
||||
(), repository_version_issues(self.meta, expected_version=self.version)
|
||||
)
|
||||
mismatch = repository_version_issues(self.meta, expected_version="0.1.11")
|
||||
self.assertTrue(
|
||||
any(
|
||||
issue.source == "packages/govoplan-meta/pyproject.toml"
|
||||
for issue in mismatch
|
||||
)
|
||||
)
|
||||
|
||||
def test_planner_and_console_display_the_explicit_nested_version(self):
|
||||
snapshot = RepositorySnapshot(
|
||||
spec=RepositorySpec(**self.specs[0]),
|
||||
absolute_path=str(self.meta),
|
||||
exists=True,
|
||||
is_git=True,
|
||||
has_head=True,
|
||||
branch="main",
|
||||
versions=VersionSnapshot(developer_meta=self.version),
|
||||
)
|
||||
unit = build_unit(snapshot, target_version=None, contracts=None)
|
||||
self.assertEqual(self.version, unit.current_version)
|
||||
self.assertEqual(self.version, unit.target_version)
|
||||
html = (ROOT / "tools/release/webui/index.html").read_text()
|
||||
self.assertIn(
|
||||
"if (versions.developer_meta) return versions.developer_meta;", html
|
||||
)
|
||||
drift = RepositorySnapshot(
|
||||
spec=snapshot.spec,
|
||||
absolute_path=str(self.meta),
|
||||
exists=True,
|
||||
is_git=True,
|
||||
has_head=True,
|
||||
branch="main",
|
||||
versions=VersionSnapshot(pyproject="0.1.9", developer_meta=self.version),
|
||||
)
|
||||
self.assertTrue(
|
||||
any(
|
||||
"version metadata is not aligned" in item
|
||||
for item in build_unit(
|
||||
drift, target_version=self.version, contracts=None
|
||||
).blockers
|
||||
)
|
||||
)
|
||||
|
||||
def test_unknown_nested_package_and_missing_or_wrong_meta_identity_fail_closed(
|
||||
self,
|
||||
):
|
||||
unknown = self.workspace / "unknown"
|
||||
nested = unknown / "packages/govoplan-meta/pyproject.toml"
|
||||
nested.parent.mkdir(parents=True)
|
||||
nested.write_text(self.package.read_text())
|
||||
self.assertIsNone(collect_versions(unknown).primary)
|
||||
self.assertIn(
|
||||
"no version metadata",
|
||||
repository_version_issues(unknown, expected_version=self.version)[
|
||||
0
|
||||
].message,
|
||||
)
|
||||
for value in ("", '[project]\nname="not-govoplan"\nversion="0.1.10"\n'):
|
||||
with self.subTest(value=value):
|
||||
self.package.write_text(value)
|
||||
self.assertTrue(
|
||||
repository_version_issues(self.meta, expected_version=self.version)
|
||||
)
|
||||
|
||||
def test_preview_local_tag_and_publish_share_complete_nested_contract(self):
|
||||
preview = self.tag(push=True)
|
||||
self.assertEqual("planned", preview["status"], preview)
|
||||
self.assertEqual(
|
||||
["govoplan-core", "govoplan"],
|
||||
[row["repo"] for row in preview["repositories"]],
|
||||
)
|
||||
self.assertEqual("registered-meta-batch-v1", preview["source_contract"])
|
||||
self.assert_no_tags()
|
||||
local = self.tag(apply=True)
|
||||
self.assertEqual("tagged", local["status"], local)
|
||||
for repo in (self.core, self.meta):
|
||||
self.assertEqual(
|
||||
"tag", git_text(repo, "cat-file", "-t", "refs/tags/v0.1.10")
|
||||
)
|
||||
self.assertFalse(ref_exists(self.meta_remote, "refs/tags/v0.1.10"))
|
||||
published = self.tag(apply=True, push=True)
|
||||
self.assertEqual("published", published["status"], published)
|
||||
for repo, remote in (
|
||||
(self.core, self.core_remote),
|
||||
(self.meta, self.meta_remote),
|
||||
):
|
||||
self.assertEqual(
|
||||
git_text(repo, "rev-parse", "HEAD"),
|
||||
git_text(remote, "rev-parse", "refs/heads/main"),
|
||||
)
|
||||
self.assertEqual(
|
||||
git_text(repo, "rev-parse", "refs/tags/v0.1.10"),
|
||||
git_text(remote, "rev-parse", "refs/tags/v0.1.10"),
|
||||
)
|
||||
again = self.tag(apply=True, push=True)
|
||||
self.assertEqual("published", again["status"], again)
|
||||
|
||||
def test_stale_composition_blocks_whole_batch_before_local_tag_or_push(self):
|
||||
self.package.write_text(
|
||||
self.package.read_text().replace(
|
||||
"govoplan-access==0.1.10", "govoplan-access==0.1.9"
|
||||
)
|
||||
)
|
||||
self.commit_meta()
|
||||
for apply, push in ((False, False), (True, False), (True, True)):
|
||||
result = self.tag(
|
||||
repos=("govoplan-core", "govoplan-access", "govoplan"),
|
||||
apply=apply,
|
||||
push=push,
|
||||
)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assert_no_tags()
|
||||
|
||||
def test_core_outside_batch_requires_matching_existing_and_published_tag(self):
|
||||
self.assertEqual("blocked", self.tag(repos=("govoplan",))["status"])
|
||||
git(self.core, "tag", "-a", "v0.1.10", "-m", "Core release")
|
||||
self.assertEqual("planned", self.tag(repos=("govoplan",))["status"])
|
||||
self.assertEqual("blocked", self.tag(repos=("govoplan",), push=True)["status"])
|
||||
git(self.core, "push", "origin", "refs/tags/v0.1.10")
|
||||
self.assertEqual("planned", self.tag(repos=("govoplan",), push=True)["status"])
|
||||
|
||||
def test_changed_core_version_and_explicit_selected_version_mismatch_block(self):
|
||||
mismatch = tag_repositories(
|
||||
repos=("govoplan", "govoplan-core"),
|
||||
repo_versions={"govoplan": self.version, "govoplan-core": "0.1.11"},
|
||||
workspace_root=self.workspace,
|
||||
apply=True,
|
||||
push=True,
|
||||
)
|
||||
self.assertEqual("blocked", mismatch["status"], mismatch)
|
||||
(self.core / "pyproject.toml").write_text(
|
||||
'[project]\nname="govoplan-core"\nversion="0.1.11"\n'
|
||||
)
|
||||
git(self.core, "add", ".")
|
||||
git(self.core, "commit", "-m", "Core new version")
|
||||
result = self.tag(apply=True, push=True)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assert_no_tags()
|
||||
|
||||
def test_unsafe_origin_on_any_selected_repo_blocks_every_effect(self):
|
||||
for repo in (self.meta, self.core, self.access):
|
||||
with self.subTest(repo=repo.name):
|
||||
git(
|
||||
repo,
|
||||
"config",
|
||||
"remote.origin.pushurl",
|
||||
str(self.root / "unregistered.git"),
|
||||
)
|
||||
result = self.tag(
|
||||
repos=("govoplan-core", "govoplan-access", "govoplan"),
|
||||
apply=True,
|
||||
push=True,
|
||||
)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assertIn("registered origin", result["repositories"][0]["detail"])
|
||||
self.assert_no_tags()
|
||||
git(repo, "config", "--unset", "remote.origin.pushurl")
|
||||
|
||||
def test_world_writable_nonsticky_parent_blocks_without_changing_permissions(self):
|
||||
original = self.root.stat().st_mode & 0o7777
|
||||
self.root.chmod(0o777)
|
||||
try:
|
||||
for apply in (False, True):
|
||||
result = self.tag(apply=apply, push=True)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assertIn(
|
||||
"group/world writable", result["repositories"][0]["detail"]
|
||||
)
|
||||
self.assertEqual(0o777, self.root.stat().st_mode & 0o7777)
|
||||
self.assert_no_tags()
|
||||
finally:
|
||||
self.root.chmod(original)
|
||||
|
||||
def test_wrong_metadata_owner_blocks_before_remote_lookup(self):
|
||||
config = self.meta / ".git/config"
|
||||
original = Path.lstat
|
||||
|
||||
def wrong_owner(path, *args, **kwargs):
|
||||
observed = original(path, *args, **kwargs)
|
||||
if path == config:
|
||||
fields = list(observed)
|
||||
fields[4] = os.geteuid() + 1
|
||||
return os.stat_result(fields)
|
||||
return observed
|
||||
|
||||
with (
|
||||
patch.object(Path, "lstat", new=wrong_owner),
|
||||
patch.object(
|
||||
meta_source_tag,
|
||||
"registered_source_origin_issues",
|
||||
side_effect=AssertionError("must validate ownership before Git"),
|
||||
),
|
||||
):
|
||||
result = self.tag(apply=True, push=True)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assertIn("current operator", result["repositories"][0]["detail"])
|
||||
self.assert_no_tags()
|
||||
|
||||
def test_hidden_index_flags_cannot_disguise_modified_release_metadata(self):
|
||||
for flag, undo in (
|
||||
("--assume-unchanged", "--no-assume-unchanged"),
|
||||
("--skip-worktree", "--no-skip-worktree"),
|
||||
):
|
||||
for repo, relative in (
|
||||
(self.meta, "packages/govoplan-meta/pyproject.toml"),
|
||||
(self.core, "pyproject.toml"),
|
||||
):
|
||||
with self.subTest(flag=flag, repo=repo.name):
|
||||
target = repo / relative
|
||||
original = target.read_text()
|
||||
git(repo, "update-index", flag, relative)
|
||||
target.write_text(
|
||||
original
|
||||
+ "\n# Hidden working-tree input differs from frozen HEAD\n"
|
||||
)
|
||||
try:
|
||||
self.assertEqual("", git_text(repo, "status", "--porcelain"))
|
||||
result = self.tag(apply=True, push=True)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assertIn(
|
||||
"index entries", result["repositories"][0]["detail"]
|
||||
)
|
||||
self.assert_no_tags()
|
||||
finally:
|
||||
target.write_text(original)
|
||||
git(repo, "update-index", undo, relative)
|
||||
|
||||
def test_read_only_git_target_is_not_repaired_or_tagged(self):
|
||||
metadata = self.meta / ".git"
|
||||
original = metadata.stat().st_mode & 0o7777
|
||||
metadata.chmod(0o500)
|
||||
try:
|
||||
result = self.tag(apply=True, push=True)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assertEqual(0o500, metadata.stat().st_mode & 0o7777)
|
||||
self.assert_no_tags()
|
||||
finally:
|
||||
metadata.chmod(original)
|
||||
|
||||
def test_git_object_alternates_are_rejected_before_remote_lookup(self):
|
||||
(self.meta / ".git/objects/info/alternates").write_text(
|
||||
str(self.root / "outside-objects") + "\n"
|
||||
)
|
||||
with patch.object(
|
||||
meta_source_tag,
|
||||
"registered_source_origin_issues",
|
||||
side_effect=AssertionError("must reject alternates before Git"),
|
||||
):
|
||||
result = self.tag(apply=True, push=True)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assertIn("alternates", result["repositories"][0]["detail"])
|
||||
self.assert_no_tags()
|
||||
|
||||
def test_owned_worktree_metadata_inside_private_workspace_is_supported(self):
|
||||
main_checkout = self.workspace / "meta-main-storage"
|
||||
self.meta.rename(main_checkout)
|
||||
git(main_checkout, "worktree", "add", "--force", str(self.meta), "main")
|
||||
self.assertTrue((self.meta / ".git").is_file())
|
||||
result = self.tag(apply=True)
|
||||
self.assertEqual("tagged", result["status"], result)
|
||||
filesystem = result["source_receipts"]["govoplan"]["filesystem"]
|
||||
self.assertEqual(
|
||||
str(main_checkout / ".git"), filesystem["git_common_directory"][0]
|
||||
)
|
||||
|
||||
def test_git_directory_replacement_with_same_head_changes_frozen_receipt(self):
|
||||
preview = meta_source_tag._preview_repositories
|
||||
|
||||
def swapped_git_directory(**kwargs):
|
||||
result = preview(**kwargs)
|
||||
original = self.meta / ".git"
|
||||
backup = self.root / "original-meta-git"
|
||||
original.rename(backup)
|
||||
shutil.copytree(backup, original)
|
||||
return result
|
||||
|
||||
with patch.object(
|
||||
meta_source_tag,
|
||||
"_preview_repositories",
|
||||
side_effect=swapped_git_directory,
|
||||
):
|
||||
result = self.tag(apply=True, push=True)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assertIn("receipt changed", result["repositories"][0]["detail"])
|
||||
self.assert_no_tags()
|
||||
|
||||
def test_non_main_and_divergent_live_main_fail_even_with_stale_tracking(self):
|
||||
git(self.meta, "switch", "-c", "feature")
|
||||
self.assertEqual("blocked", self.tag(apply=True)["status"])
|
||||
git(self.meta, "switch", "main")
|
||||
clone = self.root / "other-writer"
|
||||
git(self.root, "clone", "--branch", "main", str(self.meta_remote), str(clone))
|
||||
git(clone, "config", "user.name", "Other synthetic writer")
|
||||
git(clone, "config", "user.email", "other@example.invalid")
|
||||
(clone / "other.txt").write_text("remote divergence\n")
|
||||
git(clone, "add", ".")
|
||||
git(clone, "commit", "-m", "Remote main advanced")
|
||||
git(clone, "push", "origin", "main")
|
||||
result = self.tag(apply=True, push=True)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assertIn("live origin/main", result["repositories"][0]["detail"])
|
||||
self.assert_no_tags()
|
||||
|
||||
def test_symlink_checkout_is_not_a_registered_source(self):
|
||||
original = self.workspace / "moved-meta"
|
||||
self.meta.rename(original)
|
||||
self.meta.symlink_to(original, target_is_directory=True)
|
||||
result = self.tag(apply=True)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assertIn("symlink", result["repositories"][0]["detail"])
|
||||
self.assert_no_tags()
|
||||
|
||||
def test_lightweight_and_conflicting_annotated_tags_block(self):
|
||||
git(self.meta, "tag", "v0.1.10")
|
||||
self.assertEqual("blocked", self.tag(apply=True)["status"])
|
||||
git(self.meta, "tag", "-d", "v0.1.10")
|
||||
git(self.meta, "tag", "-a", "v0.1.10", "-m", "First annotation")
|
||||
git(self.meta, "push", "origin", "refs/tags/v0.1.10")
|
||||
git(self.meta, "tag", "-d", "v0.1.10")
|
||||
git(self.meta, "tag", "-a", "v0.1.10", "-m", "Different annotation")
|
||||
self.assertEqual("blocked", self.tag(apply=True, push=True)["status"])
|
||||
self.assertFalse(ref_exists(self.core, "refs/tags/v0.1.10"))
|
||||
|
||||
def test_meta_source_receipt_changed_after_preflight_blocks_before_first_effect(
|
||||
self,
|
||||
):
|
||||
preview = meta_source_tag._preview_repositories
|
||||
|
||||
def changed_after_preflight(**kwargs):
|
||||
result = preview(**kwargs)
|
||||
(self.meta / "new-review.txt").write_text("changed after preflight\n")
|
||||
self.commit_meta()
|
||||
return result
|
||||
|
||||
with patch.object(
|
||||
meta_source_tag,
|
||||
"_preview_repositories",
|
||||
side_effect=changed_after_preflight,
|
||||
):
|
||||
result = self.tag(apply=True, push=True)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assertIn("receipt changed", result["repositories"][0]["detail"])
|
||||
self.assert_no_tags()
|
||||
|
||||
def test_fabricated_push_success_without_remote_receipt_fails_and_stops_batch(self):
|
||||
original = meta_source_tag.run
|
||||
|
||||
def run(command, **kwargs):
|
||||
if command[:3] == ("git", "push", "--atomic"):
|
||||
return subprocess.CompletedProcess(command, 0, "", "")
|
||||
return original(command, **kwargs)
|
||||
|
||||
with patch.object(meta_source_tag, "run", side_effect=run):
|
||||
result = self.tag(apply=True, push=True)
|
||||
self.assertEqual("partial", result["status"], result)
|
||||
self.assertEqual("failed", result["repositories"][0]["status"])
|
||||
self.assertEqual("skipped", result["repositories"][1]["status"])
|
||||
self.assertFalse(ref_exists(self.meta, "refs/tags/v0.1.10"))
|
||||
self.assertFalse(ref_exists(self.core_remote, "refs/tags/v0.1.10"))
|
||||
|
||||
def test_remote_tag_without_expected_main_receipt_is_not_success(self):
|
||||
(self.core / "reviewed-change.txt").write_text("release source changes\n")
|
||||
git(self.core, "add", ".")
|
||||
git(self.core, "commit", "-m", "Advance reviewed Core source")
|
||||
original = meta_source_tag.run
|
||||
pushes = []
|
||||
|
||||
def tag_only(command, **kwargs):
|
||||
if command[:3] == ("git", "push", "--atomic"):
|
||||
pushes.append(command)
|
||||
# Simulate a defective transport that claims atomic success,
|
||||
# while publishing only the exact expected annotation object.
|
||||
return original(("git", "push", "origin", command[-1]), **kwargs)
|
||||
return original(command, **kwargs)
|
||||
|
||||
with patch.object(meta_source_tag, "run", side_effect=tag_only):
|
||||
result = self.tag(apply=True, push=True)
|
||||
self.assertEqual("partial", result["status"], result)
|
||||
self.assertEqual(1, len(pushes))
|
||||
self.assertIn("receipt changed", result["repositories"][0]["detail"])
|
||||
self.assertEqual(
|
||||
git_text(self.core, "rev-parse", "refs/tags/v0.1.10"),
|
||||
git_text(self.core_remote, "rev-parse", "refs/tags/v0.1.10"),
|
||||
)
|
||||
self.assertNotEqual(
|
||||
git_text(self.core, "rev-parse", "HEAD"),
|
||||
git_text(self.core_remote, "rev-parse", "refs/heads/main"),
|
||||
)
|
||||
self.assertFalse(ref_exists(self.meta, "refs/tags/v0.1.10"))
|
||||
|
||||
def test_meta_receipt_is_rechecked_after_an_earlier_successful_publication(self):
|
||||
original = meta_source_tag.run
|
||||
|
||||
def changed_after_core(command, **kwargs):
|
||||
result = original(command, **kwargs)
|
||||
if (
|
||||
command[:3] == ("git", "push", "--atomic")
|
||||
and kwargs["cwd"] == self.core
|
||||
):
|
||||
(self.meta / "changed-review.txt").write_text(
|
||||
"new Meta source after Core publication\n"
|
||||
)
|
||||
self.commit_meta()
|
||||
return result
|
||||
|
||||
with patch.object(meta_source_tag, "run", side_effect=changed_after_core):
|
||||
result = self.tag(apply=True, push=True)
|
||||
self.assertEqual("partial", result["status"], result)
|
||||
self.assertTrue(ref_exists(self.core_remote, "refs/tags/v0.1.10"))
|
||||
self.assertFalse(ref_exists(self.meta, "refs/tags/v0.1.10"))
|
||||
self.assertEqual("skipped", result["repositories"][1]["status"])
|
||||
|
||||
def test_unknown_selected_repository_cannot_use_meta_support_exception(self):
|
||||
result = self.tag(repos=("govoplan", "unknown"), apply=True, push=True)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assertIn("not registered", result["repositories"][0]["detail"])
|
||||
self.assert_no_tags()
|
||||
|
||||
def test_selected_checkout_generator_is_never_executed(self):
|
||||
malicious = self.meta / "tools/release/generate-developer-meta-package.py"
|
||||
malicious.parent.mkdir(parents=True)
|
||||
malicious.write_text(
|
||||
'raise RuntimeError("selected checkout must not execute")\n'
|
||||
)
|
||||
self.commit_meta()
|
||||
self.assertEqual("planned", self.tag()["status"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -6,6 +6,7 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -17,6 +18,7 @@ if str(RELEASE_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(RELEASE_ROOT))
|
||||
|
||||
from govoplan_release.repository_tag import tag_repositories # noqa: E402
|
||||
from govoplan_release import workspace as release_workspace # noqa: E402
|
||||
from server.app import create_app # noqa: E402
|
||||
|
||||
|
||||
@@ -39,6 +41,14 @@ class ReleaseRepositoryTagTests(unittest.TestCase):
|
||||
version="0.1.10",
|
||||
)
|
||||
add_scoped_workflow_manifest(self.manifest_repo)
|
||||
# The operator's test catalog explicitly registers known synthetic
|
||||
# endpoints; production trust checks are not patched or bypassed.
|
||||
self.registry = self.root / "registered-test-repositories.json"
|
||||
self.registered = json.loads((META_ROOT / "repositories.json").read_text())
|
||||
for spec in self.registered["repositories"]:
|
||||
spec["remote"] = str(self.root / f"{spec['name']}.git")
|
||||
self.registry.write_text(json.dumps(self.registered))
|
||||
self.enterContext(patch.object(release_workspace, "REPOSITORIES_FILE", self.registry))
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temporary.cleanup()
|
||||
@@ -128,6 +138,10 @@ class ReleaseRepositoryTagTests(unittest.TestCase):
|
||||
name="govoplan-core",
|
||||
version="0.1.10",
|
||||
)
|
||||
for spec in self.registered["repositories"]:
|
||||
if spec["name"] == "govoplan-core":
|
||||
spec["remote"] = str(remote_root / "govoplan-core.git")
|
||||
self.registry.write_text(json.dumps(self.registered))
|
||||
|
||||
result = tag_repositories(
|
||||
repos=("govoplan-core",),
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "tools/release"))
|
||||
|
||||
from govoplan_release import source_tag_batch # noqa: E402
|
||||
from govoplan_release.repository_tag import tag_repositories # noqa: E402
|
||||
import test_release_meta_source_tag as meta_fixture # noqa: E402
|
||||
import test_release_repository_tag as release_fixture # noqa: E402
|
||||
from test_release_repository_tag import git, git_text, ref_exists # noqa: E402
|
||||
|
||||
|
||||
class RegisteredSourceTagBatchTests(unittest.TestCase):
|
||||
setUp = meta_fixture.MetaSourceTagTests.setUp
|
||||
synchronize = meta_fixture.MetaSourceTagTests.synchronize
|
||||
assert_no_tags = meta_fixture.MetaSourceTagTests.assert_no_tags
|
||||
|
||||
def tag(self, *, repos=("govoplan-access",), apply=False, push=False):
|
||||
return tag_repositories(
|
||||
repos=repos,
|
||||
repo_versions={repo: self.version for repo in repos},
|
||||
workspace_root=self.workspace,
|
||||
apply=apply,
|
||||
push=push,
|
||||
)
|
||||
|
||||
def test_python_only_module_publishes_without_meta_or_core_checkout(self):
|
||||
self.meta.rename(self.root / "unused-meta")
|
||||
self.core.rename(self.root / "unused-core")
|
||||
preview = self.tag(push=True)
|
||||
self.assertEqual("planned", preview["status"], preview)
|
||||
self.assertEqual("registered-source-batch-v1", preview["source_contract"])
|
||||
self.assertEqual({}, preview["bundle_input_receipts"])
|
||||
self.assertFalse(ref_exists(self.access, "refs/tags/v0.1.10"))
|
||||
published = self.tag(apply=True, push=True)
|
||||
self.assertEqual("published", published["status"], published)
|
||||
self.assertEqual(["govoplan-access"], list(published["source_receipts"]))
|
||||
self.assertEqual(
|
||||
git_text(self.access, "rev-parse", "HEAD"),
|
||||
git_text(self.access_remote, "rev-parse", "refs/heads/main"),
|
||||
)
|
||||
|
||||
def test_core_only_batch_has_no_meta_composition_requirement(self):
|
||||
self.meta.rename(self.root / "unused-meta")
|
||||
result = self.tag(repos=("govoplan-core",), apply=True, push=True)
|
||||
self.assertEqual("published", result["status"], result)
|
||||
self.assertEqual(["govoplan-core"], list(result["source_receipts"]))
|
||||
|
||||
def test_backend_only_publication_never_reads_irrelevant_unsafe_core_json(self):
|
||||
webui = self.core / "webui"
|
||||
webui.mkdir()
|
||||
(webui / "package.release.json").write_text("invalid unselected Core JSON")
|
||||
(webui / "package-lock.release.json").symlink_to(
|
||||
self.root / "not-a-core-release-lock"
|
||||
)
|
||||
from govoplan_release import version_alignment
|
||||
|
||||
original = version_alignment._json_object
|
||||
|
||||
def read(path):
|
||||
if path.is_relative_to(webui):
|
||||
raise AssertionError(
|
||||
"backend-only publication must not read unrelated Core JSON"
|
||||
)
|
||||
return original(path)
|
||||
|
||||
with patch.object(version_alignment, "_json_object", side_effect=read):
|
||||
result = self.tag(apply=True, push=True)
|
||||
self.assertEqual("published", result["status"], result)
|
||||
self.assertEqual({}, result["bundle_input_receipts"])
|
||||
|
||||
def test_source_origin_parent_and_read_only_target_guards_apply_without_meta(self):
|
||||
for problem in ("origin", "parent", "read_only"):
|
||||
with self.subTest(problem=problem):
|
||||
parent_mode = self.root.stat().st_mode & 0o7777
|
||||
git_directory = self.access / ".git"
|
||||
git_mode = git_directory.stat().st_mode & 0o7777
|
||||
if problem == "origin":
|
||||
git(
|
||||
self.access,
|
||||
"config",
|
||||
"remote.origin.pushurl",
|
||||
str(self.root / "unknown.git"),
|
||||
)
|
||||
elif problem == "parent":
|
||||
self.root.chmod(0o777)
|
||||
else:
|
||||
git_directory.chmod(0o500)
|
||||
try:
|
||||
for apply in (False, True):
|
||||
result = self.tag(apply=apply, push=True)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assert_no_tags()
|
||||
finally:
|
||||
self.root.chmod(parent_mode)
|
||||
git_directory.chmod(git_mode)
|
||||
if problem == "origin":
|
||||
git(self.access, "config", "--unset", "remote.origin.pushurl")
|
||||
|
||||
def test_wrong_owner_symlink_and_hidden_index_are_rejected_without_meta(self):
|
||||
config = self.access / ".git/config"
|
||||
original_stat = Path.lstat
|
||||
|
||||
def wrong_owner(path, *args, **kwargs):
|
||||
observed = original_stat(path, *args, **kwargs)
|
||||
if path == config:
|
||||
fields = list(observed)
|
||||
fields[4] = os.geteuid() + 1
|
||||
return os.stat_result(fields)
|
||||
return observed
|
||||
|
||||
with patch.object(Path, "lstat", new=wrong_owner):
|
||||
self.assertEqual("blocked", self.tag(apply=True)["status"])
|
||||
moved = self.root / "moved-access"
|
||||
self.access.rename(moved)
|
||||
self.access.symlink_to(moved, target_is_directory=True)
|
||||
try:
|
||||
self.assertEqual("blocked", self.tag(apply=True)["status"])
|
||||
finally:
|
||||
self.access.unlink()
|
||||
moved.rename(self.access)
|
||||
for flag, undo in (
|
||||
("--assume-unchanged", "--no-assume-unchanged"),
|
||||
("--skip-worktree", "--no-skip-worktree"),
|
||||
):
|
||||
with self.subTest(flag=flag):
|
||||
git(self.access, "update-index", flag, "pyproject.toml")
|
||||
try:
|
||||
self.assertEqual("blocked", self.tag(apply=True)["status"])
|
||||
self.assert_no_tags()
|
||||
finally:
|
||||
git(self.access, "update-index", undo, "pyproject.toml")
|
||||
|
||||
def test_live_remote_divergence_blocks_without_cached_tracking_update(self):
|
||||
clone = self.root / "other-access-writer"
|
||||
git(self.root, "clone", "--branch", "main", str(self.access_remote), str(clone))
|
||||
git(clone, "config", "user.name", "Synthetic writer")
|
||||
git(clone, "config", "user.email", "writer@example.invalid")
|
||||
(clone / "advance.txt").write_text("new remote main\n")
|
||||
git(clone, "add", ".")
|
||||
git(clone, "commit", "-m", "Advance remote without updating original tracking")
|
||||
git(clone, "push", "origin", "main")
|
||||
result = self.tag(apply=True, push=True)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assertIn("live origin/main", result["repositories"][0]["detail"])
|
||||
self.assert_no_tags()
|
||||
|
||||
def test_ignored_selected_version_metadata_cannot_supply_an_untagged_artifact(self):
|
||||
project = self.access / "pyproject.toml"
|
||||
original = project.read_text()
|
||||
git(self.access, "rm", "pyproject.toml")
|
||||
(self.access / ".gitignore").write_text("/pyproject.toml\n")
|
||||
git(self.access, "add", ".gitignore")
|
||||
git(self.access, "commit", "-m", "Ignored metadata absent from frozen tree")
|
||||
project.write_text(original)
|
||||
self.assertEqual("", git_text(self.access, "status", "--porcelain"))
|
||||
result = self.tag(apply=True, push=True)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assertIn("must be tracked", result["repositories"][0]["detail"])
|
||||
self.assert_no_tags()
|
||||
|
||||
def test_lightweight_and_different_annotation_objects_remain_immutable(self):
|
||||
git(self.access, "tag", "v0.1.10")
|
||||
self.assertEqual("blocked", self.tag(apply=True)["status"])
|
||||
git(self.access, "tag", "-d", "v0.1.10")
|
||||
git(self.access, "tag", "-a", "v0.1.10", "-m", "Original immutable annotation")
|
||||
git(self.access, "push", "origin", "refs/tags/v0.1.10")
|
||||
original = git_text(self.access_remote, "rev-parse", "refs/tags/v0.1.10")
|
||||
git(self.access, "tag", "-d", "v0.1.10")
|
||||
git(
|
||||
self.access,
|
||||
"tag",
|
||||
"-a",
|
||||
"v0.1.10",
|
||||
"-m",
|
||||
"Conflicting immutable annotation",
|
||||
)
|
||||
self.assertEqual("blocked", self.tag(apply=True, push=True)["status"])
|
||||
self.assertEqual(
|
||||
original, git_text(self.access_remote, "rev-parse", "refs/tags/v0.1.10")
|
||||
)
|
||||
|
||||
def test_changed_source_after_preflight_stops_before_first_batch_effect(self):
|
||||
original = source_tag_batch._preview_repositories
|
||||
|
||||
def changed(**kwargs):
|
||||
result = original(**kwargs)
|
||||
(self.access / "changed-source.txt").write_text(
|
||||
"new source after preflight\n"
|
||||
)
|
||||
git(self.access, "add", ".")
|
||||
git(self.access, "commit", "-m", "Source changed")
|
||||
return result
|
||||
|
||||
with patch.object(
|
||||
source_tag_batch, "_preview_repositories", side_effect=changed
|
||||
):
|
||||
result = self.tag(
|
||||
repos=("govoplan-core", "govoplan-access"), apply=True, push=True
|
||||
)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assertIn("receipt changed", result["repositories"][0]["detail"])
|
||||
self.assert_no_tags()
|
||||
|
||||
def test_false_push_success_is_not_a_receipt_and_never_retries(self):
|
||||
original = source_tag_batch.run
|
||||
pushes = []
|
||||
|
||||
def run(command, **kwargs):
|
||||
if command[:3] == ("git", "push", "--atomic"):
|
||||
pushes.append(command)
|
||||
return subprocess.CompletedProcess(command, 0, "", "")
|
||||
return original(command, **kwargs)
|
||||
|
||||
with patch.object(source_tag_batch, "run", side_effect=run):
|
||||
result = self.tag(
|
||||
repos=("govoplan-access", "govoplan-core"), apply=True, push=True
|
||||
)
|
||||
self.assertEqual("partial", result["status"], result)
|
||||
self.assertEqual(1, len(pushes))
|
||||
self.assertEqual("skipped", result["repositories"][1]["status"])
|
||||
self.assertFalse(ref_exists(self.core, "refs/tags/v0.1.10"))
|
||||
self.assertFalse(ref_exists(self.access_remote, "refs/tags/v0.1.10"))
|
||||
|
||||
def _ready_bundle(self):
|
||||
self.repo = self.core # Existing fixture's Core name.
|
||||
campaign, remote = (
|
||||
release_fixture.ReleaseRepositoryTagTests._staged_campaign_bundle(self)
|
||||
)
|
||||
self.specs.append(
|
||||
{
|
||||
"name": "govoplan-campaign",
|
||||
"category": "module",
|
||||
"subtype": "domain",
|
||||
"path": "govoplan-campaign",
|
||||
"remote": str(remote),
|
||||
}
|
||||
)
|
||||
self.registry.write_text(json.dumps({"repositories": self.specs}))
|
||||
self.assertEqual(
|
||||
"tagged", self.tag(repos=("govoplan-campaign",), apply=True)["status"]
|
||||
)
|
||||
lock_path = self.core / "webui/package-lock.release.json"
|
||||
payload = json.loads(lock_path.read_text())
|
||||
package = payload["packages"]["node_modules/@govoplan/campaign-webui"]
|
||||
package["version"] = self.version
|
||||
package["resolved"] = (
|
||||
"git+ssh://git@example.test/acme/govoplan-campaign.git#"
|
||||
+ git_text(campaign, "rev-parse", "HEAD")
|
||||
)
|
||||
lock_path.write_text(json.dumps(payload))
|
||||
# Core is a reviewed input only here: do not require an unrelated tag
|
||||
# or silently impose a new clean-Core prerequisite for module release.
|
||||
return campaign, remote, lock_path
|
||||
|
||||
def test_module_publication_freezes_core_inputs_without_requiring_core_tag(self):
|
||||
_campaign, _remote, _lock = self._ready_bundle()
|
||||
self.assertFalse(ref_exists(self.core, "refs/tags/v0.1.10"))
|
||||
result = self.tag(repos=("govoplan-campaign",), apply=True, push=True)
|
||||
self.assertEqual("published", result["status"], result)
|
||||
self.assertEqual(["govoplan-campaign"], list(result["source_receipts"]))
|
||||
self.assertEqual(
|
||||
{"webui/package.release.json", "webui/package-lock.release.json"},
|
||||
set(result["bundle_input_receipts"]),
|
||||
)
|
||||
self.assertFalse(ref_exists(self.core, "refs/tags/v0.1.10"))
|
||||
|
||||
def test_changed_or_group_writable_core_bundle_inputs_block_module_publication(
|
||||
self,
|
||||
):
|
||||
campaign, remote, lock = self._ready_bundle()
|
||||
original = source_tag_batch._preview_repositories
|
||||
|
||||
def changed(**kwargs):
|
||||
result = original(**kwargs)
|
||||
lock.write_text(lock.read_text() + "\n")
|
||||
return result
|
||||
|
||||
with patch.object(
|
||||
source_tag_batch, "_preview_repositories", side_effect=changed
|
||||
):
|
||||
result = self.tag(repos=("govoplan-campaign",), apply=True, push=True)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assertIn(
|
||||
"bundle input receipt changed", result["repositories"][0]["detail"]
|
||||
)
|
||||
self.assertFalse(ref_exists(remote, "refs/tags/v0.1.10"))
|
||||
lock.chmod(0o666)
|
||||
try:
|
||||
self.assertEqual(
|
||||
"blocked",
|
||||
self.tag(repos=("govoplan-campaign",), apply=True, push=True)["status"],
|
||||
)
|
||||
finally:
|
||||
lock.chmod(0o644)
|
||||
self.assertTrue(ref_exists(campaign, "refs/tags/v0.1.10"))
|
||||
self.assertFalse(ref_exists(remote, "refs/tags/v0.1.10"))
|
||||
|
||||
def test_read_only_shared_preflight_has_no_apply_or_mutating_legacy_entry(self):
|
||||
import inspect
|
||||
from govoplan_release import repository_tag
|
||||
|
||||
self.assertNotIn(
|
||||
"apply", inspect.signature(repository_tag._preview_repositories).parameters
|
||||
)
|
||||
self.assertFalse(hasattr(repository_tag, "_tag_repositories_legacy"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -51,6 +51,8 @@ cd "$META_ROOT"
|
||||
"$PYTHON" -m unittest tests.test_webui_package_facades
|
||||
"$PYTHON" -m unittest tests.test_module_package_workflows tests.test_package_registry_release
|
||||
"$PYTHON" -m unittest tests.test_deployment_installer tests.test_webui_release_dependency_retries
|
||||
"$PYTHON" -m pytest -q tests/test_release_meta_source_tag.py tests/test_release_source_tag_batch.py tests/test_release_meta_preparation.py
|
||||
"$PYTHON" -m unittest tests.test_isolated_work_composition
|
||||
"$PYTHON" -m unittest tests.test_capability_fit_evidence
|
||||
"$PYTHON" -m unittest tests.test_capability_fit_generation tests.test_capability_fit_review
|
||||
"$PYTHON" tools/assessments/generate-capability-fit-report.py --check
|
||||
@@ -101,6 +103,7 @@ PY
|
||||
"$PYTHON" "$META_ROOT/tools/checks/check-shared-webui-primitives.py"
|
||||
"$PYTHON" "$META_ROOT/tools/checks/check-shared-webui-foundations.py"
|
||||
"$PYTHON" -m unittest tests.test_module_system
|
||||
"$PYTHON" -m unittest tests.test_bounded_process
|
||||
"$PYTHON" -m unittest tests.test_ownership_history_migration tests.test_ownership tests.test_ownership_api
|
||||
"$PYTHON" -m unittest tests.test_navigation_preferences tests.test_api_smoke.ApiSmokeTests.test_navigation_separator_layout_survives_system_tenant_and_personal_saves
|
||||
"$PYTHON" -m pytest -q \
|
||||
@@ -109,7 +112,13 @@ PY
|
||||
/mnt/DATA/git/govoplan-files/tests/test_archive_staging.py \
|
||||
/mnt/DATA/git/govoplan-files/tests/test_upload_response_batching.py \
|
||||
/mnt/DATA/git/govoplan-files/tests/test_archive_performance.py
|
||||
"$PYTHON" -m pytest -q \
|
||||
/mnt/DATA/git/govoplan-files/tests/test_archive_workers.py \
|
||||
/mnt/DATA/git/govoplan-files/tests/test_archive_inspection_bounds.py \
|
||||
/mnt/DATA/git/govoplan-files/tests/test_archives.py
|
||||
"$PYTHON" -m pytest -q /mnt/DATA/git/govoplan-access/tests/test_external_function_mapping_migration.py
|
||||
"$PYTHON" -m pytest -q /mnt/DATA/git/govoplan-access/tests
|
||||
"$PYTHON" -m pytest -q /mnt/DATA/git/govoplan-templates/tests
|
||||
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-connectors/tests
|
||||
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-datasources/tests
|
||||
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-dataflow/tests
|
||||
@@ -151,9 +160,14 @@ PY
|
||||
"$PYTHON" -m pytest -q /mnt/DATA/git/govoplan-mail/tests/test_campaign_protocol_authorization.py /mnt/DATA/git/govoplan-mail/tests/test_campaign_imap_batch.py
|
||||
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-mail/tests
|
||||
"$PYTHON" -m unittest tests.test_api_smoke.ApiSmokeTests.test_mailbox_message_listing_reports_total_count
|
||||
"$PYTHON" -m unittest \
|
||||
tests.test_api_smoke.ApiSmokeTests.test_managed_attachment_patterns_preview_build_and_mock_send \
|
||||
tests.test_api_smoke.ApiSmokeTests.test_reports_and_job_review_are_scoped_to_the_selected_version \
|
||||
tests.test_api_smoke.ApiSmokeTests.test_worker_loss_becomes_unknown_and_requires_reconciliation_before_retry
|
||||
|
||||
cd "$ROOT/webui"
|
||||
"$NPM" run test:api-client-cache
|
||||
"$NPM" run test:auth-action-state
|
||||
"$NPM" run test:dependency-security
|
||||
"$NPM" run test:layout-primitives
|
||||
"$NPM" run test:mail-components
|
||||
@@ -161,6 +175,9 @@ cd "$ROOT/webui"
|
||||
"$NPM" run test:module-permutations
|
||||
"$NPM" run test:conformance
|
||||
|
||||
cd /mnt/DATA/git/govoplan-access/webui
|
||||
"$NPM" run test:passwords
|
||||
|
||||
"$WEBUI_BIN/tsc" -p /mnt/DATA/git/govoplan-payments/webui/tsconfig.json
|
||||
|
||||
cd /mnt/DATA/git/govoplan-payments/webui
|
||||
|
||||
@@ -13,7 +13,10 @@ from urllib.parse import urlsplit
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
DEFAULT_GARAGE_IMAGE = "dxflrs/garage:v2.3.0"
|
||||
DEFAULT_LOAD_BALANCER_IMAGE = "haproxy:3.2.21-alpine"
|
||||
DEFAULT_LOAD_BALANCER_IMAGE = (
|
||||
"haproxy:3.2.23-alpine@sha256:"
|
||||
"6343ce34a132a5dceaa24767d739df2bd519f8f7c1079ae39e4821334e8eb42e"
|
||||
)
|
||||
DEFAULT_INGRESS_IMAGE = "caddy:2.10.2-alpine"
|
||||
INSTALLATION_ID_PATTERN = re.compile(r"^[a-z][a-z0-9-]{1,47}$")
|
||||
ENV_NAME_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]{1,63}$")
|
||||
|
||||
@@ -2133,6 +2133,34 @@
|
||||
"rationale": "This capability-first module intentionally exposes a headless API for other modules and integrations.",
|
||||
"repository": "govoplan-workflow-engine"
|
||||
},
|
||||
{
|
||||
"category": "intentionally_headless",
|
||||
"method": "GET",
|
||||
"path": "/workflow/instances/summaries",
|
||||
"rationale": "Workflow Engine publishes bounded, current-authorized summary discovery for module and API consumers; the existing Workflow UI retains its compatible full-history contract. See owning topic workflow.instance-history.",
|
||||
"repository": "govoplan-workflow-engine"
|
||||
},
|
||||
{
|
||||
"category": "intentionally_headless",
|
||||
"method": "GET",
|
||||
"path": "/workflow/instances/{}/events",
|
||||
"rationale": "Workflow Engine publishes current-authorized, sequence-bounded event history pages with explicit total and continuation semantics for module and API consumers. See owning topic workflow.instance-history.",
|
||||
"repository": "govoplan-workflow-engine"
|
||||
},
|
||||
{
|
||||
"category": "intentionally_headless",
|
||||
"method": "GET",
|
||||
"path": "/workflow/instances/{}/steps",
|
||||
"rationale": "Workflow Engine publishes current-authorized, sequence-bounded step history pages with explicit total and continuation semantics for module and API consumers. See owning topic workflow.instance-history.",
|
||||
"repository": "govoplan-workflow-engine"
|
||||
},
|
||||
{
|
||||
"category": "intentionally_headless",
|
||||
"method": "GET",
|
||||
"path": "/workflow/instances/{}/summary",
|
||||
"rationale": "Workflow Engine publishes a current-authorized, history-free instance summary for module and API consumers; the existing Workflow UI retains its compatible full-history detail contract. See owning topic workflow.instance-history.",
|
||||
"repository": "govoplan-workflow-engine"
|
||||
},
|
||||
{
|
||||
"category": "ui_reachable",
|
||||
"method": "POST",
|
||||
|
||||
@@ -11,7 +11,7 @@ import tomllib
|
||||
|
||||
from .contracts import parse_manifest_contract
|
||||
from .model import RepositorySnapshot, RepositorySpec, VersionSnapshot
|
||||
from .workspace import resolve_repo_path
|
||||
from .workspace import load_repository_specs, resolve_repo_path
|
||||
|
||||
|
||||
def collect_repository_snapshot(
|
||||
@@ -98,6 +98,7 @@ def collect_repository_snapshot(
|
||||
def collect_versions(path: Path) -> VersionSnapshot:
|
||||
return VersionSnapshot(
|
||||
pyproject=read_pyproject_version(path),
|
||||
developer_meta=read_developer_meta_version(path),
|
||||
package=read_json_version(path / "package.json"),
|
||||
webui_package=read_json_version(path / "webui" / "package.json"),
|
||||
manifests=read_manifest_versions(path),
|
||||
@@ -105,6 +106,35 @@ def collect_versions(path: Path) -> VersionSnapshot:
|
||||
)
|
||||
|
||||
|
||||
def registered_developer_meta_path(path: Path) -> Path | None:
|
||||
"""Recognize only the catalog's explicit Meta support-repository identity.
|
||||
|
||||
This is metadata discovery, not authorization to access a remote or mutate
|
||||
a checkout. Tagging applies its separate registered source trust contract.
|
||||
"""
|
||||
for spec in load_repository_specs(include_website=False):
|
||||
if (
|
||||
spec.name == "govoplan"
|
||||
and spec.category == "system"
|
||||
and spec.subtype == "meta"
|
||||
and path.absolute() == resolve_repo_path(spec, path.parent).absolute()
|
||||
):
|
||||
return path / "packages" / "govoplan-meta" / "pyproject.toml"
|
||||
return None
|
||||
|
||||
|
||||
def read_developer_meta_version(path: Path) -> str | None:
|
||||
package = registered_developer_meta_path(path)
|
||||
if package is None or not package.is_file():
|
||||
return None
|
||||
with package.open("rb") as handle:
|
||||
project = tomllib.load(handle).get("project")
|
||||
if isinstance(project, dict) and project.get("name") == "govoplan":
|
||||
version = project.get("version")
|
||||
return version if isinstance(version, str) else None
|
||||
return None
|
||||
|
||||
|
||||
def read_pyproject_version(path: Path) -> str | None:
|
||||
pyproject = path / "pyproject.toml"
|
||||
if not pyproject.exists():
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
"""Receipt-bound, out-of-run preparation of the real developer meta-package.
|
||||
|
||||
This deliberately does not commit, tag, publish, or update a running release
|
||||
console. The complete generated file is reviewed in a separate source checkout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import stat
|
||||
import tempfile
|
||||
import tomllib
|
||||
|
||||
from .git_state import (
|
||||
collect_repository_snapshot,
|
||||
git,
|
||||
git_text,
|
||||
registered_developer_meta_path,
|
||||
)
|
||||
from .repository_tag import normalize_version, remote_tag_commit, run
|
||||
from .source_provenance import registered_source_origin_issues
|
||||
from .source_tag_batch import (
|
||||
_OBJECT,
|
||||
_owned_path,
|
||||
_source_filesystem,
|
||||
_tracked_worktree,
|
||||
_trusted_ancestry,
|
||||
)
|
||||
from .workspace import META_ROOT, load_repository_specs, resolve_repo_path
|
||||
|
||||
PACKAGE = "packages/govoplan-meta/pyproject.toml"
|
||||
MAX_INPUT_FILES = 128
|
||||
MAX_INPUT_BYTES = 2 * 1024 * 1024
|
||||
MAX_TOTAL_BYTES = 16 * 1024 * 1024
|
||||
GENERATOR = ".operator/generate-developer-meta-package.py"
|
||||
|
||||
|
||||
class MetaPreparationError(ValueError):
|
||||
"""Preparation is blocked, or an applied file needs explicit reconciliation."""
|
||||
|
||||
|
||||
class MetaPreparationAmbiguous(MetaPreparationError):
|
||||
"""The file effect may have happened and requires explicit reconciliation."""
|
||||
|
||||
|
||||
def preparation_command(*, workspace: Path, target_version: str) -> str:
|
||||
import shlex
|
||||
|
||||
return " ".join(
|
||||
shlex.quote(value)
|
||||
for value in (
|
||||
"python",
|
||||
str(META_ROOT / "tools/release/prepare-developer-meta-package.py"),
|
||||
"--workspace",
|
||||
str(workspace),
|
||||
"--target-version",
|
||||
target_version,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _read_input(path: Path) -> tuple[bytes, dict]:
|
||||
def identity(value):
|
||||
return (
|
||||
value.st_dev,
|
||||
value.st_ino,
|
||||
value.st_uid,
|
||||
value.st_gid,
|
||||
value.st_mode,
|
||||
value.st_size,
|
||||
value.st_mtime_ns,
|
||||
value.st_ctime_ns,
|
||||
)
|
||||
|
||||
before = path.lstat()
|
||||
if (
|
||||
not stat.S_ISREG(before.st_mode)
|
||||
or before.st_uid != os.geteuid()
|
||||
or before.st_mode & 0o022
|
||||
or not 0 < before.st_size <= MAX_INPUT_BYTES
|
||||
):
|
||||
raise MetaPreparationError(
|
||||
"Preparation inputs must be owned, bounded regular files."
|
||||
)
|
||||
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
with os.fdopen(descriptor, "rb") as source:
|
||||
opened = os.fstat(source.fileno())
|
||||
if identity(opened) != identity(before):
|
||||
raise MetaPreparationError("Preparation input changed before reading.")
|
||||
payload = source.read(before.st_size + 1)
|
||||
if (
|
||||
identity(os.fstat(source.fileno())) != identity(opened)
|
||||
or len(payload) != before.st_size
|
||||
):
|
||||
raise MetaPreparationError("Preparation input changed while reading.")
|
||||
return payload, {
|
||||
"sha256": hashlib.sha256(payload).hexdigest(),
|
||||
"identity": [
|
||||
before.st_dev,
|
||||
before.st_ino,
|
||||
before.st_uid,
|
||||
before.st_gid,
|
||||
stat.S_IMODE(before.st_mode),
|
||||
before.st_size,
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _snapshot(*, repo_path: Path, target_version: str, output_dirty: bool = False):
|
||||
workspace = repo_path.parent
|
||||
specs = {spec.name: spec for spec in load_repository_specs(include_website=False)}
|
||||
if registered_developer_meta_path(repo_path) != repo_path / PACKAGE:
|
||||
raise MetaPreparationError(
|
||||
"Only the registered Meta nested-package identity can be prepared."
|
||||
)
|
||||
if repo_path.resolve() == META_ROOT.resolve():
|
||||
raise MetaPreparationError(
|
||||
"Prepare a separate registered source checkout, never the running operator tooling."
|
||||
)
|
||||
paths = [repo_path / PACKAGE, repo_path / "requirements-release.txt"]
|
||||
for path in workspace.glob("govoplan-*/pyproject.toml"):
|
||||
paths.append(path)
|
||||
if len(paths) > MAX_INPUT_FILES:
|
||||
raise MetaPreparationError(
|
||||
"Developer composition exceeds its input-file bound."
|
||||
)
|
||||
if workspace / "govoplan-core/pyproject.toml" not in paths:
|
||||
raise MetaPreparationError(
|
||||
"Prepare and commit matching Core metadata before Meta preparation."
|
||||
)
|
||||
repositories = {"govoplan": repo_path}
|
||||
for path in paths[2:]:
|
||||
name = path.parent.name
|
||||
if (
|
||||
name not in specs
|
||||
or resolve_repo_path(specs[name], workspace) != path.parent
|
||||
):
|
||||
raise MetaPreparationError(
|
||||
"Developer composition contains an unregistered package checkout."
|
||||
)
|
||||
repositories[name] = path.parent
|
||||
filesystems = {}
|
||||
for name, path in repositories.items():
|
||||
filesystems[name] = _source_filesystem(path=path, workspace=workspace)
|
||||
_tracked_worktree(path)
|
||||
issues = registered_source_origin_issues(
|
||||
repo_versions={name: target_version for name in repositories},
|
||||
workspace=workspace,
|
||||
remote="origin",
|
||||
)
|
||||
if issues:
|
||||
raise MetaPreparationError(
|
||||
"Preparation source origins do not match the registered repositories."
|
||||
)
|
||||
sources = {}
|
||||
for name, path in sorted(repositories.items()):
|
||||
snapshot = collect_repository_snapshot(
|
||||
specs[name],
|
||||
workspace_root=workspace,
|
||||
target_tag=None,
|
||||
online=False,
|
||||
)
|
||||
dirty_allowed = (
|
||||
output_dirty
|
||||
and name == "govoplan"
|
||||
and snapshot.dirty_entries == (f" M {PACKAGE}",)
|
||||
)
|
||||
if (
|
||||
snapshot.errors
|
||||
or not snapshot.has_head
|
||||
or snapshot.branch != "main"
|
||||
or snapshot.upstream != "origin/main"
|
||||
or snapshot.behind
|
||||
or (snapshot.dirty and not dirty_allowed)
|
||||
):
|
||||
raise MetaPreparationError(
|
||||
"Preparation requires reviewed clean main sources tracking origin/main."
|
||||
)
|
||||
head = git_text(path, "rev-parse", "--verify", "HEAD")
|
||||
live = run(
|
||||
("git", "ls-remote", "--exit-code", "--heads", "origin", "refs/heads/main"),
|
||||
cwd=path,
|
||||
)
|
||||
lines = live.stdout.strip().splitlines()
|
||||
if live.returncode or len(lines) != 1:
|
||||
raise MetaPreparationError("Could not verify live preparation source main.")
|
||||
remote_main, separator, reference = lines[0].partition("\t")
|
||||
if (
|
||||
not _OBJECT.fullmatch(head)
|
||||
or not _OBJECT.fullmatch(remote_main)
|
||||
or not separator
|
||||
or reference != "refs/heads/main"
|
||||
or git(path, "merge-base", "--is-ancestor", remote_main, head).returncode
|
||||
):
|
||||
raise MetaPreparationError(
|
||||
"Preparation source main diverged; fetch and review separately."
|
||||
)
|
||||
sources[name] = {
|
||||
"head": head,
|
||||
"remote_main": remote_main,
|
||||
"filesystem": filesystems[name],
|
||||
}
|
||||
tag = f"v{target_version}"
|
||||
published = remote_tag_commit(repo_path, remote="origin", tag=tag)
|
||||
if (
|
||||
published.error
|
||||
or published.tag_object
|
||||
or git_text(repo_path, "rev-parse", "--verify", f"refs/tags/{tag}")
|
||||
):
|
||||
raise MetaPreparationError(
|
||||
"An existing or unverifiable target Meta tag blocks source preparation."
|
||||
)
|
||||
inputs, payloads = {}, {}
|
||||
total = 0
|
||||
for path in sorted(paths):
|
||||
payload, identity = _read_input(path)
|
||||
total += len(payload)
|
||||
if total > MAX_TOTAL_BYTES:
|
||||
raise MetaPreparationError(
|
||||
"Developer composition exceeds its aggregate input bound."
|
||||
)
|
||||
relative = path.relative_to(workspace).as_posix()
|
||||
inputs[relative], payloads[relative] = identity, payload
|
||||
generator_path = META_ROOT / "tools/release/generate-developer-meta-package.py"
|
||||
_trusted_ancestry(generator_path.parent)
|
||||
_owned_path(META_ROOT, directory=True)
|
||||
generator, generator_identity = _read_input(generator_path)
|
||||
if total + len(generator) > MAX_TOTAL_BYTES:
|
||||
raise MetaPreparationError(
|
||||
"Developer composition exceeds its aggregate input bound."
|
||||
)
|
||||
payloads[GENERATOR] = generator
|
||||
current = tomllib.loads(payloads[f"govoplan/{PACKAGE}"].decode("utf-8")).get(
|
||||
"project", {}
|
||||
)
|
||||
core = tomllib.loads(payloads["govoplan-core/pyproject.toml"].decode("utf-8")).get(
|
||||
"project", {}
|
||||
)
|
||||
if (
|
||||
not isinstance(current, dict)
|
||||
or current.get("name") != "govoplan"
|
||||
or not isinstance(current.get("version"), str)
|
||||
):
|
||||
raise MetaPreparationError(
|
||||
"Nested developer-package identity and version must be exact."
|
||||
)
|
||||
if (
|
||||
not isinstance(core, dict)
|
||||
or core.get("name") != "govoplan-core"
|
||||
or core.get("version") != target_version
|
||||
):
|
||||
raise MetaPreparationError(
|
||||
"Prepare and commit Core at the requested target version before Meta preparation."
|
||||
)
|
||||
from .version_alignment import repository_version_issues
|
||||
|
||||
if repository_version_issues(
|
||||
workspace / "govoplan-core", expected_version=target_version
|
||||
):
|
||||
raise MetaPreparationError(
|
||||
"Prepare aligned Core version metadata before Meta preparation."
|
||||
)
|
||||
receipt = {
|
||||
"kind": "developer_meta_preparation_v1",
|
||||
"workspace": str(workspace),
|
||||
"target_version": target_version,
|
||||
"sources": sources,
|
||||
"inputs": inputs,
|
||||
"operator_generator": generator_identity,
|
||||
}
|
||||
return receipt, payloads
|
||||
|
||||
|
||||
def _render(payloads: dict[str, bytes]) -> bytes:
|
||||
# The trusted operator generator sees only the frozen bounded data snapshot.
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-meta-render-") as temporary:
|
||||
workspace = Path(temporary)
|
||||
for relative, payload in payloads.items():
|
||||
path = workspace / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(payload)
|
||||
render = runpy.run_path(str(workspace / GENERATOR))["render"]
|
||||
result = render(
|
||||
workspace=workspace,
|
||||
requirements=workspace / "govoplan/requirements-release.txt",
|
||||
).encode("utf-8")
|
||||
if len(result) > MAX_INPUT_BYTES:
|
||||
raise MetaPreparationError(
|
||||
"Generated developer package exceeds its output bound."
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def preview_meta_mutation(*, repo_path: Path, target_version: str):
|
||||
version = normalize_version(target_version)
|
||||
if not version:
|
||||
raise MetaPreparationError("A valid target release version is required.")
|
||||
receipt, payloads = _snapshot(repo_path=repo_path, target_version=version)
|
||||
after = _render(payloads)
|
||||
observed, _ = _snapshot(repo_path=repo_path, target_version=version)
|
||||
if observed != receipt:
|
||||
raise MetaPreparationError(
|
||||
"Developer preparation inputs changed during preview."
|
||||
)
|
||||
receipt["output_sha256"] = hashlib.sha256(after).hexdigest()
|
||||
return receipt, payloads[f"govoplan/{PACKAGE}"], after
|
||||
|
||||
|
||||
def prepare_developer_meta_package(
|
||||
*,
|
||||
repo_path: Path,
|
||||
target_version: str,
|
||||
apply: bool = False,
|
||||
expected_receipt=None,
|
||||
confirm_out_of_run: bool = False,
|
||||
) -> dict:
|
||||
"""Preview or explicitly apply one full generated file; never commit/publish."""
|
||||
try:
|
||||
receipt, before, after = preview_meta_mutation(
|
||||
repo_path=repo_path, target_version=target_version
|
||||
)
|
||||
result = {
|
||||
"status": "planned" if before != after else "noop",
|
||||
"path": PACKAGE,
|
||||
"receipt": receipt,
|
||||
"after_sha256": hashlib.sha256(after).hexdigest(),
|
||||
"changed": before != after,
|
||||
}
|
||||
if not apply:
|
||||
return result
|
||||
if not confirm_out_of_run:
|
||||
raise MetaPreparationError(
|
||||
"Explicitly confirm that no durable run is active for this source workspace."
|
||||
)
|
||||
if receipt != expected_receipt:
|
||||
raise MetaPreparationError(
|
||||
"Preparation inputs changed since the reviewed preview; create a fresh preview."
|
||||
)
|
||||
if before == after:
|
||||
return result
|
||||
from .version_metadata import _atomic_write
|
||||
|
||||
source_receipt = {
|
||||
key: value for key, value in receipt.items() if key != "output_sha256"
|
||||
}
|
||||
observed, _ = _snapshot(
|
||||
repo_path=repo_path, target_version=receipt["target_version"]
|
||||
)
|
||||
if observed != source_receipt:
|
||||
raise MetaPreparationError(
|
||||
"Preparation inputs changed before the file effect; create a fresh preview."
|
||||
)
|
||||
try:
|
||||
_atomic_write(repo_path / PACKAGE, after)
|
||||
observed, payloads = _snapshot(
|
||||
repo_path=repo_path,
|
||||
target_version=receipt["target_version"],
|
||||
output_dirty=True,
|
||||
)
|
||||
comparison = copy.deepcopy(observed)
|
||||
output = f"govoplan/{PACKAGE}"
|
||||
comparison["inputs"][output] = receipt["inputs"][output]
|
||||
if comparison != source_receipt or payloads[output] != after:
|
||||
raise MetaPreparationError("Preparation inputs changed after writing.")
|
||||
except Exception as exc:
|
||||
raise MetaPreparationAmbiguous(
|
||||
"Generated file may have been written but its write/post-check failed; "
|
||||
"review the delta and reconcile manually."
|
||||
) from exc
|
||||
return {**result, "status": "prepared", "after_receipt": observed}
|
||||
except MetaPreparationError:
|
||||
raise
|
||||
except (OSError, UnicodeError, ValueError, KeyError, TypeError) as exc:
|
||||
raise MetaPreparationError(
|
||||
f"Developer preparation failed closed ({type(exc).__name__})."
|
||||
) from exc
|
||||
@@ -23,6 +23,7 @@ class RepositorySpec:
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VersionSnapshot:
|
||||
pyproject: str | None = None
|
||||
developer_meta: str | None = None
|
||||
package: str | None = None
|
||||
webui_package: str | None = None
|
||||
manifests: tuple[str, ...] = ()
|
||||
@@ -32,6 +33,7 @@ class VersionSnapshot:
|
||||
def primary(self) -> str | None:
|
||||
return (
|
||||
self.pyproject
|
||||
or self.developer_meta
|
||||
or self.package
|
||||
or self.webui_package
|
||||
or (self.manifests[0] if self.manifests else None)
|
||||
|
||||
@@ -34,7 +34,24 @@ def tag_repositories(
|
||||
apply: bool = False, # noqa: A002 - mirrors API field.
|
||||
push: bool = False,
|
||||
) -> dict[str, object]:
|
||||
"""Create annotated tags and optionally publish branch and tag atomically.
|
||||
from .source_tag_batch import tag_source_batch
|
||||
|
||||
return tag_source_batch(
|
||||
repos=repos, repo_versions=repo_versions, workspace_root=workspace_root,
|
||||
remote=remote, message=message, apply=apply, push=push,
|
||||
)
|
||||
|
||||
|
||||
def _preview_repositories(
|
||||
*,
|
||||
repos: tuple[str, ...],
|
||||
repo_versions: dict[str, str],
|
||||
workspace_root: Path | str | None = None,
|
||||
remote: str = "origin",
|
||||
message: str | None = None,
|
||||
push: bool = False,
|
||||
) -> dict[str, object]:
|
||||
"""Read-only shared manifest/version/composition/tag preflight.
|
||||
|
||||
A release tag is only created for a clean, aligned, non-behind worktree.
|
||||
Both local and remote tags are resolved to commits before mutation so an
|
||||
@@ -64,42 +81,7 @@ def tag_repositories(
|
||||
f"{issue.source}={issue.actual!r}, expected {issue.expected!r} ({issue.message})"
|
||||
)
|
||||
|
||||
if apply:
|
||||
preflight = tag_repositories(
|
||||
repos=selected,
|
||||
repo_versions=repo_versions,
|
||||
workspace_root=workspace,
|
||||
remote=remote,
|
||||
message=message,
|
||||
apply=False,
|
||||
push=push,
|
||||
)
|
||||
preflight_rows = preflight.get("repositories")
|
||||
if isinstance(preflight_rows, list) and any(
|
||||
isinstance(item, dict) and item.get("status") in {"blocked", "failed"}
|
||||
for item in preflight_rows
|
||||
):
|
||||
blocked_rows = []
|
||||
for item in preflight_rows:
|
||||
if not isinstance(item, dict) or item.get("status") in {"blocked", "failed"}:
|
||||
blocked_rows.append(item)
|
||||
continue
|
||||
blocked_rows.append(
|
||||
{
|
||||
**item,
|
||||
"status": "skipped",
|
||||
"detail": "preflight passed, but no release tag was changed because another selected repository is blocked",
|
||||
}
|
||||
)
|
||||
return {
|
||||
"status": "blocked",
|
||||
"apply": True,
|
||||
"push": push,
|
||||
"remote": remote,
|
||||
"detail": "batch preflight failed; no selected repository was mutated",
|
||||
"repositories": blocked_rows,
|
||||
}
|
||||
elif selected:
|
||||
if selected:
|
||||
manifest_gate_issue = manifest_shape_gate_issue(workspace)
|
||||
if manifest_gate_issue:
|
||||
return {
|
||||
@@ -255,130 +237,17 @@ def tag_repositories(
|
||||
"remote_tag_object": remote_result.tag_object,
|
||||
}
|
||||
)
|
||||
if not apply:
|
||||
detail = preview_detail(tag=tag, local_commit=local_commit, remote_commit=remote_result.commit, push=push)
|
||||
status = "noop" if remote_result.commit or (local_commit and not push) else "planned"
|
||||
results.append({**row, "status": status, "detail": detail})
|
||||
continue
|
||||
|
||||
if remote_result.commit:
|
||||
if not local_commit:
|
||||
fetch_result = run(("git", "fetch", remote, f"refs/tags/{tag}:refs/tags/{tag}"), cwd=path)
|
||||
if fetch_result.returncode != 0:
|
||||
results.append(
|
||||
{
|
||||
**row,
|
||||
"status": "failed",
|
||||
"detail": f"remote tag {tag} exists at HEAD but could not be fetched locally",
|
||||
"returncode": fetch_result.returncode,
|
||||
"stdout": compact_output(fetch_result.stdout),
|
||||
"stderr": compact_output(fetch_result.stderr),
|
||||
}
|
||||
)
|
||||
continue
|
||||
results.append(
|
||||
{
|
||||
**row,
|
||||
"status": "published",
|
||||
"detail": f"immutable tag {tag} is already published at HEAD",
|
||||
"after_local_tag_commit": head_commit,
|
||||
"after_remote_tag_commit": head_commit,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
created = False
|
||||
if not local_commit:
|
||||
create_result = run(create_command, cwd=path)
|
||||
if create_result.returncode != 0:
|
||||
results.append(
|
||||
{
|
||||
**row,
|
||||
"status": "failed",
|
||||
"detail": f"could not create annotated tag {tag}",
|
||||
"returncode": create_result.returncode,
|
||||
"stdout": compact_output(create_result.stdout),
|
||||
"stderr": compact_output(create_result.stderr),
|
||||
}
|
||||
)
|
||||
continue
|
||||
created = True
|
||||
|
||||
if not push:
|
||||
results.append(
|
||||
{
|
||||
**row,
|
||||
"status": "tagged" if created else "noop",
|
||||
"detail": f"created annotated tag {tag} at HEAD" if created else f"annotated tag {tag} already exists at HEAD",
|
||||
"after_local_tag_commit": head_commit,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
publish_result = run(publish_command, cwd=path)
|
||||
if publish_result.returncode != 0:
|
||||
results.append(
|
||||
{
|
||||
**row,
|
||||
"status": "failed",
|
||||
"detail": f"created local tag {tag}, but atomic branch and tag publication failed" if created else f"atomic branch and tag publication failed for {tag}",
|
||||
"returncode": publish_result.returncode,
|
||||
"after_local_tag_commit": head_commit,
|
||||
"stdout": compact_output(publish_result.stdout),
|
||||
"stderr": compact_output(publish_result.stderr),
|
||||
}
|
||||
)
|
||||
continue
|
||||
after_local_object = git_text(path, "rev-parse", "--verify", f"refs/tags/{tag}")
|
||||
after_remote = remote_tag_commit(path, remote=remote, tag=tag)
|
||||
if (
|
||||
after_remote.error
|
||||
or not after_remote.annotated
|
||||
or after_remote.commit != head_commit
|
||||
or after_remote.tag_object != after_local_object
|
||||
):
|
||||
verification_detail = after_remote.error or "remote tag did not resolve to the published annotated tag object at HEAD"
|
||||
results.append(
|
||||
{
|
||||
**row,
|
||||
"status": "failed",
|
||||
"detail": f"Git push returned success, but the remote release-tag postcondition failed: {verification_detail}",
|
||||
"returncode": publish_result.returncode,
|
||||
"after_local_tag_commit": head_commit,
|
||||
"after_local_tag_object": after_local_object,
|
||||
"after_remote_tag_commit": after_remote.commit,
|
||||
"after_remote_tag_object": after_remote.tag_object,
|
||||
"stdout": compact_output(publish_result.stdout),
|
||||
"stderr": compact_output(publish_result.stderr),
|
||||
}
|
||||
)
|
||||
continue
|
||||
results.append(
|
||||
{
|
||||
**row,
|
||||
"status": "published",
|
||||
"detail": f"published branch {snapshot.branch} and immutable tag {tag} atomically to {remote}",
|
||||
"returncode": publish_result.returncode,
|
||||
"after_local_tag_commit": head_commit,
|
||||
"after_remote_tag_commit": head_commit,
|
||||
"after_local_tag_object": after_local_object,
|
||||
"after_remote_tag_object": after_remote.tag_object,
|
||||
"stdout": compact_output(publish_result.stdout),
|
||||
"stderr": compact_output(publish_result.stderr),
|
||||
}
|
||||
)
|
||||
detail = preview_detail(tag=tag, local_commit=local_commit, remote_commit=remote_result.commit, push=push)
|
||||
row_status = "noop" if remote_result.commit or (local_commit and not push) else "planned"
|
||||
results.append({**row, "status": row_status, "detail": detail})
|
||||
|
||||
if any(item["status"] in {"blocked", "failed"} for item in results):
|
||||
status = "blocked" if not apply else "partial"
|
||||
elif any(item["status"] == "published" for item in results):
|
||||
status = "published"
|
||||
elif any(item["status"] == "tagged" for item in results):
|
||||
status = "tagged"
|
||||
result_status = "blocked"
|
||||
elif any(item["status"] == "planned" for item in results):
|
||||
status = "planned"
|
||||
result_status = "planned"
|
||||
else:
|
||||
status = "noop"
|
||||
return {"status": status, "apply": apply, "push": push, "remote": remote, "repositories": results}
|
||||
result_status = "noop"
|
||||
return {"status": result_status, "apply": False, "push": push, "remote": remote, "repositories": results}
|
||||
|
||||
|
||||
def normalize_version(value: str | None) -> str:
|
||||
|
||||
@@ -8,6 +8,7 @@ from pathlib import Path
|
||||
import shlex
|
||||
|
||||
from .contracts import validate_contracts
|
||||
from .git_state import registered_developer_meta_path, read_pyproject_version
|
||||
from .model import (
|
||||
CompatibilityIssue,
|
||||
InterfaceProviderSnapshot,
|
||||
@@ -111,6 +112,39 @@ def apply_repository_version_gate(
|
||||
version_update_supported_by_repo: dict[str, bool] = {}
|
||||
deferred_core_lock_repos: set[str] = set()
|
||||
for unit in units:
|
||||
if registered_developer_meta_path(workspace / unit.repo) is not None:
|
||||
from .meta_preparation import preparation_command
|
||||
|
||||
try:
|
||||
core_version = read_pyproject_version(workspace / "govoplan-core")
|
||||
except (OSError, ValueError, TypeError):
|
||||
core_version = None
|
||||
core_ready = core_version == unit.target_version
|
||||
issues_by_repo.setdefault(unit.repo, []).append(
|
||||
ReleaseGateFinding(
|
||||
code="developer_meta_out_of_run" if core_ready else "developer_meta_core_preparation_required",
|
||||
severity="blocker",
|
||||
message=(
|
||||
"Meta is an out-of-run support release, not a self-updating durable executor."
|
||||
if core_ready else
|
||||
"Prepare and commit Core at the requested target before regenerating Meta."
|
||||
),
|
||||
remediation=(
|
||||
"Complete Core and module preparation first. Stop active durable runs for this workspace. "
|
||||
"In a separate trusted source checkout, preview "
|
||||
+ preparation_command(workspace=workspace, target_version=unit.target_version)
|
||||
+ ". Review its receipt; apply with --receipt <preview.json> --apply --confirm-out-of-run. "
|
||||
"Review and commit the whole generated package, publish the matching Core release first, "
|
||||
"then use guarded Meta source tagging/publication and create a fresh durable run."
|
||||
),
|
||||
repo=unit.repo, source="developer meta-package preparation",
|
||||
expected=unit.target_version, actual=core_version or "missing Core version",
|
||||
)
|
||||
)
|
||||
version_update_supported_by_repo[unit.repo] = False
|
||||
# Its complete canonical composition remains a publication gate;
|
||||
# this plan must not claim a generic in-run mutation/commit path.
|
||||
continue
|
||||
version_update_supported = unit.current_version == unit.target_version
|
||||
if unit.current_version and unit.current_version != unit.target_version:
|
||||
try:
|
||||
@@ -437,6 +471,7 @@ def build_unit(
|
||||
value
|
||||
for value in (
|
||||
repo.versions.pyproject,
|
||||
repo.versions.developer_meta,
|
||||
repo.versions.package,
|
||||
repo.versions.webui_package,
|
||||
*repo.versions.manifests,
|
||||
@@ -572,7 +607,7 @@ def repository_capabilities(
|
||||
def dependency_ordered_units(
|
||||
units: tuple[ReleasePlanUnit, ...],
|
||||
) -> tuple[ReleasePlanUnit, ...]:
|
||||
"""Order module providers before consumers while keeping Core last."""
|
||||
"""Order modules before Core, followed by the out-of-run Meta support unit."""
|
||||
|
||||
by_repo = {unit.repo: unit for unit in units}
|
||||
providers: dict[str, set[str]] = {}
|
||||
@@ -592,8 +627,10 @@ def dependency_ordered_units(
|
||||
)
|
||||
if "govoplan-core" in dependencies:
|
||||
dependencies["govoplan-core"].update(
|
||||
repo for repo in by_repo if repo != "govoplan-core"
|
||||
repo for repo in by_repo if repo not in {"govoplan-core", "govoplan"}
|
||||
)
|
||||
if "govoplan" in dependencies:
|
||||
dependencies["govoplan"].update(repo for repo in by_repo if repo != "govoplan")
|
||||
|
||||
ordered: list[ReleasePlanUnit] = []
|
||||
remaining = set(by_repo)
|
||||
@@ -690,6 +727,8 @@ def dry_run_steps(
|
||||
*, units: tuple[ReleasePlanUnit, ...], dashboard: ReleaseDashboard, channel: str
|
||||
) -> tuple[ReleasePlanStep, ...]:
|
||||
steps: list[ReleasePlanStep] = []
|
||||
meta_units = tuple(unit for unit in units if unit.repo == "govoplan")
|
||||
units = tuple(unit for unit in units if unit.repo != "govoplan")
|
||||
snapshots = {repo.spec.name: repo for repo in dashboard.repositories}
|
||||
core_unit = next((unit for unit in units if unit.repo == "govoplan-core"), None)
|
||||
non_core_units = tuple(unit for unit in units if unit.repo != "govoplan-core")
|
||||
@@ -991,6 +1030,33 @@ def dry_run_steps(
|
||||
status="planned",
|
||||
)
|
||||
)
|
||||
for unit in meta_units:
|
||||
from .meta_preparation import preparation_command
|
||||
|
||||
steps.extend((
|
||||
ReleasePlanStep(
|
||||
id="govoplan:prepare-support",
|
||||
title="Prepare the complete developer meta-package outside this run",
|
||||
detail=(
|
||||
"First prepare and commit Core at the target and review module/requirements inputs. "
|
||||
"Stop active runs, preview and explicitly apply the frozen composition in a separate "
|
||||
"source checkout; review and commit manually. No durable self-update is supported."
|
||||
),
|
||||
command=preparation_command(workspace=Path(dashboard.workspace_root), target_version=unit.target_version),
|
||||
cwd=dashboard.meta_root, repo=unit.repo, status="needs-executor",
|
||||
),
|
||||
ReleasePlanStep(
|
||||
id="govoplan:publish-support",
|
||||
title="Publish the prepared Meta support source after Core",
|
||||
detail=(
|
||||
"After the matching Core annotated tag and exact main are published, use the shared "
|
||||
"guarded Meta tag preview/local-tag/publish route. Commit/push reviewed preparation "
|
||||
"and create a fresh durable run; do not update the current runtime binding."
|
||||
),
|
||||
cwd=dashboard.meta_root, repo=unit.repo, status="needs-executor",
|
||||
mutating=True,
|
||||
),
|
||||
))
|
||||
return tuple(steps)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,792 @@
|
||||
"""Strict registered-source release contract for every source-tag batch.
|
||||
|
||||
Meta is not a root Python package. Its nested developer package is released only
|
||||
after a whole-batch source preflight and the matching immutable Core release.
|
||||
No selected checkout supplies the developer-package generator or release
|
||||
validation tooling. The trusted shared checker may load reviewed application
|
||||
manifests; this is not an untrusted-code sandbox.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
|
||||
from .git_state import collect_repository_snapshot, git, git_text
|
||||
from .repository_tag import (
|
||||
_preview_repositories,
|
||||
basic_blocker,
|
||||
normalize_version,
|
||||
ref_commit,
|
||||
remote_tag_commit,
|
||||
run,
|
||||
)
|
||||
from .source_provenance import registered_source_origin_issues
|
||||
from .version_alignment import (
|
||||
repository_version_issues,
|
||||
selected_release_webui_bundle_issues,
|
||||
selected_webui_repository_names,
|
||||
)
|
||||
from .workspace import load_repository_specs, resolve_repo_path, resolve_workspace_root
|
||||
|
||||
_OBJECT = re.compile(r"[0-9a-f]{40}(?:[0-9a-f]{24})?\Z")
|
||||
|
||||
|
||||
class SourceReceiptError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _owned_path(path, *, directory):
|
||||
observed = path.lstat()
|
||||
expected = stat.S_ISDIR if directory else stat.S_ISREG
|
||||
if stat.S_ISLNK(observed.st_mode) or not expected(observed.st_mode):
|
||||
raise SourceReceiptError(
|
||||
"source authority must use real paths, without symlinks or special files"
|
||||
)
|
||||
if observed.st_uid != os.geteuid():
|
||||
raise SourceReceiptError(
|
||||
"source authority is not owned by the current operator"
|
||||
)
|
||||
if observed.st_mode & 0o022:
|
||||
raise SourceReceiptError("source authority is group/world writable")
|
||||
return observed
|
||||
|
||||
|
||||
def _trusted_ancestry(path):
|
||||
# Same ownership/mode policy as the publisher's trust-path guard. A sticky
|
||||
# shared ancestor such as /tmp may contain an owned, non-writable child;
|
||||
# the workspace/repository themselves are never given that exception.
|
||||
for ancestor in (path, *path.parents):
|
||||
observed = ancestor.lstat()
|
||||
if stat.S_ISLNK(observed.st_mode) or not stat.S_ISDIR(observed.st_mode):
|
||||
raise SourceReceiptError(
|
||||
"source ancestry must contain real directories, not symlinks"
|
||||
)
|
||||
if observed.st_uid not in {0, os.geteuid()}:
|
||||
raise SourceReceiptError("source ancestry has an untrusted owner")
|
||||
if observed.st_mode & 0o022 and not observed.st_mode & stat.S_ISVTX:
|
||||
raise SourceReceiptError("source ancestry is group/world writable")
|
||||
|
||||
|
||||
def _git_pointer(path):
|
||||
_owned_path(path, directory=False)
|
||||
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
with os.fdopen(descriptor, "rb") as source:
|
||||
observed = os.fstat(source.fileno())
|
||||
if not stat.S_ISREG(observed.st_mode) or not 0 < observed.st_size <= 4096:
|
||||
raise SourceReceiptError("Git metadata pointer is invalid or oversized")
|
||||
value = source.read(observed.st_size + 1)
|
||||
if len(value) != observed.st_size:
|
||||
raise SourceReceiptError("Git metadata pointer changed during validation")
|
||||
return value.decode("utf-8").strip()
|
||||
|
||||
|
||||
def _git_tree(root):
|
||||
pending = [(root, 0)]
|
||||
count = 0
|
||||
while pending:
|
||||
directory, depth = pending.pop()
|
||||
_owned_path(directory, directory=True)
|
||||
if depth > 128:
|
||||
raise SourceReceiptError(
|
||||
"Git metadata exceeds its trust-validation depth limit"
|
||||
)
|
||||
with os.scandir(directory) as entries:
|
||||
for entry in entries:
|
||||
count += 1
|
||||
if count > 500_000:
|
||||
raise SourceReceiptError(
|
||||
"Git metadata exceeds its trust-validation entry limit"
|
||||
)
|
||||
candidate = Path(entry.path)
|
||||
observed = candidate.lstat()
|
||||
if stat.S_ISDIR(observed.st_mode):
|
||||
pending.append((candidate, depth + 1))
|
||||
else:
|
||||
_owned_path(candidate, directory=False)
|
||||
|
||||
|
||||
def _filesystem_identity(path, observed):
|
||||
return [
|
||||
str(path),
|
||||
observed.st_dev,
|
||||
observed.st_ino,
|
||||
observed.st_uid,
|
||||
observed.st_gid,
|
||||
stat.S_IMODE(observed.st_mode),
|
||||
]
|
||||
|
||||
|
||||
def _source_filesystem(*, path, workspace):
|
||||
"""Validate source/Git ownership before invoking even read-only Git."""
|
||||
if path.absolute() != path.resolve() or not path.resolve().is_relative_to(
|
||||
workspace.resolve()
|
||||
):
|
||||
raise SourceReceiptError(
|
||||
"source checkout leaves the private workspace or traverses a symlink"
|
||||
)
|
||||
_trusted_ancestry(path)
|
||||
workspace_info = _owned_path(workspace, directory=True)
|
||||
repo_info = _owned_path(path, directory=True)
|
||||
marker = path / ".git"
|
||||
marker_info = marker.lstat()
|
||||
if stat.S_ISDIR(marker_info.st_mode):
|
||||
git_dir = marker
|
||||
else:
|
||||
value = _git_pointer(marker)
|
||||
if not value.startswith("gitdir: "):
|
||||
raise SourceReceiptError("Git worktree pointer is invalid")
|
||||
git_dir = Path(os.path.abspath(path / value.removeprefix("gitdir: ")))
|
||||
if git_dir.absolute() != git_dir.resolve() or not git_dir.resolve().is_relative_to(
|
||||
workspace.resolve()
|
||||
):
|
||||
raise SourceReceiptError(
|
||||
"Git checkout metadata must stay inside the trusted workspace"
|
||||
)
|
||||
_trusted_ancestry(git_dir)
|
||||
_owned_path(git_dir, directory=True)
|
||||
common_dir = git_dir
|
||||
common_pointer = git_dir / "commondir"
|
||||
if common_pointer.exists() or common_pointer.is_symlink():
|
||||
common_dir = Path(os.path.abspath(git_dir / _git_pointer(common_pointer)))
|
||||
identities = {
|
||||
"workspace": _filesystem_identity(workspace, workspace_info),
|
||||
"checkout": _filesystem_identity(path, repo_info),
|
||||
}
|
||||
scanned = set()
|
||||
for label, directory in (
|
||||
("git_directory", git_dir),
|
||||
("git_common_directory", common_dir),
|
||||
):
|
||||
if (
|
||||
directory.absolute() != directory.resolve()
|
||||
or not directory.resolve().is_relative_to(workspace.resolve())
|
||||
):
|
||||
raise SourceReceiptError(
|
||||
"Git checkout metadata must stay inside the trusted workspace"
|
||||
)
|
||||
_trusted_ancestry(directory)
|
||||
observed = _owned_path(directory, directory=True)
|
||||
if observed.st_mode & 0o700 != 0o700:
|
||||
raise SourceReceiptError(
|
||||
"Git metadata target must be readable and writable by its operator"
|
||||
)
|
||||
identities[label] = _filesystem_identity(directory, observed)
|
||||
if directory not in scanned:
|
||||
_git_tree(directory)
|
||||
scanned.add(directory)
|
||||
identities["git_marker"] = _filesystem_identity(
|
||||
marker, _owned_path(marker, directory=stat.S_ISDIR(marker_info.st_mode))
|
||||
)
|
||||
for candidate in (
|
||||
common_dir / "objects/info/alternates",
|
||||
common_dir / "objects/info/http-alternates",
|
||||
common_dir / "info/grafts",
|
||||
):
|
||||
if candidate.exists() or candidate.is_symlink():
|
||||
raise SourceReceiptError(
|
||||
"Git object alternates and grafts are not permitted"
|
||||
)
|
||||
return identities
|
||||
|
||||
|
||||
def _tracked_worktree(path):
|
||||
tracked = git(path, "ls-files", "-v", "-z", timeout=30)
|
||||
if tracked.returncode or len(tracked.stdout) > 16 * 1024 * 1024:
|
||||
raise SourceReceiptError(
|
||||
"tracked release inputs exceed their trust-validation limit"
|
||||
)
|
||||
checked = {path}
|
||||
tracked_paths = set()
|
||||
names = tracked.stdout.split("\0")
|
||||
if len(names) > 100_001:
|
||||
raise SourceReceiptError(
|
||||
"tracked release inputs exceed their trust-validation count limit"
|
||||
)
|
||||
for entry in filter(None, names):
|
||||
# git status deliberately hides assume-unchanged and skip-worktree
|
||||
# paths. Never validate mutable working metadata and then tag different
|
||||
# committed bytes because an index flag suppressed the dirty evidence.
|
||||
if not entry.startswith("H "):
|
||||
raise SourceReceiptError(
|
||||
"hidden, sparse or unmerged tracked index entries are not permitted"
|
||||
)
|
||||
name = entry[2:]
|
||||
tracked_paths.add(name)
|
||||
relative = Path(name)
|
||||
if relative.is_absolute() or ".." in relative.parts:
|
||||
raise SourceReceiptError("tracked release input has an unsafe path")
|
||||
candidate = path / relative
|
||||
for parent in candidate.parents:
|
||||
if parent == path:
|
||||
break
|
||||
if parent not in checked:
|
||||
_owned_path(parent, directory=True)
|
||||
checked.add(parent)
|
||||
_owned_path(candidate, directory=False)
|
||||
# Version/composition checks inspect existing files, including ignored
|
||||
# paths. Their declarations must come from the selected committed source,
|
||||
# not ignored working bytes absent from the tag's tree.
|
||||
metadata = [
|
||||
path / name
|
||||
for name in (
|
||||
"pyproject.toml",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"webui/package.json",
|
||||
"webui/package.release.json",
|
||||
"webui/package-lock.json",
|
||||
"webui/package-lock.release.json",
|
||||
)
|
||||
]
|
||||
if path.name == "govoplan":
|
||||
metadata.extend(
|
||||
path / name
|
||||
for name in (
|
||||
"packages/govoplan-meta/pyproject.toml",
|
||||
"requirements-release.txt",
|
||||
)
|
||||
)
|
||||
metadata.extend((path / "src").glob("**/backend/manifest.py"))
|
||||
metadata.extend((path / "src").glob("*/__init__.py"))
|
||||
for candidate in metadata:
|
||||
if (candidate.exists() or candidate.is_symlink()) and candidate.relative_to(
|
||||
path
|
||||
).as_posix() not in tracked_paths:
|
||||
raise SourceReceiptError(
|
||||
"selected release version/composition metadata must be tracked in the frozen source"
|
||||
)
|
||||
|
||||
|
||||
def _receipt(*, spec, workspace, version, filesystem):
|
||||
path = resolve_repo_path(spec, workspace)
|
||||
_tracked_worktree(path)
|
||||
snapshot = collect_repository_snapshot(
|
||||
spec, workspace_root=workspace, target_tag=f"v{version}", online=False
|
||||
)
|
||||
blocker = basic_blocker(snapshot=snapshot, version=version)
|
||||
if blocker:
|
||||
raise SourceReceiptError(blocker)
|
||||
if (
|
||||
not snapshot.exists
|
||||
or not snapshot.is_git
|
||||
or not snapshot.has_head
|
||||
or snapshot.errors
|
||||
or snapshot.safe_directory_required
|
||||
or snapshot.dirty
|
||||
or snapshot.branch != "main"
|
||||
or snapshot.upstream != "origin/main"
|
||||
or snapshot.behind
|
||||
):
|
||||
raise SourceReceiptError(
|
||||
"requires a clean registered main checkout tracking origin/main, not behind"
|
||||
)
|
||||
common = git_text(path, "rev-parse", "--path-format=absolute", "--git-common-dir")
|
||||
if (
|
||||
not common
|
||||
or Path(common).absolute() != Path(common).resolve()
|
||||
or not Path(common).resolve().is_relative_to(workspace.resolve())
|
||||
or git_text(path, "rev-parse", "--show-toplevel") != str(path.resolve())
|
||||
):
|
||||
raise SourceReceiptError(
|
||||
"Git checkout metadata must stay inside the trusted workspace"
|
||||
)
|
||||
head = git_text(path, "rev-parse", "--verify", "HEAD")
|
||||
if not _OBJECT.fullmatch(head):
|
||||
raise SourceReceiptError("source HEAD is not an exact commit")
|
||||
live = run(
|
||||
("git", "ls-remote", "--exit-code", "--heads", "origin", "refs/heads/main"),
|
||||
cwd=path,
|
||||
)
|
||||
lines = live.stdout.strip().splitlines()
|
||||
if live.returncode or len(lines) != 1:
|
||||
raise SourceReceiptError("could not verify live origin/main")
|
||||
remote_main, separator, ref = lines[0].partition("\t")
|
||||
if not separator or ref != "refs/heads/main" or not _OBJECT.fullmatch(remote_main):
|
||||
raise SourceReceiptError("live origin/main returned an invalid source receipt")
|
||||
if git(path, "merge-base", "--is-ancestor", remote_main, head).returncode != 0:
|
||||
raise SourceReceiptError(
|
||||
"live origin/main is unavailable locally or diverges; fetch and review before retrying"
|
||||
)
|
||||
tag = f"v{version}"
|
||||
local_object = git_text(path, "rev-parse", "--verify", f"refs/tags/{tag}") or None
|
||||
if local_object:
|
||||
if git_text(path, "cat-file", "-t", f"refs/tags/{tag}") != "tag":
|
||||
raise SourceReceiptError("local immutable tag must be annotated")
|
||||
if ref_commit(path, f"refs/tags/{tag}") != head:
|
||||
raise SourceReceiptError(
|
||||
"local immutable tag points to another commit, not HEAD"
|
||||
)
|
||||
remote_tag = remote_tag_commit(path, remote="origin", tag=tag)
|
||||
if remote_tag.error:
|
||||
raise SourceReceiptError("could not verify the remote release tag")
|
||||
if remote_tag.tag_object:
|
||||
if not remote_tag.annotated:
|
||||
raise SourceReceiptError("remote immutable tag must be annotated")
|
||||
if remote_tag.commit != head:
|
||||
raise SourceReceiptError(
|
||||
"remote immutable tag points to another commit, not HEAD"
|
||||
)
|
||||
if local_object and remote_tag.tag_object != local_object:
|
||||
raise SourceReceiptError(
|
||||
"local and remote immutable tag annotation objects differ"
|
||||
)
|
||||
return {
|
||||
"head": head,
|
||||
"branch": "main",
|
||||
"upstream": "origin/main",
|
||||
"origin": spec.remote,
|
||||
"remote_main": remote_main,
|
||||
"tag": tag,
|
||||
"local_tag_object": local_object,
|
||||
"remote_tag_object": remote_tag.tag_object,
|
||||
"filesystem": filesystem,
|
||||
}
|
||||
|
||||
|
||||
def _collect_receipts(*, versions, specs, workspace):
|
||||
filesystems = {}
|
||||
for repo in versions:
|
||||
if repo not in specs:
|
||||
raise SourceReceiptError(f"{repo}: source repository is not registered")
|
||||
filesystems[repo] = _source_filesystem(
|
||||
path=resolve_repo_path(specs[repo], workspace), workspace=workspace
|
||||
)
|
||||
issues = registered_source_origin_issues(
|
||||
repo_versions=versions, workspace=workspace, remote="origin"
|
||||
)
|
||||
if issues:
|
||||
raise SourceReceiptError(
|
||||
"; ".join(f"{issue.repo}: {issue.message}" for issue in issues)
|
||||
)
|
||||
receipts = {}
|
||||
for repo, version in versions.items():
|
||||
if repo not in specs:
|
||||
raise SourceReceiptError(f"{repo}: source repository is not registered")
|
||||
try:
|
||||
receipts[repo] = _receipt(
|
||||
spec=specs[repo],
|
||||
workspace=workspace,
|
||||
version=version,
|
||||
filesystem=filesystems[repo],
|
||||
)
|
||||
except SourceReceiptError as exc:
|
||||
raise SourceReceiptError(f"{repo}: {exc}") from exc
|
||||
return receipts
|
||||
|
||||
|
||||
def _bundle_input_receipt(*, selected, workspace, push):
|
||||
"""Freeze only Core files used by the already-applicable WebUI gate.
|
||||
|
||||
These are read-only composition inputs, not a new Core-tag/version or
|
||||
clean-Core prerequisite. Local module candidates intentionally need none.
|
||||
"""
|
||||
if not push and "govoplan-core" not in selected:
|
||||
return {}
|
||||
if not selected_webui_repository_names(
|
||||
repo_versions=dict.fromkeys(selected, ""), workspace=workspace
|
||||
):
|
||||
return {}
|
||||
result = {}
|
||||
core = workspace / "govoplan-core"
|
||||
for relative in ("webui/package.release.json", "webui/package-lock.release.json"):
|
||||
path = core / relative
|
||||
if not path.exists() and not path.is_symlink():
|
||||
result[relative] = None # The unchanged shared gate explains missing input.
|
||||
continue
|
||||
_trusted_ancestry(path.parent)
|
||||
_owned_path(core, directory=True)
|
||||
_owned_path(path.parent, directory=True)
|
||||
observed = _owned_path(path, directory=False)
|
||||
if not 0 < observed.st_size <= 16 * 1024 * 1024:
|
||||
raise SourceReceiptError(
|
||||
"Core release-bundle input exceeds its 16 MiB limit"
|
||||
)
|
||||
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
with os.fdopen(descriptor, "rb") as source:
|
||||
before = os.fstat(source.fileno())
|
||||
if (
|
||||
before.st_dev,
|
||||
before.st_ino,
|
||||
before.st_size,
|
||||
before.st_mtime_ns,
|
||||
before.st_ctime_ns,
|
||||
) != (
|
||||
observed.st_dev,
|
||||
observed.st_ino,
|
||||
observed.st_size,
|
||||
observed.st_mtime_ns,
|
||||
observed.st_ctime_ns,
|
||||
):
|
||||
raise SourceReceiptError(
|
||||
"Core release-bundle input changed during inspection"
|
||||
)
|
||||
content = source.read(before.st_size + 1)
|
||||
after = os.fstat(source.fileno())
|
||||
if len(content) != before.st_size or (
|
||||
before.st_dev,
|
||||
before.st_ino,
|
||||
before.st_size,
|
||||
before.st_mtime_ns,
|
||||
before.st_ctime_ns,
|
||||
) != (
|
||||
after.st_dev,
|
||||
after.st_ino,
|
||||
after.st_size,
|
||||
after.st_mtime_ns,
|
||||
after.st_ctime_ns,
|
||||
):
|
||||
raise SourceReceiptError(
|
||||
"Core release-bundle input changed during inspection"
|
||||
)
|
||||
result[relative] = {
|
||||
"file": _filesystem_identity(path, observed),
|
||||
"sha256": hashlib.sha256(content).hexdigest(),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _frozen_receipts(
|
||||
*, expected, versions, specs, workspace, selected, push, bundle_inputs
|
||||
):
|
||||
actual = _collect_receipts(versions=versions, specs=specs, workspace=workspace)
|
||||
for repo in expected:
|
||||
if actual[repo] != expected[repo]:
|
||||
raise SourceReceiptError(
|
||||
f"{repo}: source receipt changed after whole-batch preflight"
|
||||
)
|
||||
for repo in versions:
|
||||
issues = repository_version_issues(
|
||||
resolve_repo_path(specs[repo], workspace), expected_version=versions[repo]
|
||||
)
|
||||
if issues:
|
||||
raise SourceReceiptError(
|
||||
f"{repo}: version/composition changed after whole-batch preflight"
|
||||
)
|
||||
if (
|
||||
_bundle_input_receipt(selected=selected, workspace=workspace, push=push)
|
||||
!= bundle_inputs
|
||||
):
|
||||
raise SourceReceiptError(
|
||||
"Core release-bundle input receipt changed after whole-batch preflight"
|
||||
)
|
||||
if push or "govoplan-core" in selected:
|
||||
if selected_release_webui_bundle_issues(
|
||||
repo_versions={repo: versions[repo] for repo in selected},
|
||||
workspace=workspace,
|
||||
):
|
||||
raise SourceReceiptError(
|
||||
"release WebUI composition changed after whole-batch preflight"
|
||||
)
|
||||
|
||||
|
||||
def _require_core(receipts, *, push):
|
||||
core = receipts["govoplan-core"]
|
||||
if not core["local_tag_object"] or (
|
||||
push and (not core["remote_tag_object"] or core["remote_main"] != core["head"])
|
||||
):
|
||||
raise SourceReceiptError(
|
||||
"Meta requires the matching annotated Core tag locally and, for publication, remotely"
|
||||
)
|
||||
|
||||
|
||||
def _blocked(*, selected, apply, push, detail, rows=()): # noqa: A002
|
||||
known = {row["repo"]: row for row in rows}
|
||||
identified = next(
|
||||
(repo for repo in selected if detail.startswith(repo + ":")), None
|
||||
)
|
||||
return {
|
||||
"status": "blocked",
|
||||
"apply": apply,
|
||||
"push": push,
|
||||
"remote": "origin",
|
||||
"detail": "whole-batch source preflight failed; no selected repository was mutated",
|
||||
"repositories": [
|
||||
{
|
||||
**known.get(repo, {"repo": repo}),
|
||||
"status": "blocked"
|
||||
if (
|
||||
known.get(repo, {}).get("status") == "blocked"
|
||||
or (not rows and (identified is None or repo == identified))
|
||||
)
|
||||
else "skipped",
|
||||
"detail": known[repo]["detail"]
|
||||
if known.get(repo, {}).get("status") == "blocked"
|
||||
else detail,
|
||||
}
|
||||
for repo in selected
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def tag_source_batch(
|
||||
*, repos, repo_versions, workspace_root, remote, message, apply, push
|
||||
): # noqa: A002
|
||||
workspace = resolve_workspace_root(workspace_root)
|
||||
has_meta = "govoplan" in repos
|
||||
selected = tuple(dict.fromkeys(repos))
|
||||
if has_meta:
|
||||
selected = tuple(repo for repo in selected if repo != "govoplan") + (
|
||||
"govoplan",
|
||||
)
|
||||
if not selected:
|
||||
return {
|
||||
"status": "noop",
|
||||
"apply": apply,
|
||||
"push": push,
|
||||
"remote": remote.strip() or "origin",
|
||||
"repositories": [],
|
||||
}
|
||||
versions = {repo: normalize_version(repo_versions.get(repo)) for repo in selected}
|
||||
specs = {spec.name: spec for spec in load_repository_specs(include_website=False)}
|
||||
meta_version = versions.get("govoplan")
|
||||
try:
|
||||
if remote.strip() not in {"", "origin"}:
|
||||
raise SourceReceiptError(
|
||||
"Source-tag batches require the registered origin remote"
|
||||
)
|
||||
if any(not version for version in versions.values()):
|
||||
raise SourceReceiptError(
|
||||
"every selected repository requires an explicit valid version"
|
||||
)
|
||||
if has_meta and versions.get("govoplan-core", meta_version) != meta_version:
|
||||
raise SourceReceiptError(
|
||||
"Meta developer-package version must match the selected Core release"
|
||||
)
|
||||
# Only Meta requires a frozen version-matched Core source/tag dependency.
|
||||
if has_meta:
|
||||
versions.setdefault("govoplan-core", meta_version)
|
||||
receipts = _collect_receipts(
|
||||
versions=versions, specs=specs, workspace=workspace
|
||||
)
|
||||
if has_meta and "govoplan-core" not in selected:
|
||||
_require_core(receipts, push=push)
|
||||
core_issues = (
|
||||
repository_version_issues(
|
||||
resolve_repo_path(specs["govoplan-core"], workspace),
|
||||
expected_version=meta_version,
|
||||
)
|
||||
if has_meta
|
||||
else ()
|
||||
)
|
||||
if core_issues:
|
||||
raise SourceReceiptError(
|
||||
"Core source metadata must match the selected Meta version"
|
||||
)
|
||||
bundle_inputs = _bundle_input_receipt(
|
||||
selected=selected, workspace=workspace, push=push
|
||||
)
|
||||
except (SourceReceiptError, OSError, ValueError) as exc:
|
||||
return _blocked(selected=selected, apply=apply, push=push, detail=str(exc))
|
||||
|
||||
# Preserve the shared complete manifest, package/lock and immutable tag
|
||||
# preflight. This invocation is always read-only; strict effects stay below.
|
||||
try:
|
||||
preview = _preview_repositories(
|
||||
repos=selected,
|
||||
repo_versions=repo_versions,
|
||||
workspace_root=workspace,
|
||||
remote="origin",
|
||||
message=message,
|
||||
push=push,
|
||||
)
|
||||
except (OSError, ValueError) as exc:
|
||||
return _blocked(
|
||||
selected=selected,
|
||||
apply=apply,
|
||||
push=push,
|
||||
detail=f"shared release preflight could not validate its inputs ({type(exc).__name__})",
|
||||
)
|
||||
rows = preview["repositories"]
|
||||
if preview["status"] in {"blocked", "partial"}:
|
||||
if not apply:
|
||||
return preview
|
||||
return _blocked(
|
||||
selected=selected,
|
||||
apply=True,
|
||||
push=push,
|
||||
detail="shared release preflight failed",
|
||||
rows=rows,
|
||||
)
|
||||
if not apply:
|
||||
rows = [
|
||||
{
|
||||
**row,
|
||||
"status": "planned",
|
||||
"detail": "existing annotated release tag requires atomic main publication",
|
||||
}
|
||||
if push
|
||||
and receipts[row["repo"]]["remote_main"] != receipts[row["repo"]]["head"]
|
||||
else row
|
||||
for row in rows
|
||||
]
|
||||
preview = {**preview, "repositories": rows}
|
||||
if any(row["status"] == "planned" for row in rows):
|
||||
preview["status"] = "planned"
|
||||
return {
|
||||
**preview,
|
||||
"source_receipts": receipts,
|
||||
"source_contract": "registered-meta-batch-v1"
|
||||
if has_meta
|
||||
else "registered-source-batch-v1",
|
||||
"bundle_input_receipts": bundle_inputs,
|
||||
}
|
||||
|
||||
results = []
|
||||
effected = False
|
||||
for row in rows:
|
||||
repo = row["repo"]
|
||||
receipt = receipts[repo]
|
||||
path = resolve_repo_path(specs[repo], workspace)
|
||||
tag = receipt["tag"]
|
||||
head = receipt["head"]
|
||||
try:
|
||||
# Recheck the entire frozen batch, including Meta and Core, before
|
||||
# every effect. Earlier successful effects update only their exact
|
||||
# anticipated tag/branch receipt fields below.
|
||||
_frozen_receipts(
|
||||
expected=receipts,
|
||||
versions=versions,
|
||||
specs=specs,
|
||||
workspace=workspace,
|
||||
selected=selected,
|
||||
push=push,
|
||||
bundle_inputs=bundle_inputs,
|
||||
)
|
||||
if repo == "govoplan":
|
||||
_require_core(receipts, push=push)
|
||||
local_object = receipt["local_tag_object"]
|
||||
created = False
|
||||
if not local_object:
|
||||
if receipt["remote_tag_object"]:
|
||||
command = (
|
||||
"git",
|
||||
"fetch",
|
||||
"--no-tags",
|
||||
"origin",
|
||||
f"refs/tags/{tag}:refs/tags/{tag}",
|
||||
)
|
||||
else:
|
||||
command = ("git", "tag", "-a", tag, head, "-m", row["message"])
|
||||
created = True
|
||||
effected = True
|
||||
if run(command, cwd=path).returncode:
|
||||
raise SourceReceiptError(
|
||||
"annotated local release tag could not be created or retrieved"
|
||||
)
|
||||
local_object = git_text(
|
||||
path, "rev-parse", "--verify", f"refs/tags/{tag}"
|
||||
)
|
||||
if (
|
||||
not local_object
|
||||
or ref_commit(path, f"refs/tags/{tag}") != head
|
||||
or git_text(path, "cat-file", "-t", f"refs/tags/{tag}") != "tag"
|
||||
or (
|
||||
receipt["remote_tag_object"]
|
||||
and local_object != receipt["remote_tag_object"]
|
||||
)
|
||||
):
|
||||
raise SourceReceiptError("local release tag postcondition failed")
|
||||
receipt["local_tag_object"] = local_object
|
||||
_frozen_receipts(
|
||||
expected=receipts,
|
||||
versions=versions,
|
||||
specs=specs,
|
||||
workspace=workspace,
|
||||
selected=selected,
|
||||
push=push,
|
||||
bundle_inputs=bundle_inputs,
|
||||
)
|
||||
if push and (
|
||||
receipt["remote_tag_object"] != local_object
|
||||
or receipt["remote_main"] != head
|
||||
):
|
||||
# Pin both effects to verified objects, not mutable HEAD/tag
|
||||
# names. No force, retagging, fallback or non-atomic retry.
|
||||
command = (
|
||||
"git",
|
||||
"push",
|
||||
"--atomic",
|
||||
"origin",
|
||||
f"{head}:refs/heads/main",
|
||||
f"{local_object}:refs/tags/{tag}",
|
||||
)
|
||||
effected = True
|
||||
if run(command, cwd=path).returncode:
|
||||
raise SourceReceiptError(
|
||||
"atomic main and annotated tag publication failed; inspect receipts before retrying"
|
||||
)
|
||||
receipt["remote_main"] = head
|
||||
receipt["remote_tag_object"] = local_object
|
||||
# Verify remote main AND exact annotated object, as well as local
|
||||
# source state. A successful Git exit alone is never a receipt.
|
||||
_frozen_receipts(
|
||||
expected=receipts,
|
||||
versions=versions,
|
||||
specs=specs,
|
||||
workspace=workspace,
|
||||
selected=selected,
|
||||
push=push,
|
||||
bundle_inputs=bundle_inputs,
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
**row,
|
||||
"status": "published" if push else "tagged" if created else "noop",
|
||||
"detail": "verified strict registered-source release"
|
||||
if not receipt["remote_tag_object"] or created
|
||||
else "verified strict registered-source release; immutable annotation already published or present",
|
||||
"after_local_tag_commit": head,
|
||||
"after_local_tag_object": local_object,
|
||||
"after_remote_tag_commit": head
|
||||
if receipt["remote_tag_object"]
|
||||
else None,
|
||||
"after_remote_tag_object": receipt["remote_tag_object"],
|
||||
"after_remote_main_commit": receipt["remote_main"],
|
||||
}
|
||||
)
|
||||
except (SourceReceiptError, OSError, ValueError) as exc:
|
||||
results.append(
|
||||
{
|
||||
**row,
|
||||
"status": "failed" if effected else "blocked",
|
||||
"detail": str(exc),
|
||||
}
|
||||
)
|
||||
results.extend(
|
||||
{
|
||||
**later,
|
||||
"status": "skipped",
|
||||
"detail": "earlier strict source effect or receipt failed",
|
||||
}
|
||||
for later in rows[len(results) :]
|
||||
)
|
||||
return {
|
||||
"status": "partial" if effected else "blocked",
|
||||
"apply": True,
|
||||
"push": push,
|
||||
"remote": "origin",
|
||||
"repositories": results,
|
||||
}
|
||||
status = (
|
||||
"published"
|
||||
if push
|
||||
else "tagged"
|
||||
if any(row["status"] == "tagged" for row in results)
|
||||
else "noop"
|
||||
)
|
||||
return {
|
||||
"status": status,
|
||||
"apply": True,
|
||||
"push": push,
|
||||
"remote": "origin",
|
||||
"repositories": results,
|
||||
"source_receipts": receipts,
|
||||
"source_contract": "registered-meta-batch-v1"
|
||||
if has_meta
|
||||
else "registered-source-batch-v1",
|
||||
"bundle_input_receipts": bundle_inputs,
|
||||
}
|
||||
@@ -6,12 +6,13 @@ from dataclasses import dataclass
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import runpy
|
||||
import subprocess
|
||||
import tomllib
|
||||
|
||||
from .git_state import collect_versions, sanitized_git_environment
|
||||
from .git_state import collect_versions, registered_developer_meta_path, sanitized_git_environment
|
||||
from .registry_reference import registry_artifact_conflicts, registry_entry_source
|
||||
from .workspace import load_repository_specs, resolve_repo_path
|
||||
from .workspace import META_ROOT, load_repository_specs, resolve_repo_path
|
||||
|
||||
|
||||
_PYTHON_RELEASE_REF = re.compile(
|
||||
@@ -43,6 +44,7 @@ def repository_version_issues(
|
||||
versions = collect_versions(repo_path)
|
||||
declared = {
|
||||
"pyproject.toml": versions.pyproject,
|
||||
"packages/govoplan-meta/pyproject.toml": versions.developer_meta,
|
||||
"package.json": versions.package,
|
||||
"webui/package.json": versions.webui_package,
|
||||
"webui/package.release.json": _json_version(repo_path / "webui" / "package.release.json")
|
||||
@@ -83,6 +85,8 @@ def repository_version_issues(
|
||||
for source, version in declared.items()
|
||||
if version is not None and version != canonical_version
|
||||
]
|
||||
if registered_developer_meta_path(repo_path) is not None:
|
||||
issues.extend(developer_meta_composition_issues(repo_path))
|
||||
|
||||
if expected_version is not None and canonical_version.removeprefix("v") != expected_version.removeprefix("v"):
|
||||
issues.append(
|
||||
@@ -120,6 +124,34 @@ def repository_version_issues(
|
||||
return tuple(issues)
|
||||
|
||||
|
||||
def developer_meta_composition_issues(repo_path: Path) -> tuple[VersionAlignmentIssue, ...]:
|
||||
"""Compare the real nested package with the trusted generator's exact output.
|
||||
|
||||
Never execute a generator from a selected checkout. Only the installed
|
||||
operator tooling provides code; selected TOML/requirements are data inputs.
|
||||
"""
|
||||
package_path = registered_developer_meta_path(repo_path)
|
||||
if package_path is None:
|
||||
return (VersionAlignmentIssue(repo_path.name, "repository", "registered Meta support repository", "", "nested developer-package identity is not registered"),)
|
||||
source = "packages/govoplan-meta/pyproject.toml"
|
||||
try:
|
||||
current = package_path.read_text(encoding="utf-8")
|
||||
project = tomllib.loads(current).get("project")
|
||||
if not isinstance(project, dict) or project.get("name") != "govoplan":
|
||||
return (VersionAlignmentIssue("govoplan", source + ":project.name", "govoplan", str(project.get("name") if isinstance(project, dict) else ""), "developer meta-package identity must be exact"),)
|
||||
# META_ROOT belongs to the running operator tools, not repo_path.
|
||||
generator = runpy.run_path(str(META_ROOT / "tools/release/generate-developer-meta-package.py"))
|
||||
expected = generator["render"](
|
||||
workspace=repo_path.parent,
|
||||
requirements=repo_path / "requirements-release.txt",
|
||||
)
|
||||
except (OSError, UnicodeError, KeyError, ValueError, TypeError) as exc:
|
||||
return (VersionAlignmentIssue("govoplan", source, "readable exact developer composition and Core version", type(exc).__name__, "developer meta-package composition could not be validated"),)
|
||||
if current != expected:
|
||||
return (VersionAlignmentIssue("govoplan", source, "trusted generator output matching Core and release requirements", "stale composition", "developer meta-package must exactly match the generator --check contract"),)
|
||||
return ()
|
||||
|
||||
|
||||
def selected_repository_version_issues(
|
||||
*,
|
||||
repo_versions: dict[str, str],
|
||||
@@ -165,6 +197,11 @@ def selected_repository_version_issues(
|
||||
return tuple(issues)
|
||||
|
||||
|
||||
def selected_webui_repository_names(*, repo_versions: dict[str, str], workspace: Path) -> tuple[str, ...]:
|
||||
"""Identify the exact selections for which Core's WebUI inputs are relevant."""
|
||||
return tuple(repo for repo in sorted(repo_versions) if repo != "govoplan-core" and (workspace / repo / "webui/package.json").exists())
|
||||
|
||||
|
||||
def selected_release_webui_bundle_issues(
|
||||
*,
|
||||
repo_versions: dict[str, str],
|
||||
@@ -177,6 +214,8 @@ def selected_release_webui_bundle_issues(
|
||||
immutable release package input and lockfile will actually install.
|
||||
"""
|
||||
|
||||
if not selected_webui_repository_names(repo_versions=repo_versions, workspace=workspace):
|
||||
return ()
|
||||
core_webui = workspace / "govoplan-core" / "webui"
|
||||
release_package_path = core_webui / "package.release.json"
|
||||
release_lock_path = core_webui / "package-lock.release.json"
|
||||
|
||||
@@ -34,6 +34,19 @@ def version_metadata_mutations(
|
||||
) -> tuple[VersionFileMutation, ...]:
|
||||
"""Render all recognized repository version files without writing them."""
|
||||
|
||||
from .git_state import registered_developer_meta_path
|
||||
|
||||
if registered_developer_meta_path(repo_path) is not None:
|
||||
from .meta_preparation import MetaPreparationError, PACKAGE, preview_meta_mutation
|
||||
|
||||
try:
|
||||
_receipt, before, after = preview_meta_mutation(
|
||||
repo_path=repo_path, target_version=target_version,
|
||||
)
|
||||
except (MetaPreparationError, OSError, ValueError, KeyError, TypeError) as exc:
|
||||
raise VersionMetadataError(str(exc)) from exc
|
||||
return (VersionFileMutation(PACKAGE, before, after),) if before != after else ()
|
||||
|
||||
version = target_version.removeprefix("v")
|
||||
candidates: list[tuple[Path, str]] = []
|
||||
if (repo_path / "pyproject.toml").is_file():
|
||||
@@ -115,6 +128,14 @@ def apply_version_metadata_mutations(
|
||||
) -> tuple[str, ...]:
|
||||
"""Apply one deterministic version update, rolling back on write failure."""
|
||||
|
||||
from .git_state import registered_developer_meta_path
|
||||
|
||||
if registered_developer_meta_path(repo_path) is not None:
|
||||
raise VersionMetadataError(
|
||||
"Meta is prepared outside durable runs with prepare-developer-meta-package.py; "
|
||||
"review its complete generated composition, commit, and create a fresh run."
|
||||
)
|
||||
|
||||
mutations = version_metadata_mutations(
|
||||
repo_path,
|
||||
target_version=target_version,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Preview or explicitly prepare Meta outside a durable release run."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_release.meta_preparation import (
|
||||
MetaPreparationAmbiguous,
|
||||
MetaPreparationError,
|
||||
_read_input,
|
||||
prepare_developer_meta_package,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--workspace", type=Path, required=True)
|
||||
parser.add_argument("--target-version", required=True)
|
||||
parser.add_argument("--apply", action="store_true")
|
||||
parser.add_argument("--receipt", type=Path)
|
||||
parser.add_argument("--confirm-out-of-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
expected = None
|
||||
if args.apply:
|
||||
if args.receipt is None:
|
||||
raise MetaPreparationError(
|
||||
"Apply requires the reviewed preview JSON via --receipt."
|
||||
)
|
||||
payload, _ = _read_input(args.receipt)
|
||||
expected = json.loads(payload)["receipt"]
|
||||
result = prepare_developer_meta_package(
|
||||
repo_path=args.workspace.absolute() / "govoplan",
|
||||
target_version=args.target_version,
|
||||
apply=args.apply,
|
||||
expected_receipt=expected,
|
||||
confirm_out_of_run=args.confirm_out_of_run,
|
||||
)
|
||||
except (MetaPreparationError, OSError, ValueError, KeyError, TypeError) as exc:
|
||||
status = "needs-reconciliation" if isinstance(exc, MetaPreparationAmbiguous) else "blocked"
|
||||
print(json.dumps({"status": status, "detail": str(exc)}))
|
||||
return 1
|
||||
print(json.dumps(result, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -2141,6 +2141,7 @@
|
||||
function primaryVersion(repo) {
|
||||
const versions = repo.versions || {};
|
||||
if (versions.pyproject) return versions.pyproject;
|
||||
if (versions.developer_meta) return versions.developer_meta;
|
||||
if (versions.package) return versions.package;
|
||||
if (versions.webui_package) return versions.webui_package;
|
||||
if (Array.isArray(versions.manifests) && versions.manifests.length) return versions.manifests[0];
|
||||
|
||||
Reference in New Issue
Block a user