diff --git a/.github/workflows/platform-manifest.yml b/.github/workflows/platform-manifest.yml index 96ed10dcb..0920b86ba 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-08-18 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: @@ -61,10 +63,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@v7 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.12' @@ -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. @@ -123,10 +129,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@v7 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.12' diff --git a/.github/workflows/release-and-publish.yml b/.github/workflows/release-and-publish.yml index a58f0515a..94203c6cb 100644 --- a/.github/workflows/release-and-publish.yml +++ b/.github/workflows/release-and-publish.yml @@ -200,14 +200,15 @@ jobs: - name: Commit the reconciled manifest run: | - if git diff --quiet -- platform-manifest.json; then - echo "platform-manifest.json already matches the published release." + if git diff --quiet -- \ + platform-manifest.json docs/platform-compatibility-report.md; then + echo "The platform BOM already matches the published release." exit 0 fi git config user.name "OpenAdapt Bot" git config user.email "bot@openadapt.ai" - git add platform-manifest.json - git commit -m "chore(release): reconcile platform manifest" + git add platform-manifest.json docs/platform-compatibility-report.md + git commit -m "chore(release): reconcile platform BOM" git push origin HEAD:main report-release-failure: diff --git a/docs/platform-compatibility-report.md b/docs/platform-compatibility-report.md new file mode 100644 index 000000000..d1f9c2779 --- /dev/null +++ b/docs/platform-compatibility-report.md @@ -0,0 +1,84 @@ +# OpenAdapt platform compatibility report + + + +- Generated: `2026-08-18T17:04: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.12.1` | [`v1.12.1`](https://github.com/OpenAdaptAI/OpenAdapt/tree/v1.12.1)
commit `aabecc8e2c3807f35d9bf3092fa302004df2e55e`
tree `20a56207439b967c98dce7349b3b3c70d8e5a121` | `openadapt-1.12.1-py3-none-any.whl` `1b469f1171ed1c0e3d9165ba31cb9eedd94f55165eba66b4ddbb7c7305683b28`
`openadapt-1.12.1.tar.gz` `d1756fcbc09c106f9172af13c4682ab3c73be9c6afacc078f78f1dc87078eef7` | +| flow | `openadapt-flow` | `1.31.0` | [`v1.31.0`](https://github.com/OpenAdaptAI/openadapt-flow/tree/v1.31.0)
commit `2d225dea9a0ad29ca84ce1b037cc0ac671367e28`
tree `bc61f08545b131464bc20c42a960c1ac3e5c9520` | `openadapt_flow-1.31.0-py3-none-any.whl` `81133db1528ad1bb1f26e3fcb6aea61b0651db6d905cf2e4943e8383c1f3d29c`
`openadapt_flow-1.31.0.tar.gz` `cf1fc356d14d267df82be188de3e9a3575734f18f46ef91ac8075438cc731540` | +| 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.2` | [`v1.0.2`](https://github.com/OpenAdaptAI/openadapt-privacy/tree/v1.0.2)
commit `170dfbbf80086973df1287065ec4fa764ecad8e4`
tree `b429f4b93e1c1e5a58e9223a40b9300980c8f0e7` | `openadapt_privacy-1.0.2-py3-none-any.whl` `256fa095eeefc9607b143c017488955e83ee3e2d360633d8027c46a49691d58f`
`openadapt_privacy-1.0.2.tar.gz` `4b557f4edf04ccb46e848cc4ad13595fc4db2bc49a8b58bd9cf1d3bbdb208a4c` | +| types | `openadapt-types` | `0.10.0` | [`v0.10.0`](https://github.com/OpenAdaptAI/openadapt-types/tree/v0.10.0)
commit `75e4471eb80099cbbf3e704997c4619003336b0a`
tree `e4a2c6845087b1ba787ce2940cfb430729adc92c` | `openadapt_types-0.10.0-py3-none-any.whl` `37317cc56968a88869cff8181179211365c3f772302e87ce76f95181565125b9`
`openadapt_types-0.10.0.tar.gz` `ea4d784da2d6dae15508d44a1e207a63539448312b3490d8c38110b4013710e8` | +| desktop | `openadapt-desktop` | `0.15.0` | [`desktop-v0.15.0`](https://github.com/OpenAdaptAI/openadapt-desktop/tree/desktop-v0.15.0)
commit `ac01e90f04e604f40e030046457f5b5167c88755`
tree `06a0c7c3fc61194149f821522939a08fadc18736` | `openadapt_desktop-0.15.0-py3-none-any.whl` `11dd8da225d660f23fc5e779d564ef34c5d85ad40b303951ec7697e94f488401`
`openadapt_desktop-0.15.0.tar.gz` `f45e35d53469fcb75710010d56a9d3f362130f1637a2f5fb50889553104a5670` | +| 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.12.1` | `openadapt==1.12.1`
`openadapt-flow==1.31.0` | +| customer_runner | `1.31.0` | `openadapt-flow==1.31.0`
`openadapt-types==0.10.0` | +| desktop_sidecar | `0.15.0` | `openadapt-capture==1.2.1`
`openadapt-flow==1.27.1`
`openadapt-privacy==1.0.0`
`openadapt-types==0.7.0` | +| agent_bridge | `2.0.1` | `openadapt-agent==2.0.1`
`openadapt-flow==1.31.0` | + +## 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.15.0` | `openadapt-capture` | `>=1.2.1` | `1.2.1` | `always` | +| desktop-sidecar-lock | `openadapt-desktop==0.15.0` | `openadapt-flow` | `==1.27.1` | `1.27.1` | `extra == "build"` | +| desktop-sidecar-lock | `openadapt-desktop==0.15.0` | `openadapt-privacy` | `>=1.0.0` | `1.0.0` | `always` | +| desktop-sidecar-lock | `openadapt-desktop==0.15.0` | `openadapt-types` | `==0.7.0` | `0.7.0` | `always` | +| selected-platform | `openadapt-agent==2.0.1` | `openadapt-flow` | `<2,>=1.18.1` | `1.31.0` | `always` | +| selected-platform | `openadapt-capture==1.2.2` | `openadapt-privacy` | `>=0.1.0` | `1.0.2` | `extra == "all"`
`extra == "privacy"` | +| selected-platform | `openadapt-flow==1.31.0` | `openadapt-capture` | `>=1.2.0` | `1.2.2` | `extra == "capture"` | +| selected-platform | `openadapt-flow==1.31.0` | `openadapt-privacy` | `>=1.0.0` | `1.0.2` | `extra == "privacy"` | +| selected-platform | `openadapt-flow==1.31.0` | `openadapt-types` | `<0.11.0,>=0.10.0` | `0.10.0` | `extra == "console"`
`extra == "dev"`
`extra == "interop"` | +| selected-platform | `openadapt==1.12.1` | `openadapt-capture` | `<2.0.0,>=1.0.4` | `1.2.2` | `extra == "all"`
`extra == "capture"`
`extra == "core"` | +| selected-platform | `openadapt==1.12.1` | `openadapt-flow` | `<2.0.0,>=1.29.0` | `1.31.0` | `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/2d225dea9a0ad29ca84ce1b037cc0ac671367e28/openadapt_flow/bundle_validation.py) `27c50778038eb59698c9b382da938f43ec3d48df73d6466d3c8a514d1b5294ad`
[`openadapt_flow/ir.py`](https://github.com/OpenAdaptAI/openadapt-flow/blob/2d225dea9a0ad29ca84ce1b037cc0ac671367e28/openadapt_flow/ir.py) `e01b5bc70341e423782765241716890840d2874139fd17a4ca9e70d6d9930739` | +| `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/75e4471eb80099cbbf3e704997c4619003336b0a/openadapt_types/control_overlay.py) `3aa01f89b43e12478c9e2ff1a9e61689397370691d15247ded9dcd5b05242790`
[`openadapt_types/control_overlay_tracking.py`](https://github.com/OpenAdaptAI/openadapt-types/blob/75e4471eb80099cbbf3e704997c4619003336b0a/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/75e4471eb80099cbbf3e704997c4619003336b0a/openadapt_types/control_overlay.py) `3aa01f89b43e12478c9e2ff1a9e61689397370691d15247ded9dcd5b05242790`
[`openadapt_types/control_overlay_tracking.py`](https://github.com/OpenAdaptAI/openadapt-types/blob/75e4471eb80099cbbf3e704997c4619003336b0a/openadapt_types/control_overlay_tracking.py) `af8d934f0572c5f4e9c63ba720d4a1d0e73c8cdf3f7b87cc4562c01a8a551755` | +| `human_decision_task` | `types` | `openadapt.human-decision-task/v` [1, 2] | [`openadapt_types/human_decision.py`](https://github.com/OpenAdaptAI/openadapt-types/blob/75e4471eb80099cbbf3e704997c4619003336b0a/openadapt_types/human_decision.py) `6ed5d2db82f5c73dfab568868f13f7a2cbfde6fb7b3f05b027b40c503ab04e29` | +| `human_decision_receipt` | `types` | `openadapt.human-decision-receipt/v` [1] | [`openadapt_types/human_decision.py`](https://github.com/OpenAdaptAI/openadapt-types/blob/75e4471eb80099cbbf3e704997c4619003336b0a/openadapt_types/human_decision.py) `6ed5d2db82f5c73dfab568868f13f7a2cbfde6fb7b3f05b027b40c503ab04e29` | +| `human_decision_relay` | `flow` | `openadapt.human-decision-relay/v` [2] | [`openadapt_flow/console/decision_relay.py`](https://github.com/OpenAdaptAI/openadapt-flow/blob/2d225dea9a0ad29ca84ce1b037cc0ac671367e28/openadapt_flow/console/decision_relay.py) `d5320404d4342dbb52be1f2ead1cdb5cf0e22877feaaa1e47974b301df697292` | +| `remote_decision_projection` | `flow` | `openadapt.remote-decision-projection/v` [1] | [`openadapt_flow/console/human_decisions.py`](https://github.com/OpenAdaptAI/openadapt-flow/blob/2d225dea9a0ad29ca84ce1b037cc0ac671367e28/openadapt_flow/console/human_decisions.py) `02651ad8888a5c17bfff6fb47165ba861193243dc0f7c951f8b94f544ca19477` | +| `runtime_validation` | `flow` | `openadapt.runtime-validation/v` [1, 2, 3] | [`openadapt_flow/runtime_validation.py`](https://github.com/OpenAdaptAI/openadapt-flow/blob/2d225dea9a0ad29ca84ce1b037cc0ac671367e28/openadapt_flow/runtime_validation.py) `69a27484f625e78b826291adba517cc00ec6c9a89477d09c703af90cc6d6b73e` | + +## 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/2d225dea9a0ad29ca84ce1b037cc0ac671367e28/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/2d225dea9a0ad29ca84ce1b037cc0ac671367e28/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..aa87db6c7 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. @@ -69,7 +112,7 @@ across repositories. Detection is not repair. Both the cron and a dispatched run validate and file an issue; regenerating and committing the manifest remains a human step. -### Why the schedule is daily and why false reds are not tolerated +### Why the schedule is daily and why false failures are not tolerated This manifest went stale once, and both causes are worth stating because they are properties of the check rather than of the manifest: @@ -80,7 +123,7 @@ are properties of the check rather than of the manifest: copy could advertise superseded sha256 digests for up to seven days. The schedule is now daily and the job is stdlib-only (no install, no cache), costing seconds a day. -2. **The check failed benignly after every release, so a real failure was +2. **The check failed for a normal condition after every release, so a real failure was invisible.** Two conditions were classified as errors when they are normal: the semantic-release version commit leaves `pyproject.toml` ahead of the not-yet-reconciled manifest, and PyPI's `info.version` lags an upload by @@ -92,15 +135,17 @@ A failed scheduled or dispatched run also files (or comments on) a `platform-manifest.json has drifted` issue, so drift has an owner rather than a stale red dot on a repository nobody has open. -`tests/test_platform_manifest_drift.py` proves the guard FAILS on a simulated +`tests/test_platform_manifest_drift.py` proves the guard fails on a simulated future release, a tampered digest, a tampered URL, and a version PyPI never published — and that it does NOT fail on the two transient release-time 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 4456340ac..42be19c9b 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-08-11T19:47:47+00:00", + "schema_version": "1.3.0", + "generated_at": "2026-08-18T17:04:07+00:00", + "generation": { + "files": [ + { + "path": "scripts/generate_platform_manifest.py", + "sha256": "553335f1c45af6cf62ce7c7862d853a7065c100bbf7a5145fc34643d2a849655" + }, + { + "path": "scripts/render_platform_versions.py", + "sha256": "d63fa4d56e219551be2d416f5c4fea62c49c6c515fcf26183475ec4a1587808e" + } + ] + }, "release_channel": "beta", + "release_selection": { + "mode": "latest-published", + "component_versions": {} + }, "components": { "launcher": { "package": "openadapt", "version": "1.12.1", "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.29.0" + ], + "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.29.0", + "extras": null, + "marker": "extra == \"all\"" + }, + { + "package": "openadapt-flow", + "requires": "<2.0.0,>=1.29.0", + "extras": null, + "marker": "extra == \"flow\"" + }, + { + "package": "openadapt-flow", + "requires": "<2.0.0,>=1.29.0", + "extras": "capture", + "marker": "extra == \"all\"" + }, + { + "package": "openadapt-flow", + "requires": "<2.0.0,>=1.29.0", + "extras": "capture", + "marker": "extra == \"capture\"" + }, + { + "package": "openadapt-flow", + "requires": "<2.0.0,>=1.29.0", + "extras": "capture", + "marker": "extra == \"core\"" + }, + { + "package": "openadapt-flow", + "requires": "<2.0.0,>=1.29.0", + "extras": "hosted", + "marker": null + }, + { + "package": "openadapt-flow", + "requires": "<2.0.0,>=1.29.0", + "extras": "linux", + "marker": "sys_platform == \"linux\" and extra == \"all\"" + }, + { + "package": "openadapt-flow", + "requires": "<2.0.0,>=1.29.0", + "extras": "linux", + "marker": "sys_platform == \"linux\" and extra == \"linux\"" + }, + { + "package": "openadapt-flow", + "requires": "<2.0.0,>=1.29.0", + "extras": "macos", + "marker": "sys_platform == \"darwin\" and extra == \"all\"" + }, + { + "package": "openadapt-flow", + "requires": "<2.0.0,>=1.29.0", + "extras": "macos", + "marker": "sys_platform == \"darwin\" and extra == \"macos\"" + }, + { + "package": "openadapt-flow", + "requires": "<2.0.0,>=1.29.0", + "extras": "privacy", + "marker": "extra == \"all\"" + }, + { + "package": "openadapt-flow", + "requires": "<2.0.0,>=1.29.0", + "extras": "privacy", + "marker": "extra == \"privacy\"" + }, + { + "package": "openadapt-flow", + "requires": "<2.0.0,>=1.29.0", + "extras": "rdp", + "marker": "extra == \"all\"" + }, + { + "package": "openadapt-flow", + "requires": "<2.0.0,>=1.29.0", + "extras": "rdp", + "marker": "extra == \"rdp\"" + }, + { + "package": "openadapt-flow", + "requires": "<2.0.0,>=1.29.0", + "extras": "windows", + "marker": "extra == \"all\"" + }, + { + "package": "openadapt-flow", + "requires": "<2.0.0,>=1.29.0", + "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.12.1", + "commit": "aabecc8e2c3807f35d9bf3092fa302004df2e55e", + "tree": "20a56207439b967c98dce7349b3b3c70d8e5a121", + "url": "https://github.com/OpenAdaptAI/OpenAdapt/tree/v1.12.1" + }, "artifacts": [ { "type": "bdist_wheel", @@ -29,6 +269,65 @@ "version": "1.31.0", "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.11.0,>=0.10.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.11.0,>=0.10.0", + "extras": null, + "marker": "extra == \"console\"" + }, + { + "package": "openadapt-types", + "requires": "<0.11.0,>=0.10.0", + "extras": null, + "marker": "extra == \"dev\"" + }, + { + "package": "openadapt-types", + "requires": "<0.11.0,>=0.10.0", + "extras": null, + "marker": "extra == \"interop\"" + } + ], + "provenance": { + "repository": "OpenAdaptAI/openadapt-flow", + "release_ref": "v1.31.0", + "commit": "2d225dea9a0ad29ca84ce1b037cc0ac671367e28", + "tree": "bc61f08545b131464bc20c42a960c1ac3e5c9520", + "url": "https://github.com/OpenAdaptAI/openadapt-flow/tree/v1.31.0" + }, "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.2", + "source": "pypi", + "requires_python": ">=3.10", + "requires": {}, + "dependency_constraints": [], + "provenance": { + "repository": "OpenAdaptAI/openadapt-privacy", + "release_ref": "v1.0.2", + "commit": "170dfbbf80086973df1287065ec4fa764ecad8e4", + "tree": "b429f4b93e1c1e5a58e9223a40b9300980c8f0e7", + "url": "https://github.com/OpenAdaptAI/openadapt-privacy/tree/v1.0.2" + }, + "artifacts": [ + { + "type": "bdist_wheel", + "filename": "openadapt_privacy-1.0.2-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/35/31/34148fdf4c3ef1cbb5a8c50be1c730b0f38acca9f45f22b19aace86ca9b7/openadapt_privacy-1.0.2-py3-none-any.whl", + "sha256": "256fa095eeefc9607b143c017488955e83ee3e2d360633d8027c46a49691d58f" + }, + { + "type": "sdist", + "filename": "openadapt_privacy-1.0.2.tar.gz", + "url": "https://files.pythonhosted.org/packages/c5/c4/afff55042c3df3ac372c79a79a3edd0ff8015f319de840de7391ca1ec69d/openadapt_privacy-1.0.2.tar.gz", + "sha256": "4b557f4edf04ccb46e848cc4ad13595fc4db2bc49a8b58bd9cf1d3bbdb208a4c" + } + ] + }, + "types": { + "package": "openadapt-types", + "version": "0.10.0", + "source": "pypi", + "requires_python": ">=3.10", + "requires": {}, + "dependency_constraints": [], + "provenance": { + "repository": "OpenAdaptAI/openadapt-types", + "release_ref": "v0.10.0", + "commit": "75e4471eb80099cbbf3e704997c4619003336b0a", + "tree": "e4a2c6845087b1ba787ce2940cfb430729adc92c", + "url": "https://github.com/OpenAdaptAI/openadapt-types/tree/v0.10.0" + }, + "artifacts": [ + { + "type": "bdist_wheel", + "filename": "openadapt_types-0.10.0-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/28/6e/1efc97ed73c84205149b7e2a7474638afa0a1c29374b867c5a357bdff59e/openadapt_types-0.10.0-py3-none-any.whl", + "sha256": "37317cc56968a88869cff8181179211365c3f772302e87ce76f95181565125b9" + }, + { + "type": "sdist", + "filename": "openadapt_types-0.10.0.tar.gz", + "url": "https://files.pythonhosted.org/packages/0c/54/4cc84122fe9405fa3dbcd1521d252f92ba5d5c9d1eb367187aef06d0d4a3/openadapt_types-0.10.0.tar.gz", + "sha256": "ea4d784da2d6dae15508d44a1e207a63539448312b3490d8c38110b4013710e8" + } + ] + }, "desktop": { "package": "openadapt-desktop", "version": "0.15.0", "source": "pypi", "requires_python": ">=3.11", + "requires": { + "openadapt-capture": [ + ">=1.2.1" + ], + "openadapt-flow": [ + "==1.27.1" + ], + "openadapt-privacy": [ + ">=1.0.0" + ], + "openadapt-types": [ + "==0.7.0" + ] + }, + "dependency_constraints": [ + { + "package": "openadapt-capture", + "requires": ">=1.2.1", + "extras": null, + "marker": null + }, + { + "package": "openadapt-flow", + "requires": "==1.27.1", + "extras": "browser,console", + "marker": "extra == \"build\"" + }, + { + "package": "openadapt-privacy", + "requires": ">=1.0.0", + "extras": null, + "marker": null + }, + { + "package": "openadapt-types", + "requires": "==0.7.0", + "extras": null, + "marker": null + } + ], + "provenance": { + "repository": "OpenAdaptAI/openadapt-desktop", + "release_ref": "desktop-v0.15.0", + "commit": "ac01e90f04e604f40e030046457f5b5167c88755", + "tree": "06a0c7c3fc61194149f821522939a08fadc18736", + "url": "https://github.com/OpenAdaptAI/openadapt-desktop/tree/desktop-v0.15.0" + }, "artifacts": [ { "type": "bdist_wheel", @@ -83,6 +513,558 @@ "sha256": "f45e35d53469fcb75710010d56a9d3f362130f1637a2f5fb50889553104a5670" } ] + }, + "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.12.1", + "source_component": "launcher", + "entry_point": "openadapt", + "requirements": { + "openadapt": "==1.12.1", + "openadapt-flow": "<2.0.0,>=1.29.0" + }, + "selected": { + "openadapt": "1.12.1", + "openadapt-flow": "1.31.0" + } + }, + "customer_runner": { + "kind": "customer-controlled-runner", + "version": "1.31.0", + "source_component": "flow", + "entry_point": "openadapt-flow", + "requirements": { + "openadapt-flow": "==1.31.0", + "openadapt-types": "<0.11.0,>=0.10.0" + }, + "selected": { + "openadapt-flow": "1.31.0", + "openadapt-types": "0.10.0" + } + }, + "desktop_sidecar": { + "kind": "frozen-desktop-sidecar", + "version": "0.15.0", + "source_component": "desktop", + "source_ref": "desktop-v0.15.0", + "lock_url": "https://raw.githubusercontent.com/OpenAdaptAI/openadapt-desktop/desktop-v0.15.0/uv.lock", + "lock_sha256": "3acbbbf9a8b4d161a51a464f850f77a1317e275e18a10ffa76f65a544e895c62", + "binary": "openadapt-engine", + "requirements": { + "openadapt-flow": "==1.27.1", + "openadapt-capture": ">=1.2.1", + "openadapt-privacy": ">=1.0.0", + "openadapt-types": "==0.7.0" + }, + "resolved": { + "openadapt-capture": "1.2.1", + "openadapt-flow": "1.27.1", + "openadapt-privacy": "1.0.0", + "openadapt-types": "0.7.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.31.0" + } + } + }, + "dependency_edges": [ + { + "scope": "desktop-sidecar-lock", + "source_role": "desktop", + "source_package": "openadapt-desktop", + "source_version": "0.15.0", + "target_role": "capture", + "target_package": "openadapt-capture", + "requires": ">=1.2.1", + "resolved": "1.2.1", + "activations": [ + { + "extras": null, + "marker": null + } + ] + }, + { + "scope": "desktop-sidecar-lock", + "source_role": "desktop", + "source_package": "openadapt-desktop", + "source_version": "0.15.0", + "target_role": "flow", + "target_package": "openadapt-flow", + "requires": "==1.27.1", + "resolved": "1.27.1", + "activations": [ + { + "extras": "browser,console", + "marker": "extra == \"build\"" + } + ] + }, + { + "scope": "desktop-sidecar-lock", + "source_role": "desktop", + "source_package": "openadapt-desktop", + "source_version": "0.15.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.15.0", + "target_role": "types", + "target_package": "openadapt-types", + "requires": "==0.7.0", + "resolved": "0.7.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.31.0", + "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.2", + "activations": [ + { + "extras": null, + "marker": "extra == \"all\"" + }, + { + "extras": null, + "marker": "extra == \"privacy\"" + } + ] + }, + { + "scope": "selected-platform", + "source_role": "flow", + "source_package": "openadapt-flow", + "source_version": "1.31.0", + "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.31.0", + "target_role": "privacy", + "target_package": "openadapt-privacy", + "requires": ">=1.0.0", + "resolved": "1.0.2", + "activations": [ + { + "extras": "presidio", + "marker": "extra == \"privacy\"" + } + ] + }, + { + "scope": "selected-platform", + "source_role": "flow", + "source_package": "openadapt-flow", + "source_version": "1.31.0", + "target_role": "types", + "target_package": "openadapt-types", + "requires": "<0.11.0,>=0.10.0", + "resolved": "0.10.0", + "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.12.1", + "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.12.1", + "target_role": "flow", + "target_package": "openadapt-flow", + "requires": "<2.0.0,>=1.29.0", + "resolved": "1.31.0", + "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": "2d225dea9a0ad29ca84ce1b037cc0ac671367e28", + "source_files": [ + { + "path": "openadapt_flow/bundle_validation.py", + "url": "https://github.com/OpenAdaptAI/openadapt-flow/blob/2d225dea9a0ad29ca84ce1b037cc0ac671367e28/openadapt_flow/bundle_validation.py", + "git_blob": "07f0c9a7fe4304e1d614f4cc7a6fce2067bfb95c", + "sha256": "27c50778038eb59698c9b382da938f43ec3d48df73d6466d3c8a514d1b5294ad" + }, + { + "path": "openadapt_flow/ir.py", + "url": "https://github.com/OpenAdaptAI/openadapt-flow/blob/2d225dea9a0ad29ca84ce1b037cc0ac671367e28/openadapt_flow/ir.py", + "git_blob": "b54bf8d60e5709de2e6fb7cee2388bb5a7035abb", + "sha256": "e01b5bc70341e423782765241716890840d2874139fd17a4ca9e70d6d9930739" + } + ] + }, + "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": "75e4471eb80099cbbf3e704997c4619003336b0a", + "source_files": [ + { + "path": "openadapt_types/control_overlay.py", + "url": "https://github.com/OpenAdaptAI/openadapt-types/blob/75e4471eb80099cbbf3e704997c4619003336b0a/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/75e4471eb80099cbbf3e704997c4619003336b0a/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": "75e4471eb80099cbbf3e704997c4619003336b0a", + "source_files": [ + { + "path": "openadapt_types/control_overlay.py", + "url": "https://github.com/OpenAdaptAI/openadapt-types/blob/75e4471eb80099cbbf3e704997c4619003336b0a/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/75e4471eb80099cbbf3e704997c4619003336b0a/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, + 2 + ], + "minimum": 1, + "maximum": 2, + "source_commit": "75e4471eb80099cbbf3e704997c4619003336b0a", + "source_files": [ + { + "path": "openadapt_types/human_decision.py", + "url": "https://github.com/OpenAdaptAI/openadapt-types/blob/75e4471eb80099cbbf3e704997c4619003336b0a/openadapt_types/human_decision.py", + "git_blob": "1b60d8d6874c666494ef3c9eaa599e8d9836db6b", + "sha256": "6ed5d2db82f5c73dfab568868f13f7a2cbfde6fb7b3f05b027b40c503ab04e29" + } + ] + }, + "human_decision_receipt": { + "source_component": "types", + "schema_prefix": "openadapt.human-decision-receipt/v", + "accepted_versions": [ + 1 + ], + "minimum": 1, + "maximum": 1, + "source_commit": "75e4471eb80099cbbf3e704997c4619003336b0a", + "source_files": [ + { + "path": "openadapt_types/human_decision.py", + "url": "https://github.com/OpenAdaptAI/openadapt-types/blob/75e4471eb80099cbbf3e704997c4619003336b0a/openadapt_types/human_decision.py", + "git_blob": "1b60d8d6874c666494ef3c9eaa599e8d9836db6b", + "sha256": "6ed5d2db82f5c73dfab568868f13f7a2cbfde6fb7b3f05b027b40c503ab04e29" + } + ] + }, + "human_decision_relay": { + "source_component": "flow", + "schema_prefix": "openadapt.human-decision-relay/v", + "accepted_versions": [ + 2 + ], + "minimum": 2, + "maximum": 2, + "source_commit": "2d225dea9a0ad29ca84ce1b037cc0ac671367e28", + "source_files": [ + { + "path": "openadapt_flow/console/decision_relay.py", + "url": "https://github.com/OpenAdaptAI/openadapt-flow/blob/2d225dea9a0ad29ca84ce1b037cc0ac671367e28/openadapt_flow/console/decision_relay.py", + "git_blob": "297d2f71727f83496221091a4237d704c25665d6", + "sha256": "d5320404d4342dbb52be1f2ead1cdb5cf0e22877feaaa1e47974b301df697292" + } + ] + }, + "remote_decision_projection": { + "source_component": "flow", + "schema_prefix": "openadapt.remote-decision-projection/v", + "accepted_versions": [ + 1 + ], + "minimum": 1, + "maximum": 1, + "source_commit": "2d225dea9a0ad29ca84ce1b037cc0ac671367e28", + "source_files": [ + { + "path": "openadapt_flow/console/human_decisions.py", + "url": "https://github.com/OpenAdaptAI/openadapt-flow/blob/2d225dea9a0ad29ca84ce1b037cc0ac671367e28/openadapt_flow/console/human_decisions.py", + "git_blob": "caaf87396aca4770eace5eead3fdf6515e31fbfc", + "sha256": "02651ad8888a5c17bfff6fb47165ba861193243dc0f7c951f8b94f544ca19477" + } + ] + }, + "runtime_validation": { + "source_component": "flow", + "schema_prefix": "openadapt.runtime-validation/v", + "accepted_versions": [ + 1, + 2, + 3 + ], + "minimum": 1, + "maximum": 3, + "source_commit": "2d225dea9a0ad29ca84ce1b037cc0ac671367e28", + "source_files": [ + { + "path": "openadapt_flow/runtime_validation.py", + "url": "https://github.com/OpenAdaptAI/openadapt-flow/blob/2d225dea9a0ad29ca84ce1b037cc0ac671367e28/openadapt_flow/runtime_validation.py", + "git_blob": "5c737d17aa9eaa3496ba01b61c2e33ce400c1492", + "sha256": "69a27484f625e78b826291adba517cc00ec6c9a89477d09c703af90cc6d6b73e" + } + ] } }, "compatibility": { @@ -111,6 +1093,11 @@ ] } }, + "compatibility_status": { + "status": "dependency-compatible", + "basis": "published-metadata-and-desktop-lock", + "failures": [] + }, "supported_os": [ "windows", "macos", @@ -163,16 +1150,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": "2d225dea9a0ad29ca84ce1b037cc0ac671367e28", + "source_object_type": "tree", + "source_object_sha": "2eac2252f62349e873b461866546dd0295169a19", + "url": "https://github.com/OpenAdaptAI/openadapt-flow/tree/2d225dea9a0ad29ca84ce1b037cc0ac671367e28/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": "2d225dea9a0ad29ca84ce1b037cc0ac671367e28", + "source_object_type": "blob", + "source_object_sha": "1c568d2289342e10e3556c5c46cd08a0e370ac84", + "url": "https://github.com/OpenAdaptAI/openadapt-flow/blob/2d225dea9a0ad29ca84ce1b037cc0ac671367e28/benchmark/effectbench/task_pack/manifest.json" } ], "signature": { diff --git a/scripts/generate_platform_manifest.py b/scripts/generate_platform_manifest.py index 1ce294316..d269971a5 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,116 @@ 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 +187,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,9 +224,11 @@ class DriftError(RuntimeError): def _fetch_json(url: str) -> dict: - request = urllib.request.Request( - url, headers={"User-Agent": "openadapt-platform-manifest-generator"} - ) + 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) @@ -132,9 +240,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 +531,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 +577,267 @@ 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 +856,119 @@ 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 +997,29 @@ 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 +1032,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 +1060,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..a18be3a46 --- /dev/null +++ b/scripts/render_platform_versions.py @@ -0,0 +1,254 @@ +#!/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')}) `{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}): {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..570c20ef8 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,11 +134,21 @@ 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=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 json.load(resp) + return resp.read().decode("utf-8") class Report: @@ -126,10 +179,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 +256,326 @@ 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( + f"qualification_evidence IDs must be exactly {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} {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 +713,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 +741,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 +769,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 +785,19 @@ 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 +806,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 +825,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 +875,101 @@ 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 +993,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 +1072,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 +1121,28 @@ 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..3aa6703fc 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,13 +42,29 @@ 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, ) MANIFEST_PATH = REPO_ROOT / "platform-manifest.json" +RELEASE_WORKFLOW_PATH = REPO_ROOT / ".github/workflows/release-and-publish.yml" @pytest.fixture() @@ -74,8 +92,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 +165,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 +230,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: @@ -239,16 +341,218 @@ def test_matching_launcher_versions_are_silent() -> None: assert report.warnings == [] +def test_release_reconciliation_commits_every_generated_bom_file() -> None: + """A launcher release must not leave the generated report stale.""" + workflow = RELEASE_WORKFLOW_PATH.read_text(encoding="utf-8") + generated_files = "platform-manifest.json docs/platform-compatibility-report.md" + normalized = " ".join(workflow.replace("\\", "").split()) + + assert f"git diff --quiet -- {generated_files}" in normalized + assert f"git add {generated_files}" in normalized + + 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 { + "workflow_bundle", + "capture_structural_observation", + "control_overlay_frame", + "control_overlay_timeline", + "human_decision_task", + "human_decision_receipt", + "remote_decision_projection", + "runtime_validation", + } <= set(schemas) + assert schemas["capture_structural_observation"]["schema_prefix"] == ( + "openadapt.capture.structural-observation/v" + ) + for schema in schemas.values(): + accepted = schema["accepted_versions"] + assert accepted == sorted(set(accepted)) + assert schema["minimum"] == accepted[0] + assert schema["maximum"] == accepted[-1] + assert ( + schema["source_commit"] + == manifest["components"][schema["source_component"]]["provenance"][ + "commit" + ] + ) + + report = Report() + check_structure(manifest, report) + assert report.errors == [] + + tampered = deepcopy(manifest) + tampered_schema = tampered["schema_compatibility"]["human_decision_receipt"] + tampered_schema["maximum"] = tampered_schema["maximum"] + 1 + 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"])