diff --git a/.github/workflows/platform-manifest.yml b/.github/workflows/platform-manifest.yml index 96ed10dcb..bb69ab85d 100644 --- a/.github/workflows/platform-manifest.yml +++ b/.github/workflows/platform-manifest.yml @@ -12,8 +12,8 @@ on: branches: - main schedule: - # DAILY drift check. The manifest goes stale because openadapt-flow, - # -capture, or -desktop released in ANOTHER repository, so no commit + # DAILY drift check. The manifest goes stale when a component releases in + # ANOTHER repository, so no commit # lands here and only the schedule can notice. This was weekly, which # meant up to seven days of serving wrong sha256 digests from # raw.githubusercontent; flow 1.24.0 published on 2026-07-27 and the @@ -24,8 +24,8 @@ on: # Receiver for a component repository to announce its release immediately # rather than waiting for the daily sweep. # - # NOT YET WIRED: as of 2026-07-28 no component repository sends this. None of - # openadapt-flow, -capture, or -desktop has a dispatch step in its release + # NOT YET WIRED: as of 2026-07-28 no component repository sends this. No + # component has a dispatch step in its release # workflow, so the daily cron above is in practice the ONLY thing that # notices a component release. That is how flow 1.25.0 and 1.25.1 published # on 2026-07-27 after the manifest was regenerated at 15:42 that day and the @@ -50,7 +50,9 @@ on: types: [component-released] concurrency: - group: platform-manifest-${{ github.ref }} + group: >- + platform-manifest-${{ github.event_name }}-${{ + github.event.pull_request.number || github.ref }} cancel-in-progress: true permissions: @@ -75,6 +77,8 @@ jobs: # Standard library only: no dependency install, no lockfile, no cache. - name: Validate platform manifest against published artifacts + env: + GITHUB_TOKEN: ${{ github.token }} run: python scripts/validate_platform_manifest.py - name: Prove the drift guard fails when it should @@ -84,7 +88,9 @@ jobs: if: github.event_name == 'pull_request' || github.event_name == 'push' run: | python -m pip install --quiet 'pytest>=8.0.0' - python -m pytest tests/test_platform_manifest_drift.py -q + python -m pytest \ + tests/test_platform_manifest_drift.py \ + tests/test_platform_version_display.py -q report-manifest-drift: # A red scheduled run in a repository nobody has open is not a signal. diff --git a/docs/platform-compatibility-report.md b/docs/platform-compatibility-report.md new file mode 100644 index 000000000..2f3af3902 --- /dev/null +++ b/docs/platform-compatibility-report.md @@ -0,0 +1,83 @@ +# OpenAdapt platform compatibility report + + + +- Generated: `2026-07-28T11:01:07+00:00` +- Release channel: **beta** +- Selection: `latest-published` +- Compatibility: **dependency-compatible** +- Signature: `unsigned (signing infrastructure pending)` + +## Published components + +| Role | Package | Version | Release source reference | Published artifacts | +|---|---|---:|---|---| +| launcher | `openadapt` | `1.10.2` | [`v1.10.2`](https://github.com/OpenAdaptAI/OpenAdapt/tree/v1.10.2)
commit `9cddb87ca5f78a47ed5d379a57e2f53ca4484c71`
tree `98464eb5f9d16afb0af32de7c585a3155843a5a0` | `openadapt-1.10.2-py3-none-any.whl` `717adffae146a3dea22f82a57317e45d15c98b1f115285e60a93b01fe45464d6`
`openadapt-1.10.2.tar.gz` `cae5a1421213521c7d60f613024d8683b2cb0c118186202279c5cb03d2731a38` | +| flow | `openadapt-flow` | `1.25.1` | [`v1.25.1`](https://github.com/OpenAdaptAI/openadapt-flow/tree/v1.25.1)
commit `b3966d01f0b574c07d7a46429f60769f859b6a23`
tree `c3ee13d43a56df9d394a60a5f9fc5f18114daedd` | `openadapt_flow-1.25.1-py3-none-any.whl` `375c516837a44bc8bea87ba0429967f92561d6df92eef41e624f55c281bed9ae`
`openadapt_flow-1.25.1.tar.gz` `15393bb978283e87f043e6fa5f05d7b1d3fc0f9a497a4cf9561a23e8fc5f76ac` | +| capture | `openadapt-capture` | `1.2.2` | [`v1.2.2`](https://github.com/OpenAdaptAI/openadapt-capture/tree/v1.2.2)
commit `06ade22948c12dcc6608bd54b5cca5c9c306e77c`
tree `b0a3b5b53f23536e01314f36aa46fd5b2d8baba6` | `openadapt_capture-1.2.2-py3-none-any.whl` `550fffe50990fa8431d332bdd5fe4755976d83baa7ad62553f50ad9e4ddcd1ae`
`openadapt_capture-1.2.2.tar.gz` `6f4298d3daadf9cced8994207e34b3116a79209ef792e98f778a0770e2f3406c` | +| privacy | `openadapt-privacy` | `1.0.1` | [`v1.0.1`](https://github.com/OpenAdaptAI/openadapt-privacy/tree/v1.0.1)
commit `741e1546aab65676f5e5811c306fc77f6131824d`
tree `ca7db739ca7c14f0b25078c13ed5e1c7566d12a6` | `openadapt_privacy-1.0.1-py3-none-any.whl` `0109127838e854b22a2e90a911f0b83812d32dd1e7b8340faf8ebdb69ef2a478`
`openadapt_privacy-1.0.1.tar.gz` `e9bc90d7a8306088341d83432faf547c16f6bc141c4776e96359571d93923a27` | +| types | `openadapt-types` | `0.6.4` | [`v0.6.4`](https://github.com/OpenAdaptAI/openadapt-types/tree/v0.6.4)
commit `a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c`
tree `abdaad39abe87521168c26b12fab5c2ad2fe5717` | `openadapt_types-0.6.4-py3-none-any.whl` `115e5040bf5a03cbe68a6f5d9b5a412d48432b46e546cded9f5e977d76687837`
`openadapt_types-0.6.4.tar.gz` `3e4752c90daf989485f9d9f5f364643a8a6bcca9e06a934b7b7598cdf145b8d3` | +| desktop | `openadapt-desktop` | `0.14.0` | [`desktop-v0.14.0`](https://github.com/OpenAdaptAI/openadapt-desktop/tree/desktop-v0.14.0)
commit `778c8384369c75b31c24c1de92c9597c0e09b469`
tree `f8b7260f9d459f1d4a3a9640c501d8125d8f353a` | `openadapt_desktop-0.14.0-py3-none-any.whl` `7ee5a1d2e00f5cbd887edd070916e53530c9548b04713a1aa7d9960f2d313082`
`openadapt_desktop-0.14.0.tar.gz` `44a50bd2c126524a76b4d0c0eb0bed9f24667940f735eed1dfed03dce7e9862b` | +| agent | `openadapt-agent` | `2.0.1` | [`v2.0.1`](https://github.com/OpenAdaptAI/openadapt-agent/tree/v2.0.1)
commit `d9f16b2946dfdd0228eeb2660047347df9dce8d8`
tree `26606c89b4294603b628f1b3aaa8283c45765326` | `openadapt_agent-2.0.1-py3-none-any.whl` `5c5f6e3eca4c127d87b3affb8944223c0836916e0df57d0c6b8ba737b3bc8b5a`
`openadapt_agent-2.0.1.tar.gz` `67e05b299a4f035a0482b3032decc151fafc22de9a12bb3d8f884f37ea4cc937` | + +## Runtime selections + +| Unit | Version | Selected or locked OpenAdapt packages | +|---|---:|---| +| launcher_environment | `1.10.2` | `openadapt==1.10.2`
`openadapt-flow==1.25.1` | +| customer_runner | `1.25.1` | `openadapt-flow==1.25.1`
`openadapt-types==0.6.4` | +| desktop_sidecar | `0.14.0` | `openadapt-capture==1.1.1`
`openadapt-flow==1.23.0`
`openadapt-privacy==1.0.0`
`openadapt-types==0.5.0` | +| agent_bridge | `2.0.1` | `openadapt-agent==2.0.1`
`openadapt-flow==1.25.1` | + +## Selected-platform dependency compatibility + +These edges show whether each selected integration version satisfies its published dependency constraint. Optional markers do not mean that every package is installed in every runtime unit. + +| Scope | Source | Target | Constraint | Selected | Activation | +|---|---|---|---|---:|---| +| desktop-sidecar-lock | `openadapt-desktop==0.14.0` | `openadapt-capture` | `>=1.1.0` | `1.1.1` | `always` | +| desktop-sidecar-lock | `openadapt-desktop==0.14.0` | `openadapt-flow` | `==1.23.0` | `1.23.0` | `extra == "build"` | +| desktop-sidecar-lock | `openadapt-desktop==0.14.0` | `openadapt-privacy` | `>=1.0.0` | `1.0.0` | `always` | +| desktop-sidecar-lock | `openadapt-desktop==0.14.0` | `openadapt-types` | `==0.5.0` | `0.5.0` | `always` | +| selected-platform | `openadapt-agent==2.0.1` | `openadapt-flow` | `<2,>=1.18.1` | `1.25.1` | `always` | +| selected-platform | `openadapt-capture==1.2.2` | `openadapt-privacy` | `>=0.1.0` | `1.0.1` | `extra == "all"`
`extra == "privacy"` | +| selected-platform | `openadapt-flow==1.25.1` | `openadapt-capture` | `>=1.2.0` | `1.2.2` | `extra == "capture"` | +| selected-platform | `openadapt-flow==1.25.1` | `openadapt-privacy` | `>=1.0.0` | `1.0.1` | `extra == "privacy"` | +| selected-platform | `openadapt-flow==1.25.1` | `openadapt-types` | `<0.7.0,>=0.6.0` | `0.6.4` | `extra == "console"`
`extra == "dev"`
`extra == "interop"` | +| selected-platform | `openadapt==1.10.2` | `openadapt-capture` | `<2.0.0,>=1.0.4` | `1.2.2` | `extra == "all"`
`extra == "capture"`
`extra == "core"` | +| selected-platform | `openadapt==1.10.2` | `openadapt-flow` | `<2.0.0,>=1.20.1` | `1.25.1` | `always`
`extra == "all"`
`extra == "capture"`
`extra == "core"`
`extra == "flow"`
`extra == "privacy"`
`extra == "rdp"`
`extra == "windows"`
`sys_platform == "darwin" and extra == "all"`
`sys_platform == "darwin" and extra == "macos"`
`sys_platform == "linux" and extra == "all"`
`sys_platform == "linux" and extra == "linux"` | + +## Cross-component schemas + +| Contract | Owner | Accepted versions | Exact source | +|---|---|---|---| +| `workflow_bundle` | `flow` | `schema_version` [1, 2] | [`openadapt_flow/bundle_validation.py`](https://github.com/OpenAdaptAI/openadapt-flow/blob/b3966d01f0b574c07d7a46429f60769f859b6a23/openadapt_flow/bundle_validation.py) `1163fe77d786b0a5481d4d4f5f82d5bd9e95c0808586951b1fcd50e6f8a8eb63`
[`openadapt_flow/ir.py`](https://github.com/OpenAdaptAI/openadapt-flow/blob/b3966d01f0b574c07d7a46429f60769f859b6a23/openadapt_flow/ir.py) `f55d9a078b208a5204e7b72ddf1c90228a5ce772bb5350323525e6f6593bc614` | +| `capture_structural_observation` | `capture` | `openadapt.capture.structural-observation/v` [1] | [`openadapt_capture/structural.py`](https://github.com/OpenAdaptAI/openadapt-capture/blob/06ade22948c12dcc6608bd54b5cca5c9c306e77c/openadapt_capture/structural.py) `8f8bce37148a3357ace147c46a7c45e6d5501b70622b24b1ac2490f2def304fc` | +| `control_overlay_frame` | `types` | `openadapt.control-overlay-frame/v` [1, 2] | [`openadapt_types/control_overlay.py`](https://github.com/OpenAdaptAI/openadapt-types/blob/a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c/openadapt_types/control_overlay.py) `3aa01f89b43e12478c9e2ff1a9e61689397370691d15247ded9dcd5b05242790`
[`openadapt_types/control_overlay_tracking.py`](https://github.com/OpenAdaptAI/openadapt-types/blob/a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c/openadapt_types/control_overlay_tracking.py) `af8d934f0572c5f4e9c63ba720d4a1d0e73c8cdf3f7b87cc4562c01a8a551755` | +| `control_overlay_timeline` | `types` | `openadapt.control-overlay-timeline/v` [1, 2] | [`openadapt_types/control_overlay.py`](https://github.com/OpenAdaptAI/openadapt-types/blob/a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c/openadapt_types/control_overlay.py) `3aa01f89b43e12478c9e2ff1a9e61689397370691d15247ded9dcd5b05242790`
[`openadapt_types/control_overlay_tracking.py`](https://github.com/OpenAdaptAI/openadapt-types/blob/a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c/openadapt_types/control_overlay_tracking.py) `af8d934f0572c5f4e9c63ba720d4a1d0e73c8cdf3f7b87cc4562c01a8a551755` | +| `human_decision_task` | `types` | `openadapt.human-decision-task/v` [1] | [`openadapt_types/human_decision.py`](https://github.com/OpenAdaptAI/openadapt-types/blob/a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c/openadapt_types/human_decision.py) `30aedcdb7bf7291d7b0f061703a1feecd672c9a0529c539bdb09c47d2bb1271b` | +| `human_decision_receipt` | `types` | `openadapt.human-decision-receipt/v` [1] | [`openadapt_types/human_decision.py`](https://github.com/OpenAdaptAI/openadapt-types/blob/a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c/openadapt_types/human_decision.py) `30aedcdb7bf7291d7b0f061703a1feecd672c9a0529c539bdb09c47d2bb1271b` | +| `remote_decision_projection` | `flow` | `openadapt.remote-decision-projection/v` [1] | [`openadapt_flow/console/human_decisions.py`](https://github.com/OpenAdaptAI/openadapt-flow/blob/b3966d01f0b574c07d7a46429f60769f859b6a23/openadapt_flow/console/human_decisions.py) `5956b9677dfe6d9fca43be8b0201ea707c7e303ac9c1987b1ef24336452dfaf5` | +| `runtime_validation` | `flow` | `openadapt.runtime-validation/v` [1, 2] | [`openadapt_flow/runtime_validation.py`](https://github.com/OpenAdaptAI/openadapt-flow/blob/b3966d01f0b574c07d7a46429f60769f859b6a23/openadapt_flow/runtime_validation.py) `5f6e81e1c170419f0b755b8cb1cbdd61064a4b02b37439c65fbd027285d6a4b2` | + +## Host operating systems + +`windows`, `macos`, `linux` + +## Substrate delivery + +| Substrate | Public label | Delivery | +|---|---|---| +| Browser | Available | Local, managed OpenAdapt Cloud, or customer-controlled | +| Windows | Available | Local or customer-controlled | +| macOS | Available | Local or customer-controlled | +| Linux | Available | Local or customer-controlled | +| RDP | Available | Local or customer-controlled | +| Citrix / VDI | Available | Local or customer-controlled | +| Hosted Cloud | Beta | Managed browser runner and control plane | + +## Qualification evidence + +- [openadapt-public-status](https://openadapt.ai/status.json): Canonical machine-readable release, capability, and substrate qualification status, maintained in openadapt-web (public/status.json). +- [flow-public-demo-evidence-packs](https://github.com/OpenAdaptAI/openadapt-flow/tree/b3966d01f0b574c07d7a46429f60769f859b6a23/public-demo/evidence-packs): Versioned public evidence packs with per-pack manifest files, replay evidence, and fault-case results. +- [flow-effectbench-task-pack](https://github.com/OpenAdaptAI/openadapt-flow/blob/b3966d01f0b574c07d7a46429f60769f859b6a23/benchmark/effectbench/task_pack/manifest.json): Effect-verification benchmark task pack manifest used by the flow qualification harness. diff --git a/docs/platform-manifest.md b/docs/platform-manifest.md index 1a3f07247..e6c5789d9 100644 --- a/docs/platform-manifest.md +++ b/docs/platform-manifest.md @@ -2,10 +2,11 @@ `platform-manifest.json` at the repository root is the single authoritative, machine-readable statement of what constitutes an OpenAdapt platform release: -which launcher, flow, capture, and desktop versions belong together, where -their published artifacts live (with sha256 digests), which operating systems -and substrate drivers are supported, what qualification evidence backs the -release, and what release channel it is on. +which Launcher, Flow, Capture, Privacy, Types, Desktop, and Agent versions +belong together. It also names the selected launcher, customer runner, and +Agent bridge packages, plus the exact frozen Desktop sidecar closure. The +manifest includes release-derived schema versions, dependency compatibility, +release source references, artifact digests, and the release channel. ## Why it lives here @@ -16,6 +17,10 @@ platform manifest is therefore generated and versioned here, next to the pins it must agree with. Other repositories contribute source data only: - PyPI is the authority for published versions, artifact URLs, and digests. +- Each exact public release tag is the authority for its release source commit + and tree. This reference is not, by itself, a build-provenance attestation. +- The exact `desktop-vX.Y.Z` tag and its `uv.lock` are the authority for the + package versions frozen into `openadapt-engine`. - `https://openadapt.ai/status.json` (maintained in `openadapt-web`, `public/status.json`) is the authority for substrate availability, release channel, and the public qualification summary. @@ -29,10 +34,12 @@ it must agree with. Other repositories contribute source data only: python scripts/generate_platform_manifest.py ``` -The generator reads the real published state (PyPI JSON API, the live -status.json) plus this repository's `pyproject.toml`. It never invents -numbers: if a source is unreachable, empty, or disagrees with the repository, -it fails loudly. If a launcher release train is in flight (pyproject.toml +The generator reads the real published state from PyPI, exact GitHub release +commits, the Desktop lock, and the live status document. It also reads this +repository's `pyproject.toml`. It derives protocol versions from the exact +selected source files. An optional protocol does not appear until a published +release contains its source. If a source is unreachable, empty, or +inconsistent, generation fails. If a launcher release train is in flight (`pyproject.toml` ahead of PyPI), pass `--allow-unreleased-launcher`; the manifest still records the published version. @@ -43,8 +50,45 @@ cannot truthfully name a launcher release before its immutable artifacts exist. If publication or reconciliation fails, `main` remains red and the release workflow opens or updates a failure issue rather than weakening validation. -Regenerate and commit the manifest manually after other component releases; -the daily scheduled validator catches component or public-status drift. +Regenerate and commit the manifest manually after other component releases. +The daily validator catches component, sidecar, and public-status drift. + +The manifest can truthfully describe an incompatible published set. Use the +strict promotion gate only when all required runtime packages exist: + +```bash +python scripts/validate_platform_manifest.py \ + --require-compatible --require-network --strict-status +``` + +The ordinary validator confirms that the recorded dependency result is +correct. It does not rewrite an incompatible result. + +An ordered release train does not require a guessed future version in source. +Select exact versions only after PyPI and the release tag contain them: + +```bash +python scripts/generate_platform_manifest.py \ + --component-version flow= \ + --component-version desktop= +``` + +The generator refuses an input until it can bind the exact PyPI files, hashes, +dependency metadata, release source commit, release tree, and Desktop lock. +The next Flow and Desktop train can use the same contract without a temporary +hard-coded version or a false release claim. + +Generate a release display from the manifest. Do not copy version values into +another source file: + +```bash +python scripts/render_platform_versions.py +python scripts/render_platform_versions.py --format json +python scripts/render_platform_versions.py --format markdown +``` + +`docs/platform-compatibility-report.md` is the generated human-readable report. +The offline validator fails if it differs from the machine-readable BOM. ## How it is validated @@ -58,10 +102,9 @@ manifest and the actually published artifacts fails loudly. The workflow also accepts a `component-released` `repository_dispatch` so a component repository can trigger the check immediately. **No component -repository sends it yet.** `openadapt-flow`, `openadapt-capture`, and -`openadapt-desktop` have no dispatch step in their release workflows, so the -daily cron is in practice the only automatic detector, and a component release -can serve stale digests for up to a day. Wiring the sender needs a token with +repository sends it yet.** The component release workflows have no dispatch +step, so the daily cron is in practice the only automatic detector. A component +release can serve stale digests for up to a day. Wiring the sender needs a token with `contents: write` on `OpenAdaptAI/OpenAdapt` stored as a secret in each component repository; the component's own `GITHUB_TOKEN` cannot dispatch across repositories. @@ -99,8 +142,10 @@ conditions above. Validated today: -- Structure: manifest kind, schema major version, required fields, artifacts - with sha256 digests per component. +- Structure: manifest kind, schema major version, UTC generation time, + generation inputs, seven required published components, four runtime units, + the complete dependency graph, exact schema-source bindings, operating + systems, substrates, evidence objects, and artifact SHA-256 digests. - Signature honesty: while `signature.value` is null, `signature.status` must read `unsigned (signing infrastructure pending)`. A non-null signature value fails validation because no verification path exists yet. @@ -108,31 +153,54 @@ Validated today: the manifest's launcher version is not ahead of `pyproject.toml`'s. A `pyproject.toml` ahead of the manifest is the normal in-flight-release state and only warns. -- Published-artifact agreement: component versions, artifact filenames, URLs, - and sha256 digests match PyPI exactly (`--offline` skips this class). A +- Published-artifact agreement: component versions, Python ranges, dependency + ranges and markers, and the exact artifact filename/type/URL/SHA-256 set + match version-specific PyPI metadata (`--offline` skips this class). A manifest version behind PyPI's latest, or naming a release PyPI never published, fails; one ahead of `info.version` but already present in `releases` is index propagation lag and warns, with digests still verified. + An exact version supplied through `--component-version` can remain behind + the latest release; the validator records a warning and still verifies all + selected files and source provenance. An unreachable PyPI warns rather than fails — it is not evidence of drift — unless `--require-network` is passed. +- Release source references: every component binds its exact public release + ref, commit, and tree. Network validation resolves each ref again and + refuses a mismatch. +- Schema agreement: schema versions and source-file hashes are derived again + from the exact selected release commits. A future schema cannot appear in + the BOM before its published source exists. +- Evidence agreement: each repository evidence path is bound to an exact Git + blob or tree and verified against the selected release tree. - Status skew: disagreement with `status.json` versions is a warning by default, because that file lives in another repository and a release here must not be blocked by an edit nobody in this repo can make; - `--strict-status` escalates it to a failure. The warning is **not** + `--strict-status` escalates it to a failure and also fails when the status + source is unreachable. Operating-system and substrate-table drift always + fails when the source is reachable. The warning is **not** self-healing: `status.json` is hand-maintained in `openadapt-web` and - nothing generates it from PyPI or from this manifest. On 2026-07-28 it - advertised flow 1.24.0 and launcher 1.10.0 against PyPI's 1.25.1 and 1.10.1. + nothing generates it from PyPI or from this manifest. `openadapt-web` runs a daily guard (`scripts/check_published_version_claims.mjs`) that fails on exactly this, but that detects after the fact and still needs a person to open the corrective pull request. Skew reported here means someone must edit `public/status.json` and `data/published-version-claims.json` in `openadapt-web`. +- Native sidecar agreement: the Desktop sidecar's resolved OpenAdapt package + versions and lock digest match the exact `desktop-vX.Y.Z` tag's `uv.lock`. +- Compatibility honesty: the validator recomputes every dependency edge among + the seven selected components. Optional-extra markers remain visible. The + Desktop edges resolve against its exact sidecar lock; other edges resolve + against the selected platform versions. +- Promotion: `--require-compatible` fails while any selected dependency edge + falls outside its published package range. Not validated yet (planned): - Cryptographic signature verification (see the signing plan below). -- Desktop OS installer artifacts (MSI/DMG); today only PyPI artifacts exist. +- Desktop installer signature and notarization verification. Desktop release + jobs verify their own installer digests and attestations, but this manifest + does not fetch or verify those large files yet. ## Schema (v1) @@ -143,9 +211,15 @@ Top-level fields: | `manifest_kind` | Always `openadapt-platform-release-manifest`. | | `schema_version` | Semver of this schema; validators reject unknown majors. | | `generated_at` | UTC timestamp of generation. | +| `generation` | SHA-256 bindings for the generator and report renderer. | | `release_channel` | Lowercased product lifecycle from status.json (currently `beta`). | -| `components` | `launcher`, `flow`, `capture`, `desktop`: package name, published version, `requires_python`, and artifacts (`type`, `filename`, `url`, `sha256`). | -| `compatibility` | The launcher's supported Python range and its real openadapt-* dependency specifiers, extracted from `pyproject.toml`. | +| `release_selection` | Latest-published selection or exact published version inputs supplied for an ordered release train. | +| `components` | `launcher`, `flow`, `capture`, `privacy`, `types`, `desktop`, and `agent`: package name, published version, Python range, dependency constraints and markers, release source commit/tree, and the exact artifact set. | +| `runtime_units` | Launcher, runner, Desktop, and Agent deployment views. Non-locked units name selected packages. The Desktop sidecar names the exact lock-resolved closure. | +| `dependency_edges` | Every selected-component dependency edge, its activation marker, version constraint, resolution scope, and exact selected or locked target. | +| `schema_compatibility` | Explicit accepted versions derived from exact selected source files, with source commit, blob, and SHA-256 bindings. Optional schemas are omitted until published. | +| `compatibility` | The launcher's supported Python range and its OpenAdapt dependency specifiers, extracted from `pyproject.toml`. | +| `compatibility_status` | `dependency-compatible` or `dependency-incompatible`, plus the exact failed package edges. The generator computes this field. | | `supported_os` | Operating systems the launcher supports. | | `substrate_drivers` | Substrate table (name, public label, delivery) read from status.json. | | `qualification_evidence` | Stable evidence IDs pointing at the public status document and the flow evidence-pack manifests. | @@ -174,10 +248,15 @@ intended rollout, in order: ever emitted. Note that PyPI publish attestations (PEP 740, also sigstore-backed) already exist for recent `openadapt-desktop` uploads and provide per-artifact provenance independent of this manifest. -2. Windows Authenticode signing for desktop installers (MSI/EXE) once those - installers are produced. -3. Apple Developer ID signing plus notarization for macOS app and DMG - artifacts. +2. Windows Authenticode signing for the MSI and EXE installers after the + signing credentials are active. +3. Apple Developer ID signing plus notarization for the macOS app and DMG + artifacts after the Apple credentials are active. + +The current Desktop release workflow supports Authenticode and Developer ID +credentials. The current public installer set still uses ad-hoc macOS signing +and unsigned Windows and Linux packages. The platform promotion gate must stay +separate from a claim that the installers have trusted OS signatures. Until step 1 lands, the validator enforces that the manifest claims nothing: a manifest with a non-null `signature.value` fails validation. diff --git a/platform-manifest.json b/platform-manifest.json index c1cf95a96..5685cc1f3 100644 --- a/platform-manifest.json +++ b/platform-manifest.json @@ -1,14 +1,254 @@ { "manifest_kind": "openadapt-platform-release-manifest", - "schema_version": "1.0.0", - "generated_at": "2026-07-28T06:27:45+00:00", + "schema_version": "1.3.0", + "generated_at": "2026-07-28T11:01:07+00:00", + "generation": { + "files": [ + { + "path": "scripts/generate_platform_manifest.py", + "sha256": "d901843ea4af60fae19794b503eeeac05611abfd521ad9a74c2d925b4458d432" + }, + { + "path": "scripts/render_platform_versions.py", + "sha256": "d4e6f9f3bd297276f0ae409c80e32e05b3bbeba1ae497bc49d65c245a98a7900" + } + ] + }, "release_channel": "beta", + "release_selection": { + "mode": "latest-published", + "component_versions": {} + }, "components": { "launcher": { "package": "openadapt", "version": "1.10.2", "source": "pypi", "requires_python": "<3.13,>=3.10", + "requires": { + "openadapt-capture": [ + "<2.0.0,>=1.0.4" + ], + "openadapt-evals": [ + ">=0.1.0" + ], + "openadapt-flow": [ + "<2.0.0,>=1.20.1" + ], + "openadapt-grounding": [ + ">=0.1.0" + ], + "openadapt-ml": [ + ">=0.2.0" + ], + "openadapt-retrieval": [ + ">=0.1.0" + ], + "openadapt-viewer": [ + ">=0.1.0" + ] + }, + "dependency_constraints": [ + { + "package": "openadapt-capture", + "requires": "<2.0.0,>=1.0.4", + "extras": null, + "marker": "extra == \"all\"" + }, + { + "package": "openadapt-capture", + "requires": "<2.0.0,>=1.0.4", + "extras": null, + "marker": "extra == \"capture\"" + }, + { + "package": "openadapt-capture", + "requires": "<2.0.0,>=1.0.4", + "extras": null, + "marker": "extra == \"core\"" + }, + { + "package": "openadapt-evals", + "requires": ">=0.1.0", + "extras": null, + "marker": "extra == \"all\"" + }, + { + "package": "openadapt-evals", + "requires": ">=0.1.0", + "extras": null, + "marker": "extra == \"core\"" + }, + { + "package": "openadapt-evals", + "requires": ">=0.1.0", + "extras": null, + "marker": "extra == \"evals\"" + }, + { + "package": "openadapt-flow", + "requires": "<2.0.0,>=1.20.1", + "extras": null, + "marker": "extra == \"all\"" + }, + { + "package": "openadapt-flow", + "requires": "<2.0.0,>=1.20.1", + "extras": null, + "marker": "extra == \"flow\"" + }, + { + "package": "openadapt-flow", + "requires": "<2.0.0,>=1.20.1", + "extras": "capture", + "marker": "extra == \"all\"" + }, + { + "package": "openadapt-flow", + "requires": "<2.0.0,>=1.20.1", + "extras": "capture", + "marker": "extra == \"capture\"" + }, + { + "package": "openadapt-flow", + "requires": "<2.0.0,>=1.20.1", + "extras": "capture", + "marker": "extra == \"core\"" + }, + { + "package": "openadapt-flow", + "requires": "<2.0.0,>=1.20.1", + "extras": "hosted", + "marker": null + }, + { + "package": "openadapt-flow", + "requires": "<2.0.0,>=1.20.1", + "extras": "linux", + "marker": "sys_platform == \"linux\" and extra == \"all\"" + }, + { + "package": "openadapt-flow", + "requires": "<2.0.0,>=1.20.1", + "extras": "linux", + "marker": "sys_platform == \"linux\" and extra == \"linux\"" + }, + { + "package": "openadapt-flow", + "requires": "<2.0.0,>=1.20.1", + "extras": "macos", + "marker": "sys_platform == \"darwin\" and extra == \"all\"" + }, + { + "package": "openadapt-flow", + "requires": "<2.0.0,>=1.20.1", + "extras": "macos", + "marker": "sys_platform == \"darwin\" and extra == \"macos\"" + }, + { + "package": "openadapt-flow", + "requires": "<2.0.0,>=1.20.1", + "extras": "privacy", + "marker": "extra == \"all\"" + }, + { + "package": "openadapt-flow", + "requires": "<2.0.0,>=1.20.1", + "extras": "privacy", + "marker": "extra == \"privacy\"" + }, + { + "package": "openadapt-flow", + "requires": "<2.0.0,>=1.20.1", + "extras": "rdp", + "marker": "extra == \"all\"" + }, + { + "package": "openadapt-flow", + "requires": "<2.0.0,>=1.20.1", + "extras": "rdp", + "marker": "extra == \"rdp\"" + }, + { + "package": "openadapt-flow", + "requires": "<2.0.0,>=1.20.1", + "extras": "windows", + "marker": "extra == \"all\"" + }, + { + "package": "openadapt-flow", + "requires": "<2.0.0,>=1.20.1", + "extras": "windows", + "marker": "extra == \"windows\"" + }, + { + "package": "openadapt-grounding", + "requires": ">=0.1.0", + "extras": null, + "marker": "extra == \"all\"" + }, + { + "package": "openadapt-grounding", + "requires": ">=0.1.0", + "extras": null, + "marker": "extra == \"grounding\"" + }, + { + "package": "openadapt-ml", + "requires": ">=0.2.0", + "extras": null, + "marker": "extra == \"all\"" + }, + { + "package": "openadapt-ml", + "requires": ">=0.2.0", + "extras": null, + "marker": "extra == \"core\"" + }, + { + "package": "openadapt-ml", + "requires": ">=0.2.0", + "extras": null, + "marker": "extra == \"ml\"" + }, + { + "package": "openadapt-retrieval", + "requires": ">=0.1.0", + "extras": null, + "marker": "extra == \"all\"" + }, + { + "package": "openadapt-retrieval", + "requires": ">=0.1.0", + "extras": null, + "marker": "extra == \"retrieval\"" + }, + { + "package": "openadapt-viewer", + "requires": ">=0.1.0", + "extras": null, + "marker": "extra == \"all\"" + }, + { + "package": "openadapt-viewer", + "requires": ">=0.1.0", + "extras": null, + "marker": "extra == \"core\"" + }, + { + "package": "openadapt-viewer", + "requires": ">=0.1.0", + "extras": null, + "marker": "extra == \"viewer\"" + } + ], + "provenance": { + "repository": "OpenAdaptAI/OpenAdapt", + "release_ref": "v1.10.2", + "commit": "9cddb87ca5f78a47ed5d379a57e2f53ca4484c71", + "tree": "98464eb5f9d16afb0af32de7c585a3155843a5a0", + "url": "https://github.com/OpenAdaptAI/OpenAdapt/tree/v1.10.2" + }, "artifacts": [ { "type": "bdist_wheel", @@ -29,6 +269,65 @@ "version": "1.25.1", "source": "pypi", "requires_python": "<3.13,>=3.10", + "requires": { + "openadapt-capture": [ + ">=1.2.0" + ], + "openadapt-grounding": [ + ">=0.1.0" + ], + "openadapt-privacy": [ + ">=1.0.0" + ], + "openadapt-types": [ + "<0.7.0,>=0.6.0" + ] + }, + "dependency_constraints": [ + { + "package": "openadapt-capture", + "requires": ">=1.2.0", + "extras": null, + "marker": "extra == \"capture\"" + }, + { + "package": "openadapt-grounding", + "requires": ">=0.1.0", + "extras": null, + "marker": "extra == \"grounding\"" + }, + { + "package": "openadapt-privacy", + "requires": ">=1.0.0", + "extras": "presidio", + "marker": "extra == \"privacy\"" + }, + { + "package": "openadapt-types", + "requires": "<0.7.0,>=0.6.0", + "extras": null, + "marker": "extra == \"console\"" + }, + { + "package": "openadapt-types", + "requires": "<0.7.0,>=0.6.0", + "extras": null, + "marker": "extra == \"dev\"" + }, + { + "package": "openadapt-types", + "requires": "<0.7.0,>=0.6.0", + "extras": null, + "marker": "extra == \"interop\"" + } + ], + "provenance": { + "repository": "OpenAdaptAI/openadapt-flow", + "release_ref": "v1.25.1", + "commit": "b3966d01f0b574c07d7a46429f60769f859b6a23", + "tree": "c3ee13d43a56df9d394a60a5f9fc5f18114daedd", + "url": "https://github.com/OpenAdaptAI/openadapt-flow/tree/v1.25.1" + }, "artifacts": [ { "type": "bdist_wheel", @@ -49,6 +348,32 @@ "version": "1.2.2", "source": "pypi", "requires_python": ">=3.10", + "requires": { + "openadapt-privacy": [ + ">=0.1.0" + ] + }, + "dependency_constraints": [ + { + "package": "openadapt-privacy", + "requires": ">=0.1.0", + "extras": null, + "marker": "extra == \"all\"" + }, + { + "package": "openadapt-privacy", + "requires": ">=0.1.0", + "extras": null, + "marker": "extra == \"privacy\"" + } + ], + "provenance": { + "repository": "OpenAdaptAI/openadapt-capture", + "release_ref": "v1.2.2", + "commit": "06ade22948c12dcc6608bd54b5cca5c9c306e77c", + "tree": "b0a3b5b53f23536e01314f36aa46fd5b2d8baba6", + "url": "https://github.com/OpenAdaptAI/openadapt-capture/tree/v1.2.2" + }, "artifacts": [ { "type": "bdist_wheel", @@ -64,11 +389,116 @@ } ] }, + "privacy": { + "package": "openadapt-privacy", + "version": "1.0.1", + "source": "pypi", + "requires_python": ">=3.10", + "requires": {}, + "dependency_constraints": [], + "provenance": { + "repository": "OpenAdaptAI/openadapt-privacy", + "release_ref": "v1.0.1", + "commit": "741e1546aab65676f5e5811c306fc77f6131824d", + "tree": "ca7db739ca7c14f0b25078c13ed5e1c7566d12a6", + "url": "https://github.com/OpenAdaptAI/openadapt-privacy/tree/v1.0.1" + }, + "artifacts": [ + { + "type": "bdist_wheel", + "filename": "openadapt_privacy-1.0.1-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/1d/55/548738d41ea72a3607bdfa7f335318a0d4943eef901181fb1ed94f4a206b/openadapt_privacy-1.0.1-py3-none-any.whl", + "sha256": "0109127838e854b22a2e90a911f0b83812d32dd1e7b8340faf8ebdb69ef2a478" + }, + { + "type": "sdist", + "filename": "openadapt_privacy-1.0.1.tar.gz", + "url": "https://files.pythonhosted.org/packages/1e/78/5a5b73d6541ecda9dce6cf96e8eedd9c43aa0ab4140011d0a089eb9d882e/openadapt_privacy-1.0.1.tar.gz", + "sha256": "e9bc90d7a8306088341d83432faf547c16f6bc141c4776e96359571d93923a27" + } + ] + }, + "types": { + "package": "openadapt-types", + "version": "0.6.4", + "source": "pypi", + "requires_python": ">=3.10", + "requires": {}, + "dependency_constraints": [], + "provenance": { + "repository": "OpenAdaptAI/openadapt-types", + "release_ref": "v0.6.4", + "commit": "a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c", + "tree": "abdaad39abe87521168c26b12fab5c2ad2fe5717", + "url": "https://github.com/OpenAdaptAI/openadapt-types/tree/v0.6.4" + }, + "artifacts": [ + { + "type": "bdist_wheel", + "filename": "openadapt_types-0.6.4-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/cb/f1/420b62271a662b1ccab7a11b28e0f7ab51919a3deeab175161467e0926a7/openadapt_types-0.6.4-py3-none-any.whl", + "sha256": "115e5040bf5a03cbe68a6f5d9b5a412d48432b46e546cded9f5e977d76687837" + }, + { + "type": "sdist", + "filename": "openadapt_types-0.6.4.tar.gz", + "url": "https://files.pythonhosted.org/packages/d8/c4/9ae01c9ffad380554a8ab349296c35cbf7995625417a334f86806965f0d7/openadapt_types-0.6.4.tar.gz", + "sha256": "3e4752c90daf989485f9d9f5f364643a8a6bcca9e06a934b7b7598cdf145b8d3" + } + ] + }, "desktop": { "package": "openadapt-desktop", "version": "0.14.0", "source": "pypi", "requires_python": ">=3.11", + "requires": { + "openadapt-capture": [ + ">=1.1.0" + ], + "openadapt-flow": [ + "==1.23.0" + ], + "openadapt-privacy": [ + ">=1.0.0" + ], + "openadapt-types": [ + "==0.5.0" + ] + }, + "dependency_constraints": [ + { + "package": "openadapt-capture", + "requires": ">=1.1.0", + "extras": null, + "marker": null + }, + { + "package": "openadapt-flow", + "requires": "==1.23.0", + "extras": null, + "marker": "extra == \"build\"" + }, + { + "package": "openadapt-privacy", + "requires": ">=1.0.0", + "extras": null, + "marker": null + }, + { + "package": "openadapt-types", + "requires": "==0.5.0", + "extras": null, + "marker": null + } + ], + "provenance": { + "repository": "OpenAdaptAI/openadapt-desktop", + "release_ref": "desktop-v0.14.0", + "commit": "778c8384369c75b31c24c1de92c9597c0e09b469", + "tree": "f8b7260f9d459f1d4a3a9640c501d8125d8f353a", + "url": "https://github.com/OpenAdaptAI/openadapt-desktop/tree/desktop-v0.14.0" + }, "artifacts": [ { "type": "bdist_wheel", @@ -83,6 +513,538 @@ "sha256": "44a50bd2c126524a76b4d0c0eb0bed9f24667940f735eed1dfed03dce7e9862b" } ] + }, + "agent": { + "package": "openadapt-agent", + "version": "2.0.1", + "source": "pypi", + "requires_python": "<3.13,>=3.10", + "requires": { + "openadapt-flow": [ + "<2,>=1.18.1" + ] + }, + "dependency_constraints": [ + { + "package": "openadapt-flow", + "requires": "<2,>=1.18.1", + "extras": null, + "marker": null + } + ], + "provenance": { + "repository": "OpenAdaptAI/openadapt-agent", + "release_ref": "v2.0.1", + "commit": "d9f16b2946dfdd0228eeb2660047347df9dce8d8", + "tree": "26606c89b4294603b628f1b3aaa8283c45765326", + "url": "https://github.com/OpenAdaptAI/openadapt-agent/tree/v2.0.1" + }, + "artifacts": [ + { + "type": "bdist_wheel", + "filename": "openadapt_agent-2.0.1-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/af/3a/5cf115ab9077a5ddd99e9e798e203c486dfcf009bbc39ab0d2b653a23a75/openadapt_agent-2.0.1-py3-none-any.whl", + "sha256": "5c5f6e3eca4c127d87b3affb8944223c0836916e0df57d0c6b8ba737b3bc8b5a" + }, + { + "type": "sdist", + "filename": "openadapt_agent-2.0.1.tar.gz", + "url": "https://files.pythonhosted.org/packages/8c/71/f717f61f27878e0331b58e6f518d938964dab42a8a4dc1704417562a911a/openadapt_agent-2.0.1.tar.gz", + "sha256": "67e05b299a4f035a0482b3032decc151fafc22de9a12bb3d8f884f37ea4cc937" + } + ] + } + }, + "runtime_units": { + "launcher_environment": { + "kind": "launcher-environment", + "version": "1.10.2", + "source_component": "launcher", + "entry_point": "openadapt", + "requirements": { + "openadapt": "==1.10.2", + "openadapt-flow": "<2.0.0,>=1.20.1" + }, + "selected": { + "openadapt": "1.10.2", + "openadapt-flow": "1.25.1" + } + }, + "customer_runner": { + "kind": "customer-controlled-runner", + "version": "1.25.1", + "source_component": "flow", + "entry_point": "openadapt-flow", + "requirements": { + "openadapt-flow": "==1.25.1", + "openadapt-types": "<0.7.0,>=0.6.0" + }, + "selected": { + "openadapt-flow": "1.25.1", + "openadapt-types": "0.6.4" + } + }, + "desktop_sidecar": { + "kind": "frozen-desktop-sidecar", + "version": "0.14.0", + "source_component": "desktop", + "source_ref": "desktop-v0.14.0", + "lock_url": "https://raw.githubusercontent.com/OpenAdaptAI/openadapt-desktop/desktop-v0.14.0/uv.lock", + "lock_sha256": "7561dc49b05f6181a38ae59b399f8162bd4ea10c05f0275b0bed9b5c072464cb", + "binary": "openadapt-engine", + "requirements": { + "openadapt-flow": "==1.23.0", + "openadapt-capture": ">=1.1.0", + "openadapt-privacy": ">=1.0.0", + "openadapt-types": "==0.5.0" + }, + "resolved": { + "openadapt-capture": "1.1.1", + "openadapt-flow": "1.23.0", + "openadapt-privacy": "1.0.0", + "openadapt-types": "0.5.0" + } + }, + "agent_bridge": { + "kind": "governed-agent-bridge", + "version": "2.0.1", + "source_component": "agent", + "entry_point": "openadapt-agent", + "requirements": { + "openadapt-agent": "==2.0.1", + "openadapt-flow": "<2,>=1.18.1" + }, + "selected": { + "openadapt-agent": "2.0.1", + "openadapt-flow": "1.25.1" + } + } + }, + "dependency_edges": [ + { + "scope": "desktop-sidecar-lock", + "source_role": "desktop", + "source_package": "openadapt-desktop", + "source_version": "0.14.0", + "target_role": "capture", + "target_package": "openadapt-capture", + "requires": ">=1.1.0", + "resolved": "1.1.1", + "activations": [ + { + "extras": null, + "marker": null + } + ] + }, + { + "scope": "desktop-sidecar-lock", + "source_role": "desktop", + "source_package": "openadapt-desktop", + "source_version": "0.14.0", + "target_role": "flow", + "target_package": "openadapt-flow", + "requires": "==1.23.0", + "resolved": "1.23.0", + "activations": [ + { + "extras": null, + "marker": "extra == \"build\"" + } + ] + }, + { + "scope": "desktop-sidecar-lock", + "source_role": "desktop", + "source_package": "openadapt-desktop", + "source_version": "0.14.0", + "target_role": "privacy", + "target_package": "openadapt-privacy", + "requires": ">=1.0.0", + "resolved": "1.0.0", + "activations": [ + { + "extras": null, + "marker": null + } + ] + }, + { + "scope": "desktop-sidecar-lock", + "source_role": "desktop", + "source_package": "openadapt-desktop", + "source_version": "0.14.0", + "target_role": "types", + "target_package": "openadapt-types", + "requires": "==0.5.0", + "resolved": "0.5.0", + "activations": [ + { + "extras": null, + "marker": null + } + ] + }, + { + "scope": "selected-platform", + "source_role": "agent", + "source_package": "openadapt-agent", + "source_version": "2.0.1", + "target_role": "flow", + "target_package": "openadapt-flow", + "requires": "<2,>=1.18.1", + "resolved": "1.25.1", + "activations": [ + { + "extras": null, + "marker": null + } + ] + }, + { + "scope": "selected-platform", + "source_role": "capture", + "source_package": "openadapt-capture", + "source_version": "1.2.2", + "target_role": "privacy", + "target_package": "openadapt-privacy", + "requires": ">=0.1.0", + "resolved": "1.0.1", + "activations": [ + { + "extras": null, + "marker": "extra == \"all\"" + }, + { + "extras": null, + "marker": "extra == \"privacy\"" + } + ] + }, + { + "scope": "selected-platform", + "source_role": "flow", + "source_package": "openadapt-flow", + "source_version": "1.25.1", + "target_role": "capture", + "target_package": "openadapt-capture", + "requires": ">=1.2.0", + "resolved": "1.2.2", + "activations": [ + { + "extras": null, + "marker": "extra == \"capture\"" + } + ] + }, + { + "scope": "selected-platform", + "source_role": "flow", + "source_package": "openadapt-flow", + "source_version": "1.25.1", + "target_role": "privacy", + "target_package": "openadapt-privacy", + "requires": ">=1.0.0", + "resolved": "1.0.1", + "activations": [ + { + "extras": "presidio", + "marker": "extra == \"privacy\"" + } + ] + }, + { + "scope": "selected-platform", + "source_role": "flow", + "source_package": "openadapt-flow", + "source_version": "1.25.1", + "target_role": "types", + "target_package": "openadapt-types", + "requires": "<0.7.0,>=0.6.0", + "resolved": "0.6.4", + "activations": [ + { + "extras": null, + "marker": "extra == \"console\"" + }, + { + "extras": null, + "marker": "extra == \"dev\"" + }, + { + "extras": null, + "marker": "extra == \"interop\"" + } + ] + }, + { + "scope": "selected-platform", + "source_role": "launcher", + "source_package": "openadapt", + "source_version": "1.10.2", + "target_role": "capture", + "target_package": "openadapt-capture", + "requires": "<2.0.0,>=1.0.4", + "resolved": "1.2.2", + "activations": [ + { + "extras": null, + "marker": "extra == \"all\"" + }, + { + "extras": null, + "marker": "extra == \"capture\"" + }, + { + "extras": null, + "marker": "extra == \"core\"" + } + ] + }, + { + "scope": "selected-platform", + "source_role": "launcher", + "source_package": "openadapt", + "source_version": "1.10.2", + "target_role": "flow", + "target_package": "openadapt-flow", + "requires": "<2.0.0,>=1.20.1", + "resolved": "1.25.1", + "activations": [ + { + "extras": null, + "marker": "extra == \"all\"" + }, + { + "extras": null, + "marker": "extra == \"flow\"" + }, + { + "extras": "capture", + "marker": "extra == \"all\"" + }, + { + "extras": "capture", + "marker": "extra == \"capture\"" + }, + { + "extras": "capture", + "marker": "extra == \"core\"" + }, + { + "extras": "hosted", + "marker": null + }, + { + "extras": "linux", + "marker": "sys_platform == \"linux\" and extra == \"all\"" + }, + { + "extras": "linux", + "marker": "sys_platform == \"linux\" and extra == \"linux\"" + }, + { + "extras": "macos", + "marker": "sys_platform == \"darwin\" and extra == \"all\"" + }, + { + "extras": "macos", + "marker": "sys_platform == \"darwin\" and extra == \"macos\"" + }, + { + "extras": "privacy", + "marker": "extra == \"all\"" + }, + { + "extras": "privacy", + "marker": "extra == \"privacy\"" + }, + { + "extras": "rdp", + "marker": "extra == \"all\"" + }, + { + "extras": "rdp", + "marker": "extra == \"rdp\"" + }, + { + "extras": "windows", + "marker": "extra == \"all\"" + }, + { + "extras": "windows", + "marker": "extra == \"windows\"" + } + ] + } + ], + "schema_compatibility": { + "workflow_bundle": { + "source_component": "flow", + "schema_field": "schema_version", + "format": "integer", + "accepted_versions": [ + 1, + 2 + ], + "minimum": 1, + "maximum": 2, + "source_commit": "b3966d01f0b574c07d7a46429f60769f859b6a23", + "source_files": [ + { + "path": "openadapt_flow/bundle_validation.py", + "url": "https://github.com/OpenAdaptAI/openadapt-flow/blob/b3966d01f0b574c07d7a46429f60769f859b6a23/openadapt_flow/bundle_validation.py", + "git_blob": "1c85315df8ed877695ad37c97be1a6b10a594bda", + "sha256": "1163fe77d786b0a5481d4d4f5f82d5bd9e95c0808586951b1fcd50e6f8a8eb63" + }, + { + "path": "openadapt_flow/ir.py", + "url": "https://github.com/OpenAdaptAI/openadapt-flow/blob/b3966d01f0b574c07d7a46429f60769f859b6a23/openadapt_flow/ir.py", + "git_blob": "e494d72ed0a58ce884ea7726d79c59e52e2a33ec", + "sha256": "f55d9a078b208a5204e7b72ddf1c90228a5ce772bb5350323525e6f6593bc614" + } + ] + }, + "capture_structural_observation": { + "source_component": "capture", + "schema_prefix": "openadapt.capture.structural-observation/v", + "accepted_versions": [ + 1 + ], + "minimum": 1, + "maximum": 1, + "source_commit": "06ade22948c12dcc6608bd54b5cca5c9c306e77c", + "source_files": [ + { + "path": "openadapt_capture/structural.py", + "url": "https://github.com/OpenAdaptAI/openadapt-capture/blob/06ade22948c12dcc6608bd54b5cca5c9c306e77c/openadapt_capture/structural.py", + "git_blob": "8301cf13eebf5762d69d1a187b9b7d15e1a0d14f", + "sha256": "8f8bce37148a3357ace147c46a7c45e6d5501b70622b24b1ac2490f2def304fc" + } + ] + }, + "control_overlay_frame": { + "source_component": "types", + "schema_prefix": "openadapt.control-overlay-frame/v", + "accepted_versions": [ + 1, + 2 + ], + "minimum": 1, + "maximum": 2, + "source_commit": "a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c", + "source_files": [ + { + "path": "openadapt_types/control_overlay.py", + "url": "https://github.com/OpenAdaptAI/openadapt-types/blob/a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c/openadapt_types/control_overlay.py", + "git_blob": "8eaa7b82ae2606fa59943218e3839efe19ebc54b", + "sha256": "3aa01f89b43e12478c9e2ff1a9e61689397370691d15247ded9dcd5b05242790" + }, + { + "path": "openadapt_types/control_overlay_tracking.py", + "url": "https://github.com/OpenAdaptAI/openadapt-types/blob/a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c/openadapt_types/control_overlay_tracking.py", + "git_blob": "222b9bade823332df869f496bc035aa31faed5a2", + "sha256": "af8d934f0572c5f4e9c63ba720d4a1d0e73c8cdf3f7b87cc4562c01a8a551755" + } + ] + }, + "control_overlay_timeline": { + "source_component": "types", + "schema_prefix": "openadapt.control-overlay-timeline/v", + "accepted_versions": [ + 1, + 2 + ], + "minimum": 1, + "maximum": 2, + "source_commit": "a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c", + "source_files": [ + { + "path": "openadapt_types/control_overlay.py", + "url": "https://github.com/OpenAdaptAI/openadapt-types/blob/a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c/openadapt_types/control_overlay.py", + "git_blob": "8eaa7b82ae2606fa59943218e3839efe19ebc54b", + "sha256": "3aa01f89b43e12478c9e2ff1a9e61689397370691d15247ded9dcd5b05242790" + }, + { + "path": "openadapt_types/control_overlay_tracking.py", + "url": "https://github.com/OpenAdaptAI/openadapt-types/blob/a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c/openadapt_types/control_overlay_tracking.py", + "git_blob": "222b9bade823332df869f496bc035aa31faed5a2", + "sha256": "af8d934f0572c5f4e9c63ba720d4a1d0e73c8cdf3f7b87cc4562c01a8a551755" + } + ] + }, + "human_decision_task": { + "source_component": "types", + "schema_prefix": "openadapt.human-decision-task/v", + "accepted_versions": [ + 1 + ], + "minimum": 1, + "maximum": 1, + "source_commit": "a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c", + "source_files": [ + { + "path": "openadapt_types/human_decision.py", + "url": "https://github.com/OpenAdaptAI/openadapt-types/blob/a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c/openadapt_types/human_decision.py", + "git_blob": "69927111405189197d7e01bbddc5694e3235e081", + "sha256": "30aedcdb7bf7291d7b0f061703a1feecd672c9a0529c539bdb09c47d2bb1271b" + } + ] + }, + "human_decision_receipt": { + "source_component": "types", + "schema_prefix": "openadapt.human-decision-receipt/v", + "accepted_versions": [ + 1 + ], + "minimum": 1, + "maximum": 1, + "source_commit": "a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c", + "source_files": [ + { + "path": "openadapt_types/human_decision.py", + "url": "https://github.com/OpenAdaptAI/openadapt-types/blob/a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c/openadapt_types/human_decision.py", + "git_blob": "69927111405189197d7e01bbddc5694e3235e081", + "sha256": "30aedcdb7bf7291d7b0f061703a1feecd672c9a0529c539bdb09c47d2bb1271b" + } + ] + }, + "remote_decision_projection": { + "source_component": "flow", + "schema_prefix": "openadapt.remote-decision-projection/v", + "accepted_versions": [ + 1 + ], + "minimum": 1, + "maximum": 1, + "source_commit": "b3966d01f0b574c07d7a46429f60769f859b6a23", + "source_files": [ + { + "path": "openadapt_flow/console/human_decisions.py", + "url": "https://github.com/OpenAdaptAI/openadapt-flow/blob/b3966d01f0b574c07d7a46429f60769f859b6a23/openadapt_flow/console/human_decisions.py", + "git_blob": "3bea46b0b122d2288b15678ee399cf2e2d1b11ed", + "sha256": "5956b9677dfe6d9fca43be8b0201ea707c7e303ac9c1987b1ef24336452dfaf5" + } + ] + }, + "runtime_validation": { + "source_component": "flow", + "schema_prefix": "openadapt.runtime-validation/v", + "accepted_versions": [ + 1, + 2 + ], + "minimum": 1, + "maximum": 2, + "source_commit": "b3966d01f0b574c07d7a46429f60769f859b6a23", + "source_files": [ + { + "path": "openadapt_flow/runtime_validation.py", + "url": "https://github.com/OpenAdaptAI/openadapt-flow/blob/b3966d01f0b574c07d7a46429f60769f859b6a23/openadapt_flow/runtime_validation.py", + "git_blob": "dde82daa748fcf05e974f4f6b0700233e266796f", + "sha256": "5f6e81e1c170419f0b755b8cb1cbdd61064a4b02b37439c65fbd027285d6a4b2" + } + ] } }, "compatibility": { @@ -111,6 +1073,11 @@ ] } }, + "compatibility_status": { + "status": "dependency-compatible", + "basis": "published-metadata-and-desktop-lock", + "failures": [] + }, "supported_os": [ "windows", "macos", @@ -163,16 +1130,26 @@ { "id": "flow-public-demo-evidence-packs", "kind": "evidence-pack-collection", + "source_component": "flow", "repository": "OpenAdaptAI/openadapt-flow", "path": "public-demo/evidence-packs", - "description": "Versioned public evidence packs (for example mockmed-triage-v3) with per-pack manifest.json files: synthetic-patient replay evidence including fault-injection trials." + "description": "Versioned public evidence packs with per-pack manifest files, replay evidence, and fault-case results.", + "source_commit": "b3966d01f0b574c07d7a46429f60769f859b6a23", + "source_object_type": "tree", + "source_object_sha": "2eac2252f62349e873b461866546dd0295169a19", + "url": "https://github.com/OpenAdaptAI/openadapt-flow/tree/b3966d01f0b574c07d7a46429f60769f859b6a23/public-demo/evidence-packs" }, { "id": "flow-effectbench-task-pack", "kind": "benchmark-manifest", + "source_component": "flow", "repository": "OpenAdaptAI/openadapt-flow", "path": "benchmark/effectbench/task_pack/manifest.json", - "description": "Effect-verification benchmark task pack manifest used by the flow qualification harness." + "description": "Effect-verification benchmark task pack manifest used by the flow qualification harness.", + "source_commit": "b3966d01f0b574c07d7a46429f60769f859b6a23", + "source_object_type": "blob", + "source_object_sha": "1c568d2289342e10e3556c5c46cd08a0e370ac84", + "url": "https://github.com/OpenAdaptAI/openadapt-flow/blob/b3966d01f0b574c07d7a46429f60769f859b6a23/benchmark/effectbench/task_pack/manifest.json" } ], "signature": { diff --git a/scripts/generate_platform_manifest.py b/scripts/generate_platform_manifest.py index 1ce294316..7b1b0b1e2 100644 --- a/scripts/generate_platform_manifest.py +++ b/scripts/generate_platform_manifest.py @@ -8,10 +8,12 @@ Design rules (see docs/platform-manifest.md): -* Versions, artifact URLs, and sha256 digests are read from the REAL published - sources (the PyPI JSON API). Nothing is invented or hand-typed. If a source - is unreachable or disagrees with this repository's ``pyproject.toml``, the - script fails loudly instead of guessing. +* Versions, dependency ranges, artifact URLs, and sha256 digests are read from + the REAL published sources (the PyPI JSON API). The frozen Desktop sidecar + closure comes from the exact native release tag's locked environment. + Nothing is invented or hand-typed. If a source is unreachable or disagrees + with this repository's ``pyproject.toml``, the script fails loudly instead + of guessing. * Substrate/driver availability and the release channel are read from the canonical public status document, https://openadapt.ai/status.json, which is maintained in openadapt-web and already drives status-aware public surfaces. @@ -31,13 +33,20 @@ from __future__ import annotations import argparse +import ast +import base64 import datetime as _dt +import hashlib import json +import os +import re import sys import urllib.error import urllib.request from pathlib import Path +from render_platform_versions import render_markdown + try: import tomllib except ModuleNotFoundError: # pragma: no cover - Python < 3.11 @@ -45,20 +54,117 @@ ROOT = Path(__file__).resolve().parents[1] -SCHEMA_VERSION = "1.0.0" +SCHEMA_VERSION = "1.3.0" MANIFEST_KIND = "openadapt-platform-release-manifest" STATUS_URL = "https://openadapt.ai/status.json" PYPI_URL_TEMPLATE = "https://pypi.org/pypi/{package}/json" +PYPI_VERSION_URL_TEMPLATE = "https://pypi.org/pypi/{package}/{version}/json" +GITHUB_COMMIT_URL_TEMPLATE = ( + "https://api.github.com/repos/{repository}/commits/{release_ref}" +) +GITHUB_CONTENT_URL_TEMPLATE = ( + "https://api.github.com/repos/{repository}/contents/{path}?ref={commit}" +) +GITHUB_TREE_URL_TEMPLATE = ( + "https://api.github.com/repos/{repository}/git/trees/{tree}?recursive=1" +) +DESKTOP_NATIVE_LOCK_URL_TEMPLATE = ( + "https://raw.githubusercontent.com/OpenAdaptAI/openadapt-desktop/" + "desktop-v{version}/uv.lock" +) HTTP_TIMEOUT_SECONDS = 30 # The platform components this manifest pins. The launcher (this repository) -# is the integration point; the others are the runtime, the native recorder, -# and the desktop shell. All four publish to PyPI. +# is the integration point; the others are the runtime, native recorder, +# privacy boundary, shared schemas, desktop shell, and governed agent bridge. +# All seven publish to PyPI. COMPONENTS: dict[str, str] = { "launcher": "openadapt", "flow": "openadapt-flow", "capture": "openadapt-capture", + "privacy": "openadapt-privacy", + "types": "openadapt-types", "desktop": "openadapt-desktop", + "agent": "openadapt-agent", +} + +COMPONENT_REPOSITORIES = { + role: f"OpenAdaptAI/{package}" + for role, package in COMPONENTS.items() +} +COMPONENT_REPOSITORIES["launcher"] = "OpenAdaptAI/OpenAdapt" + +RUNTIME_UNIT_NAMES = { + "launcher_environment", + "customer_runner", + "desktop_sidecar", + "agent_bridge", +} + +# Cross-component schema probes. The paths and prefixes identify public +# protocol surfaces. Accepted versions are extracted from the exact selected +# release commits; this table never declares a version itself. An optional +# probe appears in the BOM only after its source file exists in a published +# release. This permits an ordered release train without a false future claim. +SCHEMA_PROBES = { + "workflow_bundle": { + "source_component": "flow", + "schema_field": "schema_version", + "format": "integer", + "sources": { + "openadapt_flow/ir.py": [r"(?m)^SCHEMA_VERSION\s*=\s*(\d+)\s*$"], + "openadapt_flow/bundle_validation.py": [ + r'data\.get\("schema_version",\s*(\d+)\)' + ], + }, + }, + "capture_structural_observation": { + "source_component": "capture", + "schema_prefix": "openadapt.capture.structural-observation/v", + "sources": {"openadapt_capture/structural.py": []}, + }, + "control_overlay_frame": { + "source_component": "types", + "schema_prefix": "openadapt.control-overlay-frame/v", + "sources": { + "openadapt_types/control_overlay.py": [], + "openadapt_types/control_overlay_tracking.py": [], + }, + }, + "control_overlay_timeline": { + "source_component": "types", + "schema_prefix": "openadapt.control-overlay-timeline/v", + "sources": { + "openadapt_types/control_overlay.py": [], + "openadapt_types/control_overlay_tracking.py": [], + }, + }, + "human_decision_task": { + "source_component": "types", + "schema_prefix": "openadapt.human-decision-task/v", + "sources": {"openadapt_types/human_decision.py": []}, + }, + "human_decision_receipt": { + "source_component": "types", + "schema_prefix": "openadapt.human-decision-receipt/v", + "sources": {"openadapt_types/human_decision.py": []}, + }, + "human_decision_relay": { + "source_component": "flow", + "schema_prefix": "openadapt.human-decision-relay/v", + "optional": True, + "sources": {"openadapt_flow/console/decision_relay.py": []}, + }, + "remote_decision_projection": { + "source_component": "flow", + "schema_prefix": "openadapt.remote-decision-projection/v", + "sources": {"openadapt_flow/console/human_decisions.py": []}, + }, + "runtime_validation": { + "source_component": "flow", + "schema_prefix": "openadapt.runtime-validation/v", + "sources": {"openadapt_flow/runtime_validation.py": []}, + }, } # Operating systems the launcher supports, mirroring pyproject extras @@ -82,17 +188,18 @@ { "id": "flow-public-demo-evidence-packs", "kind": "evidence-pack-collection", + "source_component": "flow", "repository": "OpenAdaptAI/openadapt-flow", "path": "public-demo/evidence-packs", "description": ( - "Versioned public evidence packs (for example mockmed-triage-v3) " - "with per-pack manifest.json files: synthetic-patient replay " - "evidence including fault-injection trials." + "Versioned public evidence packs with per-pack manifest files, " + "replay evidence, and fault-case results." ), }, { "id": "flow-effectbench-task-pack", "kind": "benchmark-manifest", + "source_component": "flow", "repository": "OpenAdaptAI/openadapt-flow", "path": "benchmark/effectbench/task_pack/manifest.json", "description": ( @@ -118,8 +225,12 @@ class DriftError(RuntimeError): def _fetch_json(url: str) -> dict: + headers = {"User-Agent": "openadapt-platform-manifest-generator"} + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if token and url.startswith("https://api.github.com/"): + headers["Authorization"] = f"Bearer {token}" request = urllib.request.Request( - url, headers={"User-Agent": "openadapt-platform-manifest-generator"} + url, headers=headers ) try: with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_SECONDS) as resp: @@ -132,9 +243,278 @@ def _fetch_json(url: str) -> dict: ) from exc -def _pypi_component(package: str) -> dict: - doc = _fetch_json(PYPI_URL_TEMPLATE.format(package=package)) +def _fetch_text(url: str) -> str: + request = urllib.request.Request( + url, headers={"User-Agent": "openadapt-platform-manifest-generator"} + ) + try: + with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_SECONDS) as resp: + return resp.read().decode("utf-8") + except (urllib.error.URLError, UnicodeDecodeError, TimeoutError) as exc: + raise DriftError( + f"FATAL: could not fetch {url}: {exc}. The frozen sidecar closure " + "must come from its exact public release tag; refusing to guess." + ) from exc + + +def _fetch_json_optional(url: str) -> dict | list | None: + """Fetch JSON, returning ``None`` only for a real HTTP 404.""" + + headers = {"User-Agent": "openadapt-platform-manifest-generator"} + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if token and url.startswith("https://api.github.com/"): + headers["Authorization"] = f"Bearer {token}" + request = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_SECONDS) as resp: + return json.load(resp) + except urllib.error.HTTPError as exc: + if exc.code == 404: + return None + raise DriftError(f"FATAL: could not fetch {url}: {exc}") from exc + except (urllib.error.URLError, TimeoutError) as exc: + raise DriftError(f"FATAL: could not fetch {url}: {exc}") from exc + + +def _github_source_file( + repository: str, + commit: str, + path: str, + *, + optional: bool = False, +) -> tuple[str, dict] | None: + """Read and bind one source file at an exact public commit.""" + + url = GITHUB_CONTENT_URL_TEMPLATE.format( + repository=repository, path=path, commit=commit + ) + doc = _fetch_json_optional(url) + if doc is None: + if optional: + return None + raise DriftError( + f"FATAL: required schema source {repository}@{commit}:{path} is missing." + ) + if not isinstance(doc, dict) or doc.get("type") != "file": + raise DriftError( + f"FATAL: schema source {repository}@{commit}:{path} is not a file." + ) + try: + raw = base64.b64decode(doc["content"], validate=False) + text = raw.decode("utf-8") + except (KeyError, ValueError, UnicodeDecodeError) as exc: + raise DriftError( + f"FATAL: schema source {repository}@{commit}:{path} has no valid " + "UTF-8 GitHub content." + ) from exc + blob_sha = doc.get("sha") + if not isinstance(blob_sha, str) or not re.fullmatch(r"[0-9a-f]{40}", blob_sha): + raise DriftError( + f"FATAL: schema source {repository}@{commit}:{path} has no blob SHA." + ) + return text, { + "path": path, + "url": f"https://github.com/{repository}/blob/{commit}/{path}", + "git_blob": blob_sha, + "sha256": hashlib.sha256(raw).hexdigest(), + } + + +def _literal_schema_strings(source: str) -> set[str]: + """Return literal strings from source without importing remote code.""" + + try: + tree = ast.parse(source) + except SyntaxError as exc: + raise DriftError(f"FATAL: schema source is not valid Python: {exc}") from exc + return { + node.value + for node in ast.walk(tree) + if isinstance(node, ast.Constant) and isinstance(node.value, str) + } + + +def _schema_compatibility(components: dict[str, dict]) -> dict[str, dict]: + """Derive schema versions from exact selected public release sources.""" + + schemas: dict[str, dict] = {} + cache: dict[tuple[str, str, str], tuple[str, dict] | None] = {} + for name, probe in SCHEMA_PROBES.items(): + role = str(probe["source_component"]) + component = components[role] + repository = component["provenance"]["repository"] + commit = component["provenance"]["commit"] + source_rows = [] + versions: set[int] = set() + optional = bool(probe.get("optional")) + missing_optional = False + for path, patterns in probe["sources"].items(): + key = (repository, commit, path) + if key not in cache: + cache[key] = _github_source_file( + repository, commit, path, optional=optional + ) + source_file = cache[key] + if source_file is None: + missing_optional = True + continue + source, source_row = source_file + source_rows.append(source_row) + if patterns: + for pattern in patterns: + versions.update(int(value) for value in re.findall(pattern, source)) + else: + prefix = str(probe["schema_prefix"]) + for value in _literal_schema_strings(source): + match = re.fullmatch(re.escape(prefix) + r"(\d+)", value) + if match: + versions.add(int(match.group(1))) + if missing_optional and not versions: + continue + if not versions: + raise DriftError( + f"FATAL: exact {role} release sources declare no versions for " + f"schema contract {name}." + ) + accepted = sorted(versions) + row = { + key: value + for key, value in probe.items() + if key not in {"sources", "optional"} + } + row.update( + { + "accepted_versions": accepted, + "minimum": accepted[0], + "maximum": accepted[-1], + "source_commit": commit, + "source_files": sorted(source_rows, key=lambda item: item["path"]), + } + ) + schemas[name] = row + return schemas + + +def _openadapt_requirements(requirements: list[str] | None) -> dict[str, list[str]]: + """Return normalized openadapt-* dependency specifiers from PEP 508 text.""" + + pins: dict[str, set[str]] = {} + for requirement in requirements or []: + bare = requirement.split(";", 1)[0].strip() + for index, char in enumerate(bare): + if char in "><=!~ [": + name = bare[:index] + rest = bare[index:] + break + else: + name, rest = bare, "" + if not name.startswith("openadapt"): + continue + specifier = rest + if specifier.startswith("["): + close = specifier.find("]") + if close < 0: + raise DriftError( + f"FATAL: malformed dependency requirement: {requirement}" + ) + specifier = specifier[close + 1 :] + specifier = specifier.strip() + if specifier: + pins.setdefault(name, set()).add(specifier) + return {name: sorted(specs) for name, specs in sorted(pins.items())} + + +def _openadapt_dependency_constraints( + requirements: list[str] | None, +) -> list[dict[str, str | None]]: + """Preserve the activation marker on each OpenAdapt dependency edge.""" + + constraints: list[dict[str, str | None]] = [] + for requirement in requirements or []: + bare, separator, marker = requirement.partition(";") + bare = bare.strip() + for index, char in enumerate(bare): + if char in "><=!~ [": + name = bare[:index] + rest = bare[index:] + break + else: + name, rest = bare, "" + if not name.startswith("openadapt"): + continue + extras = None + if rest.startswith("["): + close = rest.find("]") + if close < 0: + raise DriftError( + f"FATAL: malformed dependency requirement: {requirement}" + ) + extras = rest[1:close] + rest = rest[close + 1 :] + specifier = rest.strip() + if not specifier: + continue + constraints.append( + { + "package": name, + "requires": specifier, + "extras": extras, + "marker": marker.strip() if separator else None, + } + ) + return sorted( + constraints, + key=lambda row: ( + str(row["package"]), + str(row["requires"]), + str(row["extras"]), + str(row["marker"]), + ), + ) + + +def _release_ref(role: str, version: str) -> str: + prefix = "desktop-v" if role == "desktop" else "v" + return f"{prefix}{version}" + + +def _source_provenance(role: str, version: str) -> dict: + repository = COMPONENT_REPOSITORIES[role] + release_ref = _release_ref(role, version) + url = GITHUB_COMMIT_URL_TEMPLATE.format( + repository=repository, release_ref=release_ref + ) + doc = _fetch_json(url) + commit = doc.get("sha") + tree = doc.get("commit", {}).get("tree", {}).get("sha") + if not isinstance(commit, str) or not isinstance(tree, str): + raise DriftError( + f"FATAL: {url} did not resolve an exact commit and tree for " + f"{repository}@{release_ref}." + ) + return { + "repository": repository, + "release_ref": release_ref, + "commit": commit, + "tree": tree, + "url": f"https://github.com/{repository}/tree/{release_ref}", + } + + +def _pypi_component(role: str, requested_version: str | None = None) -> dict: + package = COMPONENTS[role] + url = ( + PYPI_VERSION_URL_TEMPLATE.format(package=package, version=requested_version) + if requested_version + else PYPI_URL_TEMPLATE.format(package=package) + ) + doc = _fetch_json(url) version = doc["info"]["version"] + if requested_version is not None and version != requested_version: + raise DriftError( + f"FATAL: PyPI returned {package}=={version} for requested " + f"version {requested_version}." + ) artifacts = [] for url_entry in doc.get("urls", []): artifacts.append( @@ -154,6 +534,11 @@ def _pypi_component(package: str) -> dict: "version": version, "source": "pypi", "requires_python": doc["info"].get("requires_python"), + "requires": _openadapt_requirements(doc["info"].get("requires_dist")), + "dependency_constraints": _openadapt_dependency_constraints( + doc["info"].get("requires_dist") + ), + "provenance": _source_provenance(role, version), "artifacts": sorted(artifacts, key=lambda a: a["filename"]), } @@ -195,6 +580,269 @@ def _compatibility_from_pyproject() -> dict: } +def _release_tuple(version: str) -> tuple[int, ...] | None: + parts = version.split(".") + if not parts or not all(part.isdigit() for part in parts): + return None + return tuple(int(part) for part in parts) + + +def _version_satisfies(version: str, specifier: str) -> bool: + actual = _release_tuple(version) + if actual is None: + return False + for raw_term in specifier.split(","): + term = raw_term.strip() + operator = next( + ( + candidate + for candidate in (">=", "<=", "==", ">", "<") + if term.startswith(candidate) + ), + None, + ) + if operator is None: + return False + expected = _release_tuple(term[len(operator) :].strip()) + if expected is None: + return False + if operator == "==" and actual != expected: + return False + if operator == ">=" and actual < expected: + return False + if operator == "<=" and actual > expected: + return False + if operator == ">" and actual <= expected: + return False + if operator == "<" and actual >= expected: + return False + return True + + +def _dependency_spec(component: dict, package: str) -> str: + specs = component.get("requires", {}).get(package, []) + if len(specs) != 1: + raise DriftError( + f"FATAL: {component.get('package')}=={component.get('version')} must " + f"declare exactly one {package} compatibility range; got {specs!r}." + ) + return specs[0] + + +def _runtime_unit_requirements(components: dict[str, dict]) -> dict[str, dict[str, str]]: + """Build compatibility contracts from the selected published metadata.""" + + return { + "launcher_environment": { + "openadapt": f"=={components['launcher']['version']}", + "openadapt-flow": _dependency_spec( + components["launcher"], "openadapt-flow" + ), + }, + "customer_runner": { + "openadapt-flow": f"=={components['flow']['version']}", + "openadapt-types": _dependency_spec( + components["flow"], "openadapt-types" + ), + }, + "desktop_sidecar": { + package: _dependency_spec(components["desktop"], package) + for package in ( + "openadapt-flow", + "openadapt-capture", + "openadapt-privacy", + "openadapt-types", + ) + }, + "agent_bridge": { + "openadapt-agent": f"=={components['agent']['version']}", + "openadapt-flow": _dependency_spec( + components["agent"], "openadapt-flow" + ), + }, + } + + +def _desktop_sidecar_lock( + version: str, required_packages: set[str] +) -> tuple[str, str, str, dict[str, str]]: + """Read the immutable package closure used by the native sidecar build.""" + + source_ref = f"desktop-v{version}" + url = DESKTOP_NATIVE_LOCK_URL_TEMPLATE.format(version=version) + lock_text = _fetch_text(url) + try: + lock = tomllib.loads(lock_text) + except tomllib.TOMLDecodeError as exc: + raise DriftError(f"FATAL: {url} is not valid TOML: {exc}") from exc + + wanted = required_packages + resolved: dict[str, str] = {} + for package in lock.get("package", []): + name = package.get("name") + package_version = package.get("version") + if name not in wanted or not isinstance(package_version, str): + continue + if name in resolved and resolved[name] != package_version: + raise DriftError( + f"FATAL: {source_ref} resolves multiple {name} versions; " + "the sidecar closure is not exact." + ) + resolved[name] = package_version + missing = sorted(wanted - set(resolved)) + if missing: + raise DriftError( + f"FATAL: {source_ref} uv.lock omits required sidecar packages: " + + ", ".join(missing) + ) + lock_sha256 = hashlib.sha256(lock_text.encode("utf-8")).hexdigest() + return source_ref, url, lock_sha256, dict(sorted(resolved.items())) + + +def _runtime_units(components: dict[str, dict]) -> dict[str, dict]: + requirements = _runtime_unit_requirements(components) + sidecar_ref, sidecar_lock_url, sidecar_lock_sha256, sidecar_resolved = ( + _desktop_sidecar_lock( + components["desktop"]["version"], + set(requirements["desktop_sidecar"]), + ) + ) + return { + "launcher_environment": { + "kind": "launcher-environment", + "version": components["launcher"]["version"], + "source_component": "launcher", + "entry_point": "openadapt", + "requirements": requirements["launcher_environment"], + "selected": { + "openadapt": components["launcher"]["version"], + "openadapt-flow": components["flow"]["version"], + }, + }, + "customer_runner": { + "kind": "customer-controlled-runner", + "version": components["flow"]["version"], + "source_component": "flow", + "entry_point": "openadapt-flow", + "requirements": requirements["customer_runner"], + "selected": { + "openadapt-flow": components["flow"]["version"], + "openadapt-types": components["types"]["version"], + }, + }, + "desktop_sidecar": { + "kind": "frozen-desktop-sidecar", + "version": components["desktop"]["version"], + "source_component": "desktop", + "source_ref": sidecar_ref, + "lock_url": sidecar_lock_url, + "lock_sha256": sidecar_lock_sha256, + "binary": "openadapt-engine", + "requirements": requirements["desktop_sidecar"], + "resolved": sidecar_resolved, + }, + "agent_bridge": { + "kind": "governed-agent-bridge", + "version": components["agent"]["version"], + "source_component": "agent", + "entry_point": "openadapt-agent", + "requirements": requirements["agent_bridge"], + "selected": { + "openadapt-agent": components["agent"]["version"], + "openadapt-flow": components["flow"]["version"], + }, + }, + } + + +def _dependency_edges( + components: dict[str, dict], runtime_units: dict[str, dict] +) -> list[dict]: + """Return every selected-platform dependency edge and its exact target.""" + + package_roles = {package: role for role, package in COMPONENTS.items()} + sidecar = runtime_units["desktop_sidecar"]["resolved"] + grouped: dict[tuple[str, str, str, str], dict] = {} + for source_role, component in components.items(): + for constraint in component.get("dependency_constraints", []): + package = constraint["package"] + target_role = package_roles.get(package) + if target_role is None: + continue + scope = ( + "desktop-sidecar-lock" + if source_role == "desktop" + else "selected-platform" + ) + resolved = ( + sidecar.get(package) + if scope == "desktop-sidecar-lock" + else components[target_role]["version"] + ) + key = (scope, source_role, target_role, constraint["requires"]) + edge = grouped.setdefault( + key, + { + "scope": scope, + "source_role": source_role, + "source_package": component["package"], + "source_version": component["version"], + "target_role": target_role, + "target_package": package, + "requires": constraint["requires"], + "resolved": resolved, + "activations": [], + }, + ) + activation = { + "extras": constraint.get("extras"), + "marker": constraint.get("marker"), + } + if activation not in edge["activations"]: + edge["activations"].append(activation) + edges = list(grouped.values()) + for edge in edges: + edge["activations"].sort( + key=lambda row: (str(row["extras"]), str(row["marker"])) + ) + return sorted( + edges, + key=lambda edge: ( + edge["scope"], + edge["source_role"], + edge["target_role"], + edge["requires"], + ), + ) + + +def _compatibility_status(dependency_edges: list[dict]) -> dict: + failures = [] + for edge in dependency_edges: + version = edge.get("resolved") + specifier = edge.get("requires") + if not isinstance(version, str) or not _version_satisfies( + version, str(specifier) + ): + failures.append( + { + "scope": edge.get("scope"), + "source_role": edge.get("source_role"), + "target_role": edge.get("target_role"), + "package": edge.get("target_package"), + "requires": specifier, + "resolved": version, + } + ) + return { + "status": ( + "dependency-compatible" if not failures else "dependency-incompatible" + ), + "basis": "published-metadata-and-desktop-lock", + "failures": failures, + } + + def _substrates_from_status(status: dict) -> list[dict]: substrates = [] for entry in status.get("substrates", []): @@ -213,11 +861,121 @@ def _substrates_from_status(status: dict) -> list[dict]: return substrates -def generate(allow_unreleased_launcher: bool) -> dict: +def _supported_os_from_status(status: dict) -> list[str]: + status_names = { + str(entry.get("name", "")).casefold() for entry in status.get("substrates", []) + } + supported = [name for name in SUPPORTED_OS if name.casefold() in status_names] + if supported != SUPPORTED_OS: + missing = sorted(set(SUPPORTED_OS) - set(supported)) + raise DriftError( + f"FATAL: {STATUS_URL} omits required host operating systems: " + + ", ".join(missing) + ) + return supported + + +def _github_tree_entries(repository: str, tree: str) -> dict[str, dict]: + url = GITHUB_TREE_URL_TEMPLATE.format(repository=repository, tree=tree) + doc = _fetch_json(url) + if doc.get("truncated"): + raise DriftError( + f"FATAL: GitHub truncated {repository} tree {tree}; evidence paths " + "cannot be verified completely." + ) + rows = doc.get("tree") + if not isinstance(rows, list): + raise DriftError(f"FATAL: {repository} tree {tree} has no entries.") + return { + str(row["path"]): row + for row in rows + if isinstance(row, dict) and isinstance(row.get("path"), str) + } + + +def _qualification_evidence(components: dict[str, dict]) -> list[dict]: + evidence_rows = [] + trees: dict[tuple[str, str], dict[str, dict]] = {} + for evidence in QUALIFICATION_EVIDENCE: + row = dict(evidence) + role = row.get("source_component") + if isinstance(role, str): + provenance = components[role]["provenance"] + repository = row["repository"] + if repository != provenance["repository"]: + raise DriftError( + f"FATAL: evidence {row['id']} repository does not match " + f"selected {role} provenance." + ) + tree_key = (repository, provenance["tree"]) + if tree_key not in trees: + trees[tree_key] = _github_tree_entries(*tree_key) + source_object = trees[tree_key].get(row["path"]) + if source_object is None: + raise DriftError( + f"FATAL: evidence path {repository}@{provenance['commit']}:" + f"{row['path']} does not exist." + ) + object_type = source_object.get("type") + object_sha = source_object.get("sha") + if object_type not in {"blob", "tree"} or not isinstance( + object_sha, str + ): + raise DriftError( + f"FATAL: evidence path {row['path']} has no Git object binding." + ) + row["source_commit"] = provenance["commit"] + row["source_object_type"] = object_type + row["source_object_sha"] = object_sha + url_kind = "blob" if object_type == "blob" else "tree" + row["url"] = ( + f"https://github.com/{repository}/{url_kind}/" + f"{provenance['commit']}/{row['path']}" + ) + evidence_rows.append(row) + return evidence_rows + + +def _generation_metadata() -> dict: + files = [] + for relative_path in ( + "scripts/generate_platform_manifest.py", + "scripts/render_platform_versions.py", + ): + content = (ROOT / relative_path).read_bytes() + files.append( + { + "path": relative_path, + "sha256": hashlib.sha256(content).hexdigest(), + } + ) + return {"files": files} + + +def _parse_component_versions(values: list[str]) -> dict[str, str]: + selected: dict[str, str] = {} + for value in values: + role, separator, version = value.partition("=") + if not separator or role not in COMPONENTS or not version: + raise DriftError( + "FATAL: --component-version must use ROLE=VERSION, where ROLE is " + + ", ".join(sorted(COMPONENTS)) + ) + if role in selected: + raise DriftError(f"FATAL: duplicate component selection for {role}.") + selected[role] = version + return selected + + +def generate( + allow_unreleased_launcher: bool, + component_versions: dict[str, str] | None = None, +) -> dict: status = _fetch_json(STATUS_URL) + selected_versions = component_versions or {} components = { - role: _pypi_component(package) for role, package in COMPONENTS.items() + role: _pypi_component(role, selected_versions.get(role)) for role in COMPONENTS } # Drift guard: the in-repo launcher version must match the published one, @@ -246,18 +1004,31 @@ def generate(allow_unreleased_launcher: bool) -> dict: "the release channel." ) + runtime_units = _runtime_units(components) + dependency_edges = _dependency_edges(components, runtime_units) return { "manifest_kind": MANIFEST_KIND, "schema_version": SCHEMA_VERSION, "generated_at": _dt.datetime.now(_dt.timezone.utc) .replace(microsecond=0) .isoformat(), + "generation": _generation_metadata(), "release_channel": release_channel, + "release_selection": { + "mode": ( + "explicit-published" if selected_versions else "latest-published" + ), + "component_versions": dict(sorted(selected_versions.items())), + }, "components": components, + "runtime_units": runtime_units, + "dependency_edges": dependency_edges, + "schema_compatibility": _schema_compatibility(components), "compatibility": _compatibility_from_pyproject(), - "supported_os": SUPPORTED_OS, + "compatibility_status": _compatibility_status(dependency_edges), + "supported_os": _supported_os_from_status(status), "substrate_drivers": _substrates_from_status(status), - "qualification_evidence": QUALIFICATION_EVIDENCE, + "qualification_evidence": _qualification_evidence(components), "signature": UNSIGNED_SIGNATURE, } @@ -270,6 +1041,23 @@ def main() -> int: default=ROOT / "platform-manifest.json", help="Where to write the manifest (default: repo root).", ) + parser.add_argument( + "--report-output", + type=Path, + default=ROOT / "docs" / "platform-compatibility-report.md", + help="Where to write the generated human report.", + ) + parser.add_argument( + "--component-version", + action="append", + default=[], + metavar="ROLE=VERSION", + help=( + "Select an exact published component release instead of PyPI's " + "latest. Repeat for each release-train component. The generator " + "refuses a version until its immutable artifacts and release tag exist." + ), + ) parser.add_argument( "--allow-unreleased-launcher", action="store_true", @@ -281,13 +1069,16 @@ def main() -> int: args = parser.parse_args() try: - manifest = generate(args.allow_unreleased_launcher) + selected_versions = _parse_component_versions(args.component_version) + manifest = generate(args.allow_unreleased_launcher, selected_versions) except DriftError as exc: print(str(exc), file=sys.stderr) return 1 args.output.write_text(json.dumps(manifest, indent=2) + "\n") + args.report_output.write_text(render_markdown(manifest), encoding="utf-8") print(f"Wrote {args.output}") + print(f"Wrote {args.report_output}") for role, component in manifest["components"].items(): print(f" {role}: {component['package']}=={component['version']}") return 0 diff --git a/scripts/render_platform_versions.py b/scripts/render_platform_versions.py new file mode 100644 index 000000000..edee03b1c --- /dev/null +++ b/scripts/render_platform_versions.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +"""Render versions and compatibility from the exact platform BOM.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +COMPONENT_LABELS = { + "launcher": "Launcher", + "flow": "Flow", + "capture": "Capture", + "privacy": "Privacy", + "types": "Types", + "desktop": "Desktop", + "agent": "Agent", +} +RUNTIME_LABELS = { + "launcher_environment": "Launcher environment", + "customer_runner": "Runner", + "desktop_sidecar": "Desktop sidecar", + "agent_bridge": "Agent bridge", +} + + +def version_rows(manifest: dict) -> list[dict[str, str]]: + rows: list[dict[str, str]] = [] + components = manifest.get("components", {}) + for role, label in COMPONENT_LABELS.items(): + component = components.get(role) + if not isinstance(component, dict) or not isinstance( + component.get("version"), str + ): + raise ValueError(f"manifest component {role!r} has no version") + rows.append({"role": role, "label": label, "version": component["version"]}) + + runtime_units = manifest.get("runtime_units", {}) + for role, label in RUNTIME_LABELS.items(): + unit = runtime_units.get(role) + if not isinstance(unit, dict) or not isinstance(unit.get("version"), str): + raise ValueError(f"manifest runtime unit {role!r} has no version") + row = {"role": role, "label": label, "version": unit["version"]} + closure = unit.get("resolved") or unit.get("selected") + if isinstance(closure, dict): + row["resolved"] = ", ".join( + f"{package}=={version}" for package, version in sorted(closure.items()) + ) + rows.append(row) + return rows + + +def _artifact_lines(component: dict) -> str: + return "
".join( + f"`{artifact['filename']}` `{artifact['sha256']}`" + for artifact in component.get("artifacts", []) + ) + + +def render_markdown(manifest: dict) -> str: + """Render the complete human-readable report from the exact BOM.""" + + status = manifest.get("compatibility_status", {}) + selection = manifest.get("release_selection", {}) + lines = [ + "# OpenAdapt platform compatibility report", + "", + "", + "", + f"- Generated: `{manifest.get('generated_at')}`", + f"- Release channel: **{manifest.get('release_channel')}**", + f"- Selection: `{selection.get('mode')}`", + f"- Compatibility: **{status.get('status')}**", + f"- Signature: `{manifest.get('signature', {}).get('status')}`", + "", + "## Published components", + "", + "| Role | Package | Version | Release source reference | Published artifacts |", + "|---|---|---:|---|---|", + ] + for role, component in manifest.get("components", {}).items(): + provenance = component.get("provenance", {}) + source = ( + f"[`{provenance.get('release_ref')}`]({provenance.get('url')})
" + f"commit `{provenance.get('commit')}`
" + f"tree `{provenance.get('tree')}`" + ) + lines.append( + f"| {role} | `{component.get('package')}` | " + f"`{component.get('version')}` | {source} | " + f"{_artifact_lines(component)} |" + ) + + lines.extend( + [ + "", + "## Runtime selections", + "", + "| Unit | Version | Selected or locked OpenAdapt packages |", + "|---|---:|---|", + ] + ) + for role, unit in manifest.get("runtime_units", {}).items(): + closure = unit.get("resolved") or unit.get("selected") or {} + selected = "
".join( + f"`{package}=={version}`" + for package, version in sorted(closure.items()) + ) + lines.append(f"| {role} | `{unit.get('version')}` | {selected} |") + + lines.extend( + [ + "", + "## Selected-platform dependency compatibility", + "", + "These edges show whether each selected integration version satisfies " + "its published dependency constraint. Optional markers do not mean " + "that every package is installed in every runtime unit.", + "", + "| Scope | Source | Target | Constraint | Selected | Activation |", + "|---|---|---|---|---:|---|", + ] + ) + for edge in manifest.get("dependency_edges", []): + activation = "
".join( + f"`{value}`" + for value in sorted( + {row.get("marker") or "always" for row in edge.get("activations", [])} + ) + ) + lines.append( + f"| {edge.get('scope')} | `{edge.get('source_package')}==" + f"{edge.get('source_version')}` | `{edge.get('target_package')}` | " + f"`{edge.get('requires')}` | `{edge.get('resolved')}` | " + f"{activation} |" + ) + + if status.get("failures"): + lines.extend(["", "### Compatibility failures", ""]) + for failure in status["failures"]: + lines.append( + f"- `{failure.get('source_role')}` -> `{failure.get('package')}` " + f"requires `{failure.get('requires')}`; the resolved version is " + f"`{failure.get('resolved')}`." + ) + + lines.extend( + [ + "", + "## Cross-component schemas", + "", + "| Contract | Owner | Accepted versions | Exact source |", + "|---|---|---|---|", + ] + ) + for name, schema in manifest.get("schema_compatibility", {}).items(): + prefix = schema.get("schema_prefix") or schema.get("schema_field") + source = "
".join( + f"[`{item.get('path')}`]({item.get('url')}) " + f"`{item.get('sha256')}`" + for item in schema.get("source_files", []) + ) + lines.append( + f"| `{name}` | `{schema.get('source_component')}` | " + f"`{prefix}` {schema.get('accepted_versions')} | {source} |" + ) + + lines.extend( + [ + "", + "## Host operating systems", + "", + ", ".join(f"`{name}`" for name in manifest.get("supported_os", [])), + "", + "## Substrate delivery", + "", + "| Substrate | Public label | Delivery |", + "|---|---|---|", + ] + ) + for substrate in manifest.get("substrate_drivers", []): + lines.append( + f"| {substrate.get('name')} | {substrate.get('public_label')} | " + f"{substrate.get('delivery')} |" + ) + + lines.extend(["", "## Qualification evidence", ""]) + for evidence in manifest.get("qualification_evidence", []): + location = evidence.get("url") + if not location: + location = ( + f"https://github.com/{evidence.get('repository')}/tree/" + f"main/{evidence.get('path')}" + ) + lines.append( + f"- [{evidence.get('id')}]({location}): " + f"{evidence.get('description')}" + ) + lines.append("") + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--manifest", type=Path, default=ROOT / "platform-manifest.json" + ) + parser.add_argument( + "--format", choices=("text", "json", "markdown"), default="text" + ) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + try: + manifest = json.loads(args.manifest.read_text(encoding="utf-8")) + rows = version_rows(manifest) + except (OSError, ValueError, json.JSONDecodeError) as exc: + print(f"FATAL: cannot render platform versions: {exc}", file=sys.stderr) + return 1 + + if args.format == "json": + rendered = json.dumps( + { + "release_channel": manifest.get("release_channel"), + "compatibility_status": manifest.get( + "compatibility_status", {} + ).get("status"), + "supported_os": manifest.get("supported_os", []), + "substrates": manifest.get("substrate_drivers", []), + "versions": rows, + }, + indent=2, + ) + elif args.format == "markdown": + rendered = render_markdown(manifest) + else: + text_lines = [ + f"Release channel: {manifest.get('release_channel')}", + f"Compatibility: {manifest.get('compatibility_status', {}).get('status')}", + ] + for row in rows: + suffix = f" ({row['resolved']})" if row.get("resolved") else "" + text_lines.append(f"{row['label']}: {row['version']}{suffix}") + rendered = "\n".join(text_lines) + + if args.output: + args.output.write_text(rendered.rstrip() + "\n", encoding="utf-8") + print(f"Wrote {args.output}") + else: + print(rendered) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate_platform_manifest.py b/scripts/validate_platform_manifest.py index 4b4c19011..9d057fbbd 100644 --- a/scripts/validate_platform_manifest.py +++ b/scripts/validate_platform_manifest.py @@ -7,8 +7,9 @@ What it validates TODAY: -* Structure: manifest kind, schema major version, required fields, and at - least one artifact with a sha256 digest per component. +* Structure: manifest kind, schema major version, all seven public components, + four runtime units, exact schema-source bindings, every selected dependency + edge, and the complete artifact set for each published component. * Signature honesty: while the signature value is null the status must say "unsigned (signing infrastructure pending)". A non-null signature value FAILS validation, because no verification infrastructure exists yet and a @@ -19,8 +20,10 @@ manifest is the normal in-flight-release state and only warns; the PyPI comparison below is what authoritatively detects staleness. * Published-artifact agreement (network): for every component, the manifest - version equals the latest version on PyPI, and every artifact filename, - URL, and sha256 digest matches what PyPI reports. A manifest version that + version equals the latest version on PyPI unless an exact published version + was selected explicitly. Every artifact filename, + type, URL, sha256 digest, Python range, and openadapt-* dependency metadata + match the exact version-specific PyPI record. A manifest version that is BEHIND PyPI's latest is drift and fails; one that is AHEAD but already published is PyPI index propagation lag and warns (its digests are still verified); one PyPI never published fails. An unreachable PyPI warns rather @@ -33,6 +36,8 @@ class of check (for example in an airgapped environment). source of truth. It stays a warning so a release here cannot be blocked by an edit nobody in this repo can make. Pass --strict-status to escalate the skew to a failure. +* Exact-source agreement (network): release refs, schema source files, and + qualification evidence Git objects match the selected release commit/tree. Do NOT read that warning as self-healing. status.json is HAND-MAINTAINED in openadapt-web; nothing generates it from PyPI or from this manifest. On @@ -46,17 +51,23 @@ class of check (for example in an airgapped environment). * Cryptographic signature verification (sigstore for the manifest itself, Authenticode / Apple Developer ID for OS installers). -* Desktop OS installer artifacts (MSI/DMG); only PyPI artifacts exist today. +* Desktop OS installer signatures and notarization. The sidecar package + closure is checked against the exact native release tag, but installer bytes + are still covered by the Desktop release's own SHA256SUMS and attestation. Usage: python scripts/validate_platform_manifest.py [--manifest PATH] - [--offline] [--strict-status] + [--offline] [--strict-status] [--require-compatible] """ from __future__ import annotations import argparse +import datetime as _dt +import hashlib import json +import os +import re import sys import urllib.error import urllib.request @@ -67,6 +78,29 @@ class of check (for example in an airgapped environment). except ModuleNotFoundError: # pragma: no cover - Python < 3.11 import tomli as tomllib # type: ignore[no-redef] +from generate_platform_manifest import ( + COMPONENT_REPOSITORIES, + COMPONENTS, + DESKTOP_NATIVE_LOCK_URL_TEMPLATE, + GITHUB_COMMIT_URL_TEMPLATE, + PYPI_VERSION_URL_TEMPLATE, + QUALIFICATION_EVIDENCE, + RUNTIME_UNIT_NAMES, + DriftError, + _compatibility_status, + _dependency_edges, + _generation_metadata, + _github_tree_entries, + _openadapt_dependency_constraints, + _openadapt_requirements, + _release_ref, + _runtime_unit_requirements, + _schema_compatibility, + _substrates_from_status, + _supported_os_from_status, +) +from render_platform_versions import render_markdown + ROOT = Path(__file__).resolve().parents[1] EXPECTED_KIND = "openadapt-platform-release-manifest" @@ -75,14 +109,23 @@ class of check (for example in an airgapped environment). STATUS_URL = "https://openadapt.ai/status.json" PYPI_URL_TEMPLATE = "https://pypi.org/pypi/{package}/json" HTTP_TIMEOUT_SECONDS = 30 +REPORT_PATH = ROOT / "docs" / "platform-compatibility-report.md" +RELEASE_CHANNELS = {"stable", "beta", "preview", "experimental", "research"} +EXPECTED_SIGNATURE_PLAN = "docs/platform-manifest.md#signing-plan" REQUIRED_TOP_LEVEL_FIELDS = ( "manifest_kind", "schema_version", "generated_at", + "generation", "release_channel", + "release_selection", "components", + "runtime_units", + "dependency_edges", + "schema_compatibility", "compatibility", + "compatibility_status", "supported_os", "substrate_drivers", "qualification_evidence", @@ -91,13 +134,25 @@ class of check (for example in an airgapped environment). def _fetch_json(url: str) -> dict: + headers = {"User-Agent": "openadapt-platform-manifest-validator"} + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if token and url.startswith("https://api.github.com/"): + headers["Authorization"] = f"Bearer {token}" request = urllib.request.Request( - url, headers={"User-Agent": "openadapt-platform-manifest-validator"} + url, headers=headers ) with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_SECONDS) as resp: return json.load(resp) +def _fetch_text(url: str) -> str: + request = urllib.request.Request( + url, headers={"User-Agent": "openadapt-platform-manifest-validator"} + ) + with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_SECONDS) as resp: + return resp.read().decode("utf-8") + + class Report: def __init__(self) -> None: self.errors: list[str] = [] @@ -126,10 +181,76 @@ def check_structure(manifest: dict, report: Report) -> None: f"schema_version {schema_version!r} is not major version " f"{EXPECTED_SCHEMA_MAJOR}; this validator cannot interpret it" ) - for role, component in manifest.get("components", {}).items(): + generated_at = manifest.get("generated_at") + try: + generated = _dt.datetime.fromisoformat(str(generated_at)) + except ValueError: + generated = None + if generated is None or generated.tzinfo != _dt.timezone.utc: + report.error("generated_at must be an ISO 8601 UTC timestamp") + release_channel = manifest.get("release_channel") + if release_channel not in RELEASE_CHANNELS: + report.error( + f"release_channel must be one of {sorted(RELEASE_CHANNELS)!r}, " + f"got {release_channel!r}" + ) + if manifest.get("generation") != _generation_metadata(): + report.error( + "generation metadata does not match the exact generator and renderer" + ) + components = manifest.get("components", {}) + if not isinstance(components, dict): + report.error("components must be an object") + return + expected_components = set(COMPONENTS) + if set(components) != expected_components: + report.error( + "components must be exactly " + f"{sorted(expected_components)!r}, got {sorted(components)!r}" + ) + for role, component in components.items(): + for key in ( + "package", + "version", + "source", + "requires_python", + "requires", + "dependency_constraints", + "provenance", + ): + if key not in component: + report.error(f"component {role} missing {key}") + if component.get("package") != COMPONENTS.get(role): + report.error( + f"component {role} package is {component.get('package')!r}, " + f"expected {COMPONENTS.get(role)!r}" + ) + if component.get("source") != "pypi": + report.error(f"component {role} source must be 'pypi'") + provenance = component.get("provenance", {}) + expected_repository = COMPONENT_REPOSITORIES.get(role) + expected_ref = _release_ref(role, str(component.get("version"))) + if provenance.get("repository") != expected_repository: + report.error( + f"component {role} provenance repository is " + f"{provenance.get('repository')!r}, expected {expected_repository!r}" + ) + if provenance.get("release_ref") != expected_ref: + report.error( + f"component {role} release_ref is " + f"{provenance.get('release_ref')!r}, expected {expected_ref!r}" + ) + for digest in ("commit", "tree"): + if not re.fullmatch(r"[0-9a-f]{40}", str(provenance.get(digest, ""))): + report.error(f"component {role} provenance {digest} is invalid") + if not str(provenance.get("url", "")).startswith("https://github.com/"): + report.error(f"component {role} provenance URL must use GitHub HTTPS") artifacts = component.get("artifacts") or [] if not artifacts: report.error(f"component {role} lists no artifacts") + filenames = [artifact.get("filename") for artifact in artifacts] + if len(filenames) != len(set(filenames)): + report.error(f"component {role} has duplicate artifact filenames") for artifact in artifacts: for key in ("type", "filename", "url", "sha256"): if not artifact.get(key): @@ -137,19 +258,330 @@ def check_structure(manifest: dict, report: Report) -> None: f"component {role} artifact " f"{artifact.get('filename', '')} missing {key}" ) - if not manifest.get("qualification_evidence"): + if not re.fullmatch(r"[0-9a-f]{64}", str(artifact.get("sha256", ""))): + report.error(f"component {role} artifact has an invalid sha256") + if not str(artifact.get("url", "")).startswith("https://"): + report.error(f"component {role} artifact URL must use HTTPS") + constraints = component.get("dependency_constraints") + if not isinstance(constraints, list): + report.error(f"component {role} dependency_constraints must be a list") + for constraint in constraints or []: + if set(constraint) != {"package", "requires", "extras", "marker"}: + report.error( + f"component {role} dependency constraint has invalid fields" + ) + if not str(constraint.get("package", "")).startswith("openadapt"): + report.error( + f"component {role} dependency constraint has invalid package" + ) + if not constraint.get("requires"): + report.error( + f"component {role} dependency constraint has no version range" + ) + evidence_rows = manifest.get("qualification_evidence") + if not evidence_rows: report.error("qualification_evidence is empty") + expected_evidence = {row["id"]: row for row in QUALIFICATION_EVIDENCE} + actual_evidence = { + row.get("id"): row for row in evidence_rows or [] if isinstance(row, dict) + } + if ( + set(actual_evidence) != set(expected_evidence) + or len(evidence_rows or []) != len(expected_evidence) + ): + report.error( + "qualification_evidence IDs must be exactly " + f"{sorted(expected_evidence)!r}" + ) + for evidence in evidence_rows or []: + if not isinstance(evidence, dict): + report.error("qualification_evidence rows must be objects") + continue + expected = expected_evidence.get(evidence.get("id")) + if expected is not None: + for field in ("kind", "source_component", "repository", "path"): + if expected.get(field) != evidence.get(field): + report.error( + f"qualification evidence {evidence.get('id')!r} " + f"{field} drift" + ) + if expected.get("source_component") is None and evidence.get( + "url" + ) != expected.get("url"): + report.error( + f"qualification evidence {evidence.get('id')!r} URL drift" + ) + role = evidence.get("source_component") + if role is None: + continue + component = components.get(role, {}) + source_commit = component.get("provenance", {}).get("commit") + if evidence.get("source_commit") != source_commit: + report.error( + f"qualification evidence {evidence.get('id')!r} is not bound " + f"to the selected {role} source commit" + ) + if source_commit not in str(evidence.get("url", "")): + report.error( + f"qualification evidence {evidence.get('id')!r} URL is not " + "commit-pinned" + ) + object_type = evidence.get("source_object_type") + if object_type not in {"blob", "tree"}: + report.error( + f"qualification evidence {evidence.get('id')!r} has invalid " + "source_object_type" + ) + if not re.fullmatch( + r"[0-9a-f]{40}", str(evidence.get("source_object_sha", "")) + ): + report.error( + f"qualification evidence {evidence.get('id')!r} has invalid " + "source_object_sha" + ) + url_kind = "blob" if object_type == "blob" else "tree" + expected_url = ( + f"https://github.com/{evidence.get('repository')}/{url_kind}/" + f"{source_commit}/{evidence.get('path')}" + ) + if evidence.get("url") != expected_url: + report.error( + f"qualification evidence {evidence.get('id')!r} URL is not " + "canonical for its object type" + ) + + selection = manifest.get("release_selection", {}) + if not isinstance(selection, dict): + report.error("release_selection must be an object") + selection = {} + if set(selection) != {"mode", "component_versions"}: + report.error("release_selection must contain only mode and component_versions") + if selection.get("mode") not in {"latest-published", "explicit-published"}: + report.error("release_selection.mode is invalid") + selected_versions = selection.get("component_versions") + if not isinstance(selected_versions, dict): + report.error("release_selection.component_versions must be an object") + else: + for role, version in selected_versions.items(): + if role not in COMPONENTS: + report.error(f"release_selection has unknown component {role!r}") + elif components.get(role, {}).get("version") != version: + report.error( + f"release_selection requests {role}=={version}, but the " + f"component records {components.get(role, {}).get('version')!r}" + ) + if selection.get("mode") == "latest-published" and selected_versions: + report.error("latest-published selection cannot contain version overrides") + if selection.get("mode") == "explicit-published" and not selected_versions: + report.error("explicit-published selection needs a version override") + + runtime_units = manifest.get("runtime_units", {}) + if not isinstance(runtime_units, dict): + report.error("runtime_units must be an object") + return + expected_units = RUNTIME_UNIT_NAMES + if set(runtime_units) != expected_units: + report.error( + f"runtime_units must be exactly {sorted(expected_units)!r}, " + f"got {sorted(runtime_units)!r}" + ) + try: + expected_requirements = _runtime_unit_requirements(components) + except DriftError as exc: + report.error(f"cannot derive runtime requirements: {exc}") + expected_requirements = {} + for name, unit in runtime_units.items(): + closure_key = "resolved" if name == "desktop_sidecar" else "selected" + for key in ( + "kind", + "version", + "source_component", + "requirements", + closure_key, + ): + if key not in unit: + report.error(f"runtime unit {name} missing {key}") + if unit.get("requirements") != expected_requirements.get(name): + report.error( + f"runtime unit {name} requirements drift: {unit.get('requirements')!r}" + ) + source_role = unit.get("source_component") + source = components.get(source_role, {}) + if unit.get("version") != source.get("version"): + report.error( + f"runtime unit {name} version {unit.get('version')!r} does not " + f"match source component {source_role!r} version " + f"{source.get('version')!r}" + ) + + runner = runtime_units.get("customer_runner", {}) + expected_runner_selected = { + "openadapt-flow": components.get("flow", {}).get("version"), + "openadapt-types": components.get("types", {}).get("version"), + } + if runner.get("selected") != expected_runner_selected: + report.error( + f"customer_runner selection is {runner.get('selected')!r}, expected " + f"{expected_runner_selected!r}" + ) + + launcher = runtime_units.get("launcher_environment", {}) + expected_launcher_selected = { + "openadapt": components.get("launcher", {}).get("version"), + "openadapt-flow": components.get("flow", {}).get("version"), + } + if launcher.get("selected") != expected_launcher_selected: + report.error( + f"launcher_environment selection is {launcher.get('selected')!r}, " + f"expected {expected_launcher_selected!r}" + ) + + agent = runtime_units.get("agent_bridge", {}) + expected_agent_selected = { + "openadapt-agent": components.get("agent", {}).get("version"), + "openadapt-flow": components.get("flow", {}).get("version"), + } + if agent.get("selected") != expected_agent_selected: + report.error( + f"agent_bridge selection is {agent.get('selected')!r}, expected " + f"{expected_agent_selected!r}" + ) + + sidecar = runtime_units.get("desktop_sidecar", {}) + expected_sidecar_ref = f"desktop-v{sidecar.get('version')}" + if sidecar.get("source_ref") != expected_sidecar_ref: + report.error( + f"desktop_sidecar source_ref is {sidecar.get('source_ref')!r}, " + f"expected {expected_sidecar_ref!r}" + ) + expected_lock_url = DESKTOP_NATIVE_LOCK_URL_TEMPLATE.format( + version=sidecar.get("version") + ) + if sidecar.get("lock_url") != expected_lock_url: + report.error( + f"desktop_sidecar lock_url is {sidecar.get('lock_url')!r}, " + f"expected {expected_lock_url!r}" + ) + if not re.fullmatch(r"[0-9a-f]{64}", str(sidecar.get("lock_sha256", ""))): + report.error("desktop_sidecar lock_sha256 is invalid") + + dependency_edges = manifest.get("dependency_edges") + if not isinstance(dependency_edges, list): + report.error("dependency_edges must be a list") + else: + try: + expected_edges = _dependency_edges(components, runtime_units) + except (DriftError, KeyError, TypeError) as exc: + report.error(f"cannot derive dependency edges: {exc}") + else: + if dependency_edges != expected_edges: + report.error( + "dependency_edges drift: the selected-platform graph does " + "not match the published component metadata and Desktop lock" + ) + + schemas = manifest.get("schema_compatibility") + if not isinstance(schemas, dict) or not schemas: + report.error("schema_compatibility must be a non-empty object") + for name, schema in (schemas or {}).items(): + role = schema.get("source_component") + accepted = schema.get("accepted_versions") + if role not in components: + report.error(f"schema {name} has an unknown source_component") + continue + if ( + not isinstance(accepted, list) + or not accepted + or accepted != sorted(set(accepted)) + or not all(isinstance(version, int) and version > 0 for version in accepted) + ): + report.error(f"schema {name} accepted_versions is invalid") + elif schema.get("minimum") != accepted[0] or schema.get("maximum") != accepted[-1]: + report.error(f"schema {name} range does not match accepted_versions") + source_commit = components[role].get("provenance", {}).get("commit") + if schema.get("source_commit") != source_commit: + report.error(f"schema {name} is not bound to its selected release commit") + source_files = schema.get("source_files") + if not isinstance(source_files, list) or not source_files: + report.error(f"schema {name} has no source_files") + for source_file in source_files or []: + path = source_file.get("path") + if ( + not isinstance(path, str) + or path.startswith("/") + or ".." in Path(path).parts + ): + report.error(f"schema {name} has an invalid source path") + if not re.fullmatch(r"[0-9a-f]{40}", str(source_file.get("git_blob", ""))): + report.error(f"schema {name} source has an invalid git_blob") + if not re.fullmatch(r"[0-9a-f]{64}", str(source_file.get("sha256", ""))): + report.error(f"schema {name} source has an invalid sha256") + + if manifest.get("supported_os") != ["windows", "macos", "linux"]: + report.error("supported_os must be exactly windows, macos, and linux") + substrates = manifest.get("substrate_drivers") + if not isinstance(substrates, list) or not substrates: + report.error("substrate_drivers must be a non-empty list") + elif any( + set(row) != {"name", "public_label", "delivery"} for row in substrates + ): + report.error("substrate_drivers has an invalid row") + + +def check_compatibility_status(manifest: dict, report: Report) -> None: + dependency_edges = manifest.get("dependency_edges") + if not isinstance(dependency_edges, list) or not all( + isinstance(edge, dict) for edge in dependency_edges + ): + report.error("cannot compute compatibility_status from invalid edges") + return + expected = _compatibility_status(dependency_edges) + actual = manifest.get("compatibility_status") + if actual != expected: + report.error( + f"compatibility_status is {actual!r}, but the selected dependency " + f"graph implies {expected!r}" + ) + + +def check_generated_report( + manifest: dict, report: Report, report_path: Path = REPORT_PATH +) -> None: + try: + actual = report_path.read_text(encoding="utf-8") + except OSError as exc: + report.error(f"cannot read generated compatibility report: {exc}") + return + expected = render_markdown(manifest) + if actual != expected: + report.error( + f"{report_path} does not match the exact BOM; " + "rerun scripts/generate_platform_manifest.py" + ) def check_signature_honesty(manifest: dict, report: Report) -> None: signature = manifest.get("signature") or {} + expected_unsigned = { + "algorithm": None, + "value": None, + "status": EXPECTED_UNSIGNED_STATUS, + "plan": EXPECTED_SIGNATURE_PLAN, + } if signature.get("value") is None: - if signature.get("status") != EXPECTED_UNSIGNED_STATUS: + if signature != expected_unsigned: report.error( - "signature.value is null but signature.status is " - f"{signature.get('status')!r}; while unsigned it must be " - f"{EXPECTED_UNSIGNED_STATUS!r}" + "unsigned signature block must match the exact null-signature " + "contract and signing-plan path" ) + plan_path = ROOT / EXPECTED_SIGNATURE_PLAN.split("#", 1)[0] + try: + plan_text = plan_path.read_text(encoding="utf-8") + except OSError as exc: + report.error(f"cannot read signature plan: {exc}") + else: + if "## Signing plan" not in plan_text: + report.error("signature plan anchor does not exist") else: report.error( "signature.value is set, but no signature verification " @@ -287,7 +719,13 @@ def _files_for_version(doc: dict, version: str) -> list[dict] | None: def compare_component_to_pypi( - role: str, component: dict, doc: dict, report: Report + role: str, + component: dict, + doc: dict, + report: Report, + *, + allow_pinned: bool = False, + latest_version_override: str | None = None, ) -> None: """Compare one manifest component against PyPI's published state. @@ -309,7 +747,7 @@ def compare_component_to_pypi( """ package = component.get("package") manifest_version = component.get("version") - latest_version = doc.get("info", {}).get("version") + latest_version = latest_version_override or doc.get("info", {}).get("version") files = _files_for_version(doc, manifest_version) if not files: @@ -337,6 +775,12 @@ def compare_component_to_pypi( "propagation lag, not drift; digests are verified against " f"{manifest_version!r}." ) + elif allow_pinned: + report.warning( + f"{role}: the explicit platform selection pins " + f"{package}=={manifest_version}; PyPI's latest is " + f"{latest_version}. The exact selected artifacts remain verified." + ) else: report.error( f"{role} version drift: manifest has {manifest_version!r} " @@ -347,8 +791,21 @@ def compare_component_to_pypi( return published = { - entry["filename"]: (entry["url"], entry["digests"]["sha256"]) for entry in files + entry["filename"]: ( + entry["packagetype"], + entry["url"], + entry["digests"]["sha256"], + ) + for entry in files + } + declared = { + artifact.get("filename") for artifact in component.get("artifacts", []) } + if declared != set(published): + report.error( + f"{role} artifact filename-set drift: manifest has " + f"{sorted(declared, key=str)!r} but PyPI has {sorted(published)!r}" + ) for artifact in component.get("artifacts", []): filename = artifact.get("filename") if filename not in published: @@ -357,7 +814,12 @@ def compare_component_to_pypi( f"for {package}=={manifest_version}" ) continue - url, sha256 = published[filename] + artifact_type, url, sha256 = published[filename] + if artifact.get("type") != artifact_type: + report.error( + f"{role} artifact {filename} type drift: manifest has " + f"{artifact.get('type')} but PyPI has {artifact_type}" + ) if artifact.get("url") != url: report.error( f"{role} artifact {filename} URL drift: manifest has " @@ -371,12 +833,47 @@ def compare_component_to_pypi( "would fail." ) + expected_requirements = _openadapt_requirements( + doc.get("info", {}).get("requires_dist") + ) + if component.get("requires") != expected_requirements: + report.error( + f"{role} dependency-range drift: manifest has " + f"{component.get('requires')!r} but PyPI metadata has " + f"{expected_requirements!r}" + ) + expected_constraints = _openadapt_dependency_constraints( + doc.get("info", {}).get("requires_dist") + ) + if component.get("dependency_constraints") != expected_constraints: + report.error( + f"{role} dependency-constraint drift: manifest has " + f"{component.get('dependency_constraints')!r} but PyPI metadata has " + f"{expected_constraints!r}" + ) + expected_python = doc.get("info", {}).get("requires_python") + if component.get("requires_python") != expected_python: + report.error( + f"{role} requires_python drift: manifest has " + f"{component.get('requires_python')!r} but PyPI metadata has " + f"{expected_python!r}" + ) + def check_against_pypi(manifest: dict, report: Report, require_network: bool) -> None: + selected = manifest.get("release_selection", {}).get("component_versions", {}) for role, component in manifest.get("components", {}).items(): package = component.get("package") try: - doc = _fetch_json(PYPI_URL_TEMPLATE.format(package=package)) + latest_doc = _fetch_json(PYPI_URL_TEMPLATE.format(package=package)) + if role in selected: + doc = _fetch_json( + PYPI_VERSION_URL_TEMPLATE.format( + package=package, version=component.get("version") + ) + ) + else: + doc = latest_doc except (urllib.error.URLError, TimeoutError) as exc: # An unreachable index is not evidence of drift. Failing here # makes the guard flaky, and a flaky guard gets ignored -- which @@ -386,14 +883,103 @@ def check_against_pypi(manifest: dict, report: Report, require_network: bool) -> emit = report.error if require_network else report.warning emit(f"could not fetch PyPI metadata for {package}: {exc}") continue - compare_component_to_pypi(role, component, doc, report) + compare_component_to_pypi( + role, + component, + doc, + report, + allow_pinned=role in selected, + latest_version_override=latest_doc.get("info", {}).get("version"), + ) + + +def check_source_provenance( + manifest: dict, report: Report, require_network: bool +) -> None: + for role, component in manifest.get("components", {}).items(): + provenance = component.get("provenance", {}) + url = GITHUB_COMMIT_URL_TEMPLATE.format( + repository=COMPONENT_REPOSITORIES[role], + release_ref=_release_ref(role, str(component.get("version"))), + ) + try: + doc = _fetch_json(url) + except (urllib.error.URLError, TimeoutError) as exc: + emit = report.error if require_network else report.warning + emit(f"could not validate source provenance for {role}: {exc}") + continue + commit = doc.get("sha") + tree = doc.get("commit", {}).get("tree", {}).get("sha") + if provenance.get("commit") != commit: + report.error( + f"{role} source commit drift: manifest has " + f"{provenance.get('commit')!r}, release ref resolves to {commit!r}" + ) + if provenance.get("tree") != tree: + report.error( + f"{role} source tree drift: manifest has {provenance.get('tree')!r}, " + f"release ref resolves to {tree!r}" + ) + + +def check_schema_sources( + manifest: dict, report: Report, require_network: bool +) -> None: + try: + expected = _schema_compatibility(manifest.get("components", {})) + except (DriftError, KeyError, TypeError) as exc: + emit = report.error if require_network else report.warning + emit(f"could not validate exact schema sources: {exc}") + return + if manifest.get("schema_compatibility") != expected: + report.error( + "schema_compatibility drift: it does not match the schemas derived " + "from the exact selected release commits" + ) + + +def check_qualification_evidence( + manifest: dict, report: Report, require_network: bool +) -> None: + components = manifest.get("components", {}) + trees: dict[tuple[str, str], dict[str, dict]] = {} + for evidence in manifest.get("qualification_evidence", []): + role = evidence.get("source_component") + if role is None: + continue + component = components.get(role, {}) + provenance = component.get("provenance", {}) + key = (str(provenance.get("repository")), str(provenance.get("tree"))) + try: + if key not in trees: + trees[key] = _github_tree_entries(*key) + except DriftError as exc: + emit = report.error if require_network else report.warning + emit(f"could not validate qualification evidence: {exc}") + continue + source_object = trees[key].get(evidence.get("path")) + if source_object is None: + report.error( + f"qualification evidence {evidence.get('id')!r} path is absent " + "from the exact selected release tree" + ) + continue + if evidence.get("source_object_type") != source_object.get("type"): + report.error( + f"qualification evidence {evidence.get('id')!r} object type drift" + ) + if evidence.get("source_object_sha") != source_object.get("sha"): + report.error( + f"qualification evidence {evidence.get('id')!r} object SHA drift" + ) def check_against_status(manifest: dict, report: Report, strict: bool) -> None: try: status = _fetch_json(STATUS_URL) except (urllib.error.URLError, TimeoutError) as exc: - report.warning(f"could not fetch {STATUS_URL}: {exc}") + emit = report.error if strict else report.warning + emit(f"could not fetch {STATUS_URL}: {exc}") return status_versions = status.get("versions", {}) emit = report.error if strict else report.warning @@ -417,6 +1003,78 @@ def check_against_status(manifest: dict, report: Report, strict: bool) -> None: f"{manifest.get('release_channel')!r}, status.json lifecycle is " f"{lifecycle!r}" ) + try: + expected_os = _supported_os_from_status(status) + expected_substrates = _substrates_from_status(status) + except DriftError as exc: + report.error(str(exc)) + return + if manifest.get("supported_os") != expected_os: + report.error( + f"supported_os drift: manifest has {manifest.get('supported_os')!r}, " + f"status.json implies {expected_os!r}" + ) + if manifest.get("substrate_drivers") != expected_substrates: + report.error( + "substrate_drivers drift: manifest does not match the complete " + "status.json substrate projection" + ) + + +def _locked_openadapt_versions(lock_text: str, packages: set[str]) -> dict[str, str]: + lock = tomllib.loads(lock_text) + resolved: dict[str, str] = {} + for package in lock.get("package", []): + name = package.get("name") + version = package.get("version") + if name not in packages or not isinstance(version, str): + continue + if name in resolved and resolved[name] != version: + raise ValueError(f"native lock resolves multiple {name} versions") + resolved[name] = version + return dict(sorted(resolved.items())) + + +def check_desktop_sidecar_lock( + manifest: dict, report: Report, require_network: bool +) -> None: + unit = manifest.get("runtime_units", {}).get("desktop_sidecar", {}) + version = unit.get("version") + source_ref = unit.get("source_ref") + expected_ref = f"desktop-v{version}" + if source_ref != expected_ref: + report.error( + f"desktop_sidecar source_ref is {source_ref!r}, expected {expected_ref!r}" + ) + return + url = DESKTOP_NATIVE_LOCK_URL_TEMPLATE.format(version=version) + try: + lock_text = _fetch_text(url) + resolved = _locked_openadapt_versions( + lock_text, set(unit.get("requirements", {})) + ) + except ( + urllib.error.URLError, + UnicodeDecodeError, + TimeoutError, + tomllib.TOMLDecodeError, + ValueError, + ) as exc: + emit = report.error if require_network else report.warning + emit(f"could not validate the Desktop sidecar lock at {source_ref}: {exc}") + return + actual_lock_sha256 = hashlib.sha256(lock_text.encode("utf-8")).hexdigest() + if actual_lock_sha256 != unit.get("lock_sha256"): + report.error( + f"desktop_sidecar lock sha256 drift: manifest has " + f"{unit.get('lock_sha256')!r} but {source_ref} has " + f"{actual_lock_sha256!r}" + ) + if resolved != unit.get("resolved"): + report.error( + f"desktop_sidecar closure drift: manifest has {unit.get('resolved')!r} " + f"but {source_ref} uv.lock has {resolved!r}" + ) def main() -> int: @@ -424,6 +1082,21 @@ def main() -> int: parser.add_argument( "--manifest", type=Path, default=ROOT / "platform-manifest.json" ) + parser.add_argument( + "--report", + type=Path, + default=REPORT_PATH, + help="Generated report paired with --manifest.", + ) + parser.add_argument( + "--require-compatible", + action="store_true", + help=( + "Fail unless the exact runner and sidecar closures satisfy the " + "selected platform requirements. Use this at platform " + "promotion, signing, and installer release gates." + ), + ) parser.add_argument( "--offline", action="store_true", @@ -458,9 +1131,32 @@ def main() -> int: check_structure(manifest, report) check_signature_honesty(manifest, report) check_against_pyproject(manifest, report) + check_compatibility_status(manifest, report) + check_generated_report(manifest, report, args.report) + if ( + args.require_compatible + and manifest.get("compatibility_status", {}).get("status") + != "dependency-compatible" + ): + report.error( + "the exact platform BOM is incompatible; resolve every " + "compatibility_status failure before platform promotion" + ) if not args.offline: check_against_pypi(manifest, report, require_network=args.require_network) + check_source_provenance( + manifest, report, require_network=args.require_network + ) + check_schema_sources( + manifest, report, require_network=args.require_network + ) + check_qualification_evidence( + manifest, report, require_network=args.require_network + ) check_against_status(manifest, report, strict=args.strict_status) + check_desktop_sidecar_lock( + manifest, report, require_network=args.require_network + ) for warning in report.warnings: print(f"WARNING: {warning}") diff --git a/tests/test_platform_manifest_drift.py b/tests/test_platform_manifest_drift.py index 35de2daff..066f2d135 100644 --- a/tests/test_platform_manifest_drift.py +++ b/tests/test_platform_manifest_drift.py @@ -30,8 +30,10 @@ from __future__ import annotations +import hashlib import json import sys +import urllib.error from copy import deepcopy from pathlib import Path @@ -40,8 +42,23 @@ REPO_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(REPO_ROOT / "scripts")) +from generate_platform_manifest import ( # noqa: E402 + DriftError, + _compatibility_status, + _dependency_edges, + _parse_component_versions, + _runtime_unit_requirements, + _version_satisfies, +) from validate_platform_manifest import ( # noqa: E402 Report, + _locked_openadapt_versions, + check_against_pypi, + check_against_status, + check_compatibility_status, + check_desktop_sidecar_lock, + check_signature_honesty, + check_structure, compare_component_to_pypi, compare_launcher_to_pyproject, ) @@ -74,8 +91,19 @@ def pypi_doc(version: str, component: dict, latest: str | None = None) -> dict: } for artifact in component["artifacts"] ] + requires_dist = [] + for constraint in component.get("dependency_constraints", []): + extras = f"[{constraint['extras']}]" if constraint.get("extras") else "" + marker = f"; {constraint['marker']}" if constraint.get("marker") else "" + requires_dist.append( + f"{constraint['package']}{extras}{constraint['requires']}{marker}" + ) return { - "info": {"version": latest or version}, + "info": { + "version": latest or version, + "requires_dist": requires_dist, + "requires_python": component.get("requires_python"), + }, "urls": files, "releases": {version: files}, } @@ -136,6 +164,48 @@ def test_guard_fails_when_a_newer_release_supersedes_the_manifest( assert any(future_version in error for error in errors) +def test_explicit_published_selection_can_pin_an_older_exact_release( + flow_component: dict, +) -> None: + future_version = "99.0.0" + doc = pypi_doc(flow_component["version"], flow_component, latest=future_version) + report = Report() + + compare_component_to_pypi( + "flow", flow_component, doc, report, allow_pinned=True + ) + + assert report.errors == [] + assert any("explicit platform selection pins" in item for item in report.warnings) + + +def test_explicit_pin_uses_its_version_specific_metadata( + flow_component: dict, monkeypatch: pytest.MonkeyPatch +) -> None: + selected_doc = pypi_doc(flow_component["version"], flow_component) + latest_doc = pypi_doc(flow_component["version"], flow_component, latest="99.0.0") + latest_doc["info"]["requires_dist"] = ["openadapt-types>=99"] + + monkeypatch.setattr( + "validate_platform_manifest._fetch_json", + lambda url: selected_doc if f"/{flow_component['version']}/" in url else latest_doc, + ) + report = Report() + check_against_pypi( + { + "release_selection": { + "component_versions": {"flow": flow_component["version"]} + }, + "components": {"flow": flow_component}, + }, + report, + require_network=True, + ) + + assert report.errors == [] + assert report.warnings + + def test_guard_fails_on_a_tampered_digest(flow_component: dict) -> None: """A correct version with a wrong digest must still fail. @@ -159,6 +229,37 @@ def test_guard_fails_on_a_tampered_url(flow_component: dict) -> None: assert any("URL drift" in error for error in errors) +@pytest.mark.parametrize( + ("mutation", "message"), + [ + (lambda component: component["artifacts"].pop(), "filename-set drift"), + ( + lambda component: component["artifacts"][0].update(type="invented"), + "type drift", + ), + ( + lambda component: component.update(requires_python=">=99"), + "requires_python drift", + ), + ], +) +def test_guard_checks_complete_artifact_metadata( + flow_component: dict, mutation, message: str +) -> None: + doc = pypi_doc(flow_component["version"], flow_component) + mutation(flow_component) + assert any(message in error for error in errors_for(flow_component, doc)) + + +def test_guard_fails_on_a_tampered_dependency_range(flow_component: dict) -> None: + doc = pypi_doc(flow_component["version"], flow_component) + tampered = deepcopy(flow_component) + tampered["requires"]["openadapt-types"] = [">=0.1.0"] + + errors = errors_for(tampered, doc) + assert any("dependency-range drift" in error for error in errors) + + def test_guard_fails_on_a_version_pypi_never_published( flow_component: dict, ) -> None: @@ -240,15 +341,192 @@ def test_matching_launcher_versions_are_silent() -> None: def test_every_manifest_component_is_covered(manifest: dict) -> None: - """All four published components are guarded, not just flow.""" + """All seven published components are guarded, not just flow.""" assert set(manifest["components"]) == { "launcher", "flow", "capture", + "privacy", + "types", "desktop", + "agent", } for role, component in manifest["components"].items(): doc = pypi_doc(component["version"], component) report = Report() compare_component_to_pypi(role, component, doc, report) assert report.errors == [], f"{role} failed self-consistency" + + +def test_runtime_compatibility_status_is_recomputed(manifest: dict) -> None: + truthful = Report() + check_compatibility_status(manifest, truthful) + assert truthful.errors == [] + + tampered = deepcopy(manifest) + tampered["compatibility_status"] = { + "status": "dependency-incompatible", + "basis": "published-metadata-and-desktop-lock", + "failures": [{"source_role": "invented"}], + } + report = Report() + check_compatibility_status(tampered, report) + assert any("compatibility_status" in error for error in report.errors) + + +def test_runtime_requirements_come_from_selected_release_metadata(manifest: dict) -> None: + expected = _runtime_unit_requirements(manifest["components"]) + actual = { + name: unit["requirements"] + for name, unit in manifest["runtime_units"].items() + } + assert actual == expected + + assert len(manifest["dependency_edges"]) == 11 + components = deepcopy(manifest["components"]) + components["capture"]["version"] = "0.0.1" + edges = _dependency_edges(components, manifest["runtime_units"]) + status = _compatibility_status(edges) + assert status["status"] == "dependency-incompatible" + assert any( + failure["source_role"] == "launcher" + and failure["package"] == "openadapt-capture" + for failure in status["failures"] + ) + + +def test_version_range_comparison_is_closed() -> None: + assert _version_satisfies("1.2.0", ">=1.2.0,<2.0.0") + assert _version_satisfies("1.9.9", ">=1.2.0,<2.0.0") + assert not _version_satisfies("1.1.99", ">=1.2.0,<2.0.0") + assert not _version_satisfies("2.0.0", ">=1.2.0,<2.0.0") + + +def test_release_train_uses_explicit_published_version_inputs() -> None: + assert _parse_component_versions( + ["flow=9.8.7", "desktop=6.5.4"] + ) == {"flow": "9.8.7", "desktop": "6.5.4"} + with pytest.raises(DriftError, match="ROLE=VERSION"): + _parse_component_versions(["unknown=1.0.0"]) + + +def test_schema_ranges_are_closed_and_exact(manifest: dict) -> None: + schemas = manifest["schema_compatibility"] + assert schemas["workflow_bundle"]["accepted_versions"] == [1, 2] + assert schemas["capture_structural_observation"]["schema_prefix"] == ( + "openadapt.capture.structural-observation/v" + ) + assert schemas["human_decision_task"]["minimum"] == 1 + assert schemas["human_decision_task"]["maximum"] == 1 + assert schemas["human_decision_receipt"]["maximum"] == 1 + assert schemas["control_overlay_frame"]["accepted_versions"] == [1, 2] + assert "human_decision_relay" not in schemas + assert schemas["remote_decision_projection"]["accepted_versions"] == [1] + assert schemas["runtime_validation"]["minimum"] == 1 + assert schemas["runtime_validation"]["maximum"] == 2 + + report = Report() + check_structure(manifest, report) + assert report.errors == [] + + tampered = deepcopy(manifest) + tampered["schema_compatibility"]["human_decision_receipt"]["maximum"] = 2 + report = Report() + check_structure(tampered, report) + assert any("range does not match" in error for error in report.errors) + + +def test_status_contract_rejects_fabricated_substrates_and_strict_unreachable( + manifest: dict, monkeypatch: pytest.MonkeyPatch +) -> None: + status = { + "product_lifecycle": manifest["release_channel"], + "versions": { + role: component["version"] + for role, component in manifest["components"].items() + }, + "substrates": deepcopy(manifest["substrate_drivers"]), + } + tampered = deepcopy(manifest) + tampered["substrate_drivers"][0]["public_label"] = "Invented" + monkeypatch.setattr("validate_platform_manifest._fetch_json", lambda _url: status) + report = Report() + check_against_status(tampered, report, strict=False) + assert any("substrate_drivers drift" in error for error in report.errors) + + monkeypatch.setattr( + "validate_platform_manifest._fetch_json", + lambda _url: (_ for _ in ()).throw(urllib.error.URLError("offline")), + ) + report = Report() + check_against_status(manifest, report, strict=True) + assert report.errors + + +@pytest.mark.parametrize( + "mutate", + [ + lambda value: value["qualification_evidence"][1].update( + source_object_sha="0" * 39 + ), + lambda value: value["qualification_evidence"].pop(), + lambda value: value["schema_compatibility"]["runtime_validation"].update( + source_commit="0" * 40 + ), + lambda value: value["signature"].update(algorithm="invented"), + lambda value: value.update(generated_at="not-a-time"), + lambda value: value.update(release_channel="invented"), + ], +) +def test_structural_provenance_claims_fail_closed(manifest: dict, mutate) -> None: + tampered = deepcopy(manifest) + mutate(tampered) + report = Report() + check_structure(tampered, report) + check_signature_honesty(tampered, report) + assert report.errors + + +def test_native_lock_parser_returns_only_exact_platform_packages() -> None: + lock = """ +version = 1 + +[[package]] +name = "openadapt-flow" +version = "1.2.3" + +[[package]] +name = "openadapt-types" +version = "0.4.5" + +[[package]] +name = "unrelated" +version = "9.9.9" +""" + assert _locked_openadapt_versions(lock, {"openadapt-flow", "openadapt-types"}) == { + "openadapt-flow": "1.2.3", + "openadapt-types": "0.4.5", + } + + +def test_native_lock_digest_is_verified( + manifest: dict, monkeypatch: pytest.MonkeyPatch +) -> None: + resolved = manifest["runtime_units"]["desktop_sidecar"]["resolved"] + lock = "version = 1\n" + "".join( + f'\n[[package]]\nname = "{package}"\nversion = "{version}"\n' + for package, version in resolved.items() + ) + expected_hash = hashlib.sha256(lock.encode()).hexdigest() + manifest = deepcopy(manifest) + manifest["runtime_units"]["desktop_sidecar"]["lock_sha256"] = expected_hash + monkeypatch.setattr("validate_platform_manifest._fetch_text", lambda _url: lock) + + report = Report() + check_desktop_sidecar_lock(manifest, report, require_network=True) + assert report.errors == [] + + manifest["runtime_units"]["desktop_sidecar"]["lock_sha256"] = "0" * 64 + report = Report() + check_desktop_sidecar_lock(manifest, report, require_network=True) + assert any("lock sha256 drift" in error for error in report.errors) diff --git a/tests/test_platform_version_display.py b/tests/test_platform_version_display.py new file mode 100644 index 000000000..88092c9c2 --- /dev/null +++ b/tests/test_platform_version_display.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +from render_platform_versions import render_markdown, version_rows # noqa: E402 + + +def test_version_display_is_derived_from_the_platform_bom() -> None: + manifest = json.loads((REPO_ROOT / "platform-manifest.json").read_text()) + rows = version_rows(manifest) + + assert [row["role"] for row in rows] == [ + "launcher", + "flow", + "capture", + "privacy", + "types", + "desktop", + "agent", + "launcher_environment", + "customer_runner", + "desktop_sidecar", + "agent_bridge", + ] + by_role = {row["role"]: row for row in rows} + assert by_role["flow"]["version"] == manifest["components"]["flow"]["version"] + assert by_role["desktop_sidecar"]["resolved"] == ", ".join( + f"{package}=={version}" + for package, version in sorted( + manifest["runtime_units"]["desktop_sidecar"]["resolved"].items() + ) + ) + + +def test_version_display_changes_when_the_manifest_changes() -> None: + manifest = json.loads((REPO_ROOT / "platform-manifest.json").read_text()) + manifest["components"]["flow"]["version"] = "9.8.7" + + by_role = {row["role"]: row for row in version_rows(manifest)} + assert by_role["flow"]["version"] == "9.8.7" + + +def test_human_report_is_generated_from_the_exact_bom() -> None: + manifest = json.loads((REPO_ROOT / "platform-manifest.json").read_text()) + report = REPO_ROOT / "docs" / "platform-compatibility-report.md" + + text = report.read_text() + assert text == render_markdown(manifest) + assert f"`{manifest['components']['agent']['version']}`" in text + assert all(row["name"] in text for row in manifest["substrate_drivers"]) + assert all(edge["target_package"] in text for edge in manifest["dependency_edges"])