From bfafcf740f12ba9573f0be567363ae0147dce109 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Thu, 10 Sep 2026 14:35:46 -0500 Subject: [PATCH 1/2] ci: streamline PR checks and schedule full CDA coverage --- .github/actions/setup-python/action.yml | 29 ++++++ .github/scripts/ci_policy.py | 113 +++++++++++++++++++++ .github/scripts/test_ci_policy.py | 109 +++++++++++++++++++++ .github/workflows/CDA-testing.yml | 107 ++++---------------- .github/workflows/code-check.yml | 4 +- .github/workflows/codeql.yml | 11 ++- .github/workflows/pypi-deploy.yml | 2 +- .github/workflows/testing.yml | 124 ++++++++++++++---------- .gitignore | 1 + CONTRIBUTING.md | 54 ++++++++++- 10 files changed, 409 insertions(+), 145 deletions(-) create mode 100644 .github/actions/setup-python/action.yml create mode 100644 .github/scripts/ci_policy.py create mode 100644 .github/scripts/test_ci_policy.py diff --git a/.github/actions/setup-python/action.yml b/.github/actions/setup-python/action.yml new file mode 100644 index 00000000..171b8e46 --- /dev/null +++ b/.github/actions/setup-python/action.yml @@ -0,0 +1,29 @@ +name: Set up project Python +description: Install locked project dependencies with a versioned virtualenv cache +inputs: + python-version: + required: true + description: Python version to test +runs: + using: composite + steps: + - uses: actions/setup-python@v6 + id: python + with: + python-version: ${{ inputs.python-version }} + - uses: abatilo/actions-poetry@v4 + with: + poetry-version: '2.1.4' + - name: Configure virtual environment + shell: bash + run: poetry config virtualenvs.in-project true + - uses: actions/cache@v6 + with: + path: .venv + key: ${{ runner.os }}-${{ runner.arch }}-py${{ steps.python.outputs.python-version + }}-poetry2.1.4-ci-v1-${{ hashFiles('poetry.lock', 'pyproject.toml') }} + - name: Install locked dependencies + shell: bash + env: + POETRY_INSTALLER_ONLY_BINARY: ':all:' + run: poetry install --no-interaction diff --git a/.github/scripts/ci_policy.py b/.github/scripts/ci_policy.py new file mode 100644 index 00000000..a27c0351 --- /dev/null +++ b/.github/scripts/ci_policy.py @@ -0,0 +1,113 @@ +"""Select CI coverage and enforce its final result (Python 3.9+).""" + +import json +import os +import subprocess +import sys +from pathlib import Path, PurePosixPath + +PYTHONS = ["3.9", "3.x"] +CDA = { + "production": "ghcr.io/usace/cwms-data-api:2026.05.12-i", + "test": "ghcr.io/usace/cwms-data-api:2026.08.31-testd", + "latest": "ghcr.io/usace/cwms-data-api:develop-nightly", +} +SCHEMAS = {"production": "26.02.17", "test": "26.07.16-RC02", "latest": "latest-dev"} + + +def documentation(path): + p = PurePosixPath(path) + return (len(p.parts) == 1 and p.suffix.lower() == ".md") or ( + p.parts[0] in {"docs", "rtd_docs"} + and p.suffix.lower() + in {".md", ".rst", ".txt", ".png", ".jpg", ".jpeg", ".svg", ".gif"} + ) + + +def plan(event_name, event, paths=None): + if event_name == "push": + mode = "main" + elif event_name == "schedule": + mode = "full" + elif event_name == "workflow_dispatch": + mode = event.get("inputs", {}).get("coverage", "full") + if mode not in {"full", "representative"}: + raise ValueError("Unknown coverage mode") + elif event_name == "pull_request": + if event["pull_request"]["draft"]: + mode = "draft" + elif paths and all(documentation(p) for p in paths): + mode = "documentation" + else: + mode = "representative" + else: + raise ValueError("Unknown event") + rows = [ + {"python": python, "cda": cda, "image": image, "schema": schema, "tag": tag} + for python in PYTHONS + for cda, image in CDA.items() + for schema, tag in SCHEMAS.items() + if mode == "full" or (mode == "representative" and cda == schema) + ] + return { + "mode": mode, + "integration": str(bool(rows)).lower(), + "matrix": {"include": rows}, + } + + +def gate(needs): + for name in ("plan", "format", "unit"): + if needs[name]["result"] != "success": + raise ValueError(f"{name} did not pass") + mode = needs["plan"]["outputs"]["mode"] + result = needs["integration"]["result"] + if mode in {"full", "representative"}: + if result != "success": + raise ValueError("Required integration tests did not pass") + elif mode in {"main", "draft", "documentation"}: + if result != "skipped": + raise ValueError("Unexpected integration result") + else: + raise ValueError("Missing or unknown CI mode") + + +def main(): + if sys.argv[1] == "gate": + gate(json.loads(os.environ["CI_NEEDS"])) + print("All applicable CI checks passed") + return + event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text()) + event_name = os.environ["GITHUB_EVENT_NAME"] + paths = None + if event_name == "pull_request": + pr = event["pull_request"] + try: + diff = subprocess.check_output( + [ + "git", + "diff", + "--name-only", + "--no-renames", + "-z", + pr["base"]["sha"] + "..." + pr["head"]["sha"], + "--", + ] + ) + paths = [p for p in diff.decode("utf-8").split("\0") if p] + except (subprocess.CalledProcessError, UnicodeDecodeError): + print("Could not classify changed files; requiring integration tests") + result = plan(event_name, event, paths) + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + for key, value in result.items(): + output.write( + f"{key}={json.dumps(value) if isinstance(value, dict) else value}\n" + ) + with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as summary: + summary.write( + f"CI coverage: **{result['mode']}**; CDA jobs: {len(result['matrix']['include'])}.\n" + ) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/test_ci_policy.py b/.github/scripts/test_ci_policy.py new file mode 100644 index 00000000..c2968053 --- /dev/null +++ b/.github/scripts/test_ci_policy.py @@ -0,0 +1,109 @@ +"""Regression tests for CI selection and the required merge gate.""" + +import copy +import unittest + +from ci_policy import gate, plan + + +class PolicyTests(unittest.TestCase): + def pr(self, paths=None, draft=False): + return plan("pull_request", {"pull_request": {"draft": draft}}, paths) + + def test_ready_pr_pairs_and_python_versions(self): + rows = self.pr(["cwms/api.py"])["matrix"]["include"] + self.assertEqual(len(rows), 6) + self.assertEqual({r["python"] for r in rows}, {"3.9", "3.x"}) + self.assertTrue(all(r["cda"] == r["schema"] for r in rows)) + + def test_schedule_and_manual_full_cross_product(self): + for event, payload in [ + ("schedule", {}), + ("workflow_dispatch", {}), + ("workflow_dispatch", {"inputs": {"coverage": "full"}}), + ]: + with self.subTest(event=event, payload=payload): + rows = plan(event, payload)["matrix"]["include"] + self.assertEqual(len(rows), 18) + self.assertEqual( + len({(r["python"], r["cda"], r["schema"]) for r in rows}), 18 + ) + + def test_manual_representative(self): + result = plan("workflow_dispatch", {"inputs": {"coverage": "representative"}}) + self.assertEqual(len(result["matrix"]["include"]), 6) + + def test_draft_to_ready(self): + self.assertEqual(self.pr(["cwms/api.py"], draft=True)["mode"], "draft") + self.assertEqual(self.pr(["cwms/api.py"])["mode"], "representative") + + def test_documentation_only(self): + result = self.pr( + ["README.md", "CONTRIBUTING.md", "docs/guide.rst", "docs/images/plot.png"] + ) + self.assertEqual(result["mode"], "documentation") + self.assertEqual(result["integration"], "false") + + def test_code_config_unknown_empty_and_uncertain_require_cda(self): + for path in [ + "docs/conf.py", + "pyproject.toml", + "poetry.lock", + "docker-compose.yml", + ".github/workflows/testing.yml", + "tests/resources/fixture.txt", + "unknown", + ]: + with self.subTest(path=path): + self.assertEqual(self.pr(["README.md", path])["mode"], "representative") + for paths in (None, []): + self.assertEqual(self.pr(paths)["mode"], "representative") + + def test_main_skips_database(self): + result = plan("push", {}) + self.assertEqual(result["mode"], "main") + self.assertEqual(result["integration"], "false") + + def test_invalid_inputs_fail_closed(self): + with self.assertRaises(ValueError): + plan("workflow_dispatch", {"inputs": {"coverage": "none"}}) + with self.assertRaises(ValueError): + plan("unknown", {}) + + def test_gate_matrix_failures_and_skips(self): + needs = { + name: {"result": "success"} + for name in ("plan", "format", "unit", "integration") + } + needs["plan"]["outputs"] = {"mode": "representative"} + gate(needs) + for name in needs: + for result in ("failure", "cancelled", "skipped"): + with self.subTest(job=name, result=result): + changed = copy.deepcopy(needs) + changed[name]["result"] = result + with self.assertRaises(ValueError): + gate(changed) + + def test_gate_only_accepts_explicit_skip_modes(self): + for mode in ( + "draft", + "documentation", + "main", + "full", + "representative", + "", + "unknown", + ): + needs = {name: {"result": "success"} for name in ("plan", "format", "unit")} + needs["plan"]["outputs"] = {"mode": mode} + needs["integration"] = {"result": "skipped"} + if mode in {"draft", "documentation", "main"}: + gate(needs) + else: + with self.assertRaises(ValueError): + gate(needs) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/CDA-testing.yml b/.github/workflows/CDA-testing.yml index 2f41a7f2..de96e508 100644 --- a/.github/workflows/CDA-testing.yml +++ b/.github/workflows/CDA-testing.yml @@ -1,113 +1,48 @@ -name: CI +name: CDA integration on: - push: - branches: [main] - pull_request: - branches: [main] - workflow_dispatch: + workflow_call: + inputs: + matrix: + description: Explicit Python/CDA/schema combinations selected by CI + required: true + type: string jobs: integration-tests: - name: integration-tests (Python ${{ matrix.python-version }}, CDA ${{ matrix.cda.name }}, - schema ${{ matrix.schema.name }}) + name: integration-tests (Python ${{ matrix.python }}, CDA ${{ matrix.cda }}, schema + ${{ matrix.schema }}) runs-on: ubuntu-latest timeout-minutes: 60 - strategy: fail-fast: false max-parallel: 6 - matrix: - python-version: ['3.9', '3.13'] - # Keep the release pins in sync with the environments (see CONTRIBUTING.md). - cda: - - name: latest - image: ghcr.io/usace/cwms-data-api:develop-nightly - - name: production - image: ghcr.io/usace/cwms-data-api:2026.05.12-i - - name: test - image: ghcr.io/usace/cwms-data-api:2026.08.31-testd - schema: - - name: latest - tag: latest-dev - - name: production - tag: '26.02.17' - - name: test - tag: 26.07.16-RC02 - + matrix: ${{ fromJSON(inputs.matrix) }} env: - CWMS_DATA_API_IMAGE: ${{ matrix.cda.image }} - CWMS_DATABASE_IMAGE: ghcr.io/hydrologicengineeringcenter/cwms-database/cwms/database-ready-ora-23.5:${{ matrix.schema.tag }} - CWMS_SCHEMA_INSTALLER_IMAGE: ghcr.io/hydrologicengineeringcenter/cwms-database/cwms/schema_installer:${{ matrix.schema.tag }} - + CWMS_DATA_API_IMAGE: ${{ matrix.image }} + CWMS_DATABASE_IMAGE: ghcr.io/hydrologicengineeringcenter/cwms-database/cwms/database-ready-ora-23.5:${{ matrix.tag }} + CWMS_SCHEMA_INSTALLER_IMAGE: ghcr.io/hydrologicengineeringcenter/cwms-database/cwms/schema_installer:${{ matrix.tag }} steps: - uses: actions/checkout@v7 - - - name: Clean up disk space, so we don't run out. - if: runner.os == 'Linux' + - uses: ./.github/actions/setup-python + with: + python-version: ${{ matrix.python }} + - name: Clean up disk space run: | sudo rm -rf /usr/share/dotnet sudo rm -rf /usr/local/lib/android sudo rm -rf /opt/ghc - - name: Set up backend run: | docker compose pull docker compose up -d --wait --wait-timeout 2400 - - - name: Set Up Python - id: setup-python - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python-version }} - - # Use actions-poetry to handle installation - - name: Install Poetry and Dependencies - uses: abatilo/actions-poetry@v4 - - # Set Poetry to use an in-project virtual environment - - name: Configure Poetry for in-project venv - run: poetry config virtualenvs.in-project true - - # Poetry will handle installation and caching - - name: Cache Python dependencies - uses: actions/cache@v6 - id: cache-poetry-venv - with: - path: .venv - key: ${{ runner.os }}-py${{ steps.setup-python.outputs.python-version }}-poetry-${{ - hashFiles('poetry.lock') }} - - # Install dependencies only if cache is missed - - name: Install dependencies - if: steps.cache-poetry-venv.outputs.cache-hit != 'true' - run: poetry install --no-root - - # Run pytest and generate coverage report data. - - name: Run Tests and Check Types - run: | - poetry run pytest tests/cda/ --doctest-modules --cov --cov-report=xml:out/coverage.xml - poetry run mypy --strict cwms/ - - - name: Generate Coverage Report - uses: irongut/CodeCoverageSummary@v1.3.0 - with: - filename: out/coverage.xml - format: markdown - output: both - badge: true - - - name: Generate Job Summary - uses: x-color/github-actions-job-summary@v0.1.1 - with: - file: ./code-coverage-results.md - vars: |- - empty: empty - + - name: Run integration tests + run: poetry run pytest tests/cda/ --doctest-modules --cov --cov-report=xml:out/coverage.xml + - name: Summarize coverage + run: poetry run coverage report --format=markdown >> "$GITHUB_STEP_SUMMARY" - name: Show backend logs on failure if: failure() run: docker compose logs --no-color --tail 200 - - name: Stop test backend if: always() run: docker compose down --volumes diff --git a/.github/workflows/code-check.yml b/.github/workflows/code-check.yml index 306f94f4..26b02f26 100644 --- a/.github/workflows/code-check.yml +++ b/.github/workflows/code-check.yml @@ -1,7 +1,7 @@ name: Code Check -# Run the workflow on all branches. -on: [push, pull_request] +on: + workflow_call: jobs: # Run basic code quality checks. diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 33b3e58c..6463ec66 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -4,10 +4,19 @@ on: push: branches: [main] pull_request: - branches: [main] + types: [opened, synchronize, reopened] schedule: - cron: '0 0 * * 0' +permissions: + contents: read + security-events: write + +concurrency: + group: codeql-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref + }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: analyze: name: Analyze diff --git a/.github/workflows/pypi-deploy.yml b/.github/workflows/pypi-deploy.yml index 7a7c6ea1..7539afea 100644 --- a/.github/workflows/pypi-deploy.yml +++ b/.github/workflows/pypi-deploy.yml @@ -47,7 +47,7 @@ jobs: - name: Set Up Python uses: actions/setup-python@v7 with: - python-version: '3.13' + python-version: '3.x' - name: Install Poetry uses: abatilo/actions-poetry@v4 - name: Install Dependencies diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 2bcaafff..40bea282 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -1,64 +1,88 @@ -name: Testing +name: CI -# Run the workflow on all branches. -on: [push, pull_request] +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + - converted_to_draft + push: + branches: [main] + schedule: + - cron: '17 8 * * *' + workflow_dispatch: + inputs: + coverage: + description: CDA integration coverage + type: choice + options: [full, representative] + default: full + +permissions: + contents: read + +concurrency: + group: ci-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref + }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - # Run tests and generate code coverage report. - run-tests: - name: Unit tests (Python ${{ matrix.python-version }}) + plan: runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ['3.9', '3.13', '3.14'] - + outputs: + mode: ${{ steps.plan.outputs.mode }} + integration: ${{ steps.plan.outputs.integration }} + matrix: ${{ steps.plan.outputs.matrix }} steps: - uses: actions/checkout@v7 - - - name: Set Up Python - id: setup-python - uses: actions/setup-python@v6 with: - python-version: ${{ matrix.python-version }} + fetch-depth: 0 + - name: Select coverage + id: plan + run: python3 .github/scripts/ci_policy.py plan - # Unlike the code-check workflow, this job requires the dev dependencies to be - # installed to make sure we have the necessary, tools, stub files, etc. - - name: Install Poetry - uses: abatilo/actions-poetry@v4 + format: + uses: ./.github/workflows/code-check.yml - - name: Cache Virtual Environment - uses: actions/cache@v6 + unit: + name: Unit tests (Python ${{ matrix.python }}) + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + python: ['3.9', '3.x'] + steps: + - uses: actions/checkout@v7 + - uses: ./.github/actions/setup-python with: - path: ./.venv - key: ${{ runner.os }}-py${{ steps.setup-python.outputs.python-version }}-venv-${{ - hashFiles('poetry.lock') }} - - - name: Install Dependencies - env: - POETRY_INSTALLER_ONLY_BINARY: ':all:' - run: poetry install - - # Run pytest and generate coverage report data. - - name: Run Tests + python-version: ${{ matrix.python }} + - name: Run tests run: poetry run pytest tests/mock/ --doctest-modules --cov --cov-report=xml:out/coverage.xml - - # Run mypy with strict mode enabled. Only the main source code is type checked (test - # and example code is excluded). - - name: Check Types + - name: Test CI policy + run: poetry run python -m unittest discover -s .github/scripts -p 'test_*.py' + - name: Check types run: poetry run mypy --strict cwms/ + - name: Summarize coverage + run: poetry run coverage report --format=markdown >> "$GITHUB_STEP_SUMMARY" - - name: Generate Coverage Report - uses: irongut/CodeCoverageSummary@v1.3.0 - with: - filename: out/coverage.xml - format: markdown - output: both - badge: true + integration: + needs: [plan] + if: needs.plan.outputs.integration == 'true' + uses: ./.github/workflows/CDA-testing.yml + with: + matrix: ${{ needs.plan.outputs.matrix }} - - name: Generate Job Summary - uses: x-color/github-actions-job-summary@v0.1.1 - with: - file: ./code-coverage-results.md - vars: |- - empty: empty + required: + name: CI required + needs: [plan, format, unit, integration] + if: always() + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Require successful applicable checks + env: + CI_NEEDS: ${{ toJSON(needs) }} + run: python3 .github/scripts/ci_policy.py gate diff --git a/.gitignore b/.gitignore index 8bc2be71..eb56d3e7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ tmp **/\.~lock* scripts +!/.github/scripts/ # Byte-compiled / optimized / DLL files **/__pycache__/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c1029d2..080f0cc5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -190,6 +190,50 @@ subject before merging; Release Please reads commits on `main`, not PR titles directly. With other merge methods, preserve Conventional Commit messages in the merged commits. +### CI coverage + +CI tests Python **3.9** and **latest stable** (`3.x`). Latest stable intentionally +tracks new Python releases so dependency compatibility problems surface in CI. + +| Event | Validation | +| --- | --- | +| Feature branch push without a PR | No automatic tests; use manual CI dispatch | +| Draft PR | Formatting, unit tests, type checks, CI policy tests, CodeQL | +| Ready code PR (including stacked PRs) | Lightweight checks plus 6 CDA jobs | +| Documentation-only PR | Lightweight checks; CDA explicitly skipped | +| Push/merge to main | Lightweight checks, CodeQL, Release Please; no CDA jobs | +| Nightly at 08:17 UTC | Lightweight checks plus all 18 CDA combinations | +| Release created | Test exact tag on latest stable, verify version, build and publish | + +PRs test the proposed merge commit. The six representative CDA jobs pair each +Python version with production/production, test/test, and latest/latest CDA/schema +versions. The nightly matrix tests the full cross-product; mismatched-environment +failures can therefore surface after merge. Image pins live in +`.github/scripts/ci_policy.py`. Keep them synchronized with the environment pins. + +Only root Markdown files and static documentation under `docs/` or `rtd_docs/` +qualify for the documentation skip. Python/configuration changes, dependencies, +workflows, fixtures, unknown paths, and uncertain diffs require integration tests. +Marking a draft ready starts integration testing. New PR updates cancel superseded +runs; feature-branch pushes do not start a duplicate run. + +To run the full matrix on a branch, open **Actions > CI > Run workflow**, select +the branch and `coverage: full` (the default). Choose `representative` for six jobs. +Equivalent CLI: `gh workflow run testing.yml --ref -f coverage=full`. +For nightly failures, inspect the failing Python/CDA/schema combination and its +backend logs, reproduce with the documented Compose overrides, and rerun after +fixing the cause. Do not silently remove a failing combination. + +The stable **CI required** check fails if any applicable formatting, unit/type, +or integration job fails or is cancelled. It permits only the documented CDA +skips. CodeQL and any existing external checks remain separate protections. +An administrator must migrate required-check settings after the new checks pass: +add `CI required` and retain CodeQL/external requirements, then remove obsolete +individual CI names. Validate both representative and manually dispatched full +matrices before retiring the old requirements. Keep the review rule separate so +a review override cannot bypass required checks. Repository rules are not changed +by these workflow files. + ### Release flow 1. Merge reviewed changes into `main`. @@ -224,11 +268,11 @@ unreleased checkout locally, run `poetry install` or install a locally built whe Keep any environment approvals required by the repository. If the environment restricts deployment branches, allow the default branch: the workflow runs there and explicitly checks out the release tag for the build. -- The workflow uses `GITHUB_TOKEN`. GitHub does not start ordinary PR workflows - for PRs created by that token. If a release PR is missing checks, a maintainer - can close and reopen it to trigger the PR workflows. Wait for required checks - before merging; do not bypass them. The title reminder also becomes active - only after its workflow is merged into the default branch. +- The workflow uses `GITHUB_TOKEN`. Bot-created release PRs can have workflow + runs waiting for approval. A maintainer must approve those runs in Actions; + workflow approval is separate from approving the PR. If checks are absent, + a maintainer can close and reopen the PR to trigger them. Wait for required + checks before merging; do not bypass them. - A failed publication can leave a GitHub release without a PyPI package or assets. Use **Re-run failed jobs** on the original run after fixing the cause; a fresh dispatch may find the release already created and skip publishing. From 580b7bd1ffb8a0cf88e670fcd8fbe43397250f39 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Thu, 10 Sep 2026 16:53:32 -0500 Subject: [PATCH 2/2] docs: remind reviewers to run CDA tests before approval --- CONTRIBUTING.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ff7d2752..7e130ddb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -201,8 +201,19 @@ same straightforward checks as other PRs. The **CDA integration** workflow runs the full 18-job matrix nightly at 08:17 UTC: two Python versions, three CDA versions, and three schema versions. It does not run automatically on PRs or merges. Integration failures can therefore surface -after merge. To check a branch before merging, use **Actions > CDA integration > -Run workflow** or `gh workflow run CDA-testing.yml --ref `. +after merge. + +**Before approving a PR that could affect CDA integration**, run the full matrix +against the PR's head branch and review the results. You can also run it anytime +you want to check a branch. From this repository, use: + +```sh +gh workflow run CDA-testing.yml --ref +``` + +Replace `` with the branch name on GitHub. Alternatively, use **Actions > +CDA integration > Run workflow** and select the branch. Starting the workflow +does not mean the tests passed; check the completed run in Actions before approving. Inspect failed combinations and backend logs, reproduce using the Compose overrides above, and fix the cause rather than dropping failing combinations.