From d432520fca58ac5167ca6a12d08f712d4ff3ab17 Mon Sep 17 00:00:00 2001 From: Gareth Price Date: Wed, 7 Oct 2026 11:00:48 +0000 Subject: [PATCH] refactor(ci): consolidate checks into shared environment buckets --- .github/workflows/ci.yml | 460 ++++-------------- .github/workflows/container-security.yml | 95 +--- .github/workflows/integration.yml | 168 ++++++- .github/workflows/pr.yml | 98 ++-- .../workflows/windows-compose-portability.yml | 39 +- docs/contributing/ci.md | 39 +- registry/support/v1.json | 4 +- scripts/validate_support_manifest.py | 8 +- src/phlo/support_data/v1.json | 4 +- tests/scripts/test_ci_evidence.py | 7 +- .../test_container_security_workflows.py | 7 +- .../test_generated_image_publication.py | 2 +- tests/tooling/test_golden_path_scheduling.py | 47 +- .../test_postgres_concurrency_workflow.py | 73 ++- tests/tooling/test_pr_validation.py | 99 +++- tests/tooling/test_release_candidate_gate.py | 4 +- tests/tooling/test_support_manifest.py | 14 +- 17 files changed, 626 insertions(+), 542 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e40cb0ec387..b29ae282f86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ permissions: jobs: ci-config: - name: ci / config + name: ci / prepare selection and wheels runs-on: ubuntu-latest timeout-minutes: 10 outputs: @@ -23,6 +23,7 @@ jobs: python: ${{ steps.groups.outputs.python }} frontend: ${{ steps.groups.outputs.frontend }} writer: ${{ steps.groups.outputs.writer }} + plugin: ${{ steps.groups.outputs.plugin }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: @@ -50,7 +51,7 @@ jobs: if [ -z "$SELECTION" ]; then SELECTION="$(python3 scripts/select_ci.py --base HEAD --head HEAD)" fi - for key in groups python frontend writer; do + for key in groups python frontend writer plugin; do echo "$key=$(jq -c ".$key" <<< "$SELECTION")" >> "$GITHUB_OUTPUT" done @@ -60,53 +61,14 @@ jobs: - name: Lint workflows with actionlint run: make actionlint - pre-commit: - name: ci / pre-commit - runs-on: ubuntu-latest - timeout-minutes: 25 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - with: - persist-credentials: false - fetch-depth: 0 - - uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 - with: - version: "0.12.1" - - name: Check private-key hook with a non-allow-listed fixture - run: | - fixture=ci-private-key-sentinel.pem - trap 'rm -f "$fixture"' EXIT - printf '%s%s\n' '-----BEGIN ' 'RSA PRIVATE KEY-----' > "$fixture" - printf '%s\n' fixture >> "$fixture" - printf '%s%s\n' '-----END ' 'RSA PRIVATE KEY-----' >> "$fixture" - if uvx --from prek==0.5.3 prek run detect-private-key --files "$fixture"; then - echo '::error::Private-key hook did not reject the fixture' - exit 1 - fi - - name: Run remaining file hooks - env: - SKIP: ruff,ruff-complexity,ruff-format,generated-reference-docs,prettier-observatory,zizmor,sqlfluff-dbt - run: uvx --from prek==0.5.3 prek run --all-files - - provider-wheelhouse: - name: python / provider wheelhouse - needs: ci-config - if: needs.ci-config.outputs.python == 'true' - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - with: - persist-credentials: false - - uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 - with: - version: "0.12.1" - name: Build wheels and export locked external constraints once + if: steps.groups.outputs.python == 'true' run: | uv build --all-packages --wheel --out-dir provider-wheelhouse uv export --all-packages --no-dev --locked --no-emit-workspace --no-hashes \ --output-file provider-wheelhouse/constraints.txt - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + if: steps.groups.outputs.python == 'true' with: name: provider-wheelhouse path: provider-wheelhouse/ @@ -114,7 +76,8 @@ jobs: installed-provider-artifacts: name: python / installed provider artifacts (${{ matrix.docker-shard }}) - needs: provider-wheelhouse + needs: ci-config + if: needs.ci-config.outputs.python == 'true' runs-on: ubuntu-latest timeout-minutes: 45 strategy: @@ -179,9 +142,8 @@ jobs: if-no-files-found: warn python-quality: - name: python / quality + name: quality / source and file contracts needs: ci-config - if: needs.ci-config.outputs.python == 'true' runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -189,28 +151,59 @@ jobs: with: persist-credentials: false fetch-depth: 0 - - uses: ./.github/actions/python-workspace + - uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 + with: + version: "0.12.1" + - name: Install Python workspace + if: needs.ci-config.outputs.python == 'true' + run: | + uv python install 3.12 + uv sync --python 3.12 --dev --locked + + - name: Check private-key hook with a non-allow-listed fixture + run: | + fixture=ci-private-key-sentinel.pem + trap 'rm -f "$fixture"' EXIT + printf '%s%s\n' '-----BEGIN ' 'RSA PRIVATE KEY-----' > "$fixture" + printf '%s\n' fixture >> "$fixture" + printf '%s%s\n' '-----END ' 'RSA PRIVATE KEY-----' >> "$fixture" + if uvx --from prek==0.5.3 prek run detect-private-key --files "$fixture"; then + echo '::error::Private-key hook did not reject the fixture' + exit 1 + fi + - name: Run remaining file hooks + env: + SKIP: ruff,ruff-complexity,ruff-format,generated-reference-docs,prettier-observatory,zizmor,sqlfluff-dbt + run: uvx --from prek==0.5.3 prek run --all-files + - name: Audit workflow hardening + run: make zizmor - name: Validate fresh release package versions + if: needs.ci-config.outputs.python == 'true' run: | baseline="$(git describe --tags --match 'v[0-9]*' --abbrev=0 HEAD^)" uv run --locked python scripts/release_identity.py candidate --base-ref "$baseline" - name: Lint with ruff + if: needs.ci-config.outputs.python == 'true' run: | uv run --locked ruff check . uv run --locked ruff check --config pyproject.toml --select C901 . - name: Check Python formatting + if: needs.ci-config.outputs.python == 'true' run: uv run --locked ruff format --check . - name: Check generated reference documentation + if: needs.ci-config.outputs.python == 'true' run: make docs-reference-check - name: Type check with ty + if: needs.ci-config.outputs.python == 'true' run: make typecheck-python - name: Lint SQL with sqlfluff + if: needs.ci-config.outputs.python == 'true' run: | set -euo pipefail if [ -d "workflows/transforms/dbt" ]; then @@ -267,224 +260,6 @@ jobs: if-no-files-found: error include-hidden-files: true - postgres-concurrency-gates: - name: python / postgres concurrency gates - needs: ci-config - if: needs.ci-config.outputs.python == 'true' - runs-on: ubuntu-latest - timeout-minutes: 30 - services: - postgres: - image: postgres:16-alpine - env: - POSTGRES_DB: phlo_ci - POSTGRES_USER: phlo_ci - POSTGRES_PASSWORD: phlo_ci_password - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U phlo_ci -d phlo_ci" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - env: - PHLO_SERVICE_TOKEN_TEST_POSTGRES_DSN: postgresql://phlo_ci:phlo_ci_password@localhost:5432/phlo_ci - PHLO_RUN_EVIDENCE_TEST_POSTGRES_DSN: postgresql://phlo_ci:phlo_ci_password@localhost:5432/phlo_ci - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - with: - persist-credentials: false - - uses: ./.github/actions/python-workspace - - - name: Preflight PostgreSQL gate dependencies - shell: bash - run: | - set -euo pipefail - - for dsn_name in \ - PHLO_SERVICE_TOKEN_TEST_POSTGRES_DSN \ - PHLO_RUN_EVIDENCE_TEST_POSTGRES_DSN; do - if [ -z "${!dsn_name:-}" ]; then - echo "::error::${dsn_name} must be set for the required PostgreSQL gate" - exit 1 - fi - done - - uv run --locked python - <<'PY' - import os - - import psycopg2 - - for name in ( - "PHLO_SERVICE_TOKEN_TEST_POSTGRES_DSN", - "PHLO_RUN_EVIDENCE_TEST_POSTGRES_DSN", - ): - connection = psycopg2.connect(os.environ[name], connect_timeout=5) - try: - with connection.cursor() as cursor: - cursor.execute("SELECT 1") - assert cursor.fetchone() == (1,) - finally: - connection.close() - PY - - - name: Run PostgreSQL concurrency gates - shell: bash - run: | - set -euo pipefail - results_file="$(mktemp)" - trap 'rm -f "${results_file}"' EXIT - - PHLO_SERVICE_TOKEN_TEST_POSTGRES_DSN="${PHLO_SERVICE_TOKEN_TEST_POSTGRES_DSN}" \ - PHLO_RUN_EVIDENCE_TEST_POSTGRES_DSN="${PHLO_RUN_EVIDENCE_TEST_POSTGRES_DSN}" \ - uv run --locked pytest --import-mode=importlib \ - tests/unit/phlo/security/test_service_identity.py::test_postgres_nonce_store_rejects_one_of_two_simultaneous_consumers \ - tests/observability/test_run_evidence.py::test_postgres_concurrent_duplicate_replay_does_not_apply_loser_run_metadata \ - tests/observability/test_run_evidence.py::test_true_v2_to_v3_upgrade_is_idempotent_and_compatible_postgres \ - -q --junitxml=test-results/postgres-concurrency.xml | tee "${results_file}" - - python3 - "${results_file}" <<'PY' - import re - import sys - from pathlib import Path - - output = Path(sys.argv[1]).read_text(encoding="utf-8") - summary_lines = [ - line.strip() - for line in output.splitlines() - if re.search(r"\b(?:passed|skipped|failed|error|xfailed|xpassed)\b", line) - ] - summary = summary_lines[-1] if summary_lines else "" - passed_match = re.search(r"(\d+) passed\b", summary) - skipped_match = re.search(r"(\d+) skipped\b", summary) - passed = int(passed_match.group(1)) if passed_match else 0 - skipped = int(skipped_match.group(1)) if skipped_match else 0 - if passed != 3 or skipped != 0: - raise SystemExit( - f"PostgreSQL concurrency gate expected exactly 3 passed, 0 skipped; got: {summary}" - ) - print(f"PostgreSQL concurrency gates: {passed} passed, {skipped} skipped") - PY - - - name: Upload PostgreSQL contract results - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f - with: - name: postgres-concurrency-results - path: test-results/postgres-concurrency.xml - if-no-files-found: error - - quickstart-smoke: - name: python / quickstart smoke - needs: ci-config - if: needs.ci-config.outputs.python == 'true' - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - with: - persist-credentials: false - - uses: ./.github/actions/python-workspace - - - name: Run quickstart smoke test - shell: bash - env: - COVERAGE_FILE: quickstart-artifacts/.coverage.quickstart - run: | - set -euo pipefail - mkdir -p quickstart-artifacts - PHLO_QUICKSTART_SMOKE_ARTIFACT_DIR="$PWD/quickstart-artifacts" \ - uv run --locked python -m pytest -p scripts.ci_required tests/cli/test_quickstart_smoke.py --tb=short --junitxml=quickstart-artifacts/results.xml --cov=phlo --cov-report= - - - name: Upload quickstart smoke artifacts - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f - with: - name: quickstart-smoke - path: quickstart-artifacts/ - if-no-files-found: error - include-hidden-files: true - - recovery-continuity-drill: - name: python / recovery continuity drill - needs: ci-config - if: needs.ci-config.outputs.python == 'true' - runs-on: ubuntu-latest - timeout-minutes: 45 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - with: - persist-credentials: false - - - name: Install uv - uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 - with: - version: "0.12.1" - - - name: Run owned backup and restore drill - shell: bash - run: | - set -euo pipefail - mkdir -p recovery-artifacts - uv run --locked python scripts/recovery_drill.py | tee recovery-artifacts/recovery-drill.json - - - name: Summarize recovery evidence - if: always() - shell: bash - run: | - python3 - <<'PY' >> "$GITHUB_STEP_SUMMARY" - import json - from pathlib import Path - - report = Path("recovery-artifacts/recovery-drill.json") - print("### Recovery continuity drill") - if not report.is_file(): - print("The drill produced no report; inspect the job log.") - else: - result = json.loads(report.read_text(encoding="utf-8")) - print(f"- Outcome: `{result.get('outcome', 'unknown')}`") - print(f"- Backup duration: `{result.get('backup_seconds', 'unavailable')}` seconds") - print(f"- Restore duration: `{result.get('restore_seconds', 'unavailable')}` seconds") - print("- Scope: owned PostgreSQL run evidence, Nessie/Iceberg catalog state, and object checksum.") - print("- This drill records observed durations; it does not assert an RPO or RTO.") - PY - - - name: Upload recovery evidence - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f - with: - name: recovery-continuity-drill - path: recovery-artifacts/recovery-drill.json - if-no-files-found: warn - - windows-release-contract: - name: windows / release golden path contract - needs: ci-config - if: needs.ci-config.outputs.python == 'true' - runs-on: windows-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - with: - persist-credentials: false - - - name: Set up Python 3.12 - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 - with: - python-version: "3.12" - - - name: Install focused Python test dependency - shell: pwsh - run: python -m pip install --disable-pip-version-check pytest - - - name: Run focused Python harness tests - shell: pwsh - run: python -m pytest tests/scripts/test_release_golden_path.py --tb=short --junitxml=test-results/windows.xml - - - name: Run PowerShell launcher contract tests - shell: pwsh - run: ./tests/scripts/test_release_golden_path.ps1 - python-package-tests: name: python / package tests (${{ matrix.group }}, ${{ matrix.python-version }}) needs: ci-config @@ -552,56 +327,18 @@ jobs: if-no-files-found: error include-hidden-files: true - coverage: - name: coverage / combined report - needs: [ci-config, python-core-tests, python-package-tests, quickstart-smoke] - if: needs.ci-config.outputs.python == 'true' - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - with: - persist-credentials: false - - uses: ./.github/actions/python-workspace - - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 - with: - pattern: core-tests-* - path: coverage-data/core - - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 - with: - pattern: package-results-* - path: coverage-data/packages - - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 - with: - name: quickstart-smoke - path: coverage-data/quickstart - - name: Combine measured regression, core, package and quickstart coverage - run: | - set -euo pipefail - mapfile -t directories < <(find coverage-data -name '.coverage.*' -printf '%h\n' | sort -u) - test "${#directories[@]}" -gt 0 - uv run --locked coverage combine "${directories[@]}" - uv run --locked coverage xml -o coverage.xml - uv run --locked coverage json -o coverage.json - uv run --locked coverage report >> "$GITHUB_STEP_SUMMARY" - - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f - with: - name: combined-coverage - path: | - coverage.xml - coverage.json - if-no-files-found: error - windows-portability: name: windows / compose portability needs: ci-config if: needs.ci-config.outputs.python == 'true' uses: ./.github/workflows/windows-compose-portability.yml + with: + reuse-wheelhouse: true frontend: - name: frontend / observatory + name: behaviour / JavaScript needs: ci-config - if: needs.ci-config.outputs.frontend == 'true' + if: needs.ci-config.outputs.frontend == 'true' || needs.ci-config.outputs.writer == 'true' || needs.ci-config.outputs.plugin == 'true' runs-on: ubuntu-latest timeout-minutes: 25 defaults: @@ -617,59 +354,79 @@ jobs: with: node-version: "24" cache: "npm" - cache-dependency-path: packages/phlo-observatory/src/phlo_observatory/package-lock.json + cache-dependency-path: | + packages/phlo-observatory/src/phlo_observatory/package-lock.json + apps/phlo-github-writer/package-lock.json - name: Install dependencies + if: needs.ci-config.outputs.frontend == 'true' + id: observatory_install run: npm ci - name: Check formatting with Prettier + if: ${{ !cancelled() && steps.observatory_install.outcome == 'success' }} run: npx prettier --check . - name: Lint with ESLint + if: ${{ !cancelled() && steps.observatory_install.outcome == 'success' }} run: npm run lint -- --max-warnings 0 - name: Type check with TypeScript + if: ${{ !cancelled() && steps.observatory_install.outcome == 'success' }} run: npx tsc --noEmit - name: Build production bundle + if: ${{ !cancelled() && steps.observatory_install.outcome == 'success' }} run: npm run build - name: Run tests + if: ${{ !cancelled() && steps.observatory_install.outcome == 'success' }} run: npm test - phlo-github-writer: - name: agent / GitHub writer - needs: ci-config - if: needs.ci-config.outputs.writer == 'true' - runs-on: ubuntu-latest - timeout-minutes: 20 - defaults: - run: + - name: Install writer dependencies + if: ${{ !cancelled() && needs.ci-config.outputs.writer == 'true' }} + id: writer_install working-directory: apps/phlo-github-writer - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - with: - persist-credentials: false - - - name: Set up Node.js - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 - with: - node-version: "24" - cache: "npm" - cache-dependency-path: apps/phlo-github-writer/package-lock.json - - - name: Install dependencies run: npm ci - - name: Type check + - name: Type check writer + if: ${{ !cancelled() && steps.writer_install.outcome == 'success' }} + working-directory: apps/phlo-github-writer run: npm run typecheck - - name: Run tests + - name: Run writer tests + if: ${{ !cancelled() && steps.writer_install.outcome == 'success' }} + working-directory: apps/phlo-github-writer run: npm test - name: Build writer + if: ${{ !cancelled() && steps.writer_install.outcome == 'success' }} + working-directory: apps/phlo-github-writer run: npm run build + - name: Run plugin behavior tests + if: ${{ !cancelled() && needs.ci-config.outputs.plugin == 'true' }} + working-directory: . + run: | + mkdir -p test-results + node --experimental-strip-types --test --test-reporter=spec --test-reporter-destination=stdout \ + --test-reporter=junit --test-reporter-destination=test-results/plugin.xml \ + .amp/plugins/phlo-github/lib.test.ts + python3 - <<'PY' + import xml.etree.ElementTree as ET + + report = ET.parse("test-results/plugin.xml") + assert report.findall(".//testcase"), "Plugin suite must execute tests" + assert not report.findall(".//skipped"), "Required plugin tests must not skip" + PY + - name: Upload plugin results + if: always() && needs.ci-config.outputs.plugin == 'true' + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: plugin-results + path: test-results/plugin.xml + if-no-files-found: error + ci-status: name: ci / status runs-on: ubuntu-latest @@ -677,41 +434,28 @@ jobs: timeout-minutes: 5 needs: - ci-config - - pre-commit - installed-provider-artifacts - python-quality - python-core-tests - - quickstart-smoke - - postgres-concurrency-gates - - recovery-continuity-drill - - windows-release-contract - python-package-tests - - coverage - windows-portability - frontend - - phlo-github-writer steps: - name: Summarize CI lane results shell: bash env: CI_CONFIG: ${{ needs.ci-config.result }} - PRE_COMMIT: ${{ needs.pre-commit.result }} INSTALLED_PROVIDER_ARTIFACTS: ${{ needs.installed-provider-artifacts.result }} PYTHON_QUALITY: ${{ needs.python-quality.result }} PYTHON_CORE_TESTS: ${{ needs.python-core-tests.result }} - QUICKSTART_SMOKE: ${{ needs.quickstart-smoke.result }} - POSTGRES_CONCURRENCY_GATES: ${{ needs.postgres-concurrency-gates.result }} - RECOVERY_CONTINUITY_DRILL: ${{ needs.recovery-continuity-drill.result }} - WINDOWS_RELEASE_CONTRACT: ${{ needs.windows-release-contract.result }} PYTHON_PACKAGE_TESTS: ${{ needs.python-package-tests.result }} - COVERAGE: ${{ needs.coverage.result }} WINDOWS_PORTABILITY: ${{ needs.windows-portability.result }} FRONTEND: ${{ needs.frontend.result }} - PHLO_GITHUB_WRITER: ${{ needs.phlo-github-writer.result }} SELECT_PYTHON: ${{ needs.ci-config.outputs.python }} SELECT_GROUPS: ${{ needs.ci-config.outputs.groups }} SELECT_FRONTEND: ${{ needs.ci-config.outputs.frontend }} SELECT_WRITER: ${{ needs.ci-config.outputs.writer }} + SELECT_PLUGIN: ${{ needs.ci-config.outputs.plugin }} run: | set -euo pipefail @@ -721,34 +465,24 @@ jobs: echo "| Lane | Result |" echo "|---|---|" echo "| ci / config | ${CI_CONFIG} |" - echo "| ci / pre-commit | ${PRE_COMMIT} |" echo "| python / installed provider artifacts | ${INSTALLED_PROVIDER_ARTIFACTS} |" - echo "| python / quality | ${PYTHON_QUALITY} |" + echo "| quality / source and file contracts | ${PYTHON_QUALITY} |" echo "| python / core tests | ${PYTHON_CORE_TESTS} |" - echo "| python / quickstart smoke | ${QUICKSTART_SMOKE} |" - echo "| python / postgres concurrency gates | ${POSTGRES_CONCURRENCY_GATES} |" - echo "| python / recovery continuity drill | ${RECOVERY_CONTINUITY_DRILL} |" - echo "| windows / release golden path contract | ${WINDOWS_RELEASE_CONTRACT} |" + echo "| integration / Windows contracts | ${WINDOWS_PORTABILITY} |" echo "| python / package tests | ${PYTHON_PACKAGE_TESTS} |" - echo "| frontend / observatory | ${FRONTEND} |" - echo "| agent / GitHub writer | ${PHLO_GITHUB_WRITER} |" + echo "| behaviour / JavaScript | ${FRONTEND} |" } >> "$GITHUB_STEP_SUMMARY" test "$CI_CONFIG" = success - test "$PRE_COMMIT" = success + test "$PYTHON_QUALITY" = success expected_python=skipped expected_groups=skipped - expected_frontend=skipped - expected_writer=skipped + expected_node=skipped if [ "$SELECT_PYTHON" = true ]; then expected_python=success; fi if [ "$SELECT_GROUPS" != '[]' ]; then expected_groups=success; fi - if [ "$SELECT_FRONTEND" = true ]; then expected_frontend=success; fi - if [ "$SELECT_WRITER" = true ]; then expected_writer=success; fi - for result in "$INSTALLED_PROVIDER_ARTIFACTS" "$PYTHON_QUALITY" "$PYTHON_CORE_TESTS" \ - "$QUICKSTART_SMOKE" "${POSTGRES_CONCURRENCY_GATES}" "$RECOVERY_CONTINUITY_DRILL" \ - "$WINDOWS_RELEASE_CONTRACT" "$COVERAGE" "$WINDOWS_PORTABILITY"; do + if [ "$SELECT_FRONTEND" = true ] || [ "$SELECT_WRITER" = true ] || [ "$SELECT_PLUGIN" = true ]; then expected_node=success; fi + for result in "$INSTALLED_PROVIDER_ARTIFACTS" "$PYTHON_CORE_TESTS" "$WINDOWS_PORTABILITY"; do test "$result" = "$expected_python" done test "$PYTHON_PACKAGE_TESTS" = "$expected_groups" - test "$FRONTEND" = "$expected_frontend" - test "$PHLO_GITHUB_WRITER" = "$expected_writer" + test "$FRONTEND" = "$expected_node" diff --git a/.github/workflows/container-security.yml b/.github/workflows/container-security.yml index c6a79a7fdc7..86b755b3aa4 100644 --- a/.github/workflows/container-security.yml +++ b/.github/workflows/container-security.yml @@ -10,14 +10,14 @@ permissions: contents: read jobs: - waivers: - name: containers / waiver register - if: always() + checks: + name: safety / container contracts runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 45 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: + fetch-depth: 0 persist-credentials: false - uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 with: @@ -36,21 +36,6 @@ jobs: uv run --no-project --with pyyaml==6.0.3 python scripts/container_security.py render-waivers --output "$RUNNER_TEMP/container-waivers.md" cat "$RUNNER_TEMP/container-waivers.md" >> "$GITHUB_STEP_SUMMARY" - affected: - name: containers / affected images - runs-on: ubuntu-latest - timeout-minutes: 10 - outputs: - matrix: ${{ steps.images.outputs.matrix }} - has_images: ${{ steps.images.outputs.has_images }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - with: - fetch-depth: 0 - persist-credentials: false - - uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 - with: - version: "0.12.1" - id: images name: Select generated service images env: @@ -70,39 +55,25 @@ jobs: echo "matrix=$matrix" >> "$GITHUB_OUTPUT" if [ "$(jq '.include | length' <<< "$matrix")" -gt 0 ]; then echo "has_images=true" >> "$GITHUB_OUTPUT"; else echo "has_images=false" >> "$GITHUB_OUTPUT"; fi - hadolint: - name: containers / lint ${{ matrix.target.service }} - needs: affected - if: needs.affected.outputs.has_images == 'true' - runs-on: ubuntu-latest - timeout-minutes: 20 - strategy: - fail-fast: false - matrix: - target: ${{ fromJSON(needs.affected.outputs.matrix).include }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - with: - persist-credentials: false - - name: Lint generated-service Dockerfile - run: docker run --rm -i hadolint/hadolint@sha256:27086352fd5e1907ea2b934eb1023f217c5ae087992eb59fde121dce9c9ff21e < "${{ matrix.target.package }}/${{ matrix.target.dockerfile }}" - - generated-files: - name: containers / generated files - needs: affected - if: needs.affected.outputs.has_images == 'true' - runs-on: ubuntu-latest - timeout-minutes: 45 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - with: - persist-credentials: false - - uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 - with: - version: "0.12.1" + - name: Lint generated-service Dockerfiles + if: steps.images.outputs.has_images == 'true' + env: + TARGETS: ${{ steps.images.outputs.matrix }} + run: | + set -euo pipefail + failed=0 + while IFS= read -r dockerfile; do + echo "::group::$dockerfile" + if ! docker run --rm -i hadolint/hadolint@sha256:27086352fd5e1907ea2b934eb1023f217c5ae087992eb59fde121dce9c9ff21e < "$dockerfile"; then + failed=1 + fi + echo '::endgroup::' + done < <(jq -r '.include[] | .package + "/" + .dockerfile' <<< "$TARGETS") + exit "$failed" - name: Validate generated Dockerfiles and Compose configuration + if: ${{ !cancelled() && steps.images.outputs.has_images == 'true' }} env: - TARGETS: ${{ needs.affected.outputs.matrix }} + TARGETS: ${{ steps.images.outputs.matrix }} run: | set -euo pipefail uv python install 3.12 @@ -116,27 +87,3 @@ jobs: for service in "${services[@]}"; do add_args+=(--service "$service"); done uv --project "$GITHUB_WORKSPACE" run --locked phlo services add "${add_args[@]}" --no-start docker compose --profile '*' -f "$project/.phlo/docker-compose.yml" --project-directory "$project/.phlo" config --format json > "$RUNNER_TEMP/generated-compose.json" - - status: - name: containers / status - if: always() - needs: [waivers, affected, hadolint, generated-files] - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Require selected container checks - env: - WAIVERS: ${{ needs.waivers.result }} - AFFECTED: ${{ needs.affected.result }} - HAS_IMAGES: ${{ needs.affected.outputs.has_images }} - LINT: ${{ needs.hadolint.result }} - GENERATED: ${{ needs.generated-files.result }} - run: | - set -euo pipefail - test "$WAIVERS" = success - test "$AFFECTED" = success - case "$HAS_IMAGES" in - true) test "$LINT" = success; test "$GENERATED" = success ;; - false) test "$LINT" = skipped; test "$GENERATED" = skipped ;; - *) exit 1 ;; - esac diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 72102782189..7582485e3e4 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -19,13 +19,40 @@ jobs: integration: name: integration / required suites runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 90 + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: phlo_ci + POSTGRES_USER: phlo_ci + POSTGRES_PASSWORD: phlo_ci_password + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U phlo_ci -d phlo_ci" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + UV_PYTHON: "3.12" + PHLO_SERVICE_TOKEN_TEST_POSTGRES_DSN: postgresql://phlo_ci:phlo_ci_password@localhost:5432/phlo_ci + PHLO_RUN_EVIDENCE_TEST_POSTGRES_DSN: postgresql://phlo_ci:phlo_ci_password@localhost:5432/phlo_ci steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: persist-credentials: false - - uses: ./.github/actions/python-workspace + - name: Install uv + uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 + with: + version: "0.12.1" + - name: Install Python 3.12 + run: uv python install 3.12 + - name: Sync complete workspace + id: workspace + run: uv sync --all-packages --dev --locked - name: Run named behavioral contracts + if: ${{ !cancelled() && steps.workspace.outcome == 'success' }} run: uv run --locked python scripts/run_integration.py - name: Upload required suite results if: always() @@ -34,3 +61,140 @@ jobs: name: integration-results path: test-results/integration/ if-no-files-found: error + - name: Preflight PostgreSQL gate dependencies + if: ${{ !cancelled() && steps.workspace.outcome == 'success' }} + id: postgres_preflight + shell: bash + run: | + set -euo pipefail + + for dsn_name in \ + PHLO_SERVICE_TOKEN_TEST_POSTGRES_DSN \ + PHLO_RUN_EVIDENCE_TEST_POSTGRES_DSN; do + if [ -z "${!dsn_name:-}" ]; then + echo "::error::${dsn_name} must be set for the required PostgreSQL gate" + exit 1 + fi + done + + uv run --locked python - <<'PY' + import os + + import psycopg2 + + for name in ( + "PHLO_SERVICE_TOKEN_TEST_POSTGRES_DSN", + "PHLO_RUN_EVIDENCE_TEST_POSTGRES_DSN", + ): + connection = psycopg2.connect(os.environ[name], connect_timeout=5) + try: + with connection.cursor() as cursor: + cursor.execute("SELECT 1") + assert cursor.fetchone() == (1,) + finally: + connection.close() + PY + + - name: Run PostgreSQL concurrency gates + if: ${{ !cancelled() && steps.postgres_preflight.outcome == 'success' }} + shell: bash + run: | + set -euo pipefail + results_file="$(mktemp)" + trap 'rm -f "${results_file}"' EXIT + + PHLO_SERVICE_TOKEN_TEST_POSTGRES_DSN="${PHLO_SERVICE_TOKEN_TEST_POSTGRES_DSN}" \ + PHLO_RUN_EVIDENCE_TEST_POSTGRES_DSN="${PHLO_RUN_EVIDENCE_TEST_POSTGRES_DSN}" \ + uv run --locked pytest --import-mode=importlib \ + tests/unit/phlo/security/test_service_identity.py::test_postgres_nonce_store_rejects_one_of_two_simultaneous_consumers \ + tests/observability/test_run_evidence.py::test_postgres_concurrent_duplicate_replay_does_not_apply_loser_run_metadata \ + tests/observability/test_run_evidence.py::test_true_v2_to_v3_upgrade_is_idempotent_and_compatible_postgres \ + -q --junitxml=test-results/postgres-concurrency.xml | tee "${results_file}" + + python3 - "${results_file}" <<'PY' + import re + import sys + from pathlib import Path + + output = Path(sys.argv[1]).read_text(encoding="utf-8") + summary_lines = [ + line.strip() + for line in output.splitlines() + if re.search(r"\b(?:passed|skipped|failed|error|xfailed|xpassed)\b", line) + ] + summary = summary_lines[-1] if summary_lines else "" + passed_match = re.search(r"(\d+) passed\b", summary) + skipped_match = re.search(r"(\d+) skipped\b", summary) + passed = int(passed_match.group(1)) if passed_match else 0 + skipped = int(skipped_match.group(1)) if skipped_match else 0 + if passed != 3 or skipped != 0: + raise SystemExit( + f"PostgreSQL concurrency gate expected exactly 3 passed, 0 skipped; got: {summary}" + ) + print(f"PostgreSQL concurrency gates: {passed} passed, {skipped} skipped") + PY + + - name: Upload PostgreSQL contract results + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: postgres-concurrency-results + path: test-results/postgres-concurrency.xml + if-no-files-found: error + + - name: Run quickstart smoke test + if: ${{ !cancelled() && steps.workspace.outcome == 'success' }} + shell: bash + env: + COVERAGE_FILE: quickstart-artifacts/.coverage.quickstart + run: | + set -euo pipefail + mkdir -p quickstart-artifacts + PHLO_QUICKSTART_SMOKE_ARTIFACT_DIR="$PWD/quickstart-artifacts" \ + uv run --locked python -m pytest -p scripts.ci_required tests/cli/test_quickstart_smoke.py --tb=short --junitxml=quickstart-artifacts/results.xml --cov=phlo --cov-report= + + - name: Upload quickstart smoke artifacts + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: quickstart-smoke + path: quickstart-artifacts/ + if-no-files-found: error + include-hidden-files: true + + - name: Run owned backup and restore drill + if: ${{ !cancelled() && steps.workspace.outcome == 'success' }} + shell: bash + run: | + set -euo pipefail + mkdir -p recovery-artifacts + uv run --locked python scripts/recovery_drill.py | tee recovery-artifacts/recovery-drill.json + + - name: Summarize recovery evidence + if: always() + shell: bash + run: | + python3 - <<'PY' >> "$GITHUB_STEP_SUMMARY" + import json + from pathlib import Path + + report = Path("recovery-artifacts/recovery-drill.json") + print("### Recovery continuity drill") + if not report.is_file(): + print("The drill produced no report; inspect the job log.") + else: + result = json.loads(report.read_text(encoding="utf-8")) + print(f"- Outcome: `{result.get('outcome', 'unknown')}`") + print(f"- Backup duration: `{result.get('backup_seconds', 'unavailable')}` seconds") + print(f"- Restore duration: `{result.get('restore_seconds', 'unavailable')}` seconds") + print("- Scope: owned PostgreSQL run evidence, Nessie/Iceberg catalog state, and object checksum.") + print("- This drill records observed durations; it does not assert an RPO or RTO.") + PY + + - name: Upload recovery evidence + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: recovery-continuity-drill + path: recovery-artifacts/recovery-drill.json + if-no-files-found: warn diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 7ae7884ea5c..6b3fd3b4a25 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -24,9 +24,9 @@ jobs: timeout-minutes: 5 outputs: selection: ${{ steps.select.outputs.selection }} + python: ${{ steps.select.outputs.python }} integration: ${{ steps.select.outputs.integration }} docs: ${{ steps.select.outputs.docs }} - plugin: ${{ steps.select.outputs.plugin }} mutation: ${{ steps.select.outputs.mutation }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd @@ -47,9 +47,9 @@ jobs: fi { echo "selection=$selection" + echo "python=$(jq -r .python <<< "$selection")" echo "integration=$(jq -r .integration <<< "$selection")" echo "docs=$(jq -r .docs <<< "$selection")" - echo "plugin=$(jq -r .plugin <<< "$selection")" echo "mutation=$(jq -r .mutation <<< "$selection")" } >> "$GITHUB_OUTPUT" jq .reasons <<< "$selection" >> "$GITHUB_STEP_SUMMARY" @@ -62,7 +62,7 @@ jobs: integration: needs: changes - if: needs.changes.outputs.integration == 'true' + if: needs.changes.outputs.integration == 'true' || needs.changes.outputs.python == 'true' uses: ./.github/workflows/integration.yml containers: @@ -78,40 +78,6 @@ jobs: if: needs.changes.outputs.mutation == 'true' uses: ./.github/workflows/mutation.yml - plugin: - name: automation / plugin - needs: changes - if: needs.changes.outputs.plugin == 'true' - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - with: - persist-credentials: false - - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 - with: - node-version: "24" - - name: Run plugin behavior tests - run: | - mkdir -p test-results - node --experimental-strip-types --test --test-reporter=spec --test-reporter-destination=stdout \ - --test-reporter=junit --test-reporter-destination=test-results/plugin.xml \ - .amp/plugins/phlo-github/lib.test.ts - python3 - <<'PY' - import xml.etree.ElementTree as ET - - report = ET.parse("test-results/plugin.xml") - assert report.findall(".//testcase"), "Plugin suite must execute tests" - assert not report.findall(".//skipped"), "Required plugin tests must not skip" - PY - - name: Upload plugin results - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f - with: - name: plugin-results - path: test-results/plugin.xml - if-no-files-found: error - security: name: security / introduced dependency risk runs-on: ubuntu-latest @@ -127,8 +93,6 @@ jobs: - uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 with: version: "0.12.1" - - name: Audit workflow hardening - run: make zizmor - name: Inventory new suppressions and skips env: BASE_SHA: ${{ github.event.merge_group.base_sha || github.event.pull_request.base.sha || github.event.before || github.sha }} @@ -164,9 +128,9 @@ jobs: required: name: pr / required if: always() - needs: [changes, ci, integration, containers, security, docs, plugin, mutation] + needs: [changes, ci, integration, containers, security, docs, mutation] runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 10 permissions: contents: read actions: read @@ -177,12 +141,11 @@ jobs: CHANGES: ${{ needs.changes.result }} INTEGRATION: ${{ needs.integration.result }} INTEGRATION_SELECTED: ${{ needs.changes.outputs.integration }} + PYTHON_SELECTED: ${{ needs.changes.outputs.python }} CONTAINERS: ${{ needs.containers.result }} SECURITY: ${{ needs.security.result }} DOCS: ${{ needs.docs.result }} DOCS_SELECTED: ${{ needs.changes.outputs.docs }} - PLUGIN: ${{ needs.plugin.result }} - PLUGIN_SELECTED: ${{ needs.changes.outputs.plugin }} MUTATION: ${{ needs.mutation.result }} MUTATION_SELECTED: ${{ needs.changes.outputs.mutation }} run: | @@ -193,7 +156,7 @@ jobs: exit 1 fi done - if [ "$INTEGRATION_SELECTED" = true ]; then + if [ "$INTEGRATION_SELECTED" = true ] || [ "$PYTHON_SELECTED" = true ]; then test "$INTEGRATION" = success else test "$INTEGRATION" = skipped @@ -203,24 +166,57 @@ jobs: else test "$DOCS" = skipped fi - if [ "$PLUGIN_SELECTED" = true ]; then - test "$PLUGIN" = success - else - test "$PLUGIN" = skipped - fi if [ "$MUTATION_SELECTED" = true ]; then test "$MUTATION" = success else test "$MUTATION" = skipped fi - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - if: github.event_name == 'merge_group' + if: needs.changes.outputs.python == 'true' || github.event_name == 'merge_group' with: persist-credentials: false - uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 - if: github.event_name == 'merge_group' + if: needs.changes.outputs.python == 'true' || github.event_name == 'merge_group' with: version: "0.12.1" + - name: Install locked coverage tools + if: needs.changes.outputs.python == 'true' + run: | + uv python install 3.12 + uv sync --dev --locked + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + if: needs.changes.outputs.python == 'true' + with: + pattern: core-tests-* + path: coverage-data/core + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + if: needs.changes.outputs.python == 'true' && fromJSON(needs.changes.outputs.selection).groups[0] != null + with: + pattern: package-results-* + path: coverage-data/packages + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + if: needs.changes.outputs.python == 'true' + with: + name: quickstart-smoke + path: coverage-data/quickstart + - name: Combine measured regression, core, package and quickstart coverage + if: needs.changes.outputs.python == 'true' + run: | + set -euo pipefail + mapfile -t directories < <(find coverage-data -name '.coverage.*' -printf '%h\n' | sort -u) + test "${#directories[@]}" -gt 0 + uv run --locked coverage combine "${directories[@]}" + uv run --locked coverage xml -o coverage.xml + uv run --locked coverage json -o coverage.json + uv run --locked coverage report >> "$GITHUB_STEP_SUMMARY" + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + if: needs.changes.outputs.python == 'true' + with: + name: combined-coverage + path: | + coverage.xml + coverage.json + if-no-files-found: error - name: Record authenticated full-queue evidence and job timings if: github.event_name == 'merge_group' env: diff --git a/.github/workflows/windows-compose-portability.yml b/.github/workflows/windows-compose-portability.yml index 543203b0eed..2dd8d30fd77 100644 --- a/.github/workflows/windows-compose-portability.yml +++ b/.github/workflows/windows-compose-portability.yml @@ -2,15 +2,16 @@ name: Windows Compose portability on: workflow_call: + inputs: + reuse-wheelhouse: + description: Use the wheelhouse already built by the calling validation run + type: boolean + default: false workflow_dispatch: permissions: contents: read -concurrency: - group: windows-compose-portability-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - jobs: generate-linux-fixture: name: linux / generate shared fixture @@ -59,7 +60,15 @@ jobs: git ls-files -z | sort -z | xargs -0 sha256sum > "$RUNNER_TEMP/shared-layout.sha256" git bundle create "$RUNNER_TEMP/shared-layout.bundle" --all + - name: Download the validation wheelhouse + if: inputs.reuse-wheelhouse + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + name: provider-wheelhouse + path: ${{ runner.temp }}/wheelhouse + - name: Build installed CLI and service wheels + if: ${{ !inputs.reuse-wheelhouse }} working-directory: source run: uv build --all-packages --wheel --out-dir "$RUNNER_TEMP/wheelhouse" @@ -94,6 +103,28 @@ jobs: version: "0.12.1" enable-cache: true + - name: Set up Python 3.12 + uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 + with: + python-version: "3.12" + + - name: Install focused Python test dependency + shell: pwsh + run: | + $venv = Join-Path $env:RUNNER_TEMP "phlo-windows-harness-venv" + uv venv --python 3.12 $venv + $python = Join-Path $venv "Scripts\python.exe" + uv pip install --python $python pytest + "HARNESS_PYTHON=$python" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Run focused Python harness tests + shell: pwsh + run: '& $env:HARNESS_PYTHON -m pytest tests/scripts/test_release_golden_path.py --tb=short --junitxml=test-results/windows.xml' + + - name: Run PowerShell launcher contract tests + shell: pwsh + run: ./tests/scripts/test_release_golden_path.ps1 + - name: Clone the Linux-generated fixture into a path with spaces shell: pwsh run: | diff --git a/docs/contributing/ci.md b/docs/contributing/ci.md index b5c2ea231dd..34f75258e51 100644 --- a/docs/contributing/ci.md +++ b/docs/contributing/ci.md @@ -16,10 +16,41 @@ Observatory, GitHub writer and webhook plugin behaviour, documentation, dependency risk, container policy and focused mutation checks. A selected lane that is skipped, cancelled or failed cannot satisfy the aggregate. -Integration provisions disposable MinIO and Nessie and rejects missing tests, -skips and expected failures through `scripts/ci_required.py`. Local tests can -still skip unavailable services without this opt-in plugin. Reproduce the -provisioned lane with `uv run --locked python scripts/run_integration.py`; +### Check buckets and stages + +Checks share setup within four buckets. Named steps retain individual failure +logs; a failed step still fails its job. The buckets are not four literal jobs: +native Windows checks, service provisioning and slower test shards need separate +environments. + +| Bucket | Execution layout | +| --- | --- | +| Quality and contracts | File hooks, workflow hardening and Python lint, format, types and reference checks share one job. Documentation builds retain their Node-based job. Focused mutation checks remain required when selected. | +| Behaviour | Core and package tests retain parallel shards. Observatory, GitHub writer and webhook plugin checks share one Node job, with each consumer selected independently. | +| Integration and portability | Storage/catalog suites, PostgreSQL concurrency, quickstart and recovery share one Linux workspace. Windows launcher and shared-layout checks share one native Windows job. | +| Artifact and dependency safety | Preparation builds wheels once for provider shards and Windows acceptance. Container waiver, Dockerfile lint and generated Compose checks share one job. Dependency assessments remain independent. | + +| Stage | Scope | +| --- | --- | +| Pull request | Affected checks and reverse dependencies, introduced dependency risk, conservative full coverage for unknown changes. | +| Merge queue | All buckets on the exact prospective merge SHA, including the full dependency policy. | +| Main and beta | Authenticate and reuse full queue evidence. Run the full fallback only when evidence cannot be reused. | +| Scheduled maintenance | Fresh dependency and upstream-image scans, mutation and extended reliability checks. Findings enter the normal remediation PR flow. | +| Release staging | Require source health and fresh release dependency evidence, then build and scan immutable release artifacts. | +| Release acceptance and promotion | Exercise the staged bytes on required platforms and repeated runs, then publish those bytes after human approval. | + +`pr / required` collects combined coverage after both Python and service +contracts finish, then records queue evidence. Coverage does not need its own +runner. No test suite, dependency gate or release approval is removed by the +consolidation. Standalone Windows dispatch builds its own wheels; ordinary +validation reuses the preparation artifact. + +Integration provisions disposable PostgreSQL, MinIO and Nessie. Storage/catalog +suites reject missing tests, skips and expected failures through +`scripts/ci_required.py`; PostgreSQL guards require exactly three passes without +skips. Local tests can still skip unavailable services without this opt-in +plugin. Reproduce the storage/catalog suites with +`uv run --locked python scripts/run_integration.py`; Docker is required and provisioned containers are removed afterwards. Core, regression, package and quickstart coverage is combined into one report. diff --git a/registry/support/v1.json b/registry/support/v1.json index 981c8abc779..4c5651a420a 100644 --- a/registry/support/v1.json +++ b/registry/support/v1.json @@ -1193,7 +1193,7 @@ "evidence": [ "scripts/recovery_drill.py", "tests/scripts/test_recovery_drill.py", - ".github/workflows/ci.yml", + ".github/workflows/integration.yml", "docs/guides/run-in-production.md" ] }, @@ -1206,7 +1206,7 @@ "evidence": [ "scripts/release_golden_path.py", "tests/scripts/test_release_golden_path.py", - ".github/workflows/ci.yml" + ".github/workflows/windows-compose-portability.yml" ] } ], diff --git a/scripts/validate_support_manifest.py b/scripts/validate_support_manifest.py index 87566f2a5ea..029ba9a6013 100644 --- a/scripts/validate_support_manifest.py +++ b/scripts/validate_support_manifest.py @@ -59,21 +59,21 @@ "required_evidence": [ "scripts/recovery_drill.py", "tests/scripts/test_recovery_drill.py", - ".github/workflows/ci.yml", + ".github/workflows/integration.yml", ], "forbidden_evidence": [], "forbidden_reason_phrases": [ "Upgrade and recovery are documentation procedures", ], "workflow_commands": { - ".github/workflows/ci.yml": "scripts/recovery_drill.py", + ".github/workflows/integration.yml": "scripts/recovery_drill.py", }, }, "golden_path_ci": { "required_evidence": [ "scripts/release_golden_path.py", "tests/scripts/test_release_golden_path.py", - ".github/workflows/ci.yml", + ".github/workflows/windows-compose-portability.yml", ], "forbidden_evidence": [ "scripts/run_golden_path.py", @@ -82,7 +82,7 @@ "required CI does not invoke", ], "workflow_commands": { - ".github/workflows/ci.yml": "test_release_golden_path", + ".github/workflows/windows-compose-portability.yml": "test_release_golden_path", }, }, } diff --git a/src/phlo/support_data/v1.json b/src/phlo/support_data/v1.json index 981c8abc779..4c5651a420a 100644 --- a/src/phlo/support_data/v1.json +++ b/src/phlo/support_data/v1.json @@ -1193,7 +1193,7 @@ "evidence": [ "scripts/recovery_drill.py", "tests/scripts/test_recovery_drill.py", - ".github/workflows/ci.yml", + ".github/workflows/integration.yml", "docs/guides/run-in-production.md" ] }, @@ -1206,7 +1206,7 @@ "evidence": [ "scripts/release_golden_path.py", "tests/scripts/test_release_golden_path.py", - ".github/workflows/ci.yml" + ".github/workflows/windows-compose-portability.yml" ] } ], diff --git a/tests/scripts/test_ci_evidence.py b/tests/scripts/test_ci_evidence.py index b6665ac2bbb..facef444689 100644 --- a/tests/scripts/test_ci_evidence.py +++ b/tests/scripts/test_ci_evidence.py @@ -143,7 +143,12 @@ def test_full_graph_has_every_matrix_entry_and_new_required_lane(): jobs = evidence.expected_jobs() assert {f"ci / python / core tests (3.12, shard {i})" for i in range(3)} <= jobs assert {f"ci / python / installed provider artifacts ({i})" for i in range(4)} <= jobs - assert {"docs / docs / build", "automation / plugin", "ci / coverage / combined report"} <= jobs + assert { + "docs / docs / build", + "ci / behaviour / JavaScript", + "ci / quality / source and file contracts", + "containers / safety / container contracts", + } <= jobs assert not any("${{" in name for name in jobs) diff --git a/tests/tooling/test_container_security_workflows.py b/tests/tooling/test_container_security_workflows.py index 8f6e6498824..f88fa532a8f 100644 --- a/tests/tooling/test_container_security_workflows.py +++ b/tests/tooling/test_container_security_workflows.py @@ -165,7 +165,7 @@ def test_renovate_config_workflow_validates_the_repository_config() -> None: assert config_path.name in validator_runs[0] -def test_zizmor_audit_runs_in_the_scheduled_security_lane_not_ci() -> None: +def test_zizmor_audit_runs_in_scheduled_security_and_required_quality() -> None: security = _load_workflow("security.yml") ci = _load_workflow("ci.yml") @@ -177,4 +177,7 @@ def test_zizmor_audit_runs_in_the_scheduled_security_lane_not_ci() -> None: makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8") assert re.search(r"(?m)^zizmor\s*:", makefile) - assert all("zizmor" not in script for script in _run_scripts(ci)) + quality = ci["jobs"]["python-quality"] + assert sum(step.get("run") == "make zizmor" for step in quality["steps"]) == 1 + pr = _load_workflow("pr.yml") + assert all(step.get("run") != "make zizmor" for step in pr["jobs"]["security"]["steps"]) diff --git a/tests/tooling/test_generated_image_publication.py b/tests/tooling/test_generated_image_publication.py index 5f163169ff0..5c56b251674 100644 --- a/tests/tooling/test_generated_image_publication.py +++ b/tests/tooling/test_generated_image_publication.py @@ -385,6 +385,6 @@ def test_container_security_replaces_legacy_remote_image_scan() -> None: assert "generated-container-checks" not in workflow assert "--allow-vulnerable-image" not in workflow assert "--remote-images" not in workflow - assert "generated-files" in container_workflow + assert "Validate generated Dockerfiles and Compose configuration" in container_workflow assert "docker build" not in container_workflow assert "aquasec/trivy" not in container_workflow diff --git a/tests/tooling/test_golden_path_scheduling.py b/tests/tooling/test_golden_path_scheduling.py index 26f355ee16d..af7dd2d8bad 100644 --- a/tests/tooling/test_golden_path_scheduling.py +++ b/tests/tooling/test_golden_path_scheduling.py @@ -15,9 +15,6 @@ def test_nightly_maintenance_does_not_claim_artifact_qualification(tmp_path: Pat ci = yaml.safe_load((WORKFLOW_ROOT / "ci.yml").read_text(encoding="utf-8")) nightly = yaml.safe_load((WORKFLOW_ROOT / "nightly.yml").read_text(encoding="utf-8")) assert "release-golden-path" not in ci["jobs"] - assert ci["jobs"]["windows-release-contract"]["name"] == ( - "windows / release golden path contract" - ) report = nightly["jobs"]["release-artifact-acceptance"] output = tmp_path / "summary" result = subprocess.run( @@ -35,6 +32,50 @@ def test_nightly_maintenance_does_not_claim_artifact_qualification(tmp_path: Pat assert "upstream-compatibility" in summary["needs"] +def test_windows_portability_runs_release_contracts_in_an_isolated_environment() -> None: + workflow = yaml.safe_load( + (WORKFLOW_ROOT / "windows-compose-portability.yml").read_text(encoding="utf-8") + ) + assert "concurrency" not in workflow + ci = yaml.safe_load((WORKFLOW_ROOT / "ci.yml").read_text(encoding="utf-8"))["jobs"] + assert ci["windows-portability"]["needs"] == "ci-config" + assert ci["windows-portability"]["with"]["reuse-wheelhouse"] is True + preparation = workflow["jobs"]["generate-linux-fixture"]["steps"] + download = next( + step for step in preparation if step.get("name") == "Download the validation wheelhouse" + ) + assert download["if"] == "inputs.reuse-wheelhouse" + assert download["with"]["name"] == "provider-wheelhouse" + build = next( + step for step in preparation if step.get("name") == "Build installed CLI and service wheels" + ) + assert build["if"] == "${{ !inputs.reuse-wheelhouse }}" + job = workflow["jobs"]["verify-windows-fixture"] + assert job["runs-on"] == "windows-latest" + steps = {step["name"]: step for step in job["steps"] if "name" in step} + assert steps["Set up Python 3.12"]["with"]["python-version"] == "3.12" + install = steps["Install focused Python test dependency"]["run"] + assert '"phlo-windows-harness-venv"' in install + assert "uv venv --python 3.12 $venv" in install + assert "uv pip install --python $python pytest" in install + harness = steps["Run focused Python harness tests"] + assert harness["shell"] == "pwsh" + assert harness["run"] == ( + "& $env:HARNESS_PYTHON -m pytest tests/scripts/test_release_golden_path.py " + "--tb=short --junitxml=test-results/windows.xml" + ) + launcher = steps["Run PowerShell launcher contract tests"] + assert launcher["shell"] == "pwsh" + assert launcher["run"] == "./tests/scripts/test_release_golden_path.ps1" + names = list(steps) + assert ( + names.index("Set up Python 3.12") + < names.index("Install focused Python test dependency") + < names.index("Run focused Python harness tests") + < names.index("Run PowerShell launcher contract tests") + ) + + @pytest.mark.parametrize( ("overrides", "exit_code", "message"), [ diff --git a/tests/tooling/test_postgres_concurrency_workflow.py b/tests/tooling/test_postgres_concurrency_workflow.py index 5d6d275b913..fa1cb10ef45 100644 --- a/tests/tooling/test_postgres_concurrency_workflow.py +++ b/tests/tooling/test_postgres_concurrency_workflow.py @@ -2,10 +2,9 @@ Parses the checked-in workflows instead of mirroring their text: CI must run the core regression lane through the ``core_regression`` marker filter, keep -``postgres-concurrency-gates`` as a separately attributable required gate -whose pytest invocation exercises the documented Postgres lock-contention -guard suites under an exact pass-count guard, and keep the nightly cron -trigger untouched. +the PostgreSQL contract in the required integration caller, and exercise the +documented Postgres lock-contention guard suites under an exact pass-count +guard, and keep the nightly cron trigger untouched. """ from __future__ import annotations @@ -19,11 +18,13 @@ REPO_ROOT = Path(__file__).resolve().parents[2] WORKFLOW_ROOT = REPO_ROOT / ".github" / "workflows" CI_WORKFLOW = "ci.yml" +PR_WORKFLOW = "pr.yml" +INTEGRATION_WORKFLOW = "integration.yml" NIGHTLY_WORKFLOW = "nightly.yml" CORE_REGRESSION_TARGET = "test-core-regression" CORE_REGRESSION_MARKER = "core_regression" -GATE_JOB = "postgres-concurrency-gates" +GATE_JOB = "integration" GATE_STEP = "Run PostgreSQL concurrency gates" GATE_DSN_ENV_VARS = ( "PHLO_SERVICE_TOKEN_TEST_POSTGRES_DSN", @@ -92,28 +93,32 @@ def test_core_regression_lane_selects_tests_through_the_marker_filter() -> None: def test_postgres_gate_is_required_and_runs_the_lock_guard_suites() -> None: - ci = _parse_yaml(WORKFLOW_ROOT / CI_WORKFLOW) - jobs = ci["jobs"] - assert GATE_JOB in jobs, "postgres-concurrency-gates job must exist" - gate = jobs[GATE_JOB] + pr = _parse_yaml(WORKFLOW_ROOT / PR_WORKFLOW) + caller = pr["jobs"][GATE_JOB] + assert caller.get("uses") == f"./.github/workflows/{INTEGRATION_WORKFLOW}" + selection = caller.get("if") or "" + assert "needs.changes.outputs.integration == 'true'" in selection + assert "needs.changes.outputs.python == 'true'" in selection + assert "||" in selection, "Python-only changes must also run the integration contracts" + + integration = _parse_yaml(WORKFLOW_ROOT / INTEGRATION_WORKFLOW) + gate = integration["jobs"][GATE_JOB] postgres_service = (gate.get("services") or {}).get("postgres") or {} assert postgres_service.get("image") == "postgres:16-alpine" for dsn_name in GATE_DSN_ENV_VARS: assert dsn_name in (gate.get("env") or {}) - status = jobs["ci-status"] + status = pr["jobs"]["required"] assert GATE_JOB in (status.get("needs") or []) status_steps = [ step for step in _steps(status) - if isinstance(step.get("env"), dict) and "POSTGRES_CONCURRENCY_GATES" in step["env"] + if isinstance(step.get("env"), dict) and "INTEGRATION" in step["env"] ] - assert len(status_steps) == 1, "ci-status must map the gate result exactly once" - assert status_steps[0]["env"]["POSTGRES_CONCURRENCY_GATES"] == ( - f"${{{{ needs.{GATE_JOB}.result }}}}" - ) - assert '"${POSTGRES_CONCURRENCY_GATES}"' in (status_steps[0].get("run") or "") + assert len(status_steps) == 1, "pr / required must map the integration result exactly once" + assert status_steps[0]["env"]["INTEGRATION"] == f"${{{{ needs.{GATE_JOB}.result }}}}" + assert 'test "$INTEGRATION" = success' in (status_steps[0].get("run") or "") gate_script = _step_named(gate, GATE_STEP).get("run") or "" assert "uv run --locked pytest" in gate_script @@ -129,6 +134,42 @@ def test_postgres_gate_is_required_and_runs_the_lock_guard_suites() -> None: assert re.search(r"skipped\s*!=\s*0\b", gate_script), "gate must reject skipped guards" +def test_integration_contracts_share_setup_and_preserve_failure_results() -> None: + integration = _parse_yaml(WORKFLOW_ROOT / INTEGRATION_WORKFLOW) + gate = integration["jobs"][GATE_JOB] + steps = _steps(gate) + assert sum("actions/checkout@" in step.get("uses", "") for step in steps) == 1 + assert _step_named(gate, "Sync complete workspace")["run"] == ( + "uv sync --all-packages --dev --locked" + ) + assert gate["env"]["UV_PYTHON"] == "3.12" + for name in ( + "Run named behavioral contracts", + "Preflight PostgreSQL gate dependencies", + GATE_STEP, + "Run quickstart smoke test", + "Run owned backup and restore drill", + ): + step = _step_named(gate, name) + assert "!cancelled()" in step.get("if", "") + assert "outcome == 'success'" in step["if"] + assert not step.get("continue-on-error", False) + artifacts = { + step["with"]["name"]: step["with"] + for step in steps + if "actions/upload-artifact@" in step.get("uses", "") + } + assert artifacts["integration-results"]["path"] == "test-results/integration/" + assert artifacts["postgres-concurrency-results"]["path"] == ( + "test-results/postgres-concurrency.xml" + ) + assert artifacts["quickstart-smoke"]["include-hidden-files"] is True + assert artifacts["quickstart-smoke"]["path"] == "quickstart-artifacts/" + assert artifacts["recovery-continuity-drill"]["path"] == ( + "recovery-artifacts/recovery-drill.json" + ) + + def test_nightly_keeps_its_scheduled_cron_trigger() -> None: nightly = _parse_yaml(WORKFLOW_ROOT / NIGHTLY_WORKFLOW) triggers = nightly.get(True) or nightly.get("on") or {} diff --git a/tests/tooling/test_pr_validation.py b/tests/tooling/test_pr_validation.py index eb64d0e7332..c64de2fa39e 100644 --- a/tests/tooling/test_pr_validation.py +++ b/tests/tooling/test_pr_validation.py @@ -112,7 +112,6 @@ def test_one_pr_orchestrator_and_no_candidate_duplication() -> None: "containers", "security", "docs", - "plugin", "mutation", ] for name in ( @@ -138,19 +137,18 @@ def test_required_gate_executes_fail_closed(bad_result) -> None: "SECURITY", "INTEGRATION", "DOCS", - "PLUGIN", "MUTATION", ): env = dict(os.environ, **dict.fromkeys(step["env"], "success")) env["INTEGRATION_SELECTED"] = "true" + env["PYTHON_SELECTED"] = "false" env["DOCS_SELECTED"] = "true" - env["PLUGIN_SELECTED"] = "true" env["MUTATION_SELECTED"] = "true" env[lane] = bad_result result = subprocess.run(["bash", "-c", step["run"]], env=env, capture_output=True) assert (result.returncode == 0) == (bad_result == "success") env["MUTATION"] = "success" - for lane in ("INTEGRATION", "DOCS", "PLUGIN", "MUTATION"): + for lane in ("INTEGRATION", "DOCS", "MUTATION"): env[f"{lane}_SELECTED"] = "false" env[lane] = "skipped" assert ( @@ -241,6 +239,99 @@ def test_docs_reusable_without_duplicate_pr_trigger_or_queue_deployment() -> Non assert build["permissions"] == {"contents": "read"} +@pytest.mark.parametrize("selected", ["frontend", "writer", "plugin", "none"]) +@pytest.mark.parametrize("result", ["success", "failure", "skipped", "cancelled"]) +def test_combined_node_gate_requires_any_selected_consumer(tmp_path, selected, result): + step = workflow("ci.yml")["jobs"]["ci-status"]["steps"][0] + env = dict(os.environ, **dict.fromkeys(step["env"], "skipped")) + env.update( + CI_CONFIG="success", + PYTHON_QUALITY="success", + SELECT_PYTHON="false", + SELECT_GROUPS="[]", + SELECT_FRONTEND=str(selected == "frontend").lower(), + SELECT_WRITER=str(selected == "writer").lower(), + SELECT_PLUGIN=str(selected == "plugin").lower(), + FRONTEND=result, + GITHUB_STEP_SUMMARY=str(tmp_path / "summary"), + ) + actual = subprocess.run(["bash", "-c", step["run"]], env=env, capture_output=True) + assert (actual.returncode == 0) == (result == ("skipped" if selected == "none" else "success")) + + +def test_consolidated_jobs_preserve_named_checks_and_single_setup(): + jobs = workflow("ci.yml")["jobs"] + quality = jobs["python-quality"] + node = jobs["frontend"] + for job in (quality, node): + assert sum("actions/checkout@" in step.get("uses", "") for step in job["steps"]) == 1 + assert not any(step.get("continue-on-error") for step in job["steps"]) + assert {"frontend", "writer", "plugin"} <= { + key for key in ("frontend", "writer", "plugin") if f"outputs.{key}" in node["if"] + } + names = {step.get("name") for step in node["steps"]} + assert {"Run tests", "Run writer tests", "Run plugin behavior tests", "Build writer"} <= names + quality_names = {step.get("name") for step in quality["steps"]} + assert { + "Check private-key hook with a non-allow-listed fixture", + "Run remaining file hooks", + "Audit workflow hardening", + "Lint with ruff", + "Type check with ty", + } <= quality_names + assert "pre-commit" not in jobs and "phlo-github-writer" not in jobs + assert "plugin" not in workflow("pr.yml")["jobs"] + + +def test_required_result_collects_coverage_after_service_and_python_contracts(): + required = workflow("pr.yml")["jobs"]["required"] + assert {"ci", "integration"} <= set(required["needs"]) + steps = required["steps"] + downloads = [step["with"] for step in steps if "download-artifact@" in step.get("uses", "")] + assert {item.get("pattern") for item in downloads} >= {"core-tests-*", "package-results-*"} + assert any(item.get("name") == "quickstart-smoke" for item in downloads) + combine = next(step for step in steps if "coverage combine" in step.get("run", "")) + assert combine["if"] == "needs.changes.outputs.python == 'true'" + emit = next(step for step in steps if "ci_evidence.py emit" in step.get("run", "")) + assert steps.index(combine) < steps.index(emit) + assert "coverage" not in workflow("ci.yml")["jobs"] + + +@pytest.mark.parametrize("first_fails", [False, True]) +def test_container_lint_attempts_all_selected_files_and_preserves_failure(tmp_path, first_fails): + job = workflow("container-security.yml")["jobs"]["checks"] + step = next( + step for step in job["steps"] if step.get("name") == "Lint generated-service Dockerfiles" + ) + binary = tmp_path / "bin" + binary.mkdir() + executable = binary / "docker" + executable.write_text( + '#!/bin/sh\nbody="$(cat)"\nprintf "%s\\n" "$body" >> "$RUNNER_TEMP/calls"\n' + 'if [ "$FIRST_FAILS" = true ] && [ "$body" = first ]; then exit 1; fi\n' + ) + executable.chmod(0o755) + for name in ("first", "second"): + (tmp_path / name).mkdir() + (tmp_path / name / "Dockerfile").write_text(name) + env = dict( + os.environ, + PATH=f"{binary}:{os.environ['PATH']}", + RUNNER_TEMP=str(tmp_path), + FIRST_FAILS=str(first_fails).lower(), + TARGETS=json.dumps( + { + "include": [ + {"package": name, "dockerfile": "Dockerfile"} for name in ("first", "second") + ] + } + ), + ) + actual = subprocess.run(["bash", "-c", step["run"]], cwd=tmp_path, env=env, capture_output=True) + assert actual.returncode == int(first_fails) + assert (tmp_path / "calls").read_text().splitlines() == ["first", "second"] + + def test_shards_are_disjoint_complete_and_keep_module_fixtures_together() -> None: nodes = {f"tests/test_{i}.py::test_case[{j}]" for i in range(100) for j in range(3)} shards = [{node for node in nodes if shard_for(node, 3) == index} for index in range(3)] diff --git a/tests/tooling/test_release_candidate_gate.py b/tests/tooling/test_release_candidate_gate.py index 6e9acc2581e..80ed1973f97 100644 --- a/tests/tooling/test_release_candidate_gate.py +++ b/tests/tooling/test_release_candidate_gate.py @@ -76,9 +76,7 @@ def test_reusable_evidence_workflows_do_not_cancel_one_another(): def test_ci_status_includes_every_installed_provider_artifact_shard(): ci = workflow("ci.yml")["jobs"] assert ci["installed-provider-artifacts"]["strategy"]["matrix"]["docker-shard"] == [0, 1, 2, 3] - assert {"installed-provider-artifacts", "coverage", "windows-portability"} <= set( - ci["ci-status"]["needs"] - ) + assert {"installed-provider-artifacts", "windows-portability"} <= set(ci["ci-status"]["needs"]) assert ( ci["ci-status"]["steps"][0]["env"]["INSTALLED_PROVIDER_ARTIFACTS"] == "${{ needs.installed-provider-artifacts.result }}" diff --git a/tests/tooling/test_support_manifest.py b/tests/tooling/test_support_manifest.py index ff5bb7e8ea2..0ea74f2a7af 100644 --- a/tests/tooling/test_support_manifest.py +++ b/tests/tooling/test_support_manifest.py @@ -587,10 +587,10 @@ def test_upgrade_restore_evidence_binds_to_recovery_drill_and_ci() -> None: assert "scripts/recovery_drill.py" in evidence assert "tests/scripts/test_recovery_drill.py" in evidence - assert ".github/workflows/ci.yml" in evidence + assert ".github/workflows/integration.yml" in evidence for path in evidence: assert (ROOT / path.split("#", 1)[0]).exists(), f"missing evidence: {path}" - ci_text = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") + ci_text = (ROOT / ".github/workflows/integration.yml").read_text(encoding="utf-8") assert "scripts/recovery_drill.py" in ci_text assert "documentation procedures" not in capability["reason"] @@ -603,11 +603,13 @@ def test_golden_path_ci_evidence_binds_to_release_golden_path_and_ci() -> None: assert "scripts/release_golden_path.py" in evidence assert "tests/scripts/test_release_golden_path.py" in evidence - assert ".github/workflows/ci.yml" in evidence + assert ".github/workflows/windows-compose-portability.yml" in evidence assert "scripts/run_golden_path.py" not in evidence for path in evidence: assert (ROOT / path.split("#", 1)[0]).exists(), f"missing evidence: {path}" - ci_text = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") + ci_text = (ROOT / ".github/workflows/windows-compose-portability.yml").read_text( + encoding="utf-8" + ) assert "test_release_golden_path" in ci_text assert "does not invoke" not in capability["reason"] @@ -668,8 +670,8 @@ def test_named_claim_validator_catches_workflow_command_regression(tmp_path: Pat capabilities = manifest["capabilities"] workflows_dir = tmp_path / ".github" / "workflows" workflows_dir.mkdir(parents=True) - ci_text = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8") - (workflows_dir / "ci.yml").write_text( + ci_text = (ROOT / ".github" / "workflows" / "integration.yml").read_text(encoding="utf-8") + (workflows_dir / "integration.yml").write_text( ci_text.replace("scripts/recovery_drill.py", "scripts/removed.py"), encoding="utf-8", )