diff --git a/.github/workflows/native-freshness.yml b/.github/workflows/native-freshness.yml index b57a14e..76673c3 100644 --- a/.github/workflows/native-freshness.yml +++ b/.github/workflows/native-freshness.yml @@ -48,7 +48,7 @@ jobs: with: python-version: "3.12" - - name: Resolve the stable engine release + - name: Resolve the published engine release id: version env: RELEASE_TAG: ${{ github.event.release.tag_name }} diff --git a/.github/workflows/native-release.yml b/.github/workflows/native-release.yml index c5b7102..2984f31 100644 --- a/.github/workflows/native-release.yml +++ b/.github/workflows/native-release.yml @@ -1,5 +1,9 @@ name: Native Installer Release +# This workflow publishes an exact, verified release candidate. It does not +# create Production state. Only production-channel.yml can cache an already +# active central OpenAdaptAI/.github admission. + on: workflow_dispatch: inputs: @@ -13,7 +17,7 @@ permissions: concurrency: # One protected-main transaction owns tag creation, byte publication, - # mirroring, and stable-channel promotion. Never run two transactions at once. + # mirroring, and candidate-channel update. Never run two transactions at once. group: native-release cancel-in-progress: false @@ -641,7 +645,7 @@ jobs: --tag "${{ needs.validate.outputs.native_tag }}" --sbom release-assets/OpenAdapt-Desktop-${{ needs.validate.outputs.native_tag }}.cyclonedx.json - - name: Resolve the exact stable engine release identity + - name: Resolve the exact published engine release identity id: engine_release env: GH_TOKEN: ${{ github.token }} @@ -772,7 +776,7 @@ jobs: if-no-files-found: error publish-native: - name: Publish the verified Beta prerelease + name: Publish the verified candidate prerelease needs: [validate, attest] runs-on: ubuntu-22.04 # This is the irreversible boundary. One environment approval authorizes @@ -852,7 +856,7 @@ jobs: python scripts/native_release.py validate-release-order \ --candidate-tag "${NATIVE_TAG}" \ --tags remote-native-tags.txt - title="OpenAdapt Desktop Beta ${NATIVE_TAG#desktop-v}" + title="OpenAdapt Desktop release candidate ${NATIVE_TAG#desktop-v}" if gh release view "${NATIVE_TAG}" \ --json assets,body,isDraft,isPrerelease,tagName > native-release.json 2>/dev/null; then if ! jq -e --arg tag "${NATIVE_TAG}" \ @@ -892,7 +896,7 @@ jobs: "${create_ref[@]}" \ --prerelease \ --title "${title}" \ - --notes-file docs/BETA_NATIVE_INSTALLERS.md \ + --notes-file docs/RELEASE_CANDIDATE_INSTALLERS.md \ release-assets/* fi mkdir published-release-assets @@ -1215,7 +1219,7 @@ jobs: mkdir remote-mirror gh release download "${engine_tag}" --dir remote-mirror \ - --pattern 'OpenAdapt-Desktop-Beta-*' \ + --pattern 'OpenAdapt-Desktop-Candidate-*' \ --pattern 'OpenAdapt-Desktop-desktop-v*.cyclonedx.json' \ --pattern 'openadapt-desktop-release-manifest.json' \ --pattern 'openadapt-desktop-native-release-provenance.json' \ @@ -1299,7 +1303,7 @@ jobs: --cert-oidc-issuer "https://token.actions.githubusercontent.com" \ --deny-self-hosted-runners - - name: Write the monotonic stable channel descriptor + - name: Write the monotonic candidate channel descriptor shell: bash run: | set -euo pipefail @@ -1364,12 +1368,12 @@ jobs: "${existing[@]}" fi - - name: Attest the monotonic stable channel descriptor + - name: Attest the monotonic candidate channel descriptor uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4 with: subject-path: openadapt-desktop-channel.json - - name: Publish and reverify the stable channel authority + - name: Publish and reverify the candidate channel authority shell: bash run: | set -euo pipefail @@ -1386,8 +1390,8 @@ jobs: gh release create "${channel_tag}" \ --target "${SOURCE_COMMIT}" \ --prerelease \ - --title "OpenAdapt Desktop stable native channel" \ - --notes "Machine-readable signed authority for the latest verified native release." \ + --title "OpenAdapt Desktop native release-candidate channel" \ + --notes "Machine-readable signed index for the latest verified, unadmitted release candidate." \ openadapt-desktop-channel.json fi mkdir published-channel diff --git a/.github/workflows/production-channel.yml b/.github/workflows/production-channel.yml new file mode 100644 index 0000000..6f89cc8 --- /dev/null +++ b/.github/workflows/production-channel.yml @@ -0,0 +1,429 @@ +name: Production channel admission + +# The canonical OpenAdaptAI/.github ledger is the only Production authority. +# A normal Desktop release publishes an unadmitted candidate and never runs +# this workflow. This workflow only verifies or caches an already-active, +# signed central admission for the exact unchanged public artifacts. + +on: + workflow_dispatch: + inputs: + operation: + description: "Production channel operation" + required: true + type: choice + default: verify + options: + - verify + - promote + central_commit: + description: "Exact current OpenAdaptAI/.github main commit (required for promote)" + required: false + type: string + release: + types: [published] + schedule: + - cron: "17 6 * * *" + +permissions: + contents: read + +concurrency: + group: production-channel + cancel-in-progress: false + +jobs: + inspect: + name: Derive the current Production default from central admission + runs-on: ubuntu-22.04 + permissions: + attestations: read + contents: read + outputs: + active: ${{ steps.admission.outputs.active }} + cache_exists: ${{ steps.cache.outputs.cache_exists }} + central_commit: ${{ steps.refs.outputs.central_commit }} + desktop_commit: ${{ steps.refs.outputs.desktop_commit }} + env: + GH_TOKEN: ${{ github.token }} + PRODUCTION_CHANNEL_TAG: desktop-production-channel + steps: + - name: Resolve current protected-main commits + id: refs + env: + REQUESTED_CENTRAL_COMMIT: ${{ inputs.central_commit }} + OPERATION: ${{ inputs.operation }} + shell: bash + run: | + set -euo pipefail + if [ "${GITHUB_EVENT_NAME}" = workflow_dispatch ] && \ + [ "${GITHUB_REF}" != refs/heads/main ]; then + echo "error: dispatch source is not protected Desktop main" >&2 + exit 1 + fi + desktop_commit="$(gh api "repos/${GITHUB_REPOSITORY}/commits/main" --jq .sha)" + central_commit="$(gh api repos/OpenAdaptAI/.github/commits/main --jq .sha)" + if [ "${GITHUB_EVENT_NAME}" = workflow_dispatch ]; then + if [ "${GITHUB_SHA}" != "${desktop_commit}" ]; then + echo "error: dispatch source is not current protected Desktop main" >&2 + exit 1 + fi + if [ "${OPERATION}" = promote ] && [ -z "${REQUESTED_CENTRAL_COMMIT}" ]; then + echo "error: promotion requires an exact central_commit" >&2 + exit 1 + fi + if [ -n "${REQUESTED_CENTRAL_COMMIT}" ]; then + if ! printf '%s' "${REQUESTED_CENTRAL_COMMIT}" | grep -Eq '^[0-9a-f]{40}$'; then + echo "error: central_commit is not a commit id" >&2 + exit 1 + fi + if [ "${REQUESTED_CENTRAL_COMMIT}" != "${central_commit}" ]; then + echo "error: requested central commit is not current central main" >&2 + exit 1 + fi + fi + fi + echo "desktop_commit=${desktop_commit}" >> "${GITHUB_OUTPUT}" + echo "central_commit=${central_commit}" >> "${GITHUB_OUTPUT}" + + - name: Checkout the exact current Desktop verifier + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ steps.refs.outputs.desktop_commit }} + fetch-depth: 0 + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Checkout one immutable canonical lifecycle commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: OpenAdaptAI/.github + ref: ${{ steps.refs.outputs.central_commit }} + path: production-lifecycle + persist-credentials: false + + - name: Run the canonical validator and select Desktop + id: admission + shell: bash + run: | + set -euo pipefail + observed="$(git -C production-lifecycle rev-parse HEAD)" + if [ "${observed}" != "${{ steps.refs.outputs.central_commit }}" ]; then + echo "error: canonical checkout differs from its immutable input" >&2 + exit 1 + fi + git -C production-lifecycle diff --exit-code + git -C production-lifecycle diff --cached --exit-code + test -z "$(git -C production-lifecycle status --porcelain --untracked-files=all)" + python scripts/production_release.py state \ + --lifecycle-root production-lifecycle \ + --central-source-commit "${observed}" \ + --output admission-state.json + active="$(jq -r '.active_admission != null' admission-state.json)" + echo "active=${active}" >> "${GITHUB_OUTPUT}" + if [ "${active}" = true ]; then + echo "version=$(jq -r '.active_admission.release.version' admission-state.json)" \ + >> "${GITHUB_OUTPUT}" + digest="$(jq -r '.active_admission_sha256' admission-state.json)" + echo "cache_asset=openadapt-desktop-production-channel-${observed}-${digest#sha256:}.json" \ + >> "${GITHUB_OUTPUT}" + fi + + - name: Refuse promotion without an active central admission + if: >- + github.event_name == 'workflow_dispatch' && + inputs.operation == 'promote' && + steps.admission.outputs.active != 'true' + shell: bash + run: | + echo "error: Desktop has no active central Production admission" >&2 + exit 1 + + - name: Download and verify the exact admitted candidate + if: steps.admission.outputs.active == 'true' + env: + VERSION: ${{ steps.admission.outputs.version }} + shell: bash + run: | + set -euo pipefail + engine_tag="v${VERSION}" + mkdir -p candidate engine-files + gh release view "${engine_tag}" \ + --json databaseId,isDraft,isPrerelease,publishedAt,tagName,url \ + > candidate/engine-release.json + gh release download "${engine_tag}" --dir candidate \ + --pattern 'openadapt-desktop-verified-release.json' \ + --pattern 'openadapt-desktop-engine-release-provenance.json' + gh release download "${engine_tag}" --dir engine-files \ + --pattern '*.whl' \ + --pattern '*.tar.gz' + native_identity="https://github.com/${GITHUB_REPOSITORY}/.github/workflows/native-release.yml@refs/heads/main" + engine_identity="https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release.yml@refs/heads/main" + gh attestation verify candidate/openadapt-desktop-verified-release.json \ + --repo "${GITHUB_REPOSITORY}" \ + --cert-identity "${native_identity}" \ + --cert-oidc-issuer 'https://token.actions.githubusercontent.com' \ + --deny-self-hosted-runners + gh attestation verify candidate/openadapt-desktop-engine-release-provenance.json \ + --repo "${GITHUB_REPOSITORY}" \ + --cert-identity "${engine_identity}" \ + --cert-oidc-issuer 'https://token.actions.githubusercontent.com' \ + --deny-self-hosted-runners + python scripts/native_release.py validate-verified-index \ + --file candidate/openadapt-desktop-verified-release.json + engine_commit="$(jq -r .engine_commit candidate/openadapt-desktop-verified-release.json)" + python scripts/native_release.py validate-engine-provenance \ + --file candidate/openadapt-desktop-engine-release-provenance.json \ + --directory engine-files \ + --release candidate/engine-release.json \ + --repository "${GITHUB_REPOSITORY}" \ + --engine-tag "${engine_tag}" \ + --engine-commit "${engine_commit}" + + - name: Verify the derived cache or report no Production default + id: cache + env: + ACTIVE: ${{ steps.admission.outputs.active }} + CACHE_ASSET: ${{ steps.admission.outputs.cache_asset }} + OPERATION: ${{ inputs.operation }} + shell: bash + run: | + set -euo pipefail + if [ "${ACTIVE}" != true ]; then + echo "Desktop has no active Production default." + echo "cache_exists=false" >> "${GITHUB_OUTPUT}" + exit 0 + fi + exists=false + if gh release view "${PRODUCTION_CHANNEL_TAG}" \ + --json isDraft,isPrerelease,body,assets > production-cache-release.json 2>/dev/null; then + if ! jq -e \ + '.isDraft == false and .isPrerelease == true and + (.body | contains(""))' \ + production-cache-release.json >/dev/null; then + echo "error: Production cache release identity differs" >&2 + exit 1 + fi + if jq -e --arg name "${CACHE_ASSET}" \ + '.assets | any(.name == $name)' production-cache-release.json >/dev/null; then + exists=true + fi + fi + echo "cache_exists=${exists}" >> "${GITHUB_OUTPUT}" + if [ "${exists}" != true ]; then + if [ "${GITHUB_EVENT_NAME}" = workflow_dispatch ] && [ "${OPERATION}" = promote ]; then + echo "The active admission has no derived cache. Promotion can add it." + exit 0 + fi + echo "error: the active central Desktop admission has no exact derived channel cache" >&2 + exit 1 + fi + mkdir published-cache + gh release download "${PRODUCTION_CHANNEL_TAG}" --dir published-cache \ + --pattern "${CACHE_ASSET}" + identity="https://github.com/${GITHUB_REPOSITORY}/.github/workflows/production-channel.yml@refs/heads/main" + gh attestation verify "published-cache/${CACHE_ASSET}" \ + --repo "${GITHUB_REPOSITORY}" \ + --cert-identity "${identity}" \ + --cert-oidc-issuer 'https://token.actions.githubusercontent.com' \ + --deny-self-hosted-runners + python scripts/production_release.py verify-channel \ + --file "published-cache/${CACHE_ASSET}" \ + --state admission-state.json \ + --index candidate/openadapt-desktop-verified-release.json \ + --engine-provenance candidate/openadapt-desktop-engine-release-provenance.json \ + --engine-release candidate/engine-release.json \ + --engine-directory engine-files + + - name: Confirm central main did not change during derivation + shell: bash + run: | + set -euo pipefail + current="$(gh api repos/OpenAdaptAI/.github/commits/main --jq .sha)" + if [ "${current}" != "${{ steps.refs.outputs.central_commit }}" ]; then + echo "error: central main changed during Production derivation" >&2 + exit 1 + fi + + - name: Retain exact promotion inputs + if: >- + github.event_name == 'workflow_dispatch' && + inputs.operation == 'promote' && + steps.admission.outputs.active == 'true' && + steps.cache.outputs.cache_exists != 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: production-channel-inputs-${{ github.run_id }} + path: | + admission-state.json + candidate/* + engine-files/* + if-no-files-found: error + + promote: + name: Cache the already-active central Production admission + needs: inspect + if: >- + github.event_name == 'workflow_dispatch' && + inputs.operation == 'promote' && + needs.inspect.outputs.active == 'true' && + needs.inspect.outputs.cache_exists != 'true' + runs-on: ubuntu-22.04 + environment: production-release + permissions: + attestations: write + contents: write + id-token: write + env: + GH_TOKEN: ${{ github.token }} + PRODUCTION_CHANNEL_TAG: desktop-production-channel + steps: + - name: Checkout the exact current Desktop verifier + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.inspect.outputs.desktop_commit }} + fetch-depth: 0 + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Checkout the same immutable canonical lifecycle commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: OpenAdaptAI/.github + ref: ${{ needs.inspect.outputs.central_commit }} + path: production-lifecycle + persist-credentials: false + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: production-channel-inputs-${{ github.run_id }} + path: prepared + + - name: Revalidate the unchanged authority and candidate + shell: bash + run: | + set -euo pipefail + observed="$(git -C production-lifecycle rev-parse HEAD)" + if [ "${observed}" != "${{ needs.inspect.outputs.central_commit }}" ]; then + echo "error: canonical checkout differs from its immutable input" >&2 + exit 1 + fi + git -C production-lifecycle diff --exit-code + git -C production-lifecycle diff --cached --exit-code + test -z "$(git -C production-lifecycle status --porcelain --untracked-files=all)" + desktop_main="$(gh api "repos/${GITHUB_REPOSITORY}/commits/main" --jq .sha)" + central_main="$(gh api repos/OpenAdaptAI/.github/commits/main --jq .sha)" + if [ "${desktop_main}" != "${{ needs.inspect.outputs.desktop_commit }}" ] || \ + [ "${central_main}" != "${{ needs.inspect.outputs.central_commit }}" ]; then + echo "error: a protected main branch changed before promotion" >&2 + exit 1 + fi + python scripts/production_release.py state \ + --lifecycle-root production-lifecycle \ + --central-source-commit "${central_main}" \ + --output current-admission-state.json + cmp prepared/admission-state.json current-admission-state.json + python scripts/native_release.py validate-verified-index \ + --file prepared/candidate/openadapt-desktop-verified-release.json + engine_tag="$(jq -r .engine_tag prepared/candidate/openadapt-desktop-verified-release.json)" + engine_commit="$(jq -r .engine_commit prepared/candidate/openadapt-desktop-verified-release.json)" + python scripts/native_release.py validate-engine-provenance \ + --file prepared/candidate/openadapt-desktop-engine-release-provenance.json \ + --directory prepared/engine-files \ + --release prepared/candidate/engine-release.json \ + --repository "${GITHUB_REPOSITORY}" \ + --engine-tag "${engine_tag}" \ + --engine-commit "${engine_commit}" + + - name: Write the derived cache for the active admission + id: channel + shell: bash + run: | + set -euo pipefail + path="$(python scripts/production_release.py write-channel \ + --state current-admission-state.json \ + --index prepared/candidate/openadapt-desktop-verified-release.json \ + --engine-provenance prepared/candidate/openadapt-desktop-engine-release-provenance.json \ + --engine-release prepared/candidate/engine-release.json \ + --engine-directory prepared/engine-files \ + --output-directory derived-cache \ + --repository "${GITHUB_REPOSITORY}" \ + --workflow-ref "${GITHUB_WORKFLOW_REF}" \ + --workflow-commit "${{ needs.inspect.outputs.desktop_commit }}" \ + --run-id "${GITHUB_RUN_ID}" \ + --run-attempt "${GITHUB_RUN_ATTEMPT}")" + echo "path=${path}" >> "${GITHUB_OUTPUT}" + echo "name=$(basename "${path}")" >> "${GITHUB_OUTPUT}" + + - name: Attest the derived cache bytes + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4 + with: + subject-path: ${{ steps.channel.outputs.path }} + + - name: Publish once and verify the derived cache + env: + CACHE_ASSET: ${{ steps.channel.outputs.name }} + CACHE_PATH: ${{ steps.channel.outputs.path }} + DESKTOP_COMMIT: ${{ needs.inspect.outputs.desktop_commit }} + shell: bash + run: | + set -euo pipefail + identity="https://github.com/${GITHUB_REPOSITORY}/.github/workflows/production-channel.yml@refs/heads/main" + gh attestation verify "${CACHE_PATH}" \ + --repo "${GITHUB_REPOSITORY}" \ + --cert-identity "${identity}" \ + --cert-oidc-issuer 'https://token.actions.githubusercontent.com' \ + --deny-self-hosted-runners + if gh release view "${PRODUCTION_CHANNEL_TAG}" \ + --json isDraft,isPrerelease,body,assets > production-cache-release.json 2>/dev/null; then + if ! jq -e \ + '.isDraft == false and .isPrerelease == true and + (.body | contains(""))' \ + production-cache-release.json >/dev/null; then + echo "error: Production cache release identity differs" >&2 + exit 1 + fi + if jq -e --arg name "${CACHE_ASSET}" \ + '.assets | any(.name == $name)' production-cache-release.json >/dev/null; then + echo "error: the append-only Production cache asset already exists" >&2 + exit 1 + fi + gh release upload "${PRODUCTION_CHANNEL_TAG}" "${CACHE_PATH}" + else + if git ls-remote --exit-code --tags origin \ + "refs/tags/${PRODUCTION_CHANNEL_TAG}" >/dev/null; then + create_ref=(--verify-tag) + else + create_ref=(--target "${DESKTOP_COMMIT}") + fi + printf '%s\n\n%s\n' \ + '' \ + 'Derived cache only. OpenAdaptAI/.github remains the Production authority.' \ + > production-cache-notes.md + gh release create "${PRODUCTION_CHANNEL_TAG}" \ + "${create_ref[@]}" \ + --prerelease \ + --title "OpenAdapt Desktop Production channel cache" \ + --notes-file production-cache-notes.md \ + "${CACHE_PATH}" + fi + mkdir published-cache + gh release download "${PRODUCTION_CHANNEL_TAG}" --dir published-cache \ + --pattern "${CACHE_ASSET}" + cmp "${CACHE_PATH}" "published-cache/${CACHE_ASSET}" + gh attestation verify "published-cache/${CACHE_ASSET}" \ + --repo "${GITHUB_REPOSITORY}" \ + --cert-identity "${identity}" \ + --cert-oidc-issuer 'https://token.actions.githubusercontent.com' \ + --deny-self-hosted-runners + python scripts/production_release.py verify-channel \ + --file "published-cache/${CACHE_ASSET}" \ + --state current-admission-state.json \ + --index prepared/candidate/openadapt-desktop-verified-release.json \ + --engine-provenance prepared/candidate/openadapt-desktop-engine-release-provenance.json \ + --engine-release prepared/candidate/engine-release.json \ + --engine-directory prepared/engine-files diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a6ee6b5..da3142d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,6 +9,8 @@ name: Release and PyPI Publish # # Ordinary merges run Test and Build artifacts only. Publication is a separate, # deliberate operation after the release train's exact main commit is green. +# Publication creates an unadmitted release candidate. It never changes the +# admission-driven Production channel. # # Requires ADMIN_TOKEN (GitHub PAT with repo scope) to push the release commit # and tag through branch protection. PyPI publication uses Trusted Publishing. diff --git a/README.md b/README.md index 64d166c..273b712 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,15 @@ [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -> **Lifecycle: Beta supporting surface.** OpenAdapt Desktop is the -> local authoring and teaching cockpit for OpenAdapt. The canonical compiler and -> governed runtime live in +> **Role: installed authoring and operation companion.** OpenAdapt Desktop is +> the local authoring and teaching cockpit for OpenAdapt. The canonical compiler +> and governed runtime live in > [`openadapt-flow`](https://github.com/OpenAdaptAI/openadapt-flow). This -> latest published native prerelease is historical and carries its original +> repository publishes release candidates. An exact release becomes the active +> Production default only while the canonical signed lifecycle ledger has a +> current admission for its exact artifacts. An absent, inactive, expired, or +> revoked admission means that the release is not actively admitted. The latest +> published native prerelease is historical and carries its original > ad-hoc/unsigned labels. The next native release is blocked until macOS has > Developer ID plus notarization, Windows has Authenticode, and the exact Linux > bytes pass GitHub OIDC attestation verification. @@ -24,14 +28,14 @@ it **halts instead of guessing** when verification fails. Substrates are all first-class in the product design (web, Windows, macOS, Linux, RDP, and Citrix/VDI). -Substrate maturity, stated the same way across the OpenAdapt repositories: +Substrate roles and qualification evidence: -| Substrate | Maturity | -| --- | --- | -| Browser (web) | Beta; available in production today through the managed browser product | -| Native desktop (Windows, macOS, Linux) | Available for customer-controlled execution; qualification evidence is task- and environment-specific | -| Remote display (RDP) | Available for customer-controlled execution; qualification evidence is task- and environment-specific | -| Citrix / VDI | Available for customer-controlled execution; real-environment ICA/HDX qualification is deployment-specific | +| Substrate | Role | Evidence boundary | +| --- | --- | --- | +| Browser (web) | Managed browser recording and execution | Qualification is task- and environment-specific; the signed lifecycle ledger selects the active Production default | +| Native desktop (Windows, macOS, Linux) | Customer-controlled native recording and execution | Qualification is task- and environment-specific | +| Remote display (RDP) | Customer-controlled remote-display recording and execution | Qualification is task- and environment-specific | +| Citrix / VDI | Customer-controlled remote-application recording and execution | ICA/HDX qualification is deployment-specific | The compiler, replayer, certification, and governed repair all live in `openadapt-flow`. This desktop repository is the cockpit and the local wiring @@ -109,24 +113,24 @@ teaching, escalation, and terminal receipts. ## Status -| Area | Checked-out implementation | Status | +| Area | Checked-out implementation | Evidence and admission state | | --- | --- | --- | -| Python capture CLI | Record, list, inspect, scrub, review, approve, local storage, health, and cleanup commands | Beta; covered by tests | -| Local review gate | Persisted states and egress checks for the capture pipeline | Beta; not the `openadapt-flow` certification system | -| Tauri/React cockpit | Login, onboarding, workflows, target-aware record/review/replay/governed run, teach, and settings calling the engine through Tauri commands | Beta; browser and customer-controlled native/remote targets are available as scoped above, and the shell renders an engine-offline state when the sidecar binary is absent | -| Rust commands | Generic `engine_invoke` bridge plus typed commands, sidecar spawn/watchdog/shutdown, and event re-emission to the WebView | Beta; compiled and bundled in CI | -| Python sidecar IPC | JSON-lines handler backed by a shared `EngineDispatcher` (recording, compile/replay/run/teach, auth, sync/push, review, config) | Beta; unit and e2e tests with mocked boundaries | -| Tray IPC socket server | Token-authenticated loopback TCP server plus a `~/.openadapt/desktop_ipc.json` discovery file for `openadapt-tray` | Beta; not yet validated end to end against the shipped tray | +| Python capture CLI | Record, list, inspect, scrub, review, approve, local storage, health, and cleanup commands | Covered by tests; native capture comes from the canonical `openadapt-capture` component | +| Local review gate | Persisted states and egress checks for the capture pipeline | Separate from the `openadapt-flow` certification system | +| Tauri/React cockpit | Login, onboarding, workflows, target-aware record/review/replay/governed run, teach, and settings calling the engine through Tauri commands | Browser and customer-controlled native/remote targets are available as scoped above; the shell renders an engine-offline state when the sidecar binary is absent | +| Rust commands | Generic `engine_invoke` bridge plus typed commands, sidecar spawn/watchdog/shutdown, and event re-emission to the WebView | Compiled and bundled in CI | +| Python sidecar IPC | JSON-lines handler backed by a shared `EngineDispatcher` (recording, compile/replay/run/teach, auth, sync/push, review, config) | Unit and end-to-end tests use mocked external boundaries | +| Tray IPC socket server | Token-authenticated loopback TCP server plus a `~/.openadapt/desktop_ipc.json` discovery file for `openadapt-tray` | Desktop and the shipped tray are not yet validated together end to end | | Desktop-to-flow handoff | `FlowBridge` launches the pinned Flow runtime embedded in the frozen sidecar as an isolated subprocess | Self-contained; no separate Python or Flow installation | -| Hosted auth and governed handoff | Browser-PKCE and paste-token sign-in; host-bound keychain credentials; exact `openadapt.push-result/v1` review, accepted-ingest, and uncertain-delivery state; local handoff retention; and halted-run break reports | Beta implementation candidate; distribution requires a release-qualified Flow build and live Cloud acceptance before Desktop updates its exact runtime pin | -| Attended phone decisions | One-use QR pairing, protected local evidence, typed allowed actions, runner revalidation, receipts, device revocation, and an optional outbound hosted lane | Beta; device pairing does not replace the deployment's authenticated operator principal | +| Hosted auth and governed handoff | Browser-PKCE and paste-token sign-in; host-bound keychain credentials; exact `openadapt.push-result/v1` review, accepted-ingest, and uncertain-delivery state; local handoff retention; and halted-run break reports | Distribution requires a release-qualified Flow build and live Cloud acceptance before Desktop updates its exact runtime pin | +| Attended phone decisions | One-use QR pairing, protected local evidence, typed allowed actions, runner revalidation, receipts, device revocation, and an optional outbound hosted lane | Device pairing does not replace the deployment's authenticated operator principal | | Build artifacts | Wheel/sdist, a self-contained PyInstaller engine+Flow runtime, and DMG/MSI/NSIS/DEB/AppImage native jobs | Native jobs prove the frozen browser lifecycle, structurally install/uninstall, and label every platform, architecture, and signing state | -| Native installers | Distinct `desktop-v*` draft-prerelease workflow with final-byte checksums and GitHub provenance, auto-triggered at each engine release | Beta distribution lane; signing state is encoded in every filename and workflow qualification remains specific | -| Code signing and updater | Apple Developer ID/notarization and Windows Authenticode are credential-gated and fail closed on partial configuration; the updater feed is disabled | In progress; not a supported release channel | +| Native installers | Distinct `desktop-v*` prerelease workflow with final-byte checksums and GitHub provenance | Unadmitted release-candidate lane; signing state is encoded in every filename and workflow qualification remains specific | +| Code signing and updater | Apple Developer ID/notarization and Windows Authenticode are credential-gated and fail closed on partial configuration; the updater feed is disabled | Candidate publication requires the complete platform trust set; the updater is outside the current channel | -The self-contained `openadapt-engine` freeze is implemented. External code -signing/notarization and the signed updater-key lifecycle remain before a -generally available native release. +CI builds the self-contained `openadapt-engine` freeze. Candidate publication +requires the external code-signing and notarization controls. Production +selection also requires an active central admission for the exact artifacts. ## Use OpenAdapt today @@ -192,7 +196,7 @@ To work on the shell and frontend you also need Rust, Node.js, and the Tauri CLI. A dev shell runs frontend-only and shows the engine as offline until a frozen sidecar binary from CI is present. -### Native installers (Beta) +### Native installer release candidates Native packages are published under a distinct `desktop-vX.Y.Z` prerelease channel, separate from the engine's `vX.Y.Z` PyPI/GitHub releases. The native @@ -220,7 +224,7 @@ pinned sources, hashes, and modification status are recorded in - Which release to download, and the two-lane policy, are in [RELEASES.md](RELEASES.md). - Artifact names, verification scope, and provenance are in - [Beta Native Installers](docs/BETA_NATIVE_INSTALLERS.md). + [Native Release Candidates](docs/RELEASE_CANDIDATE_INSTALLERS.md). - The signing activation runbook (what to buy, which secrets to add, and what each surface may then truthfully claim) is in [docs/CODE_SIGNING.md](docs/CODE_SIGNING.md). @@ -269,9 +273,10 @@ compiler or runtime. Those remain in `openadapt-flow`. real-application qualification remains workflow-specific. - The frozen `openadapt-engine` sidecar binary is produced only by CI. A plain dev checkout runs the shell in frontend-only mode. -- Native packages remain Beta. The latest published prerelease predates the - mandatory platform trust gate. Structural install/uninstall success is not - evidence of a validated workflow. +- Native packages remain unadmitted release candidates until the central + Production lifecycle activates an exact release. The latest published + prerelease predates the mandatory platform trust gate. Structural + install/uninstall success is not evidence of a validated workflow. - Apple Developer ID/notarization and Windows Authenticode credentials must be provisioned before the next native release. A missing or partial set stops the release. The updater and rollback remain disabled pending an independent @@ -281,7 +286,7 @@ compiler or runtime. Those remain in `openadapt-flow`. ## CLI surface -The Python engine exposes these Beta commands: +The Python engine exposes these commands: | Command | Purpose | | --- | --- | @@ -340,9 +345,9 @@ current public product boundary. | --- | --- | | [`openadapt-flow`](https://github.com/OpenAdaptAI/openadapt-flow) | Canonical workflow compiler, runtime, certification, and governed repair engine | | [`OpenAdapt`](https://github.com/OpenAdaptAI/OpenAdapt) | Flagship launcher and meta-repository | -| [`openadapt-tray`](https://github.com/OpenAdaptAI/openadapt-tray) | Experimental system-tray status and launcher companion for this cockpit | -| [`openadapt-capture`](https://github.com/OpenAdaptAI/openadapt-capture) | Beta canonical native screen, mouse, keyboard, timing, window-scoping, and media-capture component | -| [`openadapt-privacy`](https://github.com/OpenAdaptAI/openadapt-privacy) | Experimental PII detection and redaction component | +| [`openadapt-tray`](https://github.com/OpenAdaptAI/openadapt-tray) | System-tray status and launcher companion for this cockpit | +| [`openadapt-capture`](https://github.com/OpenAdaptAI/openadapt-capture) | Canonical native screen, mouse, keyboard, timing, window-scoping, and media-capture component | +| [`openadapt-privacy`](https://github.com/OpenAdaptAI/openadapt-privacy) | PII detection and redaction component | Documentation for the wider stack lives at [docs.openadapt.ai](https://docs.openadapt.ai). diff --git a/RELEASES.md b/RELEASES.md index 024b094..9983f83 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -1,8 +1,8 @@ # Release Policy -This repository publishes from two lanes while the native channel remains -Beta. This document is the source of truth for what each lane produces, which -release to download, and how the lanes converge. +This repository publishes unadmitted candidates from two lanes. Production is +an admission-driven channel with a separate manual derivation step. This +document defines the outputs, ordering, and release-selection rules. ## Active release hold: the current Flow pin is not releasable @@ -24,31 +24,32 @@ following are true, in order: and its qualification evidence from that accepted release. 4. This section is deleted in the same pull request that lands the new pin. -This is a distribution hold, not a claim of production acceptance. Neither lane -carries a signed qualification-admission record today, so no release note, -manifest, or installer may state or imply production acceptance. +This is a distribution hold, not a claim of Production acceptance. A normal +release never creates a Production admission. No release note, manifest, or +installer can state or imply Production acceptance before central activation. ## The two lanes | Lane | Tag | Trigger | Marked as | Assets | | --- | --- | --- | --- | --- | | Engine (Python package) | `vX.Y.Z` | Explicit `Release and PyPI Publish` dispatch from reviewed, green `main` | Regular release ("Latest") | Wheel, sdist, an attested engine-release provenance receipt, PyPI publish attestations, **and a mirrored copy of the matching `desktop-vX.Y.Z` installer set** | -| Native installers | `desktop-vX.Y.Z` | Explicit `Native Installer Release` dispatch from reviewed, green `main` | Published **prerelease** | Beta installers, platform metadata, SBOM, website manifest, signed build provenance, and `SHA256SUMS` | -| Stable native channel | `desktop-channel` | Final promotion step in the same native dispatch | Published **prerelease authority** | The attested, strictly monotonic `openadapt-desktop-channel.json` descriptor | +| Native installers | `desktop-vX.Y.Z` | Explicit `Native Installer Release` dispatch from reviewed, green `main` | Published **prerelease candidate** | Candidate installers, platform metadata, SBOM, website manifest, signed build provenance, and `SHA256SUMS` | +| Candidate native channel | `desktop-channel` | Final candidate-index step in the same native dispatch | Published **prerelease index** | The attested, strictly monotonic `openadapt-desktop-channel.json` descriptor | +| Production derived cache | `desktop-production-channel` | Separate manual dispatch after central admission activation | Published **prerelease cache** | One append-only descriptor for each exact active central admission | -The engine lane stays non-prerelease so GitHub's "Latest" pointer always names -the canonical engine release. The native lane stays prerelease because its -installer surface is Beta. The native release workflow now requires Developer +The engine lane stays non-prerelease so GitHub's "Latest" pointer names the +newest engine candidate. "Latest" is not the Production default. The native +lane stays prerelease because an artifact does not create admission. The native release workflow requires Developer ID plus notarization on macOS, Authenticode on Windows, and GitHub OIDC attestation over the exact Linux DEB and AppImage bytes; see -[docs/BETA_NATIVE_INSTALLERS.md](docs/BETA_NATIVE_INSTALLERS.md) +[docs/RELEASE_CANDIDATE_INSTALLERS.md](docs/RELEASE_CANDIDATE_INSTALLERS.md) for the verification scope and signing states. ## Which release should I download? - **Python package / CLI**: install from PyPI (`pip install openadapt-desktop`) or take the wheel from the newest `vX.Y.Z` release. -- **Native installers (Beta)**: use the `vX.Y.Z` engine release selected by the +- **Native installer candidate**: use the `vX.Y.Z` engine release selected by the attested `openadapt-desktop-verified-release.json` channel index. The index binds the matching `desktop-vX.Y.Z` source release and the identical mirrored bytes. Authenticate `SHA256SUMS`, then verify its exact inventory. Do not use @@ -83,11 +84,11 @@ bytes. The pointer block is rewritten in place, so pointers never accumulate. If the matching engine release or its attested receipt is missing, the transaction fails before the platform builds start. -#### Why mirroring does not promote the Beta channel +#### Why mirroring does not promote the Production channel The earlier policy here was "linked, not mirrored", on the reasoning that -putting ~757 MB of Beta binaries on the release GitHub labels "Latest" would -overstate their maturity. A notes-only link was not +putting approximately 757 MB of candidate binaries on the release GitHub labels +"Latest" could be mistaken for Production selection. A notes-only link was not enough: `/releases/latest` still showed a visitor nothing but a wheel and an sdist, and that link is what launch material points at. The maturity concern is addressed directly instead of by withholding the artifact: @@ -124,7 +125,7 @@ were pushed by hand. Three workflows now keep it fresh: can rebuild and publish a pre-existing, main-contained ref after checking that ref's exact CI; ordinary merges never publish packages. 2. **Native Installer Freshness** (`.github/workflows/native-freshness.yml`): - when that engine release is published, it verifies the stable engine release + when that engine release is published, it verifies the published engine release and opens a pull request with the exact deterministic transform of `package.json`, `package-lock.json`, `src-tauri/Cargo.toml`, `src-tauri/Cargo.lock`, and `src-tauri/tauri.conf.json`. It never writes to @@ -133,11 +134,21 @@ were pushed by hand. Three workflows now keep it fresh: 3. **Native Installer Release** (`.github/workflows/native-release.yml`): a maintainer dispatches the workflow from reviewed `main` with the exact version. The workflow verifies current main, the five-file version transform, - the stable engine release, and its attested receipt before it starts the + the published engine release, and its attested receipt before it starts the fail-closed signing preflight. The same transaction builds, smoke-tests, - attests, publishes, mirrors, writes the verified index, promotes the - monotonic channel, updates the pointer, and marks older prereleases - superseded. Only this workflow creates `desktop-vX.Y.Z`. + attests, publishes, mirrors, writes the verified index, updates the + monotonic candidate channel, updates the pointer, and marks older + prereleases superseded. Only this workflow creates `desktop-vX.Y.Z`. It + never changes the Production channel. + +4. **Production channel admission** + (`.github/workflows/production-channel.yml`): a maintainer supplies one + immutable current `OpenAdaptAI/.github` commit after the canonical ledger + activates the exact release. The workflow runs the canonical validator, + requires exact version and artifact equality with the unchanged published + candidate, and then appends one attested derived-cache descriptor. The + descriptor records the central commit, admission ID, admission digest, + expiry, and complete artifact inventory. It is not a second authority. When the external controls below are active, each engine release `vX.Y.Z` can get a matching native prerelease `desktop-vX.Y.Z` from the same reviewed source. @@ -170,9 +181,24 @@ Before the next native tag or release: The `native-release` environment reviewer is an additional publish boundary. It does not replace the main and tag rulesets. +Production activation is a separate action after candidate publication: + +1. Add one exact, evidence-backed Desktop admission to the canonical + `OpenAdaptAI/.github` ledger. The canonical validator must select it as the + active latest Desktop admission. +2. Protect the `production-release` environment on Desktop `main`, with no + admin bypass. It can cache an active admission. It cannot create one. +3. Dispatch `production-channel.yml` with `operation=promote` and the exact + current central commit. The workflow refuses any version, source, artifact, + digest, expiry, or central-current mismatch. +4. Verify the appended cache attestation and the next scheduled drift check. + +Until these steps pass, the candidate is not actively admitted. A failed +promotion does not change or remove a prior cache. + ## Supersession -After the verified index and channel are published, the same transaction edits +After the verified index and candidate channel are published, the same transaction edits every lower marked `desktop-v*` prerelease to carry a prominent "Superseded by `desktop-vX.Y.Z` — do not use" notice at the top of its notes (machine marker: @@ -182,7 +208,7 @@ any deletion is a human decision. ## Machine-readable selection rule (download pages) -Consumers must not select a release from mutable release notes. Fetch the +Candidate consumers must not select a release from mutable release notes. Fetch the attested `openadapt-desktop-channel.json` asset from the `desktop-channel` release. It binds the selected `openadapt-desktop-verified-release.json` on the matching engine release. The channel must strictly advance from its prior @@ -204,6 +230,20 @@ descriptor. `verify-openadapt-native-release.py`. The helper refuses an incomplete or expanded directory. +The candidate descriptor does not select a Production release. A Production +consumer first fetches one immutable current `OpenAdaptAI/.github` commit, runs +its canonical validator, and selects the active latest Desktop admission. It +then fetches the deterministic cache asset whose filename contains that central +commit and admission digest. The consumer requires exact central commit, admission, +expiry, release, artifact, verified-index, and engine-receipt equality. If the +admission is absent, expired, revoked, or different, Desktop has no Production +default. It never falls back to an older admission or to PyPI/GitHub "Latest". + +The scheduled drift check applies this same rule. A normal release can publish +a newer candidate without changing Production. A central admission change +fails the check until the exact derived cache exists. A failed promotion leaves +all prior cache assets unchanged. + The release-note markers remain useful for human notices and historical supersession. They are not a machine trust boundary. @@ -219,7 +259,7 @@ supersession. They are not a machine trust boundary. ## Convergence plan -The next native release cannot publish until Apple Developer ID plus +The next native candidate cannot publish until Apple Developer ID plus notarization and Windows Authenticode are configured and the Linux exact-byte attestations verify. After the first complete trusted release proves the full channel, the repository can simplify the two upload targets: diff --git a/docs/CODE_SIGNING.md b/docs/CODE_SIGNING.md index a9416fd..b8c7661 100644 --- a/docs/CODE_SIGNING.md +++ b/docs/CODE_SIGNING.md @@ -1,8 +1,9 @@ # Code Signing Runbook (founder activation) -This is the step-by-step guide to move OpenAdapt Desktop installers from -**Beta / unsigned** to **signed and trusted**. It lists exactly what to -buy, which secrets to add, and what each public surface may then truthfully say. +This guide adds the required platform trust to OpenAdapt Desktop release +candidates. It lists what to buy, which secrets to add, and what each public +surface can then truthfully say. Code signing does not create a Production +admission. The regular build workflow can still produce explicit ad-hoc or unsigned CI artifacts. The native release workflow is stricter. It fails closed unless @@ -18,7 +19,7 @@ complete Authenticode method. It also fails closed on a partial set. - `method` — *how* a signed Windows artifact is produced (`pfx` vs `trusted-signing`); `pfx`/`adhoc`/`unsigned` otherwise. - The `mode` is baked into every artifact **filename**: - `OpenAdapt-Desktop-Beta-v---.`. + `OpenAdapt-Desktop-Candidate-v---.`. This is the honesty mechanism — a download page or trust center can read the filename token and never overstate maturity. When you configure macOS Developer ID, the macOS asset name flips from `-adhoc-` to @@ -193,10 +194,10 @@ release has actually built. The artifact filename token is the source of truth. | Surface | With no secrets | After macOS Developer ID | After Windows Authenticode | Linux OIDC attestation | | --- | --- | --- | --- | --- | | /download page | No new native release; the release gate stops. | "**Signed and notarized by Apple** on macOS — opens without a Gatekeeper override." | "**Signed with a trusted Authenticode certificate** on Windows." | "Linux DEB and AppImage downloads have **GitHub OIDC attestations over the exact bytes**." | -| Trust center | Existing historical Beta artifacts keep their encoded signing state. | Add: "macOS DMGs pass Apple notarization (`spctl` accepted, ticket stapled)." | Add: "Windows installers carry a valid, timestamped Authenticode signature." | Add: "Linux packages pass `gh attestation verify` against this repository." | +| Trust center | Existing historical candidate artifacts keep their encoded signing state. | Add: "macOS DMGs pass Apple notarization (`spctl` accepted, ticket stapled)." | Add: "Windows installers carry a valid, timestamped Authenticode signature." | Add: "Linux packages pass `gh attestation verify` against this repository." | | README honesty note | Describe only the latest published artifact set. | Update the note after the first trusted release. | Update the note after the first trusted release. | Update the note after the first trusted release. | -The README signing note and `docs/BETA_NATIVE_INSTALLERS.md` both point +The README signing note and `docs/RELEASE_CANDIDATE_INSTALLERS.md` both point here; update their per-platform wording when each platform's first **trusted** release ships (not when the secrets are merely added). The download page needs no code change to detect signing — it reads the `--` token in the asset diff --git a/docs/BETA_NATIVE_INSTALLERS.md b/docs/RELEASE_CANDIDATE_INSTALLERS.md similarity index 93% rename from docs/BETA_NATIVE_INSTALLERS.md rename to docs/RELEASE_CANDIDATE_INSTALLERS.md index 1ea57e6..0c789ea 100644 --- a/docs/BETA_NATIVE_INSTALLERS.md +++ b/docs/RELEASE_CANDIDATE_INSTALLERS.md @@ -1,9 +1,9 @@ -# Beta Native Installers +# Native Release Candidates -OpenAdapt Desktop native packages are the **Beta installed authoring, -teaching, and local-pairing companion** for OpenAdapt. They bundle and start the +OpenAdapt Desktop native packages are **unadmitted candidates** for the +installed authoring, teaching, and local-pairing companion. They bundle and start the Python sidecar, connect the Tauri/React cockpit to it over local JSON-lines IPC, and register the `openadapt://` operating-system handler. The handler accepts only the fixed `openadapt://connect` action and forwards it to the sidecar's @@ -48,7 +48,8 @@ that pull request. The freshness workflow never creates a tag or release. A maintainer then dispatches the Native Installer Release workflow from reviewed `main`. That one transaction verifies the engine receipt, creates the matching `desktop-vX.Y.Z` tag, publishes and verifies the installers, mirrors them to the -engine release, and promotes the signed stable channel. +engine release, and updates the signed candidate channel. This transaction does +not change the admission-driven Production channel. All lower native prereleases receive a prominent "Superseded — do not use" notice. Their assets remain for provenance. The full two-lane release policy and its planned convergence into a single release are documented in @@ -56,7 +57,7 @@ and its planned convergence into a single release are documented in ## Artifact labels -Every filename includes `Beta`, the native version, operating system, +Every new filename includes `Candidate`, the native version, operating system, architecture, and signing state. The initial matrix is: | Platform | Architectures | Packages | Signing labels | @@ -87,7 +88,7 @@ SBOM, includes it and the public build-provenance identity in the sorted `SHA256SUMS` manifest, verifies the exact inventory, and creates a GitHub artifact attestation over every named file. It also attests `SHA256SUMS` itself. The release verifier requires the signed subjects to equal that inventory. It -binds the exact reviewed-main workflow, source commit, stable engine tag and +binds the exact reviewed-main workflow, source commit, published engine tag and release, run ID, run attempt, and GitHub-hosted runner. Consumers must authenticate `SHA256SUMS` before they trust its digests: @@ -105,7 +106,7 @@ The final helper refuses missing, extra, linked, non-regular, duplicate, or digest-mismatched files. The canonical engine release also publishes an attested `openadapt-desktop-verified-release.json`. The `desktop-channel` release carries the attested, strictly monotonic -`openadapt-desktop-channel.json` authority. This closed chain +`openadapt-desktop-channel.json` candidate index. This closed chain identifies the exact native tag, engine release, source commits, workflow run, checksum digest, and complete asset set. A download service must verify this index attestation. It must not select a release from mutable release-note text. diff --git a/engine/auth/store.py b/engine/auth/store.py index f791a7f..60ee4cd 100644 --- a/engine/auth/store.py +++ b/engine/auth/store.py @@ -58,7 +58,7 @@ # ``host`` account holds the RAW bearer token (what the tray reads). _CRED_SUFFIX = "|cred" -# Suffix for the runner-lane credential (EXPERIMENTAL, spec 2.1): the per-runner +# Suffix for the runner-lane credential (spec 2.1): the per-runner # id + bearer token minted by POST /api/runners/register. Kept separate from the # user session credential -- deleting one never clobbers the other, and the raw # ``host`` account keeps holding the session token the tray reads. diff --git a/engine/cli.py b/engine/cli.py index 3a0a973..6727d8c 100644 --- a/engine/cli.py +++ b/engine/cli.py @@ -814,7 +814,7 @@ def main(argv: list[str] | None = None) -> None: """CLI entry point.""" parser = argparse.ArgumentParser( prog="openadapt-desktop", - description="OpenAdapt Desktop Beta local authoring and governed execution CLI", + description="OpenAdapt Desktop local authoring and governed execution CLI", ) subparsers = parser.add_subparsers(dest="command", required=True) diff --git a/engine/config.py b/engine/config.py index 8d84526..f34c5c7 100644 --- a/engine/config.py +++ b/engine/config.py @@ -202,8 +202,8 @@ def settings_customise_sources( runner_enabled: bool = Field( default=False, description=( - "EXPERIMENTAL: enable the outbound runner loop (long-poll " - "/api/runners/* for governed dispatches). Off by default." + "Enable the opt-in outbound runner loop (long-poll /api/runners/* " + "for governed dispatches). Off by default." ), ) diff --git a/engine/dispatch.py b/engine/dispatch.py index fd1eba0..3027940 100644 --- a/engine/dispatch.py +++ b/engine/dispatch.py @@ -300,7 +300,7 @@ def _register(self) -> None: # tray-only UI navigation (relayed to the desktop frontend) "open_workflow_library": self.open_workflow_library, "open_teach": self.open_teach, - # runner lane (EXPERIMENTAL -- outbound /api/runners/* long-poll) + # Outbound runner lane (/api/runners/* long-poll). "runner_status": self.runner_status, "runner_enable": self.runner_enable, "runner_disable": self.runner_disable, @@ -3093,7 +3093,7 @@ def get_pending_reviews(self, **params: Any) -> dict: # ------------------------------------------------------- runner lane def _runner_service(self) -> Any: - """Lazily build the shared runner-loop service (EXPERIMENTAL lane).""" + """Lazily build the shared outbound runner-loop service.""" if self.services.runner is None: from engine.runner_loop import RunnerService diff --git a/engine/main.py b/engine/main.py index 23cd486..336d028 100644 --- a/engine/main.py +++ b/engine/main.py @@ -226,8 +226,8 @@ def main() -> None: handler = IPCHandler(config=config, services=services) - # EXPERIMENTAL runner lane: resume the outbound dispatch loop only when the - # operator explicitly enabled it (off by default; toggled on the Runner screen). + # Resume the outbound dispatch loop only when the operator explicitly enabled + # it (off by default; toggled on the Runner screen). if config.runner_enabled: try: handler.dispatcher.runner_status() # builds the shared service diff --git a/engine/runner_loop.py b/engine/runner_loop.py index 86c7cd0..e4eda7f 100644 --- a/engine/runner_loop.py +++ b/engine/runner_loop.py @@ -1,4 +1,4 @@ -"""runner_loop -- EXPERIMENTAL outbound runner lane (cloud dispatch -> local execution). +"""Outbound runner lane for governed cloud dispatch to local execution. Implements the desktop half of the hosted runner platform (P0): an outbound HTTPS long-poll loop against ``/api/runners/*`` on the hosted control plane @@ -42,9 +42,8 @@ completes without a halt is acked ``halted-needs-attention`` with :data:`COMPLETION_PROOF_REQUIRED_REASON`; it is NEVER acked ``confirmed``. -The whole lane is experimental and OFF by default (``runner_enabled=false``); -the cloud half is built in parallel -- this module codes to the spec's wire -format, not to a particular server implementation. +The lane is OFF by default (``runner_enabled=false``). This module codes to the +specified wire format, not to a particular server implementation. """ from __future__ import annotations diff --git a/package.json b/package.json index 8553b64..1d14fc9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openadapt-desktop", "version": "0.15.0", - "description": "Beta installed companion for OpenAdapt authoring, teaching, and local pairing", + "description": "Installed companion for OpenAdapt authoring, teaching, and local pairing", "private": true, "type": "module", "scripts": { diff --git a/pyproject.toml b/pyproject.toml index 9941290..93375a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "openadapt-desktop" version = "0.15.0" -description = "Beta desktop authoring surface and local capture/review tooling for OpenAdapt workflows" +description = "Desktop authoring surface and local capture/review tooling for OpenAdapt workflows" readme = "README.md" requires-python = ">=3.11" license = "MIT" @@ -14,7 +14,6 @@ authors = [ ] keywords = ["desktop", "recording", "workflow-authoring", "human-in-the-loop", "automation"] classifiers = [ - "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", diff --git a/scripts/native_release.py b/scripts/native_release.py index fda3232..465971f 100644 --- a/scripts/native_release.py +++ b/scripts/native_release.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Prepare and verify honest Beta native release assets.""" +"""Prepare and verify unadmitted native release-candidate assets.""" from __future__ import annotations @@ -16,7 +16,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -LIFECYCLE = "Beta" +LIFECYCLE = "Candidate" SURFACE = "installed desktop pairing and authoring companion" NATIVE_TAG_PREFIX = "desktop-v" VERSION_PATTERN = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+") @@ -65,7 +65,8 @@ VERIFIED_RELEASE_INDEX = "openadapt-desktop-verified-release.json" VERIFIED_RELEASE_INDEX_SCHEMA = "openadapt.desktop-verified-release/v1" VERIFIED_RELEASE_CHANNEL = "openadapt-desktop-channel.json" -VERIFIED_RELEASE_CHANNEL_SCHEMA = "openadapt.desktop-release-channel/v1" +VERIFIED_RELEASE_CHANNEL_SCHEMA = "openadapt.desktop-release-channel/v2" +LEGACY_VERIFIED_RELEASE_CHANNEL_SCHEMA = "openadapt.desktop-release-channel/v1" VERIFIED_RELEASE_CHANNEL_TAG = "desktop-channel" NATIVE_PROMOTION_WORKFLOW = ".github/workflows/native-release.yml" NATIVE_RELEASE_VERIFIER = "verify-openadapt-native-release.py" @@ -108,7 +109,7 @@ def expected_release_asset_names(version: str) -> set[str]: } for platform, architecture in EXPECTED_PLATFORMS: signing = PRODUCTION_TRUST_MODES[platform] - prefix = f"OpenAdapt-Desktop-Beta-v{version}-{platform}-{architecture}-{signing}" + prefix = f"OpenAdapt-Desktop-Candidate-v{version}-{platform}-{architecture}-{signing}" names.add(f"{prefix}-metadata.json") names.update(f"{prefix}{suffix}" for _kind, _pattern, suffix in ARTIFACT_RULES[platform]) return names @@ -501,7 +502,7 @@ def superseded_notes(body: str, newer_tag: str, repo: str) -> str | None: "> [!CAUTION]\n" f"> **Superseded by [{newer_tag}](https://github.com/{repo}/releases/tag/{newer_tag})" " — do not use.**\n" - "> Newer Beta native installers replace these assets. The assets below are\n" + "> A newer admitted or candidate release replaces these assets. The assets below are\n" "> retained for provenance only; deleting releases or assets is a maintainer\n" "> decision made outside CI." f"{SUPERSEDED_SEPARATOR}" @@ -537,14 +538,15 @@ def installer_pointer_notes(body: str, native_tag: str, repo: str) -> str | None block = ( f"{INSTALLER_POINTER_START}\n" "> [!IMPORTANT]\n" - "> **Looking for the desktop app? The Beta installers are attached\n" + "> **Looking for the desktop app? The release-candidate installers are attached\n" "> below.**\n" "> macOS DMG (arm64 and x86_64), Windows MSI and NSIS `.exe`, Linux\n" "> `.deb` and `.AppImage`, plus `SHA256SUMS` — the same attested bytes\n" f"> published at [`{native_tag}`]({base}/tag/{native_tag}), mirrored\n" '> here so GitHub\'s "Latest" always carries an installer.\n' ">\n" - "> **These installers are Beta, but the release trust gate is mandatory.**\n" + "> **These installers are not admitted to Production.**\n" + "> The release trust gate is mandatory.\n" "> macOS requires Developer ID plus notarization. Windows requires\n" "> timestamped Authenticode. Linux DEB and AppImage bytes require GitHub\n" "> OIDC artifact attestations. The trust state is in every filename.\n" @@ -602,7 +604,7 @@ def stage_artifacts( output.mkdir(parents=True, exist_ok=True) version = native_version(root) - prefix = f"OpenAdapt-Desktop-Beta-v{version}-{platform}-{architecture}-{signing}" + prefix = f"OpenAdapt-Desktop-Candidate-v{version}-{platform}-{architecture}-{signing}" staged: list[Path] = [] artifact_names: list[str] = [] for kind, pattern, suffix in ARTIFACT_RULES[platform]: @@ -892,7 +894,7 @@ def validate_engine_release( or release.get("tagName") != engine_tag or release.get("url") != expected_url ): - raise ValueError("engine release is not the exact published stable release") + raise ValueError("engine release is not the exact published engine release") if provenance is not None: expected = { "engine_tag": engine_tag, @@ -1226,7 +1228,7 @@ def write_verified_release_channel( run_attempt: int, existing: Path | None = None, ) -> Path: - """Write the stable, attested, strictly monotonic release descriptor.""" + """Write the attested, strictly monotonic candidate descriptor.""" repository = _validate_repository(repository) index = validate_verified_release_index(index_path) @@ -1260,7 +1262,7 @@ def write_verified_release_channel( payload = { "schema": VERIFIED_RELEASE_CHANNEL_SCHEMA, "repository": repository, - "channel": "stable-native", + "channel": "candidate-native", "native_tag": index["native_tag"], "native_version": index["native_version"], "native_source_commit": index["native_source_commit"], @@ -1302,7 +1304,7 @@ def write_verified_release_channel( def validate_verified_release_channel(path: Path) -> dict: - """Validate the closed stable-channel descriptor without network trust.""" + """Validate the closed candidate-channel descriptor without network trust.""" if path.name != VERIFIED_RELEASE_CHANNEL or not path.is_file() or path.is_symlink(): raise ValueError(f"release channel must be a regular {VERIFIED_RELEASE_CHANNEL}") @@ -1325,11 +1327,18 @@ def validate_verified_release_channel(path: Path) -> dict: "promotion", } if not isinstance(data, dict) or set(data) != expected_keys: - raise ValueError("release channel does not use the closed v1 schema") - if data.get("schema") != VERIFIED_RELEASE_CHANNEL_SCHEMA: + raise ValueError("release channel does not use a closed supported schema") + schema = data.get("schema") + if schema not in { + VERIFIED_RELEASE_CHANNEL_SCHEMA, + LEGACY_VERIFIED_RELEASE_CHANNEL_SCHEMA, + }: raise ValueError("release channel has the wrong schema") repository = _validate_repository(str(data.get("repository") or "")) - if data.get("channel") != "stable-native": + expected_channel = ( + "candidate-native" if schema == VERIFIED_RELEASE_CHANNEL_SCHEMA else "stable-native" + ) + if data.get("channel") != expected_channel: raise ValueError("release channel has the wrong channel name") native_tag_tuple(str(data.get("native_tag") or "")) version = data["native_tag"].removeprefix(NATIVE_TAG_PREFIX) @@ -1596,7 +1605,7 @@ def validate_release_workflow_run(path: Path, *, provenance: dict) -> int: "Windows x86_64", "Linux x86_64 (GitHub-attested bytes)", "Checksum and attest exact release bytes", - "Publish the verified Beta prerelease", + "Publish the verified candidate prerelease", } failed = { name: conclusions.get(name) for name in required if conclusions.get(name) != "success" @@ -1638,7 +1647,7 @@ def validate_release_set(directory: Path) -> int: ) if metadata.get("native_version") != version: raise ValueError(f"wrong native version in {metadata_path}") - prefix = f"OpenAdapt-Desktop-Beta-v{version}-{platform}-{architecture}-{signing}" + prefix = f"OpenAdapt-Desktop-Candidate-v{version}-{platform}-{architecture}-{signing}" if metadata_path.name != f"{prefix}-metadata.json": raise ValueError(f"metadata filename does not match its labels: {metadata_path.name}") expected_artifacts = {f"{prefix}{suffix}" for _, _, suffix in ARTIFACT_RULES[platform]} @@ -2294,7 +2303,7 @@ def main() -> int: ) elif args.command == "validate-release-channel": channel = validate_verified_release_channel(args.file) - print(f"Validated stable release channel for {channel['native_tag']}") + print(f"Validated candidate release channel for {channel['native_tag']}") except (OSError, ValueError) as exc: print(f"error: {exc}", file=sys.stderr) return 2 diff --git a/scripts/production_release.py b/scripts/production_release.py new file mode 100644 index 0000000..7fc4098 --- /dev/null +++ b/scripts/production_release.py @@ -0,0 +1,645 @@ +#!/usr/bin/env python3 +"""Derive the Desktop Production channel from the canonical release admission. + +The OpenAdaptAI/.github lifecycle ledger is the only Production authority. +This module writes an append-only, attested cache for one active Desktop +admission. It never creates, signs, or changes an admission. +""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import re +import sys +from collections.abc import Callable, Mapping +from pathlib import Path +from types import ModuleType +from typing import Any + +try: + from scripts.native_release import ( + ENGINE_RELEASE_PROVENANCE, + VERIFIED_RELEASE_INDEX, + validate_engine_release_provenance, + validate_verified_release_index, + ) +except ModuleNotFoundError: # Direct execution adds scripts/, not the repository root. + from native_release import ( # type: ignore[no-redef] + ENGINE_RELEASE_PROVENANCE, + VERIFIED_RELEASE_INDEX, + validate_engine_release_provenance, + validate_verified_release_index, + ) + +ROOT = Path(__file__).resolve().parents[1] +CANONICAL_REPOSITORY = "OpenAdaptAI/.github" +DESKTOP_REPOSITORY = "OpenAdaptAI/openadapt-desktop" +DESKTOP_TARGET = "desktop" +STATE_SCHEMA = "openadapt.desktop-production-admission-state/v1" +CHANNEL_SCHEMA = "openadapt.desktop-production-channel-cache/v1" +CHANNEL_TAG = "desktop-production-channel" +CHANNEL_PREFIX = "openadapt-desktop-production-channel-" +PROMOTION_WORKFLOW = ".github/workflows/production-channel.yml" +COMMIT = re.compile(r"^[0-9a-f]{40}$") +DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$") +VERSION = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$") + + +def _bytes_digest(value: bytes) -> str: + return "sha256:" + hashlib.sha256(value).hexdigest() + + +def _canonical_digest(value: object) -> str: + payload = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode( + "utf-8" + ) + return _bytes_digest(payload) + + +def _load_json(path: Path, label: str) -> dict[str, Any]: + if not path.is_file() or path.is_symlink(): + raise ValueError(f"{label} must be a regular file") + try: + value = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ValueError(f"{label} is not valid JSON") from exc + if not isinstance(value, dict): + raise ValueError(f"{label} must be a JSON object") + return value + + +def _load_canonical_validator(root: Path) -> ModuleType: + path = root / "scripts" / "validate_production_lifecycle.py" + if not path.is_file() or path.is_symlink(): + raise ValueError("the canonical Production lifecycle validator is absent") + module_name = ( + "openadapt_canonical_production_lifecycle_" + + hashlib.sha256(str(path.resolve()).encode("utf-8")).hexdigest() + ) + spec = importlib.util.spec_from_file_location(module_name, path) + if spec is None or spec.loader is None: + raise ValueError("the canonical Production lifecycle validator cannot load") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def build_admission_state( + lifecycle_root: Path, + *, + central_source_commit: str, + validate_files: Callable[[Path], Mapping[str, str]] | None = None, +) -> dict[str, Any]: + """Run the canonical validator and select its active Desktop admission.""" + + if COMMIT.fullmatch(central_source_commit) is None: + raise ValueError("the canonical source commit must be a 40-character commit") + required = { + "policy": lifecycle_root / "production-lifecycle-policy.json", + "admissions": lifecycle_root / "production-lifecycle-admissions.json", + "lifecycle": lifecycle_root / "repository-lifecycle.yml", + "validator": lifecycle_root / "scripts" / "validate_production_lifecycle.py", + } + for label, path in required.items(): + if not path.is_file() or path.is_symlink(): + raise ValueError(f"the canonical {label} file is absent") + if validate_files is None: + validator = _load_canonical_validator(lifecycle_root) + validate_files = validator.validate_files + active = validate_files(lifecycle_root) + if not isinstance(active, Mapping) or not all( + isinstance(key, str) and isinstance(value, str) for key, value in active.items() + ): + raise ValueError("the canonical validator returned an invalid active map") + + admissions_document = _load_json(required["admissions"], "canonical admissions") + admissions = admissions_document.get("admissions") + if not isinstance(admissions, list): + raise ValueError("the canonical admissions document has no admission list") + admission_id = active.get(DESKTOP_TARGET) + selected: dict[str, Any] | None = None + if admission_id is not None: + matches = [ + item + for item in admissions + if isinstance(item, dict) and item.get("admission_id") == admission_id + ] + if len(matches) != 1 or matches[0].get("target") != DESKTOP_TARGET: + raise ValueError("the active Desktop admission is not unique and exact") + selected = matches[0] + + return { + "schema": STATE_SCHEMA, + "canonical_repository": CANONICAL_REPOSITORY, + "canonical_source_commit": central_source_commit, + "policy_sha256": _bytes_digest(required["policy"].read_bytes()), + "admissions_sha256": _bytes_digest(required["admissions"].read_bytes()), + "lifecycle_sha256": _bytes_digest(required["lifecycle"].read_bytes()), + "validator_sha256": _bytes_digest(required["validator"].read_bytes()), + "active_admission": selected, + "active_admission_sha256": (_canonical_digest(selected) if selected is not None else None), + } + + +def validate_admission_state(path: Path) -> dict[str, Any]: + state = _load_json(path, "Desktop admission state") + expected = { + "schema", + "canonical_repository", + "canonical_source_commit", + "policy_sha256", + "admissions_sha256", + "lifecycle_sha256", + "validator_sha256", + "active_admission", + "active_admission_sha256", + } + if set(state) != expected or state.get("schema") != STATE_SCHEMA: + raise ValueError("the Desktop admission state does not use the closed v1 schema") + if state.get("canonical_repository") != CANONICAL_REPOSITORY: + raise ValueError("the Desktop admission state has the wrong authority") + if COMMIT.fullmatch(str(state.get("canonical_source_commit") or "")) is None: + raise ValueError("the Desktop admission state source commit is invalid") + for field in ( + "policy_sha256", + "admissions_sha256", + "lifecycle_sha256", + "validator_sha256", + ): + if DIGEST.fullmatch(str(state.get(field) or "")) is None: + raise ValueError(f"the Desktop admission state {field} is invalid") + admission = state.get("active_admission") + if admission is not None: + if ( + not isinstance(admission, dict) + or admission.get("target") != DESKTOP_TARGET + or not isinstance(admission.get("admission_id"), str) + or not admission["admission_id"] + ): + raise ValueError("the Desktop admission state has an invalid admission") + if state.get("active_admission_sha256") != _canonical_digest(admission): + raise ValueError("the Desktop admission state digest differs") + elif state.get("active_admission_sha256") is not None: + raise ValueError("the Desktop admission state has a digest without an admission") + return state + + +def write_admission_state(path: Path, state: Mapping[str, Any]) -> Path: + if path.exists(): + raise ValueError("the Desktop admission state output already exists") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return path + + +def _installer_kind(name: str) -> str | None: + if name.endswith(".dmg"): + return "macos-installer" + if name.endswith(".msi") or name.endswith("-nsis-setup.exe"): + return "windows-installer" + if name.endswith(".deb") or name.endswith(".AppImage"): + return "linux-installer" + return None + + +def _candidate_identity( + state: Mapping[str, Any], + *, + index_path: Path, + engine_provenance_path: Path, + engine_release_path: Path, + engine_directory: Path, +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: + admission = state.get("active_admission") + if not isinstance(admission, dict): + raise ValueError("there is no active Desktop Production admission") + release = admission.get("release") + if not isinstance(release, dict) or release.get("kind") != "public_package": + raise ValueError("the Desktop admission does not name a public package") + + index = validate_verified_release_index(index_path) + if index.get("repository") != DESKTOP_REPOSITORY: + raise ValueError("the verified Desktop index belongs to another repository") + version = str(release.get("version") or "") + expected_release = { + "version": index["native_version"], + "tag": index["engine_tag"], + "source_commit": index["native_source_commit"], + } + for field, expected in expected_release.items(): + if release.get(field) != expected: + raise ValueError( + f"the active Desktop admission {field} differs from the verified candidate" + ) + if VERSION.fullmatch(version) is None: + raise ValueError("the active Desktop admission version is invalid") + + engine_provenance = validate_engine_release_provenance( + engine_provenance_path, + repository=DESKTOP_REPOSITORY, + engine_tag=index["engine_tag"], + engine_commit=index["engine_commit"], + release_path=engine_release_path, + directory=engine_directory, + ) + if engine_provenance.get("engine_release_id") != index["engine_release_id"]: + raise ValueError("the engine receipt and verified candidate release differ") + + expected_artifacts: dict[str, tuple[str, str, str]] = {} + for item in engine_provenance["assets"]: + name = item["name"] + kind = "wheel" if name.endswith(".whl") else "sdist" + expected_artifacts[name] = (kind, "pypi", "sha256:" + item["sha256"]) + for item in index["assets"]: + name = item["name"] + kind = _installer_kind(name) + if kind is not None: + expected_artifacts[name] = ( + kind, + "github_release", + "sha256:" + item["sha256"], + ) + artifacts = release.get("artifacts") + if not isinstance(artifacts, list): + raise ValueError("the active Desktop admission has no artifact inventory") + observed: dict[str, tuple[str, str, str]] = {} + for item in artifacts: + if not isinstance(item, dict) or not isinstance(item.get("name"), str): + raise ValueError("the active Desktop admission has an invalid artifact") + name = item["name"] + if name in observed: + raise ValueError("the active Desktop admission repeats an artifact") + observed[name] = ( + str(item.get("kind") or ""), + str(item.get("authority") or ""), + str(item.get("sha256") or ""), + ) + if observed != expected_artifacts: + raise ValueError( + "the active Desktop admission artifact inventory differs from the exact " + "verified Python and installer artifacts" + ) + return admission, index, engine_provenance + + +def production_channel_asset_name(admission: Mapping[str, Any], central_source_commit: str) -> str: + if COMMIT.fullmatch(central_source_commit) is None: + raise ValueError("the canonical source commit is invalid") + digest = _canonical_digest(admission).removeprefix("sha256:") + return f"{CHANNEL_PREFIX}{central_source_commit}-{digest}.json" + + +def write_production_channel( + output_directory: Path, + *, + state_path: Path, + index_path: Path, + engine_provenance_path: Path, + engine_release_path: Path, + engine_directory: Path, + repository: str, + workflow_ref: str, + workflow_commit: str, + run_id: int, + run_attempt: int, +) -> Path: + state = validate_admission_state(state_path) + admission, index, _engine = _candidate_identity( + state, + index_path=index_path, + engine_provenance_path=engine_provenance_path, + engine_release_path=engine_release_path, + engine_directory=engine_directory, + ) + if repository != DESKTOP_REPOSITORY: + raise ValueError("the Production channel repository is not Desktop") + expected_ref = f"{repository}/{PROMOTION_WORKFLOW}@refs/heads/main" + if workflow_ref != expected_ref: + raise ValueError("the Production channel workflow ref is not protected main") + if COMMIT.fullmatch(workflow_commit) is None: + raise ValueError("the Production channel workflow commit is invalid") + if run_id <= 0 or run_attempt <= 0: + raise ValueError("the Production channel run identity is invalid") + + admission_digest = _canonical_digest(admission) + engine_base = f"https://github.com/{repository}/releases/download/{index['engine_tag']}" + payload = { + "schema": CHANNEL_SCHEMA, + "cache_role": "derived-only", + "repository": repository, + "channel": "production", + "canonical_authority": { + "repository": state["canonical_repository"], + "source_commit": state["canonical_source_commit"], + "policy_sha256": state["policy_sha256"], + "admissions_sha256": state["admissions_sha256"], + "lifecycle_sha256": state["lifecycle_sha256"], + "validator_sha256": state["validator_sha256"], + }, + "admission": { + "admission_id": admission["admission_id"], + "sha256": admission_digest, + "expires_at": admission["expires_at"], + }, + "release": admission["release"], + "verified_candidate": { + "native_tag": index["native_tag"], + "native_source_commit": index["native_source_commit"], + "engine_tag": index["engine_tag"], + "engine_commit": index["engine_commit"], + "engine_release_id": index["engine_release_id"], + "verified_index": { + "name": VERIFIED_RELEASE_INDEX, + "sha256": _bytes_digest(index_path.read_bytes()), + "url": f"{engine_base}/{VERIFIED_RELEASE_INDEX}", + }, + "engine_provenance": { + "name": ENGINE_RELEASE_PROVENANCE, + "sha256": _bytes_digest(engine_provenance_path.read_bytes()), + "url": f"{engine_base}/{ENGINE_RELEASE_PROVENANCE}", + }, + }, + "derivation": { + "workflow_path": PROMOTION_WORKFLOW, + "workflow_ref": workflow_ref, + "workflow_commit": workflow_commit, + "event": "workflow_dispatch", + "run_id": run_id, + "run_attempt": run_attempt, + "runner_environment": "github-hosted", + }, + } + output_directory.mkdir(parents=True, exist_ok=True) + output = output_directory / production_channel_asset_name( + admission, state["canonical_source_commit"] + ) + if output.exists(): + raise ValueError("the derived Production channel cache already exists") + output.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return output + + +def validate_production_channel(path: Path) -> dict[str, Any]: + channel = _load_json(path, "Desktop Production channel cache") + expected = { + "schema", + "cache_role", + "repository", + "channel", + "canonical_authority", + "admission", + "release", + "verified_candidate", + "derivation", + } + if set(channel) != expected or channel.get("schema") != CHANNEL_SCHEMA: + raise ValueError("the Desktop Production channel cache is not closed v1") + if ( + channel.get("cache_role") != "derived-only" + or channel.get("repository") != DESKTOP_REPOSITORY + or channel.get("channel") != "production" + ): + raise ValueError("the Desktop Production channel cache identity is invalid") + authority = channel.get("canonical_authority") + if not isinstance(authority, dict) or set(authority) != { + "repository", + "source_commit", + "policy_sha256", + "admissions_sha256", + "lifecycle_sha256", + "validator_sha256", + }: + raise ValueError("the Desktop Production channel authority is invalid") + if ( + authority.get("repository") != CANONICAL_REPOSITORY + or COMMIT.fullmatch(str(authority.get("source_commit") or "")) is None + ): + raise ValueError("the Desktop Production channel authority differs") + for field in ( + "policy_sha256", + "admissions_sha256", + "lifecycle_sha256", + "validator_sha256", + ): + if DIGEST.fullmatch(str(authority.get(field) or "")) is None: + raise ValueError("the Desktop Production channel authority digest is invalid") + admission = channel.get("admission") + if ( + not isinstance(admission, dict) + or set(admission) + != { + "admission_id", + "sha256", + "expires_at", + } + or DIGEST.fullmatch(str(admission.get("sha256") or "")) is None + ): + raise ValueError("the Desktop Production channel admission binding is invalid") + expected_name = ( + CHANNEL_PREFIX + + authority["source_commit"] + + "-" + + admission["sha256"].removeprefix("sha256:") + + ".json" + ) + if path.name != expected_name: + raise ValueError("the Desktop Production channel cache filename differs") + candidate = channel.get("verified_candidate") + if not isinstance(candidate, dict) or set(candidate) != { + "native_tag", + "native_source_commit", + "engine_tag", + "engine_commit", + "engine_release_id", + "verified_index", + "engine_provenance", + }: + raise ValueError("the Desktop Production channel candidate binding is invalid") + for field in ("native_source_commit", "engine_commit"): + if COMMIT.fullmatch(str(candidate.get(field) or "")) is None: + raise ValueError("the Desktop Production channel candidate commit is invalid") + if ( + not isinstance(candidate.get("engine_release_id"), int) + or candidate["engine_release_id"] <= 0 + ): + raise ValueError("the Desktop Production channel release id is invalid") + for field, name in ( + ("verified_index", VERIFIED_RELEASE_INDEX), + ("engine_provenance", ENGINE_RELEASE_PROVENANCE), + ): + binding = candidate.get(field) + if ( + not isinstance(binding, dict) + or set(binding) != {"name", "sha256", "url"} + or binding.get("name") != name + or DIGEST.fullmatch(str(binding.get("sha256") or "")) is None + or not str(binding.get("url") or "").startswith("https://github.com/") + ): + raise ValueError("the Desktop Production channel artifact binding is invalid") + derivation = channel.get("derivation") + expected_derivation = { + "workflow_path": PROMOTION_WORKFLOW, + "workflow_ref": f"{DESKTOP_REPOSITORY}/{PROMOTION_WORKFLOW}@refs/heads/main", + "event": "workflow_dispatch", + "runner_environment": "github-hosted", + } + if not isinstance(derivation, dict) or set(derivation) != { + *expected_derivation, + "workflow_commit", + "run_id", + "run_attempt", + }: + raise ValueError("the Desktop Production channel derivation is invalid") + for field, expected_value in expected_derivation.items(): + if derivation.get(field) != expected_value: + raise ValueError("the Desktop Production channel derivation differs") + if COMMIT.fullmatch(str(derivation.get("workflow_commit") or "")) is None: + raise ValueError("the Desktop Production channel workflow commit is invalid") + for field in ("run_id", "run_attempt"): + if not isinstance(derivation.get(field), int) or derivation[field] <= 0: + raise ValueError("the Desktop Production channel run identity is invalid") + return channel + + +def verify_production_channel( + channel_path: Path, + *, + state_path: Path, + index_path: Path, + engine_provenance_path: Path, + engine_release_path: Path, + engine_directory: Path, +) -> dict[str, Any]: + state = validate_admission_state(state_path) + admission, index, _engine = _candidate_identity( + state, + index_path=index_path, + engine_provenance_path=engine_provenance_path, + engine_release_path=engine_release_path, + engine_directory=engine_directory, + ) + channel = validate_production_channel(channel_path) + expected_authority = { + "repository": state["canonical_repository"], + "source_commit": state["canonical_source_commit"], + "policy_sha256": state["policy_sha256"], + "admissions_sha256": state["admissions_sha256"], + "lifecycle_sha256": state["lifecycle_sha256"], + "validator_sha256": state["validator_sha256"], + } + if channel["canonical_authority"] != expected_authority: + raise ValueError("the Desktop Production channel differs from canonical main") + expected_admission = { + "admission_id": admission["admission_id"], + "sha256": _canonical_digest(admission), + "expires_at": admission["expires_at"], + } + if channel["admission"] != expected_admission: + raise ValueError("the Desktop Production channel differs from the active admission") + if channel["release"] != admission["release"]: + raise ValueError("the Desktop Production channel release differs from the admission") + expected_candidate = { + "native_tag": index["native_tag"], + "native_source_commit": index["native_source_commit"], + "engine_tag": index["engine_tag"], + "engine_commit": index["engine_commit"], + "engine_release_id": index["engine_release_id"], + "verified_index": { + "name": VERIFIED_RELEASE_INDEX, + "sha256": _bytes_digest(index_path.read_bytes()), + "url": ( + f"https://github.com/{DESKTOP_REPOSITORY}/releases/download/" + f"{index['engine_tag']}/{VERIFIED_RELEASE_INDEX}" + ), + }, + "engine_provenance": { + "name": ENGINE_RELEASE_PROVENANCE, + "sha256": _bytes_digest(engine_provenance_path.read_bytes()), + "url": ( + f"https://github.com/{DESKTOP_REPOSITORY}/releases/download/" + f"{index['engine_tag']}/{ENGINE_RELEASE_PROVENANCE}" + ), + }, + } + if channel["verified_candidate"] != expected_candidate: + raise ValueError("the Desktop Production channel candidate differs") + return channel + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + state = commands.add_parser("state") + state.add_argument("--lifecycle-root", type=Path, required=True) + state.add_argument("--central-source-commit", required=True) + state.add_argument("--output", type=Path, required=True) + for command in (commands.add_parser("write-channel"), commands.add_parser("verify-channel")): + command.add_argument("--state", type=Path, required=True) + command.add_argument("--index", type=Path, required=True) + command.add_argument("--engine-provenance", type=Path, required=True) + command.add_argument("--engine-release", type=Path, required=True) + command.add_argument("--engine-directory", type=Path, required=True) + write = commands.choices["write-channel"] + write.add_argument("--output-directory", type=Path, required=True) + write.add_argument("--repository", required=True) + write.add_argument("--workflow-ref", required=True) + write.add_argument("--workflow-commit", required=True) + write.add_argument("--run-id", type=int, required=True) + write.add_argument("--run-attempt", type=int, required=True) + commands.choices["verify-channel"].add_argument("--file", type=Path, required=True) + return parser + + +def main() -> int: + args = _parser().parse_args() + try: + if args.command == "state": + state = build_admission_state( + args.lifecycle_root, + central_source_commit=args.central_source_commit, + ) + write_admission_state(args.output, state) + admission = state["active_admission"] + if admission is None: + print("Validated canonical lifecycle state: no active Desktop admission.") + else: + print(f"Validated canonical lifecycle state for {admission['admission_id']}.") + elif args.command == "write-channel": + output = write_production_channel( + args.output_directory, + state_path=args.state, + index_path=args.index, + engine_provenance_path=args.engine_provenance, + engine_release_path=args.engine_release, + engine_directory=args.engine_directory, + repository=args.repository, + workflow_ref=args.workflow_ref, + workflow_commit=args.workflow_commit, + run_id=args.run_id, + run_attempt=args.run_attempt, + ) + print(output) + else: + channel = verify_production_channel( + args.file, + state_path=args.state, + index_path=args.index, + engine_provenance_path=args.engine_provenance, + engine_release_path=args.engine_release, + engine_directory=args.engine_directory, + ) + print( + "Validated the derived Desktop Production channel cache for " + f"{channel['admission']['admission_id']}." + ) + except (OSError, ValueError, AttributeError) as exc: + print(f"REFUSED: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_native_release_download.py b/scripts/verify_native_release_download.py index fa7dd92..8499748 100644 --- a/scripts/verify_native_release_download.py +++ b/scripts/verify_native_release_download.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Authenticate and verify an OpenAdapt native release download. -The channel mode verifies GitHub attestations for the stable descriptor, its +The channel mode verifies GitHub attestations for the candidate descriptor, its selected index, and ``SHA256SUMS`` before it accepts any installer bytes. It then checks the complete descriptor -> index -> checksum -> asset hash chain. @@ -27,7 +27,8 @@ NATIVE_RELEASE_WORKFLOW = ".github/workflows/native-release.yml" GITHUB_OIDC_ISSUER = "https://token.actions.githubusercontent.com" CHANNEL_NAME = "openadapt-desktop-channel.json" -CHANNEL_SCHEMA = "openadapt.desktop-release-channel/v1" +CHANNEL_SCHEMA = "openadapt.desktop-release-channel/v2" +LEGACY_CHANNEL_SCHEMA = "openadapt.desktop-release-channel/v1" INDEX_NAME = "openadapt-desktop-verified-release.json" INDEX_SCHEMA = "openadapt.desktop-verified-release/v1" PROVENANCE_NAME = "openadapt-desktop-native-release-provenance.json" @@ -122,7 +123,7 @@ def expected_asset_names(version: str) -> set[str]: ("linux", "x86_64", "github-attested", (".deb", ".AppImage")), } for platform, architecture, signing, suffixes in platforms: - prefix = f"OpenAdapt-Desktop-Beta-v{version}-{platform}-{architecture}-{signing}" + prefix = f"OpenAdapt-Desktop-Candidate-v{version}-{platform}-{architecture}-{signing}" names.add(f"{prefix}-metadata.json") names.update(f"{prefix}{suffix}" for suffix in suffixes) return names @@ -143,7 +144,7 @@ def _validate_hash_binding(value: object, *, name: str, url: str, label: str) -> def validate_channel( path: Path, *, repository: str = DEFAULT_REPOSITORY, raw: bytes | None = None ) -> dict: - """Validate the closed stable-channel descriptor. + """Validate the closed candidate-channel descriptor. Pass ``raw`` to parse exactly the bytes an earlier check already hashed. """ @@ -173,9 +174,14 @@ def validate_channel( "promotion", }, ) - if data.get("schema") != CHANNEL_SCHEMA or data.get("repository") != repository: + schema = data.get("schema") + if ( + schema not in {CHANNEL_SCHEMA, LEGACY_CHANNEL_SCHEMA} + or data.get("repository") != repository + ): raise ValueError("release channel identity is invalid") - if data.get("channel") != "stable-native": + expected_channel = "candidate-native" if schema == CHANNEL_SCHEMA else "stable-native" + if data.get("channel") != expected_channel: raise ValueError("release channel has the wrong channel name") version = str(data.get("native_version") or "") _version_tuple(version) @@ -387,12 +393,8 @@ def verify_authenticated_channel( raise ValueError(f"release channel version is below the trusted minimum {minimum_version}") if previous_channel is not None: - previous_bytes = _read_regular_bytes( - previous_channel, label="previous release channel" - ) - previous = validate_channel( - previous_channel, repository=repository, raw=previous_bytes - ) + previous_bytes = _read_regular_bytes(previous_channel, label="previous release channel") + previous = validate_channel(previous_channel, repository=repository, raw=previous_bytes) previous_digest = hashlib.sha256(previous_bytes).hexdigest() expected_previous = { "native_version": previous["native_version"], @@ -517,7 +519,7 @@ def main() -> int: parser.add_argument( "--channel", type=Path, - help="authenticate this stable channel descriptor before verifying assets", + help="authenticate this candidate channel descriptor before verifying assets", ) parser.add_argument( "--index", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e78e0d1..b4de11c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "openadapt-desktop" version = "0.15.0" -description = "Beta installed companion for OpenAdapt authoring, teaching, and local pairing" +description = "Installed companion for OpenAdapt authoring, teaching, and local pairing" authors = ["OpenAdapt AI"] license = "MIT" edition = "2021" diff --git a/tests/test_engine/test_runner_loop.py b/tests/test_engine/test_runner_loop.py index 0b31126..dc1891e 100644 --- a/tests/test_engine/test_runner_loop.py +++ b/tests/test_engine/test_runner_loop.py @@ -1,4 +1,4 @@ -"""Tests for the EXPERIMENTAL runner loop (spec: hosted runner platform, P0 desktop lane). +"""Tests for the outbound runner loop (spec: hosted runner platform, P0 desktop lane). Covers, against a FAKE cloud (httpx.MockTransport -- no network): * register -> poll -> lease -> execute -> evidence -> ack semantics; diff --git a/tests/test_native_release.py b/tests/test_native_release.py index ef8a8a3..5fc5985 100644 --- a/tests/test_native_release.py +++ b/tests/test_native_release.py @@ -51,6 +51,17 @@ write_verified_release_index, write_website_release_manifest, ) +from scripts.production_release import ( + CHANNEL_PREFIX, + DESKTOP_REPOSITORY, + PROMOTION_WORKFLOW, + build_admission_state, + production_channel_asset_name, + validate_admission_state, + verify_production_channel, + write_admission_state, + write_production_channel, +) from scripts.verify_native_release_download import ( verify as verify_download_inventory, ) @@ -91,7 +102,7 @@ def test_node_dependencies_are_locked_for_cross_platform_tauri_builds() -> None: assert lock["packages"]["node_modules/@tauri-apps/api"]["version"] == "2.11.1" -def test_native_workflows_are_pinned_and_preserve_beta_boundary() -> None: +def test_native_workflows_are_pinned_and_preserve_candidate_boundary() -> None: build = _workflow("build.yml") release = _workflow("native-release.yml") uses = _workflow_uses(build) + _workflow_uses(release) @@ -202,14 +213,14 @@ def test_engine_and_native_release_form_one_attested_acceptance_chain() -> None: assert mirror_steps["Attest the verified release index"]["with"] == { "subject-path": VERIFIED_RELEASE_INDEX } - channel = mirror_steps["Write the monotonic stable channel descriptor"]["run"] + channel = mirror_steps["Write the monotonic candidate channel descriptor"]["run"] assert "write-release-channel" in channel assert "--existing prior-channel/openadapt-desktop-channel.json" in channel assert "validate-release-channel" in channel - assert mirror_steps["Attest the monotonic stable channel descriptor"]["with"] == { + assert mirror_steps["Attest the monotonic candidate channel descriptor"]["with"] == { "subject-path": VERIFIED_RELEASE_CHANNEL } - publication = mirror_steps["Publish and reverify the stable channel authority"]["run"] + publication = mirror_steps["Publish and reverify the candidate channel authority"]["run"] assert "channel_tag=desktop-channel" in publication assert "gh attestation verify" in publication assert "--clobber" in publication @@ -336,9 +347,10 @@ def test_freshness_workflow_syncs_engine_releases_into_the_native_lane() -> None assert "HEAD:main" not in scripts assert "git tag" not in scripts assert 'git push origin "refs/tags/' not in scripts - assert "dispatch native-release.yml from main" in proposal_steps[ - "Open or report the protected-main pull request" - ]["run"] + assert ( + "dispatch native-release.yml from main" + in proposal_steps["Open or report the protected-main pull request"]["run"] + ) def test_native_version_pr_guard_never_skips_and_judges_content() -> None: @@ -420,7 +432,7 @@ def test_updater_feed_is_disabled_until_signing_key_lifecycle_exists() -> None: assert "updater" not in config["plugins"] assert config["bundle"]["targets"] == ["dmg", "msi", "nsis", "deb", "appimage"] # Target releases inherit APPLE_SIGNING_IDENTITY and keep hardened runtime. - # The explicit ad-hoc overlay is only for unsigned beta artifacts. + # The explicit ad-hoc overlay is only for unsigned CI artifacts. assert "signingIdentity" not in config["bundle"]["macOS"] assert config["bundle"]["macOS"]["entitlements"] == "Entitlements.plist" entitlements = (ROOT / "src-tauri" / config["bundle"]["macOS"]["entitlements"]).read_text() @@ -663,7 +675,7 @@ def test_native_tag_tuple_orders_versions_and_rejects_foreign_tags() -> None: def test_superseded_notes_prepends_marker_and_preserves_body() -> None: - body = "\n\n# Beta Native Installers\n\nDetails.\n" + body = "\n\n# Native Release Candidates\n\nDetails.\n" updated = superseded_notes(body, "desktop-v0.5.0", "OpenAdaptAI/openadapt-desktop") @@ -812,7 +824,7 @@ def test_native_release_workflow_mirrors_installers_onto_the_engine_release() -> ), ], ) -def test_stage_artifacts_renames_and_labels_beta( +def test_stage_artifacts_renames_and_labels_candidate( tmp_path: Path, platform: str, signing: str, @@ -836,13 +848,13 @@ def test_stage_artifacts_renames_and_labels_beta( asset_names = [path.name for path in staged if path.suffix != ".json"] assert len(asset_names) == len(expected_suffixes) current_version = native_version() - assert all(f"Beta-v{current_version}" in name for name in asset_names) + assert all(f"Candidate-v{current_version}" in name for name in asset_names) assert all(any(name.endswith(suffix) for name in asset_names) for suffix in expected_suffixes) metadata_path = next(path for path in staged if path.suffix == ".json") metadata = json.loads(metadata_path.read_text()) assert metadata["native_version"] == current_version - assert metadata["lifecycle"] == "Beta" + assert metadata["lifecycle"] == "Candidate" assert metadata["surface"] == "installed desktop pairing and authoring companion" assert metadata["verification_scope"] == ( "cross-platform install/uninstall, self-contained Flow runtime, " @@ -885,7 +897,7 @@ def test_stage_rejects_missing_duplicate_and_wrong_signing(tmp_path: Path) -> No def test_checksum_round_trip_and_tamper_detection(tmp_path: Path) -> None: (tmp_path / "a.bin").write_bytes(b"alpha") - (tmp_path / "b.bin").write_bytes(b"beta") + (tmp_path / "b.bin").write_bytes(b"candidate") manifest = tmp_path / "SHA256SUMS" entries = write_checksums(tmp_path, manifest) @@ -917,7 +929,7 @@ def _release(tag: str, *, marked: bool = True, draft: bool = False) -> dict: "tag_name": tag, "draft": draft, "prerelease": True, - "body": "\n" if marked else "Beta installer", + "body": "\n" if marked else "Candidate installer", } @@ -1142,7 +1154,7 @@ def test_release_provenance_attestation_and_workflow_run_bind_exact_identity( "Windows x86_64", "Linux x86_64 (GitHub-attested bytes)", "Checksum and attest exact release bytes", - "Publish the verified Beta prerelease", + "Publish the verified candidate prerelease", ] workflow_payload = { "databaseId": 123456, @@ -1423,7 +1435,7 @@ def _engine_release_file(tmp_path: Path, *, version: str | None = None) -> Path: return path -def test_engine_release_requires_exact_stable_identity(tmp_path: Path) -> None: +def test_engine_release_requires_exact_published_identity(tmp_path: Path) -> None: release = _engine_release_file(tmp_path) validated = validate_engine_release( release, @@ -1436,7 +1448,7 @@ def test_engine_release_requires_exact_stable_identity(tmp_path: Path) -> None: payload = json.loads(release.read_text(encoding="utf-8")) payload["isPrerelease"] = True release.write_text(json.dumps(payload), encoding="utf-8") - with pytest.raises(ValueError, match="exact published stable release"): + with pytest.raises(ValueError, match="exact published engine release"): validate_engine_release( release, repository="OpenAdaptAI/openadapt-desktop", @@ -1621,7 +1633,7 @@ def test_verified_release_index_is_closed_bound_and_monotonic(tmp_path: Path) -> ) -def test_stable_release_channel_is_hash_bound_and_strictly_monotonic( +def test_candidate_release_channel_is_hash_bound_and_strictly_monotonic( tmp_path: Path, ) -> None: release, _manifest, checksums = _stage_complete_release(tmp_path / "assets") @@ -1681,6 +1693,43 @@ def test_stable_release_channel_is_hash_bound_and_strictly_monotonic( ) +def test_candidate_channel_accepts_the_legacy_selector_for_one_way_migration( + tmp_path: Path, +) -> None: + release, _manifest, checksums = _stage_complete_release(tmp_path / "assets") + index = write_verified_release_index( + tmp_path / VERIFIED_RELEASE_INDEX, + directory=release, + checksums=checksums, + provenance_path=release / NATIVE_RELEASE_PROVENANCE, + repository=DESKTOP_REPOSITORY, + tag=f"desktop-v{native_version()}", + source_commit="a" * 40, + engine_release_path=_engine_release_file(tmp_path), + ) + (tmp_path / "current").mkdir() + channel = write_verified_release_channel( + tmp_path / "current" / VERIFIED_RELEASE_CHANNEL, + index_path=index, + repository=DESKTOP_REPOSITORY, + workflow_ref=( + f"{DESKTOP_REPOSITORY}/.github/workflows/native-release.yml@refs/heads/main" + ), + workflow_commit="a" * 40, + run_id=123456, + run_attempt=2, + ) + legacy_value = json.loads(channel.read_text(encoding="utf-8")) + legacy_value["schema"] = "openadapt.desktop-release-channel/v1" + legacy_value["channel"] = "stable-native" + legacy = tmp_path / "legacy" / VERIFIED_RELEASE_CHANNEL + legacy.parent.mkdir() + legacy.write_text(json.dumps(legacy_value), encoding="utf-8") + + assert validate_verified_release_channel(legacy)["schema"].endswith("/v1") + assert download_verifier.validate_channel(legacy)["channel"] == "stable-native" + + def test_release_channel_refuses_tag_origin_promotion(tmp_path: Path) -> None: release, _manifest, checksums = _stage_complete_release(tmp_path / "assets") index = write_verified_release_index( @@ -2330,3 +2379,333 @@ def test_native_version_at_ref_reads_git_objects_not_the_working_tree() -> None: assert native_version_at_ref(head) == native_version() assert native_version_at_ref("HEAD") == native_version() + + +def _production_admission_fixture(tmp_path: Path) -> tuple[Path, Path, Path, Path, Path, dict]: + version = native_version() + release, _manifest, checksums = _stage_complete_release(tmp_path / "native") + engine_release = _engine_release_file(tmp_path) + index = write_verified_release_index( + tmp_path / VERIFIED_RELEASE_INDEX, + directory=release, + checksums=checksums, + provenance_path=release / NATIVE_RELEASE_PROVENANCE, + repository=DESKTOP_REPOSITORY, + tag=f"desktop-v{version}", + source_commit="a" * 40, + engine_release_path=engine_release, + ) + engine_directory = tmp_path / "engine-files" + engine_directory.mkdir() + (engine_directory / f"openadapt_desktop-{version}-py3-none-any.whl").write_bytes(b"wheel") + (engine_directory / f"openadapt_desktop-{version}.tar.gz").write_bytes(b"sdist") + engine_provenance = write_engine_release_provenance( + tmp_path / ENGINE_RELEASE_PROVENANCE, + directory=engine_directory, + release_path=engine_release, + repository=DESKTOP_REPOSITORY, + engine_tag=f"v{version}", + engine_commit="b" * 40, + workflow_ref=(f"{DESKTOP_REPOSITORY}/.github/workflows/release.yml@refs/heads/main"), + workflow_commit="a" * 40, + run_id=100, + run_attempt=1, + runner_environment="github-hosted", + ) + receipt = json.loads(engine_provenance.read_text(encoding="utf-8")) + index_value = json.loads(index.read_text(encoding="utf-8")) + artifacts: list[dict] = [] + for number, asset in enumerate(receipt["assets"], start=1): + kind = "wheel" if asset["name"].endswith(".whl") else "sdist" + artifacts.append( + { + "name": asset["name"], + "kind": kind, + "authority": "pypi", + "url": f"https://files.pythonhosted.org/packages/{number}/{asset['name']}", + "sha256": "sha256:" + asset["sha256"], + "size_bytes": 10 + number, + } + ) + next_asset_id = 1000 + suffix_to_kind = { + ".dmg": "macos-installer", + ".msi": "windows-installer", + "-nsis-setup.exe": "windows-installer", + ".deb": "linux-installer", + ".AppImage": "linux-installer", + } + for asset in index_value["assets"]: + kind = next( + ( + artifact_kind + for suffix, artifact_kind in suffix_to_kind.items() + if asset["name"].endswith(suffix) + ), + None, + ) + if kind is None: + continue + artifacts.append( + { + "name": asset["name"], + "kind": kind, + "authority": "github_release", + "url": ( + "https://api.github.com/repos/OpenAdaptAI/openadapt-desktop/" + f"releases/assets/{next_asset_id}" + ), + "sha256": "sha256:" + asset["sha256"], + "size_bytes": next_asset_id, + } + ) + next_asset_id += 1 + artifacts.sort(key=lambda item: (item["kind"], item["name"])) + admission = { + "admission_id": "production:desktop:1", + "target": "desktop", + "claim_scope": "qualified_native_workflow_desktop_release", + "release_identity": { + "schema_version": "openadapt.monotonic-production-release/v1", + "channel": "production", + "sequence": 1, + "previous_admission_sha256": None, + }, + "policy_revision": 1, + "release": { + "kind": "public_package", + "version": version, + "tag": f"v{version}", + "source_commit": "a" * 40, + "immutable_release_url": ( + "https://github.com/OpenAdaptAI/openadapt-desktop/commit/" + "a" * 40 + ), + "artifacts": artifacts, + }, + "acceptance_evidence": { + "summary_url": "https://example.test/summary.json", + "summary_sha256": "sha256:" + "1" * 64, + "attestation_bundle_url": "https://example.test/bundle.json", + "attestation_bundle_sha256": "sha256:" + "2" * 64, + "authority_source_commit": "c" * 40, + }, + "issued_at": "2026-08-20T12:00:00Z", + "expires_at": "2026-09-19T12:00:00Z", + "revoked_at": None, + } + lifecycle = tmp_path / "lifecycle" + (lifecycle / "scripts").mkdir(parents=True) + (lifecycle / "production-lifecycle-policy.json").write_text("{}\n", encoding="utf-8") + (lifecycle / "production-lifecycle-admissions.json").write_text( + json.dumps({"admissions": [admission]}) + "\n", encoding="utf-8" + ) + (lifecycle / "repository-lifecycle.yml").write_text( + "lifecycle:\n production: []\n", encoding="utf-8" + ) + (lifecycle / "scripts" / "validate_production_lifecycle.py").write_text( + "# canonical validator fixture\n", encoding="utf-8" + ) + state_value = build_admission_state( + lifecycle, + central_source_commit="c" * 40, + validate_files=lambda _root: {"desktop": admission["admission_id"]}, + ) + state = write_admission_state(tmp_path / "admission-state.json", state_value) + return state, index, engine_provenance, engine_release, engine_directory, admission + + +def test_canonical_state_keeps_no_admission_as_no_production(tmp_path: Path) -> None: + lifecycle = tmp_path / "lifecycle" + (lifecycle / "scripts").mkdir(parents=True) + (lifecycle / "production-lifecycle-policy.json").write_text("{}\n", encoding="utf-8") + (lifecycle / "production-lifecycle-admissions.json").write_text( + '{"admissions": []}\n', encoding="utf-8" + ) + (lifecycle / "repository-lifecycle.yml").write_text("lifecycle: {}\n", encoding="utf-8") + (lifecycle / "scripts" / "validate_production_lifecycle.py").write_text( + "# canonical validator fixture\n", encoding="utf-8" + ) + state_value = build_admission_state( + lifecycle, + central_source_commit="c" * 40, + validate_files=lambda _root: {}, + ) + state = write_admission_state(tmp_path / "state.json", state_value) + assert validate_admission_state(state)["active_admission"] is None + assert state_value["active_admission_sha256"] is None + + +def test_production_channel_is_an_exact_derived_cache(tmp_path: Path) -> None: + state, index, receipt, engine_release, engine_directory, admission = ( + _production_admission_fixture(tmp_path) + ) + channel = write_production_channel( + tmp_path / "cache", + state_path=state, + index_path=index, + engine_provenance_path=receipt, + engine_release_path=engine_release, + engine_directory=engine_directory, + repository=DESKTOP_REPOSITORY, + workflow_ref=f"{DESKTOP_REPOSITORY}/{PROMOTION_WORKFLOW}@refs/heads/main", + workflow_commit="d" * 40, + run_id=200, + run_attempt=1, + ) + assert channel.name == production_channel_asset_name(admission, "c" * 40) + assert channel.name.startswith(CHANNEL_PREFIX) + validated = verify_production_channel( + channel, + state_path=state, + index_path=index, + engine_provenance_path=receipt, + engine_release_path=engine_release, + engine_directory=engine_directory, + ) + assert validated["cache_role"] == "derived-only" + assert validated["admission"]["admission_id"] == admission["admission_id"] + assert validated["release"]["artifacts"] == admission["release"]["artifacts"] + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("version", "99.0.0"), + ("tag", "v99.0.0"), + ("source_commit", "f" * 40), + ], +) +def test_production_channel_refuses_an_unadmitted_candidate_identity( + tmp_path: Path, field: str, value: str +) -> None: + state, index, receipt, engine_release, engine_directory, _admission = ( + _production_admission_fixture(tmp_path) + ) + state_value = json.loads(state.read_text(encoding="utf-8")) + state_value["active_admission"]["release"][field] = value + canonical = json.dumps( + state_value["active_admission"], + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode() + state_value["active_admission_sha256"] = "sha256:" + hashlib.sha256(canonical).hexdigest() + changed = tmp_path / "changed-state.json" + changed.write_text(json.dumps(state_value), encoding="utf-8") + with pytest.raises(ValueError, match=f"{field} differs"): + write_production_channel( + tmp_path / "cache", + state_path=changed, + index_path=index, + engine_provenance_path=receipt, + engine_release_path=engine_release, + engine_directory=engine_directory, + repository=DESKTOP_REPOSITORY, + workflow_ref=f"{DESKTOP_REPOSITORY}/{PROMOTION_WORKFLOW}@refs/heads/main", + workflow_commit="d" * 40, + run_id=200, + run_attempt=1, + ) + + +def test_production_channel_refuses_an_unadmitted_artifact(tmp_path: Path) -> None: + state, index, receipt, engine_release, engine_directory, _admission = ( + _production_admission_fixture(tmp_path) + ) + state_value = json.loads(state.read_text(encoding="utf-8")) + state_value["active_admission"]["release"]["artifacts"][0]["sha256"] = ( + "sha256:" + "f" * 64 + ) + canonical = json.dumps( + state_value["active_admission"], + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode() + state_value["active_admission_sha256"] = "sha256:" + hashlib.sha256(canonical).hexdigest() + changed = tmp_path / "changed-artifact-state.json" + changed.write_text(json.dumps(state_value), encoding="utf-8") + with pytest.raises(ValueError, match="artifact inventory differs"): + write_production_channel( + tmp_path / "cache", + state_path=changed, + index_path=index, + engine_provenance_path=receipt, + engine_release_path=engine_release, + engine_directory=engine_directory, + repository=DESKTOP_REPOSITORY, + workflow_ref=f"{DESKTOP_REPOSITORY}/{PROMOTION_WORKFLOW}@refs/heads/main", + workflow_commit="d" * 40, + run_id=200, + run_attempt=1, + ) + + +def test_production_channel_refuses_current_default_drift(tmp_path: Path) -> None: + state, index, receipt, engine_release, engine_directory, _admission = ( + _production_admission_fixture(tmp_path) + ) + channel = write_production_channel( + tmp_path / "cache", + state_path=state, + index_path=index, + engine_provenance_path=receipt, + engine_release_path=engine_release, + engine_directory=engine_directory, + repository=DESKTOP_REPOSITORY, + workflow_ref=f"{DESKTOP_REPOSITORY}/{PROMOTION_WORKFLOW}@refs/heads/main", + workflow_commit="d" * 40, + run_id=200, + run_attempt=1, + ) + state_value = json.loads(state.read_text(encoding="utf-8")) + state_value["canonical_source_commit"] = "e" * 40 + drifted = tmp_path / "drifted-state.json" + drifted.write_text(json.dumps(state_value), encoding="utf-8") + with pytest.raises(ValueError, match="canonical main"): + verify_production_channel( + channel, + state_path=drifted, + index_path=index, + engine_provenance_path=receipt, + engine_release_path=engine_release, + engine_directory=engine_directory, + ) + + +def test_production_workflow_keeps_normal_publication_unadmitted() -> None: + production = _workflow("production-channel.yml") + trigger = production[True] + assert set(trigger) == {"workflow_dispatch", "release", "schedule"} + assert production["permissions"] == {"contents": "read"} + assert production["concurrency"] == { + "group": "production-channel", + "cancel-in-progress": False, + } + inspect = production["jobs"]["inspect"] + promote = production["jobs"]["promote"] + assert "if" not in inspect + assert inspect["permissions"] == {"attestations": "read", "contents": "read"} + assert promote["environment"] == "production-release" + assert promote["permissions"] == { + "attestations": "write", + "contents": "write", + "id-token": "write", + } + production_text = (ROOT / ".github/workflows/production-channel.yml").read_text() + assert "repository: OpenAdaptAI/.github" in production_text + assert "ref: ${{ steps.refs.outputs.central_commit }}" in production_text + assert "scripts/production_release.py state" in production_text + assert "--clobber" not in production_text + assert "active central Production admission" in production_text + assert '"${GITHUB_REF}" != refs/heads/main' in production_text + assert "${observed}-${digest#sha256:}" in production_text + assert production_text.count("status --porcelain --untracked-files=all") == 2 + + normal_release_text = (ROOT / ".github/workflows/release.yml").read_text() + native_release_text = (ROOT / ".github/workflows/native-release.yml").read_text() + for text in (normal_release_text, native_release_text): + assert "desktop-production-channel" not in text + assert "write-channel" not in text + assert "unadmitted release candidate" in normal_release_text + assert "unadmitted release candidate" in native_release_text diff --git a/tests/test_public_metadata.py b/tests/test_public_metadata.py index d838e86..56a55ae 100644 --- a/tests/test_public_metadata.py +++ b/tests/test_public_metadata.py @@ -12,19 +12,23 @@ ROOT = Path(__file__).resolve().parents[1] -def test_public_metadata_identifies_beta_supporting_surface() -> None: +def test_public_metadata_identifies_component_role_and_admission_boundary() -> None: readme = (ROOT / "README.md").read_text() pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text()) package = json.loads((ROOT / "package.json").read_text()) cargo = tomllib.loads((ROOT / "src-tauri" / "Cargo.toml").read_text()) - assert "Lifecycle: Beta supporting surface" in readme + assert "Role: installed authoring and operation companion" in readme + assert "active Production default" in readme + assert "not actively admitted" in readme + assert "Experimental" not in readme + assert "Beta" not in readme assert "openadapt-flow" in readme assert "AI training data collection" not in readme assert "AI training data collection" not in pyproject["project"]["description"] assert "AI training data collection" not in package["description"] expected_native_description = ( - "Beta installed companion for OpenAdapt authoring, teaching, and local pairing" + "Installed companion for OpenAdapt authoring, teaching, and local pairing" ) assert package["description"] == expected_native_description assert cargo["package"]["description"] == expected_native_description @@ -207,8 +211,8 @@ def test_release_recovery_ref_is_main_contained_and_exact_ci_green() -> None: assert build_index < publish_index -def test_beta_release_notes_describe_the_bundled_flow_runtime() -> None: - notes = (ROOT / "docs/BETA_NATIVE_INSTALLERS.md").read_text() +def test_candidate_release_notes_describe_the_bundled_flow_runtime() -> None: + notes = (ROOT / "docs/RELEASE_CANDIDATE_INSTALLERS.md").read_text() pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text()) lock = (ROOT / "uv.lock").read_text() native_release = (ROOT / ".github/workflows/native-release.yml").read_text() @@ -217,7 +221,7 @@ def test_beta_release_notes_describe_the_bundled_flow_runtime() -> None: classifiers = pyproject["project"]["classifiers"] assert not (ROOT / "docs/EXPERIMENTAL_NATIVE_INSTALLERS.md").exists() - assert native_release.count("--notes-file docs/BETA_NATIVE_INSTALLERS.md") == 1 + assert native_release.count("--notes-file docs/RELEASE_CANDIDATE_INSTALLERS.md") == 1 assert "EXPERIMENTAL_NATIVE_INSTALLERS" not in native_release flow_dependencies = [ dependency for dependency in build_dependencies if dependency.startswith("openadapt-flow") @@ -230,7 +234,7 @@ def test_beta_release_notes_describe_the_bundled_flow_runtime() -> None: assert "openadapt-capture>=1.2.1" in dependencies assert 'name = "openadapt-capture"\nversion = "1.2.1"' in lock assert "openadapt-privacy>=1.0.0" in dependencies - assert "Development Status :: 4 - Beta" in classifiers + assert not any(item.startswith("Development Status ::") for item in classifiers) assert "Development Status :: 2 - Pre-Alpha" not in classifiers assert bundled_flow_version() == "1.31.0" assert bundled_flow_banner() == "openadapt-flow 1.31.0"