Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions .github/workflows/deploy-agent-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
name: Deploy Agent Tests (OMN-15378)

# OMN-15378: scripts/deploy-agent is a standalone uv sub-project (its own
# pyproject.toml + uv.lock, deployed onto the .201 host independently of the
# main omnibase_infra dependency tree — see scripts/deploy-agent/pyproject.toml
# and scripts/deploy-agent/deploy/deploy-agent.service.d). Its tests
# (scripts/deploy-agent/tests/) were never wired into ANY CI job: the main
# suite only ever collects `tests/` (pyproject.toml testpaths = ["tests"]),
# and folding these files into that tree is not a drop-in move — both trees
# declare a top-level `tests` package (tests/__init__.py vs
# scripts/deploy-agent/tests/__init__.py), so collecting them together in one
# pytest session raises ImportPathMismatchError (verified locally 2026-07-29).
# The tests sat completely uncollected for ~5 weeks; one of them (RED against
# the OMN-12988->OMN-12990 superseded literal) went unnoticed the entire time
# and was only fixed by hand, incidentally, in PR #2536 (OMN-14968).
#
# This workflow runs them the same way their own `dod_evidence` proof commands
# always have — `uv run --project scripts/deploy-agent`, using deploy-agent's
# OWN pyproject.toml/uv.lock, not the root project's. This is deliberate: the
# deploy-agent runtime on the .201 host has its own minimal dependency set,
# and testing it through the main repo's heavier venv could mask a real
# dependency mismatch between test and deploy environments.
#
# scripts/validation/validate_test_root_collection.py (wired into the main
# suite via tests/ci/test_validate_test_root_collection.py) registers this
# workflow as the CI wiring behind scripts/deploy-agent's
# STANDALONE_PROJECT_ROOTS entry — if this file is renamed/removed without
# updating that registration, the guard fails closed.

on:
pull_request:
branches: [main, dev]
paths:
- "scripts/deploy-agent/**"
- ".github/workflows/deploy-agent-tests.yml"
push:
branches: [main, dev]
paths:
- "scripts/deploy-agent/**"
- ".github/workflows/deploy-agent-tests.yml"
merge_group:
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

env:
UV_VERSION: "0.6.14"
PYTHON_VERSION: "3.12"
CACHE_VERSION: "0.6.0"

jobs:
deploy-agent-tests:
name: deploy-agent-tests
permissions:
contents: read
# OMNI_RUNNER_SELECTOR_V1 — trusted CI defaults to self-hosted omnibase-ci; public forks default to ubuntu-latest
runs-on: >-
${{
(github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name != github.repository)
&& fromJSON(vars.OMNI_PUBLIC_PR_RUNS_ON_JSON || '["ubuntu-latest"]')
|| fromJSON(vars.OMNI_TRUSTED_CI_RUNS_ON_JSON || '["self-hosted","omnibase-ci"]')
}}
timeout-minutes: 10

steps:
- name: Checkout code
uses: actions/checkout@v6
with:
persist-credentials: false

- name: Setup Python and uv (deploy-agent sub-project)
uses: ./.github/actions/setup-python-uv
with:
python-version: ${{ env.PYTHON_VERSION }}
uv-version: ${{ env.UV_VERSION }}
cache-version: ${{ env.CACHE_VERSION }}
cache-enabled: "false"
working-directory: scripts/deploy-agent
install-args: "--extra dev"

- name: Run deploy-agent unit tests (OMN-15378)
run: |
uv run --project scripts/deploy-agent --extra dev \
pytest scripts/deploy-agent/tests -v --tb=short
11 changes: 11 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,17 @@ repos:
pass_filenames: false
always_run: false
stages: [pre-commit]
# OMN-15378: fail closed on any new `tests/` directory pytest can never
# collect (scripts/deploy-agent/tests/ sat uncollected + RED for ~5
# weeks with zero CI signal). always_run — a new stray root can appear
# anywhere in the tree, not just under files already staged.
- id: onex-validate-test-root-collection
name: ONEX Uncollected Test Root Check (OMN-15378)
entry: uv run --frozen python scripts/validation/validate_test_root_collection.py
language: system
pass_filenames: false
always_run: true
stages: [pre-commit]
# OMN-12559 / OMN-13124: vendored omnimarket node migrations must stay in
# sync with source. MUST always_run — the prior files:-scoped trigger only
# fired when a file under docker/migrations/forward/nodes/ (or the sync
Expand Down
17 changes: 13 additions & 4 deletions scripts/deploy-agent/tests/unit/test_executor_lane_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
file(s), compose project, and runtime health targets by ``runtime_lane`` so the
agent can deploy ``stability-test`` (18085/18086, project
``omnibase-infra-stability-test``) and ``prod`` (28085/28086, project
``omnibase-infra-prod``). The dev lane is unchanged.
``omnibase-infra-prod``).

OMN-15379 later gave the dev/lab lane its own overlay too
(``docker-compose.dev-lane.yml``) — the migration-fence lane indicator, per
operator ruling 15 making the lab the FORCE proving ground. Dev is no longer
single-file; it layers exactly like stability-test and prod do.
"""

from __future__ import annotations
Expand Down Expand Up @@ -48,7 +53,10 @@ def test_dev_lane_matches_legacy_module_constants(self) -> None:
cfg = lane_config_for(EnumRuntimeLane.DEV)
assert cfg.compose_project == COMPOSE_PROJECT == "omnibase-infra"
assert cfg.postgres_container == "omnibase-infra-postgres"
assert cfg.compose_files == (COMPOSE_FILE,)
# OMN-15379: dev/lab now layers its own migration-fence overlay on top
# of the base infra compose file (previously single-file).
assert cfg.compose_files[0] == COMPOSE_FILE
assert any("docker-compose.dev-lane.yml" in f for f in cfg.compose_files)
assert cfg.runtime_health_targets == RUNTIME_HEALTH_TARGETS
assert cfg.runtime_health_targets == (
("omninode-runtime", 8085),
Expand Down Expand Up @@ -115,8 +123,9 @@ def fake_run(
assert cmd[3] == COMPOSE_FILE
assert "-p" in cmd
assert cmd[cmd.index("-p") + 1] == "omnibase-infra"
# dev does not layer an overlay
assert cmd.count("-f") == 1
# OMN-15379: dev/lab layers its own migration-fence overlay.
assert cmd.count("-f") == 2, f"dev lane must layer its overlay: {cmd}"
assert any("docker-compose.dev-lane.yml" in tok for tok in cmd)

def test_stability_lane_compose_up_layers_overlay_and_project(self) -> None:
executor = DeployExecutor()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,15 @@
"INFISICAL_ENCRYPTION_KEY",
"INFISICAL_AUTH_SECRET",
"PROD_REDPANDA_ADVERTISE_HOST",
# OMN-15378: docker-compose.infra.yml (merged as the base file for
# EVERY lane, including prod) hard-requires DEV_REDPANDA_ADVERTISE_HOST
# with no default (OMN-15173's deliberate no-silent-default design).
# The original 2026-07-26 enumeration missed it because the author's
# shell already had it set via a sourced ~/.omnibase/.env -- these
# tests never ran anywhere else (this uncollected-tests-root ticket)
# until now, so a CI runner's clean shell was the first environment to
# expose the gap.
"DEV_REDPANDA_ADVERTISE_HOST",
),
"dummy-placeholder-value",
)
Expand Down
4 changes: 4 additions & 0 deletions scripts/enforcement_parity_manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ repos:
coverage: direct
rule: >-
OMN-14556: node-migration-sync.yml is its OWN workflow file (separate run_id from ci.yml), so ci_summary_gate.py's poll of actions/runs/${RUN_ID}/jobs structurally cannot observe it -- a needs_child declaration would be a false COVERED. Confirmed live on PR #2288 (2026-07-13): node-migration-sync=FAIL, CI Summary=PASS, different run_ids. Absent from required_status_checks today (MISSING) -- this entry makes the report-only ratchet flag it until the branch-protection PUT lands.
- context: "deploy-agent-tests"
coverage: direct
rule: >-
OMN-15378: deploy-agent-tests.yml is its OWN workflow file (separate run_id from ci.yml, path-triggered on scripts/deploy-agent/**), so ci_summary_gate.py's poll of actions/runs/${RUN_ID}/jobs structurally cannot observe it -- same class as node-migration-sync (OMN-14556) above. Wiring the tests to RUN on PR (this ticket's fix) does not make them GATE merge: a red deploy-agent-tests run today leaves CI Summary (the sole required context on this branch) green, so the exact "test exists but doesn't block anything" failure mode the ticket was filed to close is only partially closed -- visibility yes, enforcement no. Absent from required_status_checks today (MISSING) -- this entry makes the report-only ratchet flag it until the branch-protection PUT lands.
# --- The two repos the audit CONFIRMED are missing the gates (M1-M4) ---
omnimarket:
dev:
Expand Down
171 changes: 171 additions & 0 deletions scripts/validation/validate_test_root_collection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc.
# SPDX-License-Identifier: MIT

"""OMN-15378: fail closed on a `tests/` directory pytest can never collect.

Why this exists
----------------
``scripts/deploy-agent/tests/`` sat completely uncollected for ~5 weeks: no
entry in ``pyproject.toml`` ``testpaths``, no CI job, no governed selector
mapping reached it. A test asserting a literal superseded by OMN-12990 was RED
that entire time with zero signal, found only by hand in an unrelated PR
(OMN-14968 / #2536). This is a false-green class, not a stale-test nit — any
regression covered by an uncollected root is unenforced.

What this checks
-----------------
Every directory containing at least one ``test_*.py`` file must be reachable
by SOME real, wired pytest invocation:

1. Under the root-collected tree (``tests/`` — see ``pyproject.toml``
``[tool.pytest.ini_options] testpaths``), collected by the full-suite and
governed-selector CI jobs in ``.github/workflows/ci.yml``; or
2. A registered :data:`STANDALONE_PROJECT_ROOTS` entry — a directory that owns
its own ``pyproject.toml`` (a genuinely separate uv (sub-)project, e.g.
``scripts/deploy-agent``) AND has a live GitHub Actions workflow file that
runs its tests. Both must be real on disk or the guard fails closed —
an allowlist entry with no wiring behind it IS the OMN-15378 defect; or
3. Named in :data:`KNOWN_UNCOLLECTED_DEBT` — pre-existing uncollected roots
present at the time this guard landed, called out explicitly rather than
silently grandfathered. Do NOT add a new root here to make a violation
pass — wire its collection (path 1 or 2 above) instead. Every entry here
is a live defect; see the OMN-15378 PR body for the follow-up-ticket ask.

Deliberately excluded: ``scripts/deploy-agent/tests/`` cannot simply be added
to ``pyproject.toml`` ``testpaths`` or folded into the main ``tests/`` tree —
both trees declare a top-level ``tests`` package (``tests/__init__.py`` vs
``scripts/deploy-agent/tests/__init__.py``), so pytest raises
``ImportPathMismatchError`` when both are collected in one session (verified
locally 2026-07-29). That is why it is a registered standalone project, not a
folded-in directory.
"""

from __future__ import annotations

import sys
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parents[2]

# The canonical, root-collected pytest tree — everything under here is
# reachable via `uv run pytest tests/` (ci.yml's full-suite / smart-selection
# invocations), per pyproject.toml's `testpaths = ["tests"]`.
COLLECTED_ROOT = "tests/"

# Directories that own a private pyproject.toml (a genuine separate uv
# (sub-)project) and are exercised by a DEDICATED CI workflow rather than
# folded into the main `tests/` tree. Both the pyproject.toml and the workflow
# file are verified to actually exist on disk -- an unwired allowlist entry is
# exactly the class of defect this guard exists to catch.
STANDALONE_PROJECT_ROOTS: dict[str, str] = {
"scripts/deploy-agent": ".github/workflows/deploy-agent-tests.yml",
}

# Pre-existing debt at the time OMN-15378 landed this guard (2026-07-29).
# These roots are REAL, currently-uncollected test directories with zero CI
# wiring -- flagged, not silently forgiven. Do not add to this set to make a
# NEW violation pass; that defeats the guard. Fixing these is out of scope for
# OMN-15378 (see PR body RESIDUALS); each needs its own follow-up ticket.
KNOWN_UNCOLLECTED_DEBT: frozenset[str] = frozenset(
{
"scripts/ci/tests",
"scripts/tests",
"scripts/runtime_build/tests",
"src/omnibase_infra/services/observability/agent_actions/tests",
}
)

_IGNORED_DIR_PARTS = (".git", "__pycache__", ".venv", "node_modules")


def find_test_dirs(repo_root: Path) -> list[str]:
"""Return every repo-relative directory literally named ``tests`` (POSIX,
trailing slash) that directly contains at least one ``test_*.py`` file.

Scoped to directories named ``tests`` -- not "any file matching
``test_*.py`` anywhere" -- because several modules in this repo are named
``test_selection_*.py`` as PRODUCT CODE (the change-aware selector,
``scripts/ci/test_selection_models.py`` / ``test_selection_loader.py``),
not pytest tests; a bare ``test_*.py`` glob false-positives on those.
"""
found: set[str] = set()
for tests_dir in repo_root.rglob("tests"):
if not tests_dir.is_dir():
continue
rel = tests_dir.relative_to(repo_root)
if any(part in _IGNORED_DIR_PARTS for part in rel.parts):
continue
# Recursive: scripts/deploy-agent/tests/unit/test_*.py must count as
# content of the scripts/deploy-agent/tests/ root, not be missed
# because the test files sit one level below the `tests` dir itself.
if any(tests_dir.rglob("test_*.py")):
found.add(rel.as_posix() + "/")
return sorted(found)


def _is_collected(test_dir: str) -> bool:
return test_dir.startswith(COLLECTED_ROOT)


def _standalone_problem(test_dir: str, repo_root: Path) -> str | None:
"""Return None if legitimately wired, 'unregistered' if no match, else a
description of what is wrong with the registered entry."""
for root, workflow in STANDALONE_PROJECT_ROOTS.items():
root_prefix = root.rstrip("/") + "/"
if test_dir == root_prefix or test_dir.startswith(root_prefix):
pyproject = repo_root / root / "pyproject.toml"
workflow_path = repo_root / workflow
if not pyproject.is_file():
return (
f"registered as a STANDALONE_PROJECT_ROOTS entry but "
f"{root}/pyproject.toml does not exist"
)
if not workflow_path.is_file():
return (
f"registered as a STANDALONE_PROJECT_ROOTS entry but "
f"the wiring workflow {workflow} does not exist"
)
return None
return "unregistered"


def find_violations(repo_root: Path = REPO_ROOT) -> list[str]:
"""Return a human-readable violation string per uncollected test root."""
violations: list[str] = []
for test_dir in find_test_dirs(repo_root):
if _is_collected(test_dir):
continue
if test_dir.rstrip("/") in KNOWN_UNCOLLECTED_DEBT:
continue
problem = _standalone_problem(test_dir, repo_root)
if problem is None:
continue
if problem == "unregistered":
violations.append(
f"{test_dir}: not under '{COLLECTED_ROOT}', not a registered "
"STANDALONE_PROJECT_ROOTS entry, and not in "
"KNOWN_UNCOLLECTED_DEBT — no pytest invocation in CI can ever "
"run these tests (OMN-15378 class). Wire it into tests/, "
"register it in STANDALONE_PROJECT_ROOTS with real CI wiring, "
"or (last resort, requires a follow-up ticket) add it to "
"KNOWN_UNCOLLECTED_DEBT with a comment explaining why."
)
else:
violations.append(f"{test_dir}: {problem}")
return violations


def main() -> int:
violations = find_violations()
if violations:
print("FAIL: uncollected pytest root(s) detected (OMN-15378 class):")
for violation in violations:
print(f" - {violation}")
return 1
print("OK: every tests/ directory is reachable by a wired pytest invocation.")
return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading