From 4e874b62f970be5f03f8e02aade19ccc5294d1da Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 26 Aug 2026 13:53:17 -0400 Subject: [PATCH 1/3] ci: enforce protected workflow identities --- .github/workflows/azure-cost-guard.yml | 18 +- .github/workflows/db-backup-freshness.yml | 25 ++- .github/workflows/db-backup.yml | 25 ++- .github/workflows/default-branch-sweep.yml | 28 ++- .github/workflows/prod-health-alert.yml | 25 ++- .../workflows/production-lifecycle-policy.yml | 35 ++-- .../production-lifecycle-projection.yml | 190 ++++++++++++++++++ .../workflows/published-version-claims.yml | 26 ++- .../workflows/workspace-staleness-sweep.yml | 28 ++- ...prepare_production_lifecycle_projection.py | 188 +++++++++++++++++ scripts/validate_docs_sync.py | 98 +++++++++ tests/test_azure_cost_guard.py | 6 +- tests/test_docs_sync_contract.py | 52 +++++ ...prepare_production_lifecycle_projection.py | 81 ++++++++ tests/test_production_ops_workflows.py | 103 ++++++++++ 15 files changed, 891 insertions(+), 37 deletions(-) create mode 100644 .github/workflows/production-lifecycle-projection.yml create mode 100755 scripts/prepare_production_lifecycle_projection.py create mode 100755 scripts/validate_docs_sync.py create mode 100644 tests/test_docs_sync_contract.py create mode 100644 tests/test_prepare_production_lifecycle_projection.py diff --git a/.github/workflows/azure-cost-guard.yml b/.github/workflows/azure-cost-guard.yml index dbcdd51..6cbb260 100644 --- a/.github/workflows/azure-cost-guard.yml +++ b/.github/workflows/azure-cost-guard.yml @@ -9,11 +9,27 @@ permissions: contents: read concurrency: - group: azure-cost-guard + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: false jobs: + reject-lifecycle-app: + permissions: {} + runs-on: ubuntu-latest + steps: + - name: Reject the lifecycle App + env: + ACTOR: ${{ github.actor }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + run: | + test "$ACTOR" != 'openadapt-lifecycle[bot]' + test "$TRIGGERING_ACTOR" != 'openadapt-lifecycle[bot]' + report: + needs: reject-lifecycle-app + if: >- + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/db-backup-freshness.yml b/.github/workflows/db-backup-freshness.yml index d676a65..05e9976 100644 --- a/.github/workflows/db-backup-freshness.yml +++ b/.github/workflows/db-backup-freshness.yml @@ -13,11 +13,27 @@ permissions: contents: read concurrency: - group: production-db-backup-freshness + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: false jobs: + reject-lifecycle-app: + permissions: {} + runs-on: ubuntu-latest + steps: + - name: Reject the lifecycle App + env: + ACTOR: ${{ github.actor }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + run: | + test "$ACTOR" != 'openadapt-lifecycle[bot]' + test "$TRIGGERING_ACTOR" != 'openadapt-lifecycle[bot]' + verify: + needs: reject-lifecycle-app + if: >- + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' runs-on: ubuntu-latest timeout-minutes: 10 environment: production-backup-monitor @@ -110,8 +126,11 @@ jobs: record-alert: name: Keep one durable backup freshness alert - needs: verify - if: ${{ always() }} + needs: [reject-lifecycle-app, verify] + if: >- + always() && + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/db-backup.yml b/.github/workflows/db-backup.yml index 3643168..30f65b1 100644 --- a/.github/workflows/db-backup.yml +++ b/.github/workflows/db-backup.yml @@ -17,11 +17,27 @@ permissions: contents: read concurrency: - group: production-db-backup + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: false jobs: + reject-lifecycle-app: + permissions: {} + runs-on: ubuntu-latest + steps: + - name: Reject the lifecycle App + env: + ACTOR: ${{ github.actor }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + run: | + test "$ACTOR" != 'openadapt-lifecycle[bot]' + test "$TRIGGERING_ACTOR" != 'openadapt-lifecycle[bot]' + dump: + needs: reject-lifecycle-app + if: >- + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' # The complete production database is plaintext until the local age step. # Route this job only to the workflow-restricted production backup group. # GitHub-hosted runners and the default self-hosted group are not permitted. @@ -244,8 +260,11 @@ jobs: record-alert: name: Keep one durable backup alert - needs: dump - if: ${{ always() }} + needs: [reject-lifecycle-app, dump] + if: >- + always() && + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/default-branch-sweep.yml b/.github/workflows/default-branch-sweep.yml index ff244e4..d066e9a 100644 --- a/.github/workflows/default-branch-sweep.yml +++ b/.github/workflows/default-branch-sweep.yml @@ -33,19 +33,35 @@ on: - '.github/workflows/default-branch-sweep.yml' concurrency: - group: default-branch-sweep-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: false permissions: contents: read jobs: + reject-lifecycle-app: + permissions: {} + runs-on: ubuntu-latest + steps: + - name: Reject the lifecycle App + env: + ACTOR: ${{ github.actor }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + run: | + test "$ACTOR" != 'openadapt-lifecycle[bot]' + test "$TRIGGERING_ACTOR" != 'openadapt-lifecycle[bot]' + self-test: # A detector nobody has seen fire is a detector nobody should trust. The # offline classification tests also run in Docs CI; running them here keeps # a change to the detector self-contained. name: Prove the classifier fires and stays quiet - if: github.event_name == 'pull_request' + needs: reject-lifecycle-app + if: >- + github.event_name == 'pull_request' && + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -60,7 +76,11 @@ jobs: sweep: name: Sweep every default branch we own - if: github.event_name != 'pull_request' + needs: reject-lifecycle-app + if: >- + github.event_name != 'pull_request' && + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/prod-health-alert.yml b/.github/workflows/prod-health-alert.yml index 54b645a..23efb51 100644 --- a/.github/workflows/prod-health-alert.yml +++ b/.github/workflows/prod-health-alert.yml @@ -31,11 +31,27 @@ permissions: contents: read concurrency: - group: prod-health + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: false jobs: + reject-lifecycle-app: + permissions: {} + runs-on: ubuntu-latest + steps: + - name: Reject the lifecycle App + env: + ACTOR: ${{ github.actor }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + run: | + test "$ACTOR" != 'openadapt-lifecycle[bot]' + test "$TRIGGERING_ACTOR" != 'openadapt-lifecycle[bot]' + probe: + needs: reject-lifecycle-app + if: >- + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -88,8 +104,11 @@ jobs: record-alert: name: Keep one durable production health alert - needs: probe - if: ${{ always() }} + needs: [reject-lifecycle-app, probe] + if: >- + always() && + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/production-lifecycle-policy.yml b/.github/workflows/production-lifecycle-policy.yml index c22e3a9..cc87ec5 100644 --- a/.github/workflows/production-lifecycle-policy.yml +++ b/.github/workflows/production-lifecycle-policy.yml @@ -5,15 +5,6 @@ on: - cron: "37 8 * * *" workflow_dispatch: pull_request: - paths: - - ".github/workflows/production-lifecycle-policy.yml" - - "docs/production-lifecycle.json" - - "docs/reference/production-lifecycle.md" - - "docs/schemas/production-lifecycle-public.schema.json" - - "mkdocs.yml" - - "production-lifecycle-source.json" - - "scripts/render_production_lifecycle.py" - - "tests/test_production_lifecycle_policy.py" push: branches: [main] paths: @@ -30,11 +21,27 @@ permissions: contents: read concurrency: - group: production-lifecycle-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: false jobs: + reject-lifecycle-app: + permissions: {} + runs-on: ubuntu-latest + steps: + - name: Reject the lifecycle App + env: + ACTOR: ${{ github.actor }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + run: | + test "$ACTOR" != 'openadapt-lifecycle[bot]' + test "$TRIGGERING_ACTOR" != 'openadapt-lifecycle[bot]' + validate: + needs: reject-lifecycle-app + if: >- + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -51,10 +58,12 @@ jobs: run: python3 -m unittest tests.test_production_lifecycle_policy report-failure: - needs: validate + needs: [reject-lifecycle-app, validate] if: >- ${{ always() && needs.validate.result == 'failure' && - (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') }} + (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' }} runs-on: ubuntu-latest permissions: issues: write diff --git a/.github/workflows/production-lifecycle-projection.yml b/.github/workflows/production-lifecycle-projection.yml new file mode 100644 index 0000000..28467e4 --- /dev/null +++ b/.github/workflows/production-lifecycle-projection.yml @@ -0,0 +1,190 @@ +name: Production lifecycle projection + +on: + workflow_dispatch: + inputs: + source_event: + description: Canonical lifecycle source event + required: true + type: string + source_repository: + description: Canonical lifecycle repository + required: true + type: string + source_ref: + description: Canonical lifecycle ref + required: true + type: string + source_commit: + description: Exact canonical lifecycle commit + required: true + type: string + candidate_admissions_sha256: + description: Exact admissions digest + required: true + type: string + candidate_ledger_head_sha256: + description: Exact bound lifecycle ledger head + required: true + type: string + idempotency_key: + description: Exact projection idempotency key + required: true + type: string + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + authorize-lifecycle-app: + permissions: {} + runs-on: ubuntu-latest + steps: + - name: Refuse any identity other than the lifecycle App + env: + ACTOR: ${{ github.actor }} + ACTOR_ID: ${{ github.actor_id }} + EXPECTED_ACTOR_ID: ${{ vars.OPENADAPT_LIFECYCLE_ACTOR_ID }} + EVENT_NAME: ${{ github.event_name }} + REF: ${{ github.ref }} + REPOSITORY: ${{ github.repository }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + run: | + set -euo pipefail + test "$REPOSITORY" = 'OpenAdaptAI/openadapt-ops' + test "$REF" = 'refs/heads/main' + test "$EVENT_NAME" = 'workflow_dispatch' + test "$ACTOR" = 'openadapt-lifecycle[bot]' + test "$TRIGGERING_ACTOR" = 'openadapt-lifecycle[bot]' + test -n "$EXPECTED_ACTOR_ID" + test "$ACTOR_ID" = "$EXPECTED_ACTOR_ID" + + project-lifecycle: + name: Project canonical Production lifecycle + needs: authorize-lifecycle-app + if: >- + github.repository == 'OpenAdaptAI/openadapt-ops' && + github.ref == 'refs/heads/main' && + github.event_name == 'workflow_dispatch' && + github.actor == 'openadapt-lifecycle[bot]' && + github.triggering_actor == 'openadapt-lifecycle[bot]' && + github.actor_id == vars.OPENADAPT_LIFECYCLE_ACTOR_ID && + inputs.source_event == 'production_lifecycle_ledger_changed' && + inputs.source_repository == 'OpenAdaptAI/.github' && + inputs.source_ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: production-lifecycle-projection + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Create an exact lifecycle App installation token + id: lifecycle-app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ vars.OPENADAPT_LIFECYCLE_APP_ID }} + private-key: ${{ secrets.OPENADAPT_LIFECYCLE_APP_PRIVATE_KEY }} + owner: OpenAdaptAI + repositories: | + .github + openadapt-evals + openadapt-ops + permission-actions: write + permission-metadata: read + permission-pull-requests: write + + - name: Verify the exact lifecycle App installation + env: + EXPECTED_APP_ID: ${{ vars.OPENADAPT_LIFECYCLE_APP_ID }} + EXPECTED_INSTALLATION_ID: ${{ vars.OPENADAPT_LIFECYCLE_INSTALLATION_ID }} + GH_TOKEN: ${{ steps.lifecycle-app.outputs.token }} + run: | + set -euo pipefail + test -n "$EXPECTED_APP_ID" + test -n "$EXPECTED_INSTALLATION_ID" + actual_app_id=$(gh api /installation --jq '.app_id | tostring') + actual_installation_id=$(gh api /installation --jq '.id | tostring') + test "$actual_app_id" = "$EXPECTED_APP_ID" + test "$actual_installation_id" = "$EXPECTED_INSTALLATION_ID" + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.12' + + - name: Verify the exact source commit + env: + GH_TOKEN: ${{ github.token }} + SOURCE_COMMIT: ${{ inputs.source_commit }} + run: | + set -euo pipefail + test "${SOURCE_COMMIT}" != '' + test "${SOURCE_COMMIT}" = "$(gh api repos/OpenAdaptAI/.github/commits/main --jq .sha)" + + - name: Prepare and render the exact lifecycle projection + env: + CANDIDATE_ADMISSIONS_SHA256: ${{ inputs.candidate_admissions_sha256 }} + CANDIDATE_LEDGER_HEAD_SHA256: ${{ inputs.candidate_ledger_head_sha256 }} + IDEMPOTENCY_KEY: ${{ inputs.idempotency_key }} + SOURCE_COMMIT: ${{ inputs.source_commit }} + SOURCE_EVENT: ${{ inputs.source_event }} + SOURCE_REF: ${{ inputs.source_ref }} + SOURCE_REPOSITORY: ${{ inputs.source_repository }} + run: | + set -euo pipefail + python scripts/prepare_production_lifecycle_projection.py \ + --source-commit "$SOURCE_COMMIT" \ + --source-repository "$SOURCE_REPOSITORY" \ + --source-ref "$SOURCE_REF" \ + --source-event "$SOURCE_EVENT" \ + --candidate-admissions-sha256 "$CANDIDATE_ADMISSIONS_SHA256" \ + --candidate-ledger-head-sha256 "$CANDIDATE_LEDGER_HEAD_SHA256" \ + --idempotency-key "$IDEMPOTENCY_KEY" \ + --output production-lifecycle-source.json + python scripts/render_production_lifecycle.py + python scripts/render_production_lifecycle.py --check + python -m unittest tests.test_production_lifecycle_policy + + - name: Commit the projection to an automation branch + id: commit + env: + IDEMPOTENCY_KEY: ${{ inputs.idempotency_key }} + SOURCE_COMMIT: ${{ inputs.source_commit }} + run: | + set -euo pipefail + branch="automation/production-lifecycle-${SOURCE_COMMIT:0:16}" + git switch -c "$branch" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add production-lifecycle-source.json docs/production-lifecycle.json + if git diff --staged --quiet; then + echo 'changed=false' >> "$GITHUB_OUTPUT" + exit 0 + fi + git commit -m "docs: project lifecycle ${SOURCE_COMMIT:0:12}" \ + -m "${IDEMPOTENCY_KEY}" + git push origin "HEAD:refs/heads/${branch}" + echo "branch=${branch}" >> "$GITHUB_OUTPUT" + echo 'changed=true' >> "$GITHUB_OUTPUT" + + - name: Open the projection PR + if: steps.commit.outputs.changed == 'true' + env: + BRANCH: ${{ steps.commit.outputs.branch }} + GH_TOKEN: ${{ steps.lifecycle-app.outputs.token }} + SOURCE_COMMIT: ${{ inputs.source_commit }} + run: | + set -euo pipefail + existing=$(gh pr list --head "$BRANCH" --state open --json url --jq '.[0].url // empty') + if [ -n "$existing" ]; then + echo "The lifecycle projection PR already exists: $existing" + exit 0 + fi + gh pr create --base main --head "$BRANCH" \ + --title "docs: project lifecycle ${SOURCE_COMMIT:0:12}" \ + --body "Projects the exact canonical lifecycle source at OpenAdaptAI/.github@${SOURCE_COMMIT}." diff --git a/.github/workflows/published-version-claims.yml b/.github/workflows/published-version-claims.yml index 62193cb..acb2420 100644 --- a/.github/workflows/published-version-claims.yml +++ b/.github/workflows/published-version-claims.yml @@ -22,15 +22,31 @@ on: - '.github/workflows/published-version-claims.yml' concurrency: - group: published-version-claims-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: false permissions: contents: read jobs: + reject-lifecycle-app: + permissions: {} + runs-on: ubuntu-latest + steps: + - name: Reject the lifecycle App + env: + ACTOR: ${{ github.actor }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + run: | + test "$ACTOR" != 'openadapt-lifecycle[bot]' + test "$TRIGGERING_ACTOR" != 'openadapt-lifecycle[bot]' + check: name: Compare documented versions to PyPI + needs: reject-lifecycle-app + if: >- + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -46,11 +62,13 @@ jobs: report-drift: # A red scheduled run in a repository nobody has open is not a signal. # Give the drift an owner and a paper trail instead of a stale red dot. - needs: check + needs: [reject-lifecycle-app, check] if: >- ${{ always() && needs.check.result == 'failure' && (github.event_name == 'schedule' || - github.event_name == 'workflow_dispatch') }} + github.event_name == 'workflow_dispatch') && + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' }} runs-on: ubuntu-latest permissions: issues: write diff --git a/.github/workflows/workspace-staleness-sweep.yml b/.github/workflows/workspace-staleness-sweep.yml index 038f7b0..1438029 100644 --- a/.github/workflows/workspace-staleness-sweep.yml +++ b/.github/workflows/workspace-staleness-sweep.yml @@ -63,19 +63,35 @@ on: - '.github/workflows/workspace-staleness-sweep.yml' concurrency: - group: workspace-staleness-sweep-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: false permissions: contents: read jobs: + reject-lifecycle-app: + permissions: {} + runs-on: ubuntu-latest + steps: + - name: Reject the lifecycle App + env: + ACTOR: ${{ github.actor }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + run: | + test "$ACTOR" != 'openadapt-lifecycle[bot]' + test "$TRIGGERING_ACTOR" != 'openadapt-lifecycle[bot]' + self-test: # A detector nobody has seen fire is a detector nobody should trust. The # offline classification tests also run in Docs CI; running them here keeps # a change to the detector self-contained. name: Prove the classifiers fire and stay quiet - if: github.event_name == 'pull_request' + needs: reject-lifecycle-app + if: >- + github.event_name == 'pull_request' && + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -90,7 +106,11 @@ jobs: sweep: name: Sweep for stranded work - if: github.event_name != 'pull_request' + needs: reject-lifecycle-app + if: >- + github.event_name != 'pull_request' && + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' runs-on: ubuntu-latest permissions: contents: read diff --git a/scripts/prepare_production_lifecycle_projection.py b/scripts/prepare_production_lifecycle_projection.py new file mode 100755 index 0000000..2f9e6b8 --- /dev/null +++ b/scripts/prepare_production_lifecycle_projection.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Prepare an exact canonical source descriptor for a lifecycle projection.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import urllib.request +from collections.abc import Callable +from pathlib import Path + +SOURCE_SCHEMA = "openadapt.production-lifecycle-source/v1" +SOURCE_REPOSITORY = "OpenAdaptAI/.github" +SOURCE_REF = "refs/heads/main" +SOURCE_EVENT = "production_lifecycle_ledger_changed" +HEX40 = re.compile(r"^[0-9a-f]{40}$") +SHA256 = re.compile(r"^sha256:[0-9a-f]{64}$") +MAX_SOURCE_BYTES = 2 * 1024 * 1024 +LEDGER_DOMAIN = b"OpenAdapt production lifecycle ledger head v1\0" +IDEMPOTENCY_DOMAIN = b"OpenAdapt production lifecycle projection idempotency v1\0" +EXPECTED_PATHS = { + "admissions": "production-lifecycle-admissions.json", + "admissions_schema": "schemas/production-lifecycle-admissions.schema.json", + "evidence_registry": "evidence-registry.json", + "evidence_registry_schema": "schemas/evidence-registry.schema.json", + "evidence_registry_validator": "scripts/validate_evidence_registry.py", + "evidence_manifest_schema": ( + "schemas/production-lifecycle-evidence-manifest.schema.json" + ), + "evidence_summary_schema": ( + "schemas/production-lifecycle-evidence-summary.schema.json" + ), + "lifecycle": "repository-lifecycle.yml", + "policy": "production-lifecycle-policy.json", + "policy_schema": "schemas/production-lifecycle-policy.schema.json", + "validator": "scripts/validate_production_lifecycle.py", +} + + +class ProjectionInputError(ValueError): + """The lifecycle projection input is not exact or internally consistent.""" + + +def digest(value: bytes) -> str: + return "sha256:" + hashlib.sha256(value).hexdigest() + + +def _digest_fields(domain: bytes, fields: tuple[str, ...]) -> str: + payload = domain + b"".join(value.encode("ascii") + b"\0" for value in fields) + return digest(payload) + + +def ledger_head_sha256(admissions_sha256: str, evidence_registry_sha256: str) -> str: + return _digest_fields(LEDGER_DOMAIN, (admissions_sha256, evidence_registry_sha256)) + + +def projection_idempotency_key( + *, + source_commit: str, + candidate_admissions_sha256: str, + candidate_ledger_head_sha256: str, +) -> str: + return _digest_fields( + IDEMPOTENCY_DOMAIN, + ( + SOURCE_EVENT, + SOURCE_REPOSITORY, + SOURCE_REF, + source_commit, + candidate_admissions_sha256, + candidate_ledger_head_sha256, + ), + ) + + +def fetch(url: str) -> bytes: + request = urllib.request.Request( + url, headers={"User-Agent": "openadapt-lifecycle-projection/1"} + ) + with urllib.request.urlopen(request, timeout=30) as response: + body = response.read(MAX_SOURCE_BYTES + 1) + if len(body) > MAX_SOURCE_BYTES: + raise ProjectionInputError("a canonical source file exceeds the size limit") + return body + + +def build_source( + source_commit: str, + *, + fetch_bytes: Callable[[str], bytes] = fetch, +) -> tuple[dict[str, object], dict[str, bytes]]: + if HEX40.fullmatch(source_commit) is None: + raise ProjectionInputError("the source commit is not an exact lowercase SHA") + files: dict[str, dict[str, str]] = {} + contents: dict[str, bytes] = {} + for key, path in sorted(EXPECTED_PATHS.items()): + url = ( + "https://raw.githubusercontent.com/" + f"{SOURCE_REPOSITORY}/{source_commit}/{path}" + ) + body = fetch_bytes(url) + if len(body) > MAX_SOURCE_BYTES: + raise ProjectionInputError("a canonical source file exceeds the size limit") + contents[key] = body + files[key] = {"path": path, "url": url, "sha256": digest(body)} + return ( + { + "schema_version": SOURCE_SCHEMA, + "repository": SOURCE_REPOSITORY, + "source_commit": source_commit, + "files": files, + }, + contents, + ) + + +def prepare( + *, + source_commit: str, + source_repository: str, + source_ref: str, + source_event: str, + candidate_admissions_sha256: str, + candidate_ledger_head_sha256: str, + idempotency_key: str, + fetch_bytes: Callable[[str], bytes] = fetch, +) -> dict[str, object]: + if source_repository != SOURCE_REPOSITORY: + raise ProjectionInputError("the source repository is not canonical") + if source_ref != SOURCE_REF: + raise ProjectionInputError("the source ref is not exact main") + if source_event != SOURCE_EVENT: + raise ProjectionInputError("the source event is not canonical") + for label, value in ( + ("candidate admissions", candidate_admissions_sha256), + ("candidate ledger head", candidate_ledger_head_sha256), + ("idempotency key", idempotency_key), + ): + if SHA256.fullmatch(value) is None: + raise ProjectionInputError(f"the {label} digest is malformed") + + source, contents = build_source(source_commit, fetch_bytes=fetch_bytes) + actual_admissions = digest(contents["admissions"]) + if candidate_admissions_sha256 != actual_admissions: + raise ProjectionInputError("the admissions digest does not match the source") + actual_head = ledger_head_sha256( + actual_admissions, digest(contents["evidence_registry"]) + ) + if candidate_ledger_head_sha256 != actual_head: + raise ProjectionInputError("the lifecycle ledger head does not match the source") + expected_idempotency = projection_idempotency_key( + source_commit=source_commit, + candidate_admissions_sha256=actual_admissions, + candidate_ledger_head_sha256=actual_head, + ) + if idempotency_key != expected_idempotency: + raise ProjectionInputError("the projection idempotency key does not match") + return source + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--source-commit", required=True) + parser.add_argument("--source-repository", required=True) + parser.add_argument("--source-ref", required=True) + parser.add_argument("--source-event", required=True) + parser.add_argument("--candidate-admissions-sha256", required=True) + parser.add_argument("--candidate-ledger-head-sha256", required=True) + parser.add_argument("--idempotency-key", required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + source = prepare( + source_commit=args.source_commit, + source_repository=args.source_repository, + source_ref=args.source_ref, + source_event=args.source_event, + candidate_admissions_sha256=args.candidate_admissions_sha256, + candidate_ledger_head_sha256=args.candidate_ledger_head_sha256, + idempotency_key=args.idempotency_key, + ) + args.output.write_text(json.dumps(source, indent=2, sort_keys=True) + "\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate_docs_sync.py b/scripts/validate_docs_sync.py new file mode 100755 index 0000000..9a85a28 --- /dev/null +++ b/scripts/validate_docs_sync.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Validate an exact documentation synchronization dispatch.""" + +from __future__ import annotations + +import argparse +import hashlib +import re +from pathlib import Path + +import yaml + +REF = "refs/heads/main" +EVENT = "push" +HEX40 = re.compile(r"^[0-9a-f]{40}$") +KEY = re.compile(r"^docs-sync:[0-9a-f]{64}$") +DOMAIN = b"OpenAdapt docs sync dispatch v1\0" + + +class DocsSyncError(ValueError): + """The documentation dispatch does not match its exact contract.""" + + +def allowed_repositories(path: Path) -> set[str]: + value = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(value, dict) or not isinstance(value.get("repos"), list): + raise DocsSyncError("the repository inventory is invalid") + allowed: set[str] = set() + for entry in value["repos"]: + if not isinstance(entry, dict): + raise DocsSyncError("the repository inventory contains an invalid entry") + repository = entry.get("github") + if entry.get("changelog") is True and isinstance(repository, str): + allowed.add(repository) + if not allowed: + raise DocsSyncError("the repository inventory has no public sync source") + return allowed + + +def expected_idempotency_key( + source_repository: str, + source_ref: str, + source_commit: str, + source_event: str, +) -> str: + fields = (source_repository, source_ref, source_commit, source_event) + payload = DOMAIN + b"".join(value.encode("utf-8") + b"\0" for value in fields) + return "docs-sync:" + hashlib.sha256(payload).hexdigest() + + +def validate( + *, + repositories: Path, + source_repository: str, + source_ref: str, + source_commit: str, + source_event: str, + idempotency_key: str, +) -> None: + if source_repository not in allowed_repositories(repositories): + raise DocsSyncError("the source repository is not in the public sync inventory") + if source_ref != REF: + raise DocsSyncError("the source ref is not exact main") + if source_event != EVENT: + raise DocsSyncError("the source event is not a main push") + if HEX40.fullmatch(source_commit) is None: + raise DocsSyncError("the source commit is not an exact lowercase SHA") + if KEY.fullmatch(idempotency_key) is None: + raise DocsSyncError("the docs-sync idempotency key is malformed") + expected = expected_idempotency_key( + source_repository, source_ref, source_commit, source_event + ) + if idempotency_key != expected: + raise DocsSyncError("the docs-sync idempotency key does not match the source") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--repositories", type=Path, required=True) + parser.add_argument("--source-repository", required=True) + parser.add_argument("--source-ref", required=True) + parser.add_argument("--source-commit", required=True) + parser.add_argument("--source-event", required=True) + parser.add_argument("--idempotency-key", required=True) + args = parser.parse_args() + validate( + repositories=args.repositories, + source_repository=args.source_repository, + source_ref=args.source_ref, + source_commit=args.source_commit, + source_event=args.source_event, + idempotency_key=args.idempotency_key, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_azure_cost_guard.py b/tests/test_azure_cost_guard.py index b666c68..10a34d9 100644 --- a/tests/test_azure_cost_guard.py +++ b/tests/test_azure_cost_guard.py @@ -105,8 +105,10 @@ def test_scheduled_guard_uses_step_level_secret_gate(): workflow = yaml.safe_load( (REPO_ROOT / ".github/workflows/azure-cost-guard.yml").read_text(encoding="utf-8") ) - steps = workflow["jobs"]["report"]["steps"] - assert "if" not in workflow["jobs"]["report"] + report = workflow["jobs"]["report"] + steps = report["steps"] + assert "github.actor != 'openadapt-lifecycle[bot]'" in report["if"] + assert "github.triggering_actor != 'openadapt-lifecycle[bot]'" in report["if"] credentials = next(step for step in steps if step.get("id") == "credentials") assert credentials["env"]["AZURE_CREDENTIALS"] == "${{ secrets.AZURE_CREDENTIALS }}" login = next(step for step in steps if "Azure/login@" in step.get("uses", "")) diff --git a/tests/test_docs_sync_contract.py b/tests/test_docs_sync_contract.py new file mode 100644 index 0000000..2497b93 --- /dev/null +++ b/tests/test_docs_sync_contract.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "validate_docs_sync", ROOT / "scripts" / "validate_docs_sync.py" +) +assert SPEC is not None and SPEC.loader is not None +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +def values() -> dict[str, str | Path]: + source_repository = "OpenAdaptAI/openadapt-evals" + source_ref = "refs/heads/main" + source_commit = "a" * 40 + source_event = "push" + return { + "repositories": ROOT / "repos.yml", + "source_repository": source_repository, + "source_ref": source_ref, + "source_commit": source_commit, + "source_event": source_event, + "idempotency_key": MODULE.expected_idempotency_key( + source_repository, source_ref, source_commit, source_event + ), + } + + +def test_exact_allowlisted_dispatch_passes() -> None: + MODULE.validate(**values()) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("source_repository", "OpenAdaptAI/openadapt-cloud"), + ("source_ref", "refs/heads/release"), + ("source_commit", "A" * 40), + ("source_event", "repository_dispatch"), + ("idempotency_key", "docs-sync:" + "0" * 64), + ], +) +def test_dispatch_contract_fails_closed(field: str, value: str) -> None: + candidate = values() + candidate[field] = value + with pytest.raises(MODULE.DocsSyncError): + MODULE.validate(**candidate) diff --git a/tests/test_prepare_production_lifecycle_projection.py b/tests/test_prepare_production_lifecycle_projection.py new file mode 100644 index 0000000..a161c17 --- /dev/null +++ b/tests/test_prepare_production_lifecycle_projection.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "prepare_production_lifecycle_projection", + ROOT / "scripts" / "prepare_production_lifecycle_projection.py", +) +assert SPEC is not None and SPEC.loader is not None +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +def source_bytes() -> dict[str, bytes]: + return {key: f"{key}\n".encode() for key in MODULE.EXPECTED_PATHS} + + +def fetcher(values: dict[str, bytes]): + def fetch(url: str) -> bytes: + path = url.split("/", 6)[-1] + key = next(key for key, value in MODULE.EXPECTED_PATHS.items() if value == path) + return values[key] + + return fetch + + +def inputs() -> tuple[dict[str, str], dict[str, bytes]]: + values = source_bytes() + commit = "a" * 40 + admissions = MODULE.digest(values["admissions"]) + head = MODULE.ledger_head_sha256( + admissions, MODULE.digest(values["evidence_registry"]) + ) + return ( + { + "source_commit": commit, + "source_repository": MODULE.SOURCE_REPOSITORY, + "source_ref": MODULE.SOURCE_REF, + "source_event": MODULE.SOURCE_EVENT, + "candidate_admissions_sha256": admissions, + "candidate_ledger_head_sha256": head, + "idempotency_key": MODULE.projection_idempotency_key( + source_commit=commit, + candidate_admissions_sha256=admissions, + candidate_ledger_head_sha256=head, + ), + }, + values, + ) + + +def test_exact_projection_inputs_build_a_commit_bound_inventory() -> None: + candidate, values = inputs() + source = MODULE.prepare(**candidate, fetch_bytes=fetcher(values)) + assert source["source_commit"] == "a" * 40 + assert set(source["files"]) == set(MODULE.EXPECTED_PATHS) + assert source["files"]["admissions"]["sha256"] == MODULE.digest( + values["admissions"] + ) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("source_repository", "OpenAdaptAI/openadapt-ops"), + ("source_ref", "refs/heads/release"), + ("source_event", "repository_dispatch"), + ("candidate_admissions_sha256", "sha256:" + "0" * 64), + ("candidate_ledger_head_sha256", "sha256:" + "0" * 64), + ("idempotency_key", "sha256:" + "0" * 64), + ], +) +def test_projection_inputs_fail_closed(field: str, value: str) -> None: + candidate, values = inputs() + candidate[field] = value + with pytest.raises(MODULE.ProjectionInputError): + MODULE.prepare(**candidate, fetch_bytes=fetcher(values)) diff --git a/tests/test_production_ops_workflows.py b/tests/test_production_ops_workflows.py index 4aef4c4..d26d197 100644 --- a/tests/test_production_ops_workflows.py +++ b/tests/test_production_ops_workflows.py @@ -2,15 +2,118 @@ from __future__ import annotations +import re from pathlib import Path +import yaml + ROOT = Path(__file__).resolve().parent.parent +REJECT_LIFECYCLE_WORKFLOWS = ( + ".github/workflows/azure-cost-guard.yml", + ".github/workflows/db-backup-freshness.yml", + ".github/workflows/db-backup.yml", + ".github/workflows/default-branch-sweep.yml", + ".github/workflows/prod-health-alert.yml", + ".github/workflows/production-lifecycle-policy.yml", + ".github/workflows/published-version-claims.yml", + ".github/workflows/workspace-staleness-sweep.yml", +) + def read(path: str) -> str: return (ROOT / path).read_text(encoding="utf-8") +def workflow(path: str) -> dict: + value = yaml.load(read(path), Loader=yaml.BaseLoader) + assert isinstance(value, dict) + return value + + +def test_every_non_lifecycle_dispatch_refuses_the_lifecycle_app() -> None: + for path in REJECT_LIFECYCLE_WORKFLOWS: + value = workflow(path) + concurrency = value["concurrency"] + assert "github.workflow" in concurrency["group"], path + assert "github.event_name" in concurrency["group"], path + assert concurrency["cancel-in-progress"] == "false", path + + jobs = value["jobs"] + guard = jobs["reject-lifecycle-app"] + assert guard["permissions"] == {}, path + guard_text = str(guard) + assert "github.actor" in guard_text, path + assert "github.triggering_actor" in guard_text, path + assert "openadapt-lifecycle[bot]" in guard_text, path + for name, job in jobs.items(): + if name == "reject-lifecycle-app": + continue + needs = job.get("needs", []) + if isinstance(needs, str): + needs = [needs] + assert "reject-lifecycle-app" in needs, f"{path}:{name}" + condition = job.get("if", "") + assert re.search( + r"github\.actor\s*!=\s*'openadapt-lifecycle\[bot\]'", condition + ), f"{path}:{name}" + assert re.search( + r"github\.triggering_actor\s*!=\s*'openadapt-lifecycle\[bot\]'", + condition, + ), f"{path}:{name}" + + +def test_dispatch_workflow_inventory_is_complete() -> None: + discovered = set() + for path in sorted((ROOT / ".github" / "workflows").glob("*.y*ml")): + content = path.read_text(encoding="utf-8") + if re.search(r"(?m)^ (?:workflow_dispatch|repository_dispatch):\s*$", content): + discovered.add(str(path.relative_to(ROOT))) + assert discovered == set(REJECT_LIFECYCLE_WORKFLOWS) | { + ".github/workflows/production-lifecycle-projection.yml", + ".github/workflows/sync.yml", + } + + +def test_docs_sync_is_app_only_and_never_pushes_main() -> None: + content = read(".github/workflows/sync.yml") + assert "repository_dispatch:" not in content + assert "repo-updated" not in content + assert "git push origin HEAD:main" not in content + assert "github.actor == 'openadapt-docs[bot]'" in content + assert "github.triggering_actor == 'openadapt-docs[bot]'" in content + assert "github.actor_id == vars.OPENADAPT_DOCS_ACTOR_ID" in content + assert "vars.OPENADAPT_DOCS_APP_ID" in content + assert "vars.OPENADAPT_DOCS_INSTALLATION_ID" in content + assert "secrets.OPENADAPT_DOCS_APP_PRIVATE_KEY" in content + assert "environment: production-docs-deploy" in content + assert "name: github-pages" in content + assert "gh pr create" in content + assert "cancel-in-progress: false" in content + + +def test_lifecycle_projection_is_app_only_and_never_pushes_main() -> None: + content = read(".github/workflows/production-lifecycle-projection.yml") + assert "github.actor == 'openadapt-lifecycle[bot]'" in content + assert "github.triggering_actor == 'openadapt-lifecycle[bot]'" in content + assert "github.actor_id == vars.OPENADAPT_LIFECYCLE_ACTOR_ID" in content + assert "vars.OPENADAPT_LIFECYCLE_APP_ID" in content + assert "vars.OPENADAPT_LIFECYCLE_INSTALLATION_ID" in content + assert "secrets.OPENADAPT_LIFECYCLE_APP_PRIVATE_KEY" in content + assert "environment: production-lifecycle-projection" in content + assert "production_lifecycle_ledger_changed" in content + assert "git push origin HEAD:main" not in content + assert "gh pr create" in content + assert "cancel-in-progress: false" in content + + +def test_lifecycle_required_check_runs_on_every_pull_request() -> None: + content = read(".github/workflows/production-lifecycle-policy.yml") + pull_request = content.split(" pull_request:", 1)[1].split(" push:", 1)[0] + assert "paths:" not in pull_request + assert "paths-ignore:" not in pull_request + + def test_backup_configuration_fails_before_credentials_or_tool_install() -> None: workflow = read(".github/workflows/db-backup.yml") preflight = workflow.index("Validate the protected environment configuration") From 7c940b82623e0b7ffd38ba2ca2a779ce81c78302 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 26 Aug 2026 14:03:10 -0400 Subject: [PATCH 2/3] ci: run lifecycle policy check on every pull request --- .github/workflows/production-lifecycle-policy.yml | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/.github/workflows/production-lifecycle-policy.yml b/.github/workflows/production-lifecycle-policy.yml index cc87ec5..8c96ee0 100644 --- a/.github/workflows/production-lifecycle-policy.yml +++ b/.github/workflows/production-lifecycle-policy.yml @@ -7,15 +7,6 @@ on: pull_request: push: branches: [main] - paths: - - ".github/workflows/production-lifecycle-policy.yml" - - "docs/production-lifecycle.json" - - "docs/reference/production-lifecycle.md" - - "docs/schemas/production-lifecycle-public.schema.json" - - "mkdocs.yml" - - "production-lifecycle-source.json" - - "scripts/render_production_lifecycle.py" - - "tests/test_production_lifecycle_policy.py" permissions: contents: read @@ -38,6 +29,7 @@ jobs: test "$TRIGGERING_ACTOR" != 'openadapt-lifecycle[bot]' validate: + name: Validate Production lifecycle needs: reject-lifecycle-app if: >- github.actor != 'openadapt-lifecycle[bot]' && From 9618eb4d08f2c9abb6b75000d6100a1e098ff7c8 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 27 Aug 2026 13:15:12 -0400 Subject: [PATCH 3/3] fix: bind protected lifecycle feed updates --- .github/workflows/action-pin-sweep.yml | 27 +- .github/workflows/default-branch-sweep.yml | 9 +- .../workflows/production-lifecycle-policy.yml | 7 +- .../production-lifecycle-projection.yml | 169 +++---- .../workflows/published-version-claims.yml | 7 +- .../workflows/workspace-staleness-sweep.yml | 9 +- ...database_backup_dispatch_reconciliation.py | 438 ++++++++++++++++++ ...prepare_production_lifecycle_projection.py | 337 +++++++++----- scripts/validate_docs_sync.py | 98 ---- ...database_backup_dispatch_reconciliation.py | 236 ++++++++++ tests/test_docs_sync_contract.py | 52 --- ...prepare_production_lifecycle_projection.py | 159 +++++-- tests/test_production_ops_workflows.py | 58 ++- 13 files changed, 1134 insertions(+), 472 deletions(-) create mode 100755 scripts/database_backup_dispatch_reconciliation.py delete mode 100755 scripts/validate_docs_sync.py create mode 100644 tests/test_database_backup_dispatch_reconciliation.py delete mode 100644 tests/test_docs_sync_contract.py diff --git a/.github/workflows/action-pin-sweep.yml b/.github/workflows/action-pin-sweep.yml index df5daf2..b9cf8fe 100644 --- a/.github/workflows/action-pin-sweep.yml +++ b/.github/workflows/action-pin-sweep.yml @@ -39,18 +39,35 @@ on: - '.github/workflows/action-pin-sweep.yml' concurrency: - group: action-pin-sweep-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: false permissions: contents: read jobs: + reject-lifecycle-app: + permissions: {} + runs-on: ubuntu-latest + steps: + - name: Reject the lifecycle App + env: + ACTOR: ${{ github.actor }} + EVENT_NAME: ${{ github.event_name }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + run: | + if [ "$EVENT_NAME" = 'pull_request' ]; then + exit 0 + fi + test "$ACTOR" != 'openadapt-lifecycle[bot]' + test "$TRIGGERING_ACTOR" != 'openadapt-lifecycle[bot]' + self-test: # A detector nobody has seen fire is a detector nobody should trust. These # tests prove it fires on the exact reference that stranded openadapt-evals # 0.91.0 on PyPI, and stays quiet on an accepted backlog entry. name: Prove the detector fires and stays quiet + needs: reject-lifecycle-app if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: @@ -66,7 +83,11 @@ jobs: sweep: name: Sweep every repository we own for an unpinned action - if: github.event_name != 'pull_request' + needs: reject-lifecycle-app + if: >- + github.event_name != 'pull_request' && + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/default-branch-sweep.yml b/.github/workflows/default-branch-sweep.yml index d066e9a..92aabac 100644 --- a/.github/workflows/default-branch-sweep.yml +++ b/.github/workflows/default-branch-sweep.yml @@ -47,8 +47,12 @@ jobs: - name: Reject the lifecycle App env: ACTOR: ${{ github.actor }} + EVENT_NAME: ${{ github.event_name }} TRIGGERING_ACTOR: ${{ github.triggering_actor }} run: | + if [ "$EVENT_NAME" = 'pull_request' ]; then + exit 0 + fi test "$ACTOR" != 'openadapt-lifecycle[bot]' test "$TRIGGERING_ACTOR" != 'openadapt-lifecycle[bot]' @@ -58,10 +62,7 @@ jobs: # a change to the detector self-contained. name: Prove the classifier fires and stays quiet needs: reject-lifecycle-app - if: >- - github.event_name == 'pull_request' && - github.actor != 'openadapt-lifecycle[bot]' && - github.triggering_actor != 'openadapt-lifecycle[bot]' + if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/production-lifecycle-policy.yml b/.github/workflows/production-lifecycle-policy.yml index 8c96ee0..7e726bf 100644 --- a/.github/workflows/production-lifecycle-policy.yml +++ b/.github/workflows/production-lifecycle-policy.yml @@ -23,17 +23,18 @@ jobs: - name: Reject the lifecycle App env: ACTOR: ${{ github.actor }} + EVENT_NAME: ${{ github.event_name }} TRIGGERING_ACTOR: ${{ github.triggering_actor }} run: | + if [ "$EVENT_NAME" = 'pull_request' ] || [ "$EVENT_NAME" = 'push' ]; then + exit 0 + fi test "$ACTOR" != 'openadapt-lifecycle[bot]' test "$TRIGGERING_ACTOR" != 'openadapt-lifecycle[bot]' validate: name: Validate Production lifecycle needs: reject-lifecycle-app - if: >- - github.actor != 'openadapt-lifecycle[bot]' && - github.triggering_actor != 'openadapt-lifecycle[bot]' runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/.github/workflows/production-lifecycle-projection.yml b/.github/workflows/production-lifecycle-projection.yml index 28467e4..aa3b513 100644 --- a/.github/workflows/production-lifecycle-projection.yml +++ b/.github/workflows/production-lifecycle-projection.yml @@ -1,41 +1,41 @@ -name: Production lifecycle projection +name: Production lifecycle feed source on: workflow_dispatch: inputs: - source_event: - description: Canonical lifecycle source event - required: true + expected_old_commit: + description: Exact current feed commit; empty only for first creation + required: false type: string - source_repository: - description: Canonical lifecycle repository + new_commit: + description: Exact .github main commit that contains the feed required: true type: string - source_ref: - description: Canonical lifecycle ref + feed_sha256: + description: Exact sha256 digest of production-lifecycle-feed.json required: true type: string - source_commit: - description: Exact canonical lifecycle commit + checkpoint_sha256: + description: Exact checkpoint object digest named by the feed required: true type: string - candidate_admissions_sha256: - description: Exact admissions digest + registry_head_sha256: + description: Exact content-addressed registry head digest required: true type: string - candidate_ledger_head_sha256: - description: Exact bound lifecycle ledger head + expires_at: + description: Exact feed expiry required: true type: string idempotency_key: - description: Exact projection idempotency key + description: Domain-bound lifecycle-feed-update idempotency key required: true type: string permissions: {} concurrency: - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + group: production-lifecycle-feed-source cancel-in-progress: false jobs: @@ -43,7 +43,7 @@ jobs: permissions: {} runs-on: ubuntu-latest steps: - - name: Refuse any identity other than the lifecycle App + - name: Refuse any identity or source other than the exact lifecycle App on main env: ACTOR: ${{ github.actor }} ACTOR_ID: ${{ github.actor_id }} @@ -62,8 +62,8 @@ jobs: test -n "$EXPECTED_ACTOR_ID" test "$ACTOR_ID" = "$EXPECTED_ACTOR_ID" - project-lifecycle: - name: Project canonical Production lifecycle + dispatch-feed-update: + name: Dispatch one exact Production lifecycle feed update needs: authorize-lifecycle-app if: >- github.repository == 'OpenAdaptAI/openadapt-ops' && @@ -71,20 +71,53 @@ jobs: github.event_name == 'workflow_dispatch' && github.actor == 'openadapt-lifecycle[bot]' && github.triggering_actor == 'openadapt-lifecycle[bot]' && - github.actor_id == vars.OPENADAPT_LIFECYCLE_ACTOR_ID && - inputs.source_event == 'production_lifecycle_ledger_changed' && - inputs.source_repository == 'OpenAdaptAI/.github' && - inputs.source_ref == 'refs/heads/main' + github.actor_id == vars.OPENADAPT_LIFECYCLE_ACTOR_ID runs-on: ubuntu-latest timeout-minutes: 10 environment: production-lifecycle-projection permissions: - contents: write - pull-requests: write + contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Create an exact lifecycle App installation token + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.12' + + - name: Verify exact current protected Ops main and .github candidate ancestry + env: + GH_TOKEN: ${{ github.token }} + NEW_COMMIT: ${{ inputs.new_commit }} + run: | + set -euo pipefail + test "${GITHUB_REF}" = 'refs/heads/main' + test "${GITHUB_REF_PROTECTED}" = 'true' + test "${GITHUB_SHA}" = "$(gh api repos/OpenAdaptAI/openadapt-ops/commits/main --jq .sha)" + test "${NEW_COMMIT}" = "$(gh api repos/OpenAdaptAI/.github/commits/main --jq .sha)" + + - name: Build the closed canonical feed update + env: + CHECKPOINT_SHA256: ${{ inputs.checkpoint_sha256 }} + EXPECTED_OLD_COMMIT: ${{ inputs.expected_old_commit }} + EXPIRES_AT: ${{ inputs.expires_at }} + FEED_SHA256: ${{ inputs.feed_sha256 }} + IDEMPOTENCY_KEY: ${{ inputs.idempotency_key }} + NEW_COMMIT: ${{ inputs.new_commit }} + REGISTRY_HEAD_SHA256: ${{ inputs.registry_head_sha256 }} + run: | + set -euo pipefail + python scripts/prepare_production_lifecycle_projection.py \ + --source-commit "$GITHUB_SHA" \ + --expected-old-commit "$EXPECTED_OLD_COMMIT" \ + --new-commit "$NEW_COMMIT" \ + --feed-sha256 "$FEED_SHA256" \ + --checkpoint-sha256 "$CHECKPOINT_SHA256" \ + --registry-head-sha256 "$REGISTRY_HEAD_SHA256" \ + --expires-at "$EXPIRES_AT" \ + --idempotency-key "$IDEMPOTENCY_KEY" \ + --output /tmp/production-lifecycle-feed-update.json + + - name: Create the exact lifecycle App installation token id: lifecycle-app uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: @@ -96,6 +129,7 @@ jobs: openadapt-evals openadapt-ops permission-actions: write + permission-contents: write permission-metadata: read permission-pull-requests: write @@ -108,83 +142,16 @@ jobs: set -euo pipefail test -n "$EXPECTED_APP_ID" test -n "$EXPECTED_INSTALLATION_ID" - actual_app_id=$(gh api /installation --jq '.app_id | tostring') - actual_installation_id=$(gh api /installation --jq '.id | tostring') - test "$actual_app_id" = "$EXPECTED_APP_ID" - test "$actual_installation_id" = "$EXPECTED_INSTALLATION_ID" - - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: '3.12' - - - name: Verify the exact source commit - env: - GH_TOKEN: ${{ github.token }} - SOURCE_COMMIT: ${{ inputs.source_commit }} - run: | - set -euo pipefail - test "${SOURCE_COMMIT}" != '' - test "${SOURCE_COMMIT}" = "$(gh api repos/OpenAdaptAI/.github/commits/main --jq .sha)" - - - name: Prepare and render the exact lifecycle projection - env: - CANDIDATE_ADMISSIONS_SHA256: ${{ inputs.candidate_admissions_sha256 }} - CANDIDATE_LEDGER_HEAD_SHA256: ${{ inputs.candidate_ledger_head_sha256 }} - IDEMPOTENCY_KEY: ${{ inputs.idempotency_key }} - SOURCE_COMMIT: ${{ inputs.source_commit }} - SOURCE_EVENT: ${{ inputs.source_event }} - SOURCE_REF: ${{ inputs.source_ref }} - SOURCE_REPOSITORY: ${{ inputs.source_repository }} - run: | - set -euo pipefail - python scripts/prepare_production_lifecycle_projection.py \ - --source-commit "$SOURCE_COMMIT" \ - --source-repository "$SOURCE_REPOSITORY" \ - --source-ref "$SOURCE_REF" \ - --source-event "$SOURCE_EVENT" \ - --candidate-admissions-sha256 "$CANDIDATE_ADMISSIONS_SHA256" \ - --candidate-ledger-head-sha256 "$CANDIDATE_LEDGER_HEAD_SHA256" \ - --idempotency-key "$IDEMPOTENCY_KEY" \ - --output production-lifecycle-source.json - python scripts/render_production_lifecycle.py - python scripts/render_production_lifecycle.py --check - python -m unittest tests.test_production_lifecycle_policy - - - name: Commit the projection to an automation branch - id: commit - env: - IDEMPOTENCY_KEY: ${{ inputs.idempotency_key }} - SOURCE_COMMIT: ${{ inputs.source_commit }} - run: | - set -euo pipefail - branch="automation/production-lifecycle-${SOURCE_COMMIT:0:16}" - git switch -c "$branch" - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add production-lifecycle-source.json docs/production-lifecycle.json - if git diff --staged --quiet; then - echo 'changed=false' >> "$GITHUB_OUTPUT" - exit 0 - fi - git commit -m "docs: project lifecycle ${SOURCE_COMMIT:0:12}" \ - -m "${IDEMPOTENCY_KEY}" - git push origin "HEAD:refs/heads/${branch}" - echo "branch=${branch}" >> "$GITHUB_OUTPUT" - echo 'changed=true' >> "$GITHUB_OUTPUT" + test "$(gh api /installation --jq '.app_id | tostring')" = "$EXPECTED_APP_ID" + test "$(gh api /installation --jq '.id | tostring')" = "$EXPECTED_INSTALLATION_ID" - - name: Open the projection PR - if: steps.commit.outputs.changed == 'true' + - name: Dispatch the protected feed ref producer once env: - BRANCH: ${{ steps.commit.outputs.branch }} GH_TOKEN: ${{ steps.lifecycle-app.outputs.token }} - SOURCE_COMMIT: ${{ inputs.source_commit }} run: | set -euo pipefail - existing=$(gh pr list --head "$BRANCH" --state open --json url --jq '.[0].url // empty') - if [ -n "$existing" ]; then - echo "The lifecycle projection PR already exists: $existing" - exit 0 - fi - gh pr create --base main --head "$BRANCH" \ - --title "docs: project lifecycle ${SOURCE_COMMIT:0:12}" \ - --body "Projects the exact canonical lifecycle source at OpenAdaptAI/.github@${SOURCE_COMMIT}." + feed_update_json=$(- - github.actor != 'openadapt-lifecycle[bot]' && - github.triggering_actor != 'openadapt-lifecycle[bot]' runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/workspace-staleness-sweep.yml b/.github/workflows/workspace-staleness-sweep.yml index 1438029..3b4f062 100644 --- a/.github/workflows/workspace-staleness-sweep.yml +++ b/.github/workflows/workspace-staleness-sweep.yml @@ -77,8 +77,12 @@ jobs: - name: Reject the lifecycle App env: ACTOR: ${{ github.actor }} + EVENT_NAME: ${{ github.event_name }} TRIGGERING_ACTOR: ${{ github.triggering_actor }} run: | + if [ "$EVENT_NAME" = 'pull_request' ]; then + exit 0 + fi test "$ACTOR" != 'openadapt-lifecycle[bot]' test "$TRIGGERING_ACTOR" != 'openadapt-lifecycle[bot]' @@ -88,10 +92,7 @@ jobs: # a change to the detector self-contained. name: Prove the classifiers fire and stay quiet needs: reject-lifecycle-app - if: >- - github.event_name == 'pull_request' && - github.actor != 'openadapt-lifecycle[bot]' && - github.triggering_actor != 'openadapt-lifecycle[bot]' + if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/scripts/database_backup_dispatch_reconciliation.py b/scripts/database_backup_dispatch_reconciliation.py new file mode 100755 index 0000000..e1d27f5 --- /dev/null +++ b/scripts/database_backup_dispatch_reconciliation.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +"""Retain Cloud dispatch identity and sign exact NOT_RECEIVED resolutions.""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import json +import re +from collections.abc import Callable, Mapping, Sequence +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +INITIAL_EVENT = "verified_first_payment" +INITIAL_SCHEMA = "openadapt.database-backup-activation-dispatch/v1" +RENEWAL_EVENT = "database_backup_renewal" +RENEWAL_SCHEMA = "openadapt.database-backup-renewal-dispatch/v1" +RESOLUTION_SCHEMA = "openadapt.database-backup-dispatch-resolution/v1" +RESOLUTION_AUDIENCE = "openadapt-cloud:database-backup-dispatch-resolution" +OFFER_CONTRACT = "openadapt-cloud-managed-browser-v1" +SIGNATURE_ALGORITHM = "AWS-KMS-ECDSA-SHA256" +SIGNATURE_KEY_ID = "ops-backup-dispatch-resolution-kms-p256-2026-01" +KMS_ACCOUNT_ID = "992382684924" +KMS_REGION = "us-east-1" +KMS_ALIAS = "alias/openadapt-production-backup-dispatch-resolution" +KMS_KEY_SPEC = "ECC_NIST_P256" +KMS_KEY_USAGE = "SIGN_VERIFY" +KMS_SIGNING_ALGORITHM = "ECDSA_SHA_256" +HEX64 = re.compile(r"^[0-9a-f]{64}$") +ACTIVATION_ID = re.compile(r"^act_[0-9a-f]{64}$") +LEASE_EVENT_ID = re.compile(r"^lease_[0-9a-f]{64}$") +RESOLUTION_ID = re.compile(r"^resolution_[0-9a-f]{64}$") +CANDIDATE_FIELDS = { + "resolution_id", + "dispatch_kind", + "activation_id", + "organization_id_sha256", + "dispatch_attempt_id_sha256", + "dispatch_envelope_sha256", + "lease_event_id", + "prior_lease_sha256", + "requested_lease_sequence", + "last_error_code", + "reconciliation_required_at", +} + + +class DispatchContractError(ValueError): + """A dispatch identity or absence proof is incomplete or inconsistent.""" + + +def canonical_json(value: Mapping[str, Any]) -> bytes: + return json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") + + +def _sha256(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def _decode_envelope(value: str) -> Mapping[str, Any]: + try: + raw = base64.b64decode(value, validate=True) + envelope = json.loads(raw) + except (ValueError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise DispatchContractError( + "the signed envelope is not valid base64 JSON" + ) from exc + if not isinstance(envelope, Mapping): + raise DispatchContractError("the signed envelope is not an object") + return envelope + + +def retain_ingress( + event_name: str, + payload: Mapping[str, Any], + *, + github_repository: str, + github_run_id: str, + github_run_attempt: int, +) -> dict[str, Any]: + if github_repository != "OpenAdaptAI/openadapt-ops": + raise DispatchContractError("the GitHub repository identity is not exact") + if not github_run_id.isdigit() or github_run_attempt < 1: + raise DispatchContractError("the GitHub run identity is malformed") + if event_name == INITIAL_EVENT: + expected_fields = { + "schema", + "dispatch_attempt_id_sha256", + "dispatch_envelope_sha256", + "activation_request_b64", + } + if set(payload) != expected_fields or payload.get("schema") != INITIAL_SCHEMA: + raise DispatchContractError("the initial dispatch payload is not exact") + envelope_b64 = payload["activation_request_b64"] + kind = "INITIAL_ACTIVATION" + identity_parent = "request" + identity_name = "activation_id" + lease_event_id = None + elif event_name == RENEWAL_EVENT: + expected_fields = { + "schema", + "lease_event_id", + "dispatch_attempt_id_sha256", + "dispatch_envelope_sha256", + "continuation_assertion_b64", + } + if set(payload) != expected_fields or payload.get("schema") != RENEWAL_SCHEMA: + raise DispatchContractError("the renewal dispatch payload is not exact") + envelope_b64 = payload["continuation_assertion_b64"] + kind = "LEASE_RENEWAL" + identity_parent = "assertion" + identity_name = "lease_event_id" + lease_event_id = payload["lease_event_id"] + if ( + not isinstance(lease_event_id, str) + or LEASE_EVENT_ID.fullmatch(lease_event_id) is None + ): + raise DispatchContractError("the renewal lease event identity is malformed") + else: + raise DispatchContractError("the dispatch event type is not authorized") + attempt_digest = payload.get("dispatch_attempt_id_sha256") + envelope_digest = payload.get("dispatch_envelope_sha256") + if not isinstance(attempt_digest, str) or HEX64.fullmatch(attempt_digest) is None: + raise DispatchContractError("the dispatch attempt digest is malformed") + if not isinstance(envelope_digest, str) or HEX64.fullmatch(envelope_digest) is None: + raise DispatchContractError("the dispatch envelope digest is malformed") + if not isinstance(envelope_b64, str): + raise DispatchContractError("the signed envelope is missing") + envelope = _decode_envelope(envelope_b64) + if _sha256(canonical_json(envelope)) != envelope_digest: + raise DispatchContractError("the dispatch envelope digest does not match") + identity = envelope.get(identity_parent) + if not isinstance(identity, Mapping): + raise DispatchContractError("the signed envelope identity is missing") + embedded_identity = identity.get(identity_name) + if kind == "INITIAL_ACTIVATION": + if ( + not isinstance(embedded_identity, str) + or ACTIVATION_ID.fullmatch(embedded_identity) is None + ): + raise DispatchContractError("the activation identity is malformed") + activation_id = embedded_identity + else: + if embedded_identity != lease_event_id: + raise DispatchContractError("the lease event identity does not match") + activation_id = identity.get("activation_id") + if ( + not isinstance(activation_id, str) + or ACTIVATION_ID.fullmatch(activation_id) is None + ): + raise DispatchContractError("the renewal activation identity is malformed") + return { + "schema": "openadapt.ops-backup-dispatch-ingress/v1", + "event_name": event_name, + "dispatch_kind": kind, + "activation_id": activation_id, + "lease_event_id": lease_event_id, + "dispatch_attempt_id_sha256": attempt_digest, + "dispatch_envelope_sha256": envelope_digest, + "signed_envelope_b64": envelope_b64, + "github_repository": github_repository, + "github_run_id": github_run_id, + "github_run_attempt": github_run_attempt, + } + + +def ingress_ledger_key(ingress: Mapping[str, Any]) -> str: + digest = ingress.get("dispatch_attempt_id_sha256") + if not isinstance(digest, str) or HEX64.fullmatch(digest) is None: + raise DispatchContractError("the retained attempt digest is malformed") + return f"dispatch-ingress/sha256/{digest[:2]}/{digest}.json" + + +def classify_ingress_write( + ingress: Mapping[str, Any], existing_bytes: bytes | None +) -> str: + """Return CREATE or IDEMPOTENT; reject an occupied key with other bytes.""" + + expected = canonical_json(ingress) + if existing_bytes is None: + return "CREATE" + if existing_bytes == expected: + return "IDEMPOTENT" + raise DispatchContractError("the dispatch ingress ledger key has conflicting bytes") + + +def resolution_id(candidate: Mapping[str, Any]) -> str: + identity = { + "activation_id": candidate.get("activation_id"), + "dispatch_attempt_id_sha256": candidate.get("dispatch_attempt_id_sha256"), + "dispatch_envelope_sha256": candidate.get("dispatch_envelope_sha256"), + "dispatch_kind": candidate.get("dispatch_kind"), + "lease_event_id": candidate.get("lease_event_id"), + "requested_lease_sequence": candidate.get("requested_lease_sequence"), + } + return "resolution_" + _sha256(canonical_json(identity)) + + +def _timestamp(value: str) -> datetime: + try: + result = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise DispatchContractError("issued_at is not an ISO-8601 time") from exc + if result.tzinfo is None: + raise DispatchContractError("issued_at has no timezone") + return result.astimezone(timezone.utc) + + +def _assert_github_run_absence( + runs: Sequence[Mapping[str, Any]], *, reconciliation_required_at: str +) -> None: + _timestamp(reconciliation_required_at) + for run in runs: + event = run.get("event") + created_at = run.get("created_at") + run_id = run.get("id") + if ( + not isinstance(event, str) + or not isinstance(created_at, str) + or not isinstance(run_id, int) + ): + raise DispatchContractError("the GitHub run inventory is incomplete") + _timestamp(created_at) + if event == "repository_dispatch": + raise DispatchContractError( + "a repository_dispatch run can match the missing delivery" + ) + + +def prepare_not_received_resolution( + candidate: Mapping[str, Any], + *, + expected_attempt_sha256: str, + expected_envelope_sha256: str, + issued_at: str, + ingress_ledger_object: bytes | None, + github_runs: Sequence[Mapping[str, Any]], +) -> dict[str, Any]: + if set(candidate) != CANDIDATE_FIELDS: + raise DispatchContractError("the reconciliation candidate is not closed") + for field in ( + "organization_id_sha256", + "dispatch_attempt_id_sha256", + "dispatch_envelope_sha256", + ): + value = candidate.get(field) + if not isinstance(value, str) or HEX64.fullmatch(value) is None: + raise DispatchContractError(f"the candidate {field} is malformed") + if candidate["dispatch_attempt_id_sha256"] != expected_attempt_sha256: + raise DispatchContractError("the candidate dispatch attempt does not match") + if candidate["dispatch_envelope_sha256"] != expected_envelope_sha256: + raise DispatchContractError("the candidate dispatch envelope does not match") + if ingress_ledger_object is not None: + raise DispatchContractError("the dispatch attempt exists in the ingress ledger") + reconciliation_required_at = candidate.get("reconciliation_required_at") + if not isinstance(reconciliation_required_at, str): + raise DispatchContractError("the reconciliation time is missing") + _assert_github_run_absence( + github_runs, reconciliation_required_at=reconciliation_required_at + ) + activation_id = candidate.get("activation_id") + if ( + not isinstance(activation_id, str) + or ACTIVATION_ID.fullmatch(activation_id) is None + ): + raise DispatchContractError("the candidate activation identity is malformed") + kind = candidate.get("dispatch_kind") + lease_event_id = candidate.get("lease_event_id") + prior_lease = candidate.get("prior_lease_sha256") + sequence = candidate.get("requested_lease_sequence") + if kind == "INITIAL_ACTIVATION": + if any(value is not None for value in (lease_event_id, prior_lease, sequence)): + raise DispatchContractError("the initial candidate has renewal identity") + elif kind == "LEASE_RENEWAL": + if ( + not isinstance(lease_event_id, str) + or LEASE_EVENT_ID.fullmatch(lease_event_id) is None + or not isinstance(prior_lease, str) + or HEX64.fullmatch(prior_lease) is None + or not isinstance(sequence, int) + or isinstance(sequence, bool) + or sequence < 1 + ): + raise DispatchContractError("the renewal candidate identity is incomplete") + else: + raise DispatchContractError("the candidate dispatch kind is not authorized") + expected_resolution_id = resolution_id(candidate) + if candidate.get("resolution_id") != expected_resolution_id: + raise DispatchContractError("the candidate resolution identity does not match") + issued = _timestamp(issued_at) + expires = issued + timedelta(minutes=5) + resolution = { + "activation_id": activation_id, + "audience": RESOLUTION_AUDIENCE, + "dispatch_attempt_id_sha256": candidate["dispatch_attempt_id_sha256"], + "dispatch_envelope_sha256": candidate["dispatch_envelope_sha256"], + "dispatch_kind": kind, + "expires_at": expires.isoformat().replace("+00:00", "Z"), + "issued_at": issued.isoformat().replace("+00:00", "Z"), + "issuer": "openadapt-ops", + "lease_event_id": lease_event_id, + "offer_contract": OFFER_CONTRACT, + "organization_id_sha256": candidate["organization_id_sha256"], + "prior_lease_sha256": prior_lease, + "requested_lease_sequence": sequence, + "resolution_id": expected_resolution_id, + "resolution_state": "NOT_RECEIVED", + } + encoded = canonical_json(resolution) + return { + "schema": "openadapt.ops-backup-dispatch-resolution-signing-request/v1", + "resolution": resolution, + "resolution_sha256": _sha256(encoded), + "canonical_resolution_b64": base64.b64encode(encoded).decode("ascii"), + } + + +def sign_prepared_resolution( + prepared: Mapping[str, Any], + signer: Callable[[bytes], Mapping[str, Any]], +) -> dict[str, Any]: + if set(prepared) != { + "schema", + "resolution", + "resolution_sha256", + "canonical_resolution_b64", + } or prepared.get("schema") != ( + "openadapt.ops-backup-dispatch-resolution-signing-request/v1" + ): + raise DispatchContractError("the resolution signing request is not exact") + resolution = prepared.get("resolution") + if not isinstance(resolution, Mapping): + raise DispatchContractError("the prepared resolution is missing") + encoded = canonical_json(resolution) + if _sha256(encoded) != prepared.get("resolution_sha256"): + raise DispatchContractError("the prepared resolution digest does not match") + if base64.b64encode(encoded).decode("ascii") != prepared.get( + "canonical_resolution_b64" + ): + raise DispatchContractError("the prepared canonical bytes do not match") + signature = signer(encoded) + if not isinstance(signature, Mapping) or set(signature) != { + "algorithm", + "key_id", + "value", + }: + raise DispatchContractError("the asymmetric signer result is not exact") + if signature.get("algorithm") != SIGNATURE_ALGORITHM: + raise DispatchContractError("the resolution signature algorithm is not exact") + if signature.get("key_id") != SIGNATURE_KEY_ID: + raise DispatchContractError( + "the resolution signature key identity is not exact" + ) + value = signature.get("value") + if not isinstance(value, str): + raise DispatchContractError("the resolution signature value is missing") + try: + der = base64.b64decode(value, validate=True) + except ValueError as exc: + raise DispatchContractError( + "the resolution signature is not base64 DER" + ) from exc + if not der or der[0] != 0x30: + raise DispatchContractError("the resolution signature is not a DER sequence") + return { + "schema": RESOLUTION_SCHEMA, + "resolution": dict(resolution), + "resolution_sha256": prepared["resolution_sha256"], + "signature": dict(signature), + } + + +def main() -> int: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command", required=True) + ingress = subparsers.add_parser("retain-ingress") + ingress.add_argument("--event-name", required=True) + ingress.add_argument("--payload", type=Path, required=True) + ingress.add_argument("--output", type=Path, required=True) + ingress.add_argument("--github-repository", required=True) + ingress.add_argument("--github-run-id", required=True) + ingress.add_argument("--github-run-attempt", type=int, required=True) + resolve = subparsers.add_parser("prepare-not-received") + resolve.add_argument("--candidate", type=Path, required=True) + resolve.add_argument("--expected-attempt-sha256", required=True) + resolve.add_argument("--expected-envelope-sha256", required=True) + resolve.add_argument("--issued-at", required=True) + resolve.add_argument("--github-runs", type=Path, required=True) + resolve.add_argument("--ingress-ledger-object", type=Path) + resolve.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + value = ( + json.loads(args.payload.read_text()) + if args.command == "retain-ingress" + else json.loads(args.candidate.read_text()) + ) + if not isinstance(value, Mapping): + raise DispatchContractError("the input file is not an object") + if args.command == "retain-ingress": + output = retain_ingress( + args.event_name, + value, + github_repository=args.github_repository, + github_run_id=args.github_run_id, + github_run_attempt=args.github_run_attempt, + ) + else: + runs = json.loads(args.github_runs.read_text()) + if not isinstance(runs, list) or not all( + isinstance(run, Mapping) for run in runs + ): + raise DispatchContractError("the GitHub run inventory is not an array") + existing = ( + args.ingress_ledger_object.read_bytes() + if args.ingress_ledger_object is not None + else None + ) + output = prepare_not_received_resolution( + value, + expected_attempt_sha256=args.expected_attempt_sha256, + expected_envelope_sha256=args.expected_envelope_sha256, + issued_at=args.issued_at, + ingress_ledger_object=existing, + github_runs=runs, + ) + if args.command == "retain-ingress": + args.output.write_bytes(canonical_json(output)) + else: + args.output.write_text(json.dumps(output, indent=2, sort_keys=True) + "\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/prepare_production_lifecycle_projection.py b/scripts/prepare_production_lifecycle_projection.py index 2f9e6b8..f99234b 100755 --- a/scripts/prepare_production_lifecycle_projection.py +++ b/scripts/prepare_production_lifecycle_projection.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Prepare an exact canonical source descriptor for a lifecycle projection.""" +"""Validate and build one canonical Production lifecycle feed update.""" from __future__ import annotations @@ -8,179 +8,266 @@ import json import re import urllib.request -from collections.abc import Callable +from collections.abc import Callable, Mapping from pathlib import Path +from typing import Any -SOURCE_SCHEMA = "openadapt.production-lifecycle-source/v1" -SOURCE_REPOSITORY = "OpenAdaptAI/.github" +UPDATE_SCHEMA = "openadapt.production-lifecycle-feed-update/v1" +FEED_SCHEMA = "openadapt.production-lifecycle-feed/v1" +EVENT_TYPE = "production_lifecycle_feed_updated" +SOURCE_REPOSITORY = "OpenAdaptAI/openadapt-ops" +SOURCE_REPOSITORY_ID = "1172011294" SOURCE_REF = "refs/heads/main" -SOURCE_EVENT = "production_lifecycle_ledger_changed" +TARGET_REPOSITORY = "OpenAdaptAI/.github" +TARGET_REPOSITORY_ID = "858454062" +TARGET_OWNER_ID = "132681217" +TARGET_REF = "refs/heads/production-lifecycle-feed" +FEED_PATH = "production-lifecycle-feed.json" +IDEMPOTENCY_DOMAIN = b"OpenAdapt production lifecycle feed update idempotency v1\0" HEX40 = re.compile(r"^[0-9a-f]{40}$") SHA256 = re.compile(r"^sha256:[0-9a-f]{64}$") -MAX_SOURCE_BYTES = 2 * 1024 * 1024 -LEDGER_DOMAIN = b"OpenAdapt production lifecycle ledger head v1\0" -IDEMPOTENCY_DOMAIN = b"OpenAdapt production lifecycle projection idempotency v1\0" -EXPECTED_PATHS = { - "admissions": "production-lifecycle-admissions.json", - "admissions_schema": "schemas/production-lifecycle-admissions.schema.json", - "evidence_registry": "evidence-registry.json", - "evidence_registry_schema": "schemas/evidence-registry.schema.json", - "evidence_registry_validator": "scripts/validate_evidence_registry.py", - "evidence_manifest_schema": ( - "schemas/production-lifecycle-evidence-manifest.schema.json" - ), - "evidence_summary_schema": ( - "schemas/production-lifecycle-evidence-summary.schema.json" - ), - "lifecycle": "repository-lifecycle.yml", - "policy": "production-lifecycle-policy.json", - "policy_schema": "schemas/production-lifecycle-policy.schema.json", - "validator": "scripts/validate_production_lifecycle.py", +IDEMPOTENCY = re.compile(r"^lifecycle-feed-update:[0-9a-f]{64}$") +MAX_FEED_BYTES = 2 * 1024 * 1024 +UPDATE_FIELDS = { + "schema_version", + "event_type", + "source_repository", + "source_repository_id", + "source_ref", + "source_commit", + "target_repository", + "target_repository_id", + "target_ref", + "expected_old_commit", + "new_commit", + "feed_path", + "feed_sha256", + "checkpoint_sha256", + "registry_head_sha256", + "expires_at", + "idempotency_key", +} +FEED_FIELDS = { + "schema_version", + "repository", + "repository_id", + "repository_owner_id", + "ref", + "feed_revision", + "generated_at", + "expires_at", + "registry_source_commit", + "registry_revision", + "registry_head_sha256", + "signer_registry", + "checkpoints", } class ProjectionInputError(ValueError): - """The lifecycle projection input is not exact or internally consistent.""" - + """The feed update is malformed or does not match the exact feed bytes.""" -def digest(value: bytes) -> str: - return "sha256:" + hashlib.sha256(value).hexdigest() +def canonical_json(value: Mapping[str, Any]) -> bytes: + return json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") -def _digest_fields(domain: bytes, fields: tuple[str, ...]) -> str: - payload = domain + b"".join(value.encode("ascii") + b"\0" for value in fields) - return digest(payload) - -def ledger_head_sha256(admissions_sha256: str, evidence_registry_sha256: str) -> str: - return _digest_fields(LEDGER_DOMAIN, (admissions_sha256, evidence_registry_sha256)) +def sha256(value: bytes) -> str: + return "sha256:" + hashlib.sha256(value).hexdigest() -def projection_idempotency_key( - *, - source_commit: str, - candidate_admissions_sha256: str, - candidate_ledger_head_sha256: str, -) -> str: - return _digest_fields( - IDEMPOTENCY_DOMAIN, - ( - SOURCE_EVENT, - SOURCE_REPOSITORY, - SOURCE_REF, - source_commit, - candidate_admissions_sha256, - candidate_ledger_head_sha256, - ), +def idempotency_key(value_without_key: Mapping[str, Any]) -> str: + return ( + "lifecycle-feed-update:" + + hashlib.sha256( + IDEMPOTENCY_DOMAIN + canonical_json(value_without_key) + ).hexdigest() ) def fetch(url: str) -> bytes: request = urllib.request.Request( - url, headers={"User-Agent": "openadapt-lifecycle-projection/1"} + url, headers={"User-Agent": "openadapt-lifecycle-feed-source/1"} ) with urllib.request.urlopen(request, timeout=30) as response: - body = response.read(MAX_SOURCE_BYTES + 1) - if len(body) > MAX_SOURCE_BYTES: - raise ProjectionInputError("a canonical source file exceeds the size limit") + body = response.read(MAX_FEED_BYTES + 1) + if len(body) > MAX_FEED_BYTES: + raise ProjectionInputError("the lifecycle feed exceeds the size limit") return body -def build_source( - source_commit: str, +def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ProjectionInputError(f"the lifecycle feed repeats field {key!r}") + result[key] = value + return result + + +def _parse_feed(body: bytes) -> Mapping[str, Any]: + try: + value = json.loads(body, object_pairs_hook=_unique_object) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ProjectionInputError( + "the lifecycle feed is not valid UTF-8 JSON" + ) from exc + if not isinstance(value, Mapping) or set(value) != FEED_FIELDS: + raise ProjectionInputError( + "the lifecycle feed does not have the closed field set" + ) + return value + + +def _validate_feed( + feed: Mapping[str, Any], *, - fetch_bytes: Callable[[str], bytes] = fetch, -) -> tuple[dict[str, object], dict[str, bytes]]: - if HEX40.fullmatch(source_commit) is None: - raise ProjectionInputError("the source commit is not an exact lowercase SHA") - files: dict[str, dict[str, str]] = {} - contents: dict[str, bytes] = {} - for key, path in sorted(EXPECTED_PATHS.items()): - url = ( - "https://raw.githubusercontent.com/" - f"{SOURCE_REPOSITORY}/{source_commit}/{path}" + new_commit: str, + feed_sha256: str, + checkpoint_sha256: str, + registry_head_sha256: str, + expires_at: str, + body: bytes, +) -> None: + if feed.get("schema_version") != FEED_SCHEMA: + raise ProjectionInputError("the lifecycle feed schema is not exact") + if ( + feed.get("repository") != TARGET_REPOSITORY + or feed.get("repository_id") != TARGET_REPOSITORY_ID + or feed.get("repository_owner_id") != TARGET_OWNER_ID + or feed.get("ref") != TARGET_REF + ): + raise ProjectionInputError( + "the lifecycle feed repository identity is not exact" ) - body = fetch_bytes(url) - if len(body) > MAX_SOURCE_BYTES: - raise ProjectionInputError("a canonical source file exceeds the size limit") - contents[key] = body - files[key] = {"path": path, "url": url, "sha256": digest(body)} - return ( - { - "schema_version": SOURCE_SCHEMA, - "repository": SOURCE_REPOSITORY, - "source_commit": source_commit, - "files": files, - }, - contents, - ) + if feed.get("registry_source_commit") != new_commit: + raise ProjectionInputError( + "the feed registry source commit is not the new commit" + ) + if sha256(body) != feed_sha256: + raise ProjectionInputError("the feed digest does not match the exact bytes") + if feed.get("registry_head_sha256") != registry_head_sha256: + raise ProjectionInputError("the feed registry head does not match") + if feed.get("expires_at") != expires_at: + raise ProjectionInputError("the feed expiry does not match") + checkpoints = feed.get("checkpoints") + if not isinstance(checkpoints, list) or len(checkpoints) not in {1, 2}: + raise ProjectionInputError("the feed must contain one or two checkpoints") + checkpoint_digests: list[str] = [] + for pair in checkpoints: + if not isinstance(pair, Mapping) or set(pair) != { + "checkpoint_reference", + "checkpoint_bundle_reference", + }: + raise ProjectionInputError("a feed checkpoint pair is not closed") + reference = pair["checkpoint_reference"] + if not isinstance(reference, Mapping): + raise ProjectionInputError("a checkpoint reference is not an object") + digest = reference.get("object_sha256") + if not isinstance(digest, str) or SHA256.fullmatch(digest) is None: + raise ProjectionInputError("a checkpoint reference digest is malformed") + checkpoint_digests.append(digest) + if checkpoint_sha256 not in checkpoint_digests: + raise ProjectionInputError("the named checkpoint is not in the feed") def prepare( *, source_commit: str, - source_repository: str, - source_ref: str, - source_event: str, - candidate_admissions_sha256: str, - candidate_ledger_head_sha256: str, - idempotency_key: str, + expected_old_commit: str | None, + new_commit: str, + feed_sha256: str, + checkpoint_sha256: str, + registry_head_sha256: str, + expires_at: str, + supplied_idempotency_key: str, fetch_bytes: Callable[[str], bytes] = fetch, -) -> dict[str, object]: - if source_repository != SOURCE_REPOSITORY: - raise ProjectionInputError("the source repository is not canonical") - if source_ref != SOURCE_REF: - raise ProjectionInputError("the source ref is not exact main") - if source_event != SOURCE_EVENT: - raise ProjectionInputError("the source event is not canonical") +) -> dict[str, Any]: + if HEX40.fullmatch(source_commit) is None or HEX40.fullmatch(new_commit) is None: + raise ProjectionInputError("source_commit and new_commit must be exact SHAs") + if expected_old_commit == "": + expected_old_commit = None + if expected_old_commit is not None and HEX40.fullmatch(expected_old_commit) is None: + raise ProjectionInputError("expected_old_commit must be null or an exact SHA") for label, value in ( - ("candidate admissions", candidate_admissions_sha256), - ("candidate ledger head", candidate_ledger_head_sha256), - ("idempotency key", idempotency_key), + ("feed", feed_sha256), + ("checkpoint", checkpoint_sha256), + ("registry head", registry_head_sha256), ): if SHA256.fullmatch(value) is None: raise ProjectionInputError(f"the {label} digest is malformed") - - source, contents = build_source(source_commit, fetch_bytes=fetch_bytes) - actual_admissions = digest(contents["admissions"]) - if candidate_admissions_sha256 != actual_admissions: - raise ProjectionInputError("the admissions digest does not match the source") - actual_head = ledger_head_sha256( - actual_admissions, digest(contents["evidence_registry"]) + if not isinstance(expires_at, str) or not expires_at: + raise ProjectionInputError("expires_at is required") + if IDEMPOTENCY.fullmatch(supplied_idempotency_key) is None: + raise ProjectionInputError("the idempotency key is malformed") + url = ( + "https://raw.githubusercontent.com/" + f"{TARGET_REPOSITORY}/{new_commit}/{FEED_PATH}" ) - if candidate_ledger_head_sha256 != actual_head: - raise ProjectionInputError("the lifecycle ledger head does not match the source") - expected_idempotency = projection_idempotency_key( - source_commit=source_commit, - candidate_admissions_sha256=actual_admissions, - candidate_ledger_head_sha256=actual_head, + body = fetch_bytes(url) + if len(body) > MAX_FEED_BYTES: + raise ProjectionInputError("the lifecycle feed exceeds the size limit") + _validate_feed( + _parse_feed(body), + new_commit=new_commit, + feed_sha256=feed_sha256, + checkpoint_sha256=checkpoint_sha256, + registry_head_sha256=registry_head_sha256, + expires_at=expires_at, + body=body, ) - if idempotency_key != expected_idempotency: - raise ProjectionInputError("the projection idempotency key does not match") - return source + update: dict[str, Any] = { + "schema_version": UPDATE_SCHEMA, + "event_type": EVENT_TYPE, + "source_repository": SOURCE_REPOSITORY, + "source_repository_id": SOURCE_REPOSITORY_ID, + "source_ref": SOURCE_REF, + "source_commit": source_commit, + "target_repository": TARGET_REPOSITORY, + "target_repository_id": TARGET_REPOSITORY_ID, + "target_ref": TARGET_REF, + "expected_old_commit": expected_old_commit, + "new_commit": new_commit, + "feed_path": FEED_PATH, + "feed_sha256": feed_sha256, + "checkpoint_sha256": checkpoint_sha256, + "registry_head_sha256": registry_head_sha256, + "expires_at": expires_at, + } + expected_key = idempotency_key(update) + if supplied_idempotency_key != expected_key: + raise ProjectionInputError("the idempotency key does not bind the update") + update["idempotency_key"] = supplied_idempotency_key + if set(update) != UPDATE_FIELDS: + raise AssertionError("the update builder emitted the wrong field set") + return update def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--source-commit", required=True) - parser.add_argument("--source-repository", required=True) - parser.add_argument("--source-ref", required=True) - parser.add_argument("--source-event", required=True) - parser.add_argument("--candidate-admissions-sha256", required=True) - parser.add_argument("--candidate-ledger-head-sha256", required=True) + parser.add_argument("--expected-old-commit", default="") + parser.add_argument("--new-commit", required=True) + parser.add_argument("--feed-sha256", required=True) + parser.add_argument("--checkpoint-sha256", required=True) + parser.add_argument("--registry-head-sha256", required=True) + parser.add_argument("--expires-at", required=True) parser.add_argument("--idempotency-key", required=True) parser.add_argument("--output", type=Path, required=True) args = parser.parse_args() - source = prepare( + value = prepare( source_commit=args.source_commit, - source_repository=args.source_repository, - source_ref=args.source_ref, - source_event=args.source_event, - candidate_admissions_sha256=args.candidate_admissions_sha256, - candidate_ledger_head_sha256=args.candidate_ledger_head_sha256, - idempotency_key=args.idempotency_key, + expected_old_commit=args.expected_old_commit, + new_commit=args.new_commit, + feed_sha256=args.feed_sha256, + checkpoint_sha256=args.checkpoint_sha256, + registry_head_sha256=args.registry_head_sha256, + expires_at=args.expires_at, + supplied_idempotency_key=args.idempotency_key, ) - args.output.write_text(json.dumps(source, indent=2, sort_keys=True) + "\n") + args.output.write_bytes(canonical_json(value)) return 0 diff --git a/scripts/validate_docs_sync.py b/scripts/validate_docs_sync.py deleted file mode 100755 index 9a85a28..0000000 --- a/scripts/validate_docs_sync.py +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env python3 -"""Validate an exact documentation synchronization dispatch.""" - -from __future__ import annotations - -import argparse -import hashlib -import re -from pathlib import Path - -import yaml - -REF = "refs/heads/main" -EVENT = "push" -HEX40 = re.compile(r"^[0-9a-f]{40}$") -KEY = re.compile(r"^docs-sync:[0-9a-f]{64}$") -DOMAIN = b"OpenAdapt docs sync dispatch v1\0" - - -class DocsSyncError(ValueError): - """The documentation dispatch does not match its exact contract.""" - - -def allowed_repositories(path: Path) -> set[str]: - value = yaml.safe_load(path.read_text(encoding="utf-8")) - if not isinstance(value, dict) or not isinstance(value.get("repos"), list): - raise DocsSyncError("the repository inventory is invalid") - allowed: set[str] = set() - for entry in value["repos"]: - if not isinstance(entry, dict): - raise DocsSyncError("the repository inventory contains an invalid entry") - repository = entry.get("github") - if entry.get("changelog") is True and isinstance(repository, str): - allowed.add(repository) - if not allowed: - raise DocsSyncError("the repository inventory has no public sync source") - return allowed - - -def expected_idempotency_key( - source_repository: str, - source_ref: str, - source_commit: str, - source_event: str, -) -> str: - fields = (source_repository, source_ref, source_commit, source_event) - payload = DOMAIN + b"".join(value.encode("utf-8") + b"\0" for value in fields) - return "docs-sync:" + hashlib.sha256(payload).hexdigest() - - -def validate( - *, - repositories: Path, - source_repository: str, - source_ref: str, - source_commit: str, - source_event: str, - idempotency_key: str, -) -> None: - if source_repository not in allowed_repositories(repositories): - raise DocsSyncError("the source repository is not in the public sync inventory") - if source_ref != REF: - raise DocsSyncError("the source ref is not exact main") - if source_event != EVENT: - raise DocsSyncError("the source event is not a main push") - if HEX40.fullmatch(source_commit) is None: - raise DocsSyncError("the source commit is not an exact lowercase SHA") - if KEY.fullmatch(idempotency_key) is None: - raise DocsSyncError("the docs-sync idempotency key is malformed") - expected = expected_idempotency_key( - source_repository, source_ref, source_commit, source_event - ) - if idempotency_key != expected: - raise DocsSyncError("the docs-sync idempotency key does not match the source") - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--repositories", type=Path, required=True) - parser.add_argument("--source-repository", required=True) - parser.add_argument("--source-ref", required=True) - parser.add_argument("--source-commit", required=True) - parser.add_argument("--source-event", required=True) - parser.add_argument("--idempotency-key", required=True) - args = parser.parse_args() - validate( - repositories=args.repositories, - source_repository=args.source_repository, - source_ref=args.source_ref, - source_commit=args.source_commit, - source_event=args.source_event, - idempotency_key=args.idempotency_key, - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/test_database_backup_dispatch_reconciliation.py b/tests/test_database_backup_dispatch_reconciliation.py new file mode 100644 index 0000000..f2f02a7 --- /dev/null +++ b/tests/test_database_backup_dispatch_reconciliation.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +import base64 +import importlib.util +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "database_backup_dispatch_reconciliation", + ROOT / "scripts" / "database_backup_dispatch_reconciliation.py", +) +assert SPEC is not None and SPEC.loader is not None +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + +ACTIVATION_ID = "act_2d49200bcccc6d2b70a392991a6390325d1ab2e7e0c16c81dfc63d1673efa10b" +ENVELOPE_DIGEST = "b31d46c369c8a754dba069033a900b46d64009138834b06d4ece5eede827602f" +ATTEMPT_DIGEST = "76c5e382d0045c0bc1ba849090f947b7076d2cbd04e56d35820fdf7e8d9dec6e" +RESOLUTION_ID = ( + "resolution_fada2d1a0ce8d86df505b2df27f89b49145e3ea9ea3798133d9a6fc1f108b4e4" +) + + +def ingress_payload() -> tuple[dict[str, object], dict[str, object]]: + envelope = { + "schema": "openadapt.database-backup-activation-request/v1", + "request": {"activation_id": ACTIVATION_ID}, + "signature": {"algorithm": "fixture", "key_id": "fixture", "value": "fixture"}, + } + digest = MODULE._sha256(MODULE.canonical_json(envelope)) + payload = { + "schema": MODULE.INITIAL_SCHEMA, + "dispatch_attempt_id_sha256": ATTEMPT_DIGEST, + "dispatch_envelope_sha256": digest, + "activation_request_b64": base64.b64encode( + json.dumps(envelope, indent=2).encode() + ).decode(), + } + return payload, envelope + + +def candidate() -> dict[str, object]: + return { + "resolution_id": RESOLUTION_ID, + "dispatch_kind": "INITIAL_ACTIVATION", + "activation_id": ACTIVATION_ID, + "organization_id_sha256": "1" * 64, + "dispatch_attempt_id_sha256": ATTEMPT_DIGEST, + "dispatch_envelope_sha256": ENVELOPE_DIGEST, + "lease_event_id": None, + "prior_lease_sha256": None, + "requested_lease_sequence": None, + "last_error_code": "DISPATCH_NOT_CONFIRMED", + "reconciliation_required_at": "2026-08-27T16:00:00Z", + } + + +def prepare() -> dict[str, object]: + return MODULE.prepare_not_received_resolution( + candidate(), + expected_attempt_sha256=ATTEMPT_DIGEST, + expected_envelope_sha256=ENVELOPE_DIGEST, + issued_at="2026-08-27T16:01:00Z", + ingress_ledger_object=None, + github_runs=[], + ) + + +def test_normal_ingress_retains_exact_attempt_envelope_and_run_identity() -> None: + payload, _ = ingress_payload() + retained = MODULE.retain_ingress( + MODULE.INITIAL_EVENT, + payload, + github_repository="OpenAdaptAI/openadapt-ops", + github_run_id="123456", + github_run_attempt=1, + ) + assert retained["dispatch_attempt_id_sha256"] == ATTEMPT_DIGEST + assert retained["dispatch_envelope_sha256"] == payload["dispatch_envelope_sha256"] + assert retained["github_run_id"] == "123456" + assert MODULE.ingress_ledger_key(retained).endswith(f"/{ATTEMPT_DIGEST}.json") + + +def test_ingress_ledger_is_no_overwrite_idempotent_and_conflict_hard() -> None: + payload, _ = ingress_payload() + retained = MODULE.retain_ingress( + MODULE.INITIAL_EVENT, + payload, + github_repository="OpenAdaptAI/openadapt-ops", + github_run_id="123456", + github_run_attempt=1, + ) + assert MODULE.classify_ingress_write(retained, None) == "CREATE" + assert ( + MODULE.classify_ingress_write(retained, MODULE.canonical_json(retained)) + == "IDEMPOTENT" + ) + with pytest.raises(MODULE.DispatchContractError, match="conflicting bytes"): + MODULE.classify_ingress_write(retained, b"{}") + + +def test_ingress_rejects_missing_or_mismatched_dispatch_identity() -> None: + payload, _ = ingress_payload() + payload["dispatch_envelope_sha256"] = "0" * 64 + with pytest.raises(MODULE.DispatchContractError, match="does not match"): + MODULE.retain_ingress( + MODULE.INITIAL_EVENT, + payload, + github_repository="OpenAdaptAI/openadapt-ops", + github_run_id="123456", + github_run_attempt=1, + ) + + +def test_shared_lost_before_github_vector_builds_exact_five_minute_resolution() -> None: + prepared = prepare() + resolution = prepared["resolution"] + assert len(resolution) == 15 + assert resolution["resolution_id"] == RESOLUTION_ID + assert resolution["resolution_state"] == "NOT_RECEIVED" + assert resolution["dispatch_attempt_id_sha256"] == ATTEMPT_DIGEST + assert resolution["dispatch_envelope_sha256"] == ENVELOPE_DIGEST + assert resolution["issued_at"] == "2026-08-27T16:01:00Z" + assert resolution["expires_at"] == "2026-08-27T16:06:00Z" + canonical = base64.b64decode(prepared["canonical_resolution_b64"]) + assert MODULE._sha256(canonical) == prepared["resolution_sha256"] + + +def test_no_resolution_is_prepared_when_absence_is_uncertain() -> None: + kwargs = { + "expected_attempt_sha256": ATTEMPT_DIGEST, + "expected_envelope_sha256": ENVELOPE_DIGEST, + "issued_at": "2026-08-27T16:01:00Z", + } + with pytest.raises(MODULE.DispatchContractError, match="ingress ledger"): + MODULE.prepare_not_received_resolution( + candidate(), ingress_ledger_object=b"present", github_runs=[], **kwargs + ) + with pytest.raises(MODULE.DispatchContractError, match="can match"): + MODULE.prepare_not_received_resolution( + candidate(), + ingress_ledger_object=None, + github_runs=[ + { + "id": 9, + "event": "repository_dispatch", + "created_at": "2026-08-27T15:59:59Z", + } + ], + **kwargs, + ) + + +def test_local_p256_fixture_signs_the_exact_canonical_resolution( + tmp_path: Path, +) -> None: + private_key = tmp_path / "fixture-private.pem" + public_key = tmp_path / "fixture-public.pem" + message = tmp_path / "resolution.json" + signature_path = tmp_path / "resolution.sig" + subprocess.run( + [ + "openssl", + "ecparam", + "-name", + "prime256v1", + "-genkey", + "-noout", + "-out", + str(private_key), + ], + check=True, + capture_output=True, + ) + subprocess.run( + ["openssl", "ec", "-in", str(private_key), "-pubout", "-out", str(public_key)], + check=True, + capture_output=True, + ) + prepared = prepare() + canonical = base64.b64decode(prepared["canonical_resolution_b64"]) + message.write_bytes(canonical) + subprocess.run( + [ + "openssl", + "dgst", + "-sha256", + "-sign", + str(private_key), + "-out", + str(signature_path), + str(message), + ], + check=True, + capture_output=True, + ) + + def signer(value: bytes) -> dict[str, str]: + assert value == canonical + return { + "algorithm": MODULE.SIGNATURE_ALGORITHM, + "key_id": MODULE.SIGNATURE_KEY_ID, + "value": base64.b64encode(signature_path.read_bytes()).decode(), + } + + envelope = MODULE.sign_prepared_resolution(prepared, signer) + assert set(envelope) == {"schema", "resolution", "resolution_sha256", "signature"} + verified = subprocess.run( + [ + "openssl", + "dgst", + "-sha256", + "-verify", + str(public_key), + "-signature", + str(signature_path), + str(message), + ], + check=False, + capture_output=True, + text=True, + ) + assert verified.returncode == 0 + assert "Verified OK" in verified.stdout + + +def test_shared_hmac_signature_is_rejected() -> None: + with pytest.raises(MODULE.DispatchContractError, match="algorithm is not exact"): + MODULE.sign_prepared_resolution( + prepare(), + lambda _: {"algorithm": "HMAC-SHA256", "key_id": "shared", "value": "AA=="}, + ) diff --git a/tests/test_docs_sync_contract.py b/tests/test_docs_sync_contract.py deleted file mode 100644 index 2497b93..0000000 --- a/tests/test_docs_sync_contract.py +++ /dev/null @@ -1,52 +0,0 @@ -from __future__ import annotations - -import importlib.util -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[1] -SPEC = importlib.util.spec_from_file_location( - "validate_docs_sync", ROOT / "scripts" / "validate_docs_sync.py" -) -assert SPEC is not None and SPEC.loader is not None -MODULE = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(MODULE) - - -def values() -> dict[str, str | Path]: - source_repository = "OpenAdaptAI/openadapt-evals" - source_ref = "refs/heads/main" - source_commit = "a" * 40 - source_event = "push" - return { - "repositories": ROOT / "repos.yml", - "source_repository": source_repository, - "source_ref": source_ref, - "source_commit": source_commit, - "source_event": source_event, - "idempotency_key": MODULE.expected_idempotency_key( - source_repository, source_ref, source_commit, source_event - ), - } - - -def test_exact_allowlisted_dispatch_passes() -> None: - MODULE.validate(**values()) - - -@pytest.mark.parametrize( - ("field", "value"), - [ - ("source_repository", "OpenAdaptAI/openadapt-cloud"), - ("source_ref", "refs/heads/release"), - ("source_commit", "A" * 40), - ("source_event", "repository_dispatch"), - ("idempotency_key", "docs-sync:" + "0" * 64), - ], -) -def test_dispatch_contract_fails_closed(field: str, value: str) -> None: - candidate = values() - candidate[field] = value - with pytest.raises(MODULE.DocsSyncError): - MODULE.validate(**candidate) diff --git a/tests/test_prepare_production_lifecycle_projection.py b/tests/test_prepare_production_lifecycle_projection.py index a161c17..e0dac2d 100644 --- a/tests/test_prepare_production_lifecycle_projection.py +++ b/tests/test_prepare_production_lifecycle_projection.py @@ -1,6 +1,7 @@ from __future__ import annotations import importlib.util +import json from pathlib import Path import pytest @@ -15,67 +16,127 @@ SPEC.loader.exec_module(MODULE) -def source_bytes() -> dict[str, bytes]: - return {key: f"{key}\n".encode() for key in MODULE.EXPECTED_PATHS} +def feed_bytes(new_commit: str = "b" * 40) -> bytes: + checkpoint = "sha256:" + "c" * 64 + value = { + "schema_version": MODULE.FEED_SCHEMA, + "repository": MODULE.TARGET_REPOSITORY, + "repository_id": MODULE.TARGET_REPOSITORY_ID, + "repository_owner_id": MODULE.TARGET_OWNER_ID, + "ref": MODULE.TARGET_REF, + "feed_revision": 9, + "generated_at": "2026-08-27T16:00:00Z", + "expires_at": "2026-08-28T16:00:00Z", + "registry_source_commit": new_commit, + "registry_revision": 14, + "registry_head_sha256": "sha256:" + "d" * 64, + "signer_registry": {"schema_version": "test"}, + "checkpoints": [ + { + "checkpoint_reference": {"object_sha256": checkpoint}, + "checkpoint_bundle_reference": {"object_sha256": "sha256:" + "e" * 64}, + } + ], + } + return json.dumps(value, sort_keys=True, separators=(",", ":")).encode() -def fetcher(values: dict[str, bytes]): - def fetch(url: str) -> bytes: - path = url.split("/", 6)[-1] - key = next(key for key, value in MODULE.EXPECTED_PATHS.items() if value == path) - return values[key] +def inputs() -> tuple[dict[str, object], bytes]: + body = feed_bytes() + value: dict[str, object] = { + "source_commit": "a" * 40, + "expected_old_commit": "f" * 40, + "new_commit": "b" * 40, + "feed_sha256": MODULE.sha256(body), + "checkpoint_sha256": "sha256:" + "c" * 64, + "registry_head_sha256": "sha256:" + "d" * 64, + "expires_at": "2026-08-28T16:00:00Z", + } + unsigned = { + "schema_version": MODULE.UPDATE_SCHEMA, + "event_type": MODULE.EVENT_TYPE, + "source_repository": MODULE.SOURCE_REPOSITORY, + "source_repository_id": MODULE.SOURCE_REPOSITORY_ID, + "source_ref": MODULE.SOURCE_REF, + "source_commit": value["source_commit"], + "target_repository": MODULE.TARGET_REPOSITORY, + "target_repository_id": MODULE.TARGET_REPOSITORY_ID, + "target_ref": MODULE.TARGET_REF, + "expected_old_commit": value["expected_old_commit"], + "new_commit": value["new_commit"], + "feed_path": MODULE.FEED_PATH, + "feed_sha256": value["feed_sha256"], + "checkpoint_sha256": value["checkpoint_sha256"], + "registry_head_sha256": value["registry_head_sha256"], + "expires_at": value["expires_at"], + } + value["supplied_idempotency_key"] = MODULE.idempotency_key(unsigned) + return value, body - return fetch - -def inputs() -> tuple[dict[str, str], dict[str, bytes]]: - values = source_bytes() - commit = "a" * 40 - admissions = MODULE.digest(values["admissions"]) - head = MODULE.ledger_head_sha256( - admissions, MODULE.digest(values["evidence_registry"]) - ) - return ( - { - "source_commit": commit, - "source_repository": MODULE.SOURCE_REPOSITORY, - "source_ref": MODULE.SOURCE_REF, - "source_event": MODULE.SOURCE_EVENT, - "candidate_admissions_sha256": admissions, - "candidate_ledger_head_sha256": head, - "idempotency_key": MODULE.projection_idempotency_key( - source_commit=commit, - candidate_admissions_sha256=admissions, - candidate_ledger_head_sha256=head, - ), - }, - values, +def test_exact_feed_builds_one_closed_canonical_update() -> None: + candidate, body = inputs() + result = MODULE.prepare(**candidate, fetch_bytes=lambda _: body) + assert set(result) == MODULE.UPDATE_FIELDS + assert result["source_repository_id"] == "1172011294" + assert result["target_repository_id"] == "858454062" + assert result["target_ref"] == "refs/heads/production-lifecycle-feed" + assert MODULE.canonical_json(result) == MODULE.canonical_json( + json.loads(MODULE.canonical_json(result)) ) -def test_exact_projection_inputs_build_a_commit_bound_inventory() -> None: - candidate, values = inputs() - source = MODULE.prepare(**candidate, fetch_bytes=fetcher(values)) - assert source["source_commit"] == "a" * 40 - assert set(source["files"]) == set(MODULE.EXPECTED_PATHS) - assert source["files"]["admissions"]["sha256"] == MODULE.digest( - values["admissions"] +def test_first_feed_creation_uses_json_null_old_commit() -> None: + candidate, body = inputs() + candidate["expected_old_commit"] = "" + candidate_without_key = { + "schema_version": MODULE.UPDATE_SCHEMA, + "event_type": MODULE.EVENT_TYPE, + "source_repository": MODULE.SOURCE_REPOSITORY, + "source_repository_id": MODULE.SOURCE_REPOSITORY_ID, + "source_ref": MODULE.SOURCE_REF, + "source_commit": candidate["source_commit"], + "target_repository": MODULE.TARGET_REPOSITORY, + "target_repository_id": MODULE.TARGET_REPOSITORY_ID, + "target_ref": MODULE.TARGET_REF, + "expected_old_commit": None, + "new_commit": candidate["new_commit"], + "feed_path": MODULE.FEED_PATH, + "feed_sha256": candidate["feed_sha256"], + "checkpoint_sha256": candidate["checkpoint_sha256"], + "registry_head_sha256": candidate["registry_head_sha256"], + "expires_at": candidate["expires_at"], + } + candidate["supplied_idempotency_key"] = MODULE.idempotency_key( + candidate_without_key + ) + assert ( + MODULE.prepare(**candidate, fetch_bytes=lambda _: body)["expected_old_commit"] + is None ) @pytest.mark.parametrize( - ("field", "value"), + "field", [ - ("source_repository", "OpenAdaptAI/openadapt-ops"), - ("source_ref", "refs/heads/release"), - ("source_event", "repository_dispatch"), - ("candidate_admissions_sha256", "sha256:" + "0" * 64), - ("candidate_ledger_head_sha256", "sha256:" + "0" * 64), - ("idempotency_key", "sha256:" + "0" * 64), + "source_commit", + "new_commit", + "feed_sha256", + "checkpoint_sha256", + "registry_head_sha256", + "expires_at", + "supplied_idempotency_key", ], ) -def test_projection_inputs_fail_closed(field: str, value: str) -> None: - candidate, values = inputs() - candidate[field] = value +def test_feed_update_fails_closed_for_mismatched_identity(field: str) -> None: + candidate, body = inputs() + candidate[field] = "wrong" with pytest.raises(MODULE.ProjectionInputError): - MODULE.prepare(**candidate, fetch_bytes=fetcher(values)) + MODULE.prepare(**candidate, fetch_bytes=lambda _: body) + + +def test_feed_update_rejects_checkpoint_not_named_by_feed() -> None: + candidate, body = inputs() + candidate["checkpoint_sha256"] = "sha256:" + "9" * 64 + with pytest.raises(MODULE.ProjectionInputError, match="not in the feed"): + MODULE.prepare(**candidate, fetch_bytes=lambda _: body) diff --git a/tests/test_production_ops_workflows.py b/tests/test_production_ops_workflows.py index d26d197..1314815 100644 --- a/tests/test_production_ops_workflows.py +++ b/tests/test_production_ops_workflows.py @@ -10,6 +10,7 @@ ROOT = Path(__file__).resolve().parent.parent REJECT_LIFECYCLE_WORKFLOWS = ( + ".github/workflows/action-pin-sweep.yml", ".github/workflows/azure-cost-guard.yml", ".github/workflows/db-backup-freshness.yml", ".github/workflows/db-backup.yml", @@ -53,14 +54,6 @@ def test_every_non_lifecycle_dispatch_refuses_the_lifecycle_app() -> None: if isinstance(needs, str): needs = [needs] assert "reject-lifecycle-app" in needs, f"{path}:{name}" - condition = job.get("if", "") - assert re.search( - r"github\.actor\s*!=\s*'openadapt-lifecycle\[bot\]'", condition - ), f"{path}:{name}" - assert re.search( - r"github\.triggering_actor\s*!=\s*'openadapt-lifecycle\[bot\]'", - condition, - ), f"{path}:{name}" def test_dispatch_workflow_inventory_is_complete() -> None: @@ -75,24 +68,7 @@ def test_dispatch_workflow_inventory_is_complete() -> None: } -def test_docs_sync_is_app_only_and_never_pushes_main() -> None: - content = read(".github/workflows/sync.yml") - assert "repository_dispatch:" not in content - assert "repo-updated" not in content - assert "git push origin HEAD:main" not in content - assert "github.actor == 'openadapt-docs[bot]'" in content - assert "github.triggering_actor == 'openadapt-docs[bot]'" in content - assert "github.actor_id == vars.OPENADAPT_DOCS_ACTOR_ID" in content - assert "vars.OPENADAPT_DOCS_APP_ID" in content - assert "vars.OPENADAPT_DOCS_INSTALLATION_ID" in content - assert "secrets.OPENADAPT_DOCS_APP_PRIVATE_KEY" in content - assert "environment: production-docs-deploy" in content - assert "name: github-pages" in content - assert "gh pr create" in content - assert "cancel-in-progress: false" in content - - -def test_lifecycle_projection_is_app_only_and_never_pushes_main() -> None: +def test_lifecycle_feed_source_is_app_only_and_dispatches_one_closed_json() -> None: content = read(".github/workflows/production-lifecycle-projection.yml") assert "github.actor == 'openadapt-lifecycle[bot]'" in content assert "github.triggering_actor == 'openadapt-lifecycle[bot]'" in content @@ -101,12 +77,34 @@ def test_lifecycle_projection_is_app_only_and_never_pushes_main() -> None: assert "vars.OPENADAPT_LIFECYCLE_INSTALLATION_ID" in content assert "secrets.OPENADAPT_LIFECYCLE_APP_PRIVATE_KEY" in content assert "environment: production-lifecycle-projection" in content - assert "production_lifecycle_ledger_changed" in content + assert "production_lifecycle_feed_updated" not in content + assert "feed_update_json" in content + assert "production-lifecycle-ref.yml" in content + assert "--repo OpenAdaptAI/.github" in content + assert "--ref main" in content + assert "permission-actions: write" in content + assert "permission-contents: write" in content assert "git push origin HEAD:main" not in content - assert "gh pr create" in content + assert "gh pr create" not in content assert "cancel-in-progress: false" in content +def test_app_authored_pull_request_validation_does_not_deadlock() -> None: + for path in ( + ".github/workflows/action-pin-sweep.yml", + ".github/workflows/default-branch-sweep.yml", + ".github/workflows/production-lifecycle-policy.yml", + ".github/workflows/published-version-claims.yml", + ".github/workflows/workspace-staleness-sweep.yml", + ): + value = workflow(path) + guard = value["jobs"]["reject-lifecycle-app"] + text = str(guard) + assert "github.event_name" in text, path + assert "pull_request" in text, path + assert "openadapt-lifecycle[bot]" in text, path + + def test_lifecycle_required_check_runs_on_every_pull_request() -> None: content = read(".github/workflows/production-lifecycle-policy.yml") pull_request = content.split(" pull_request:", 1)[1].split(" push:", 1)[0] @@ -126,7 +124,7 @@ def test_backup_configuration_fails_before_credentials_or_tool_install() -> None assert "group: production-backup" in workflow assert "labels: self-hosted" in workflow assert "RUNNER_BOUNDARY: ${{ runner.environment }}" in workflow - assert '"${RUNNER_BOUNDARY}" != \'self-hosted\'' in workflow + assert "\"${RUNNER_BOUNDARY}\" != 'self-hosted'" in workflow assert "check_github_environment_gate.py" in workflow target = workflow.index("check_live_database_backup_target.sh") dump = workflow.index("supabase db dump") @@ -147,7 +145,7 @@ def test_backup_uses_one_s3_validated_full_object_put() -> None: verify = workflow.index("verify-single-put") assert prepare < put < verify assert '--checksum-algorithm SHA256 --checksum-sha256 "$local_checksum"' in workflow - assert "--content-length \"$cipher_bytes\"" in workflow + assert '--content-length "$cipher_bytes"' in workflow assert 'aws s3 cp "$cipher"' not in workflow