Skip to content

Pull request CI scope

Lint's python-unit-tests job discovers tests/ on every pull request and every push to main. On a pull request, scripts/ci/pr_unit_test_selector.py may replace that discovery with an explicit unittest module list. The map is scripts/ci/pr-unit-test-map.yaml. Pushes to main do not call the selector.

Three other Lint jobs can fast-success on a pull request when none of their inputs changed. The job still finishes as success. It does not skip the job. ci-gate still requires success for every needed job, and a skipped job would fail that gate.

The dependency-update contract step still runs tests.test_dependency_update_contracts and tests.test_oneuptime_env_render on every Lint run, before the selector.

Decision

The selector reads the pull-request files API with pagination and compares the collected paths to the pull request's changed_files count. It prints full or one tests.* module per line. The job writes that decision to the GitHub step summary.

Full discovery (python -m unittest discover -s tests -v) is required when any of these are true:

  • The event is not pull_request, including every push to main.
  • The files API errors, the token is missing, or a page does not add up to changed_files.
  • Any changed path is unmapped, or the module list would be empty.
  • A changed path matches a force-full pattern: .github/**, root and nested pyproject.toml, uv.lock, requirements*.txt, package.json, package-lock.json, .pre-commit-config.yaml, inventory/generators/**, inventory/schema/**, AGENTS.md, .cursor/**, PLAN.md, or README.md.
  • The selector or its map is in the diff.
  • A path under tests/ was removed or renamed.
  • The selector crashes or prints something that is not full or a module list. The job says so in the step summary and still runs full discovery. A crash is not a successful narrow run.

When every path is mapped, the module list is the union of those rules. A rule marked service_backup_scanners also adds the three tests that read services/*/backup.yml.

flowchart TD
  start[python-unit-tests] --> contracts[contract modules always run]
  contracts --> kind{pull_request?}
  kind -->|no| discover[unittest discover -s tests]
  kind -->|yes| api[paginated pulls files API]
  api -->|error, short page, or crash| discover
  api --> mapped{every path mapped?}
  mapped -->|no| discover
  mapped -->|yes| narrow[union of mapped modules]

Mapped prefixes

A docs path that no other test reads uses only the docs allowlist. A docs path named in the map also runs the tests that read that file.

Changed path Modules beyond the docs allowlist
docs/**, mkdocs.yml, .markdownlint-cli2.yaml, tests/test_docs.py, tests/test_documentation_remediation.py none; tests.test_docs and tests.test_documentation_remediation
docs/runbooks/restore.md and the other WS2 render runbooks in the map tests.test_ws2_secret_procedures, tests.test_ws2_secret_rendering
Komodo webhook, LXC, socket, and project-map paths in the map the WS5 modules and tests.test_explicit_deployment_review227682da_contracts that read them
WS4, WS6, WS7, runner, and OneUptime dispatch docs named in the map the single contract module that reads that file
services/monitoring-canary/** tests.test_monitoring_canary plus tests.test_backup_freshness_incidents, tests.test_backup_snapshot_contract, and tests.test_restore_health_incidents
scripts/hotfix-syno-capacity.sh tests.test_synology_capacity_policy
scripts/render-alertmanager-config.py tests.test_alertmanager_render
scripts/render-prometheus-validation-config.py tests.test_prometheus_validation_render
scripts/network-scan.py tests.test_network_scan
scripts/check-docs.py tests.test_docs
scripts/check-documentation-remediation.py tests.test_documentation_remediation
scripts/lint-alert-schema.py tests.test_alert_schema and tests.test_ws4_monitoring_contracts

A pull request that changes two mapped prefixes runs the union. docs/** plus services/monitoring-canary/** is that union. docs/** plus an unmapped service such as services/litellm/** is full discovery.

Still full

These areas stay unmapped. A diff that includes one of them runs the whole suite:

  • Almost every services/<name>/ tree other than monitoring-canary. LiteLLM, Komodo, Paperless, Orders, Reddit Explorer, and the image-update cohorts are in this set. Their tests share compose files, backup manifests, Komodo resource checks, and runbook readers, and several of those reads are loops rather than one literal path.
  • infra/ansible/**, inventory YAML outside the force-full generator and schema trees, monitoring/**, backups/**, reviews/**, and scripts/** other than the scripts in the table.
  • Any new test module that is not itself a mapped test path. Adding, editing, renaming, or deleting it is full discovery unless the map names that file.

test_docs.py reads mkdocs.yml and two docs pages, and the docs checker it imports walks services, inventory, workflows, and generator entry points when the documentation job runs. That is why the policy files above stay full even for a docs-heavy pull request. The documentation job still runs scripts/check-docs.py and the MkDocs build on every Lint run. See Documentation validation.

What the unit-test selector does not change

ci-gate still requires every existing Lint job to finish as success. phoenix-api-routing stays outside the gate. Deploy workflows are unchanged. Runner labels and CI_RUNS_ON / DOCKER_RUNS_ON are unchanged. Security keeps its own gate and has no paths: filter. Lint has no paths: filter either: fast-success is a step inside the job, not a skipped workflow.

Fast-success jobs

On a pull request, scripts/ci/pr_job_scope.py reads the same paginated pulls files list as the unit-test selector. The workflow wrapper is scripts/ci/pr_job_scope_step.sh. Stdout must be the single token fast or run. The reason goes to stderr and the step summary. Any other stdout, a crash, a missing token, an API error, a short page, or a non-pull-request event runs the real job. A crash says in the step summary that it is not a fast success. Pushes to main do not call the selector and run the real work.

fast is allowed only when every changed path is outside that job's inputs. An unclassified path runs the real job. These paths always run all three jobs, because they are the selector or the workflow that honors it:

  • .github/workflows/lint.yml
  • scripts/ci/pr_job_scope.py
  • scripts/ci/pr_job_scope_step.sh
  • scripts/ci/pr_unit_test_selector.py
flowchart TD
  start[Lint job] --> kind{pull_request?}
  kind -->|no| real[today's checks]
  kind -->|yes| api[paginated pulls files API]
  api -->|error, short page, or crash| real
  api --> paths{every path outside this job?}
  paths -->|no| real
  paths -->|yes| fast[exit 0 and step summary]

komodo-resources-check

The real steps are unchanged when they run: scripts/render-komodo-actions.py --check, scripts/validate-komodo-resources.py, and the unittest discoveries test_komodo_*.py, test_trigger_komodo_deploy.py, test_reddit_explorer*.py, test_orders_*.py, test_gh_pr_dashboard_deployment.py, test_rexit_deployment.py, and test_oneuptime_stack.py.

Those commands and modules read Komodo resources, Action sources, image policy, several service trees, and every stack compose file the validator stats. The scope rule therefore runs the job for any services/** path, including services/litellm/compose.yml. It also runs for the scripts those tests load (scripts/trigger-komodo-deploy.py, scripts/komodo-deploy-inputs.py, scripts/komodo-deploy-inputs.sh, scripts/render-sops-env.py, and the other Komodo renderers named in scripts/ci/pr_job_scope.py), for infra/ansible/inventory/** and infra/ansible/ansible.cfg, for the role files the modules open (the whole github_runner role, three common task/default files, and the Rexit exporter task plus its three templates), for four monitoring files (monitoring/prometheus/alerts/komodo.yml, monitoring/alertmanager/alertmanager.yml, monitoring/prometheus/alert-schema-baseline.json, monitoring/grafana/dashboards/alert-triage.json), for backups/restore-test.sh, backups/restore-drills/rexit.py, backups/restore-drills/postgresql-companion.sh, and backups/external/fetch-postgresql-companion.sh, for .github/workflows/komodo-deploy.yml, .github/workflows/komodo-image-updates.yml, and .github/workflows/rexit-release.yml, and for docs/runbooks/komodo-connect-lxc-server.md. The discovered test modules themselves are inputs. Other tests/test_rexit_*.py and tests/test_oneuptime_*.py modules are not.

A docs page other than that runbook, inventory/**, an Ansible playbook, an unread role file, and monitoring/targets/** do not run this job. An unclassified script does run it. The tests do not walk all of services/ or all of .github/workflows/komodo*; those broader trees are not the rule. services/** still runs because a service path that is not in the proven-fast set is unclassified.

ansible-lint

The job installs collections from infra/ansible/requirements.yml when that file exists, then runs ansible-lint with project_dir: infra/ansible and .ansible-lint. Any infra/ansible/** path or a change to .ansible-lint runs the real lint. A docs-only diff, a services/** diff such as LiteLLM compose, an inventory/** diff, and a single script outside the scope files above fast-success. secrets/** and operator/** are not proven irrelevant, because playbooks look up files there, so those paths run lint.

generators-check

The eleven --check commands are the CI rows of the generator matrix. They read inventory/hosts/*.yaml, inventory/appliances/*.yaml, inventory/customer-apps/*.yaml, inventory/services/*.yaml, inventory/networks.yaml, inventory/oneuptime.yaml, infra/github-runners/fleet.yml, infra/github-runners/hyperv-scaler/placements.yml, and .github/workflows/runner-fleet-dispatch.yml. They compare infra/ansible/inventory/generated.yml, the Homepage, AdGuard, and OneUptime generated files, monitoring/targets/nodes.yml, monitoring/targets/services.yml, docs/architecture/network-diagram.md, docs/architecture/runner-fleet-diagram.md, and the discovery and Hyper-V outputs under inventory/ and infra/github-runners/.

Any inventory/** path runs the check, including schema and generator scripts the job does not execute. A removed or renamed doc stub (docs/hosts/*.md, docs/appliances/*.md, docs/customer-apps/*.md, or docs/services/*.md) runs the check because doc-stub --check only stats those pages. Editing the body of an existing stub does not. services/litellm/compose.yml is not a generator input. An infra/ansible/** path other than infra/ansible/inventory/generated.yml is not either.

Diff shapes that skip the real work

These are the shapes the rules allow. They are not a measured timing claim.

Diff komodo-resources-check ansible-lint generators-check
Docs, MkDocs, or markdownlint config, other than the Komodo LXC runbook, generated diagrams, and a deleted doc stub fast fast fast
services/litellm/compose.yml run fast fast
infra/ansible/playbooks/** fast run fast
inventory/hosts/*.yaml fast fast run
.github/workflows/lint.yml or the scope scripts run run run

Still not fast-success

These jobs still do their real work on every pull request. This slice does not fast-success them:

  • python-unit-tests keeps the module selector above. The job itself still runs, and pushes to main still discover tests/.
  • phoenix-api-routing, oneuptime-incident-adapter, github-actions-collector, and paperless-stirling-bridge.
  • YAML lint, schema validation, SOPS, documentation validation, monitoring checks, patch notification checks, ARA, and both runner-observability jobs.
  • Security workflow jobs, including security-gate. Lint and Security still have no paths: filter. ci-gate and security-gate are not renamed.