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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "uv run python -m pytest tests/e2e -x -q",
"ran_at": 1788558615,
"exit_code": 0,
"commit": "7ec541e56dcc16b9d569c7d0f34dbe0e1d2211cc",
"branch": "claude/tackle-1037-checks16"
}
3 changes: 2 additions & 1 deletion internals/checks/src/checks/preflight/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@

import click

from .discovery import sources
from .gate import default_runner, read_e2e, run
from .matrix import NoGateMatrix, WORKFLOWS, sources
from .matrix import NoGateMatrix, WORKFLOWS


@click.command()
Expand Down
3 changes: 2 additions & 1 deletion internals/checks/src/checks/preflight/cli_test.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Colocated unit tests for the preflight command (isolation -- no `CliRunner`).

Driven through `.callback`; `sources` and `run` are mocked at their import site.
Driven through `.callback`; `sources` (from `.discovery`) and `run` (from `.gate`)
are mocked at their import site.
"""

from unittest import mock
Expand Down
61 changes: 61 additions & 0 deletions internals/checks/src/checks/preflight/discovery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Find the workflows the gate matrix is derived from (#781).

Which workflows hold the reusable-workflow callers is itself discovered rather
than named. The matrix used to come from `conventions.yml` alone; #834 split it
into six per-domain workflows and deleted it, so the named default resolved to
nothing and `just preflight` died on a bare `FileNotFoundError` (#973). A list of
six names would only move the same failure one rename away.
"""

from __future__ import annotations

import os
from collections.abc import Callable, Sequence

from .matrix import NoGateMatrix, REUSABLE, WORKFLOWS, named, parse_gate_matrix, read_text


def discovered(
directory: str,
listdir: Callable[[str], list[str]],
read: Callable[[str], str],
) -> list[tuple[str, str]]:
"""(path, text) for every workflow in `directory` holding a caller."""
try:
names = sorted(listdir(directory))
except OSError as err:
raise NoGateMatrix(
f"no {directory} directory here. Run preflight from the repo root, or "
"name a workflow with --conventions."
) from err
found = []
for name in names:
if not name.endswith((".yml", ".yaml")):
continue
path = f"{directory}/{name}"
text = read(path)
if parse_gate_matrix(text):
found.append((path, text))
if not found:
raise NoGateMatrix(
f"no workflow in {directory} calls {REUSABLE}, so there is no gate matrix "
"to run. If the reusable workflow moved, update REUSABLE in "
"internals/checks/src/checks/preflight/matrix.py; otherwise name the "
"workflow with --conventions."
)
return found


def sources(
conventions: Sequence[str],
*,
directory: str = WORKFLOWS,
listdir: Callable[[str], list[str]] = os.listdir,
read: Callable[[str], str] = read_text,
) -> list[tuple[str, str]]:
"""(path, text) for the workflows the gate matrix is derived from."""
if conventions:
return [(path, named(path, read)) for path in conventions]
return discovered(directory, listdir, read)


110 changes: 110 additions & 0 deletions internals/checks/src/checks/preflight/discovery_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""Colocated unit tests for workflow discovery (#781)."""

import pytest

from checks.preflight.discovery import NoGateMatrix, discovered, sources

CONVENTIONS = """
jobs:
python-sdk:
uses: x/y/.github/workflows/testing-conventions.yml@v0
with:
languages: '["python"]'
source: packages/python/dirsql
config: testing-conventions.toml
"""
NOT_A_CALLER = "jobs:\n build:\n runs-on: ubuntu-latest\n"


def reader(files):
"""A `read` that only knows the files it was given -- anything else raises."""
return lambda path: files[path]


def describe_discovered():
FILES = {
".github/workflows/b-ci.yml": CONVENTIONS,
".github/workflows/a-ci.yaml": CONVENTIONS,
".github/workflows/docs.yml": NOT_A_CALLER,
}

def it_returns_every_caller_it_finds_in_sorted_order():
found = discovered(
".github/workflows",
lambda _directory: ["b-ci.yml", "docs.yml", "a-ci.yaml"],
reader(FILES),
)

assert found == [
(".github/workflows/a-ci.yaml", CONVENTIONS),
(".github/workflows/b-ci.yml", CONVENTIONS),
]

def it_never_reads_a_file_that_is_not_a_workflow():
# `reader` raises for anything it was not given, so a README that got
# opened fails here rather than passing quietly.
found = discovered(
".github/workflows",
lambda _directory: ["README.md", "b-ci.yml"],
reader(FILES),
)

assert [path for path, _text in found] == [".github/workflows/b-ci.yml"]

def it_names_the_directory_and_the_fix_when_it_does_not_exist():
def listdir(directory):
raise FileNotFoundError(2, "No such file or directory", directory)

with pytest.raises(NoGateMatrix) as caught:
discovered(".github/workflows", listdir, reader(FILES))

assert "no .github/workflows directory here" in str(caught.value)
assert "--conventions" in str(caught.value)

def it_fails_when_no_workflow_in_the_directory_calls_the_reusable_workflow():
with pytest.raises(NoGateMatrix) as caught:
discovered(".github/workflows", lambda _directory: ["docs.yml"], reader(FILES))

message = str(caught.value)
assert "no workflow in .github/workflows calls" in message
# The fix names where REUSABLE lives, which is matrix.py, not this module.
assert "preflight/matrix.py" in message


def describe_sources():
def it_reads_each_named_workflow_in_the_order_given():
files = {"b.yml": CONVENTIONS, "a.yml": CONVENTIONS}

assert sources(["b.yml", "a.yml"], read=reader(files)) == [
("b.yml", CONVENTIONS),
("a.yml", CONVENTIONS),
]

def it_discovers_the_callers_when_no_workflow_is_named():
found = sources(
(),
directory="wf",
listdir=lambda _directory: ["ci.yml"],
read=reader({"wf/ci.yml": CONVENTIONS}),
)

assert found == [("wf/ci.yml", CONVENTIONS)]

def it_takes_its_seams_by_keyword_only():
# `*` (not `/`) before the injected seams: a third positional argument
# would otherwise land silently on `listdir`.
with pytest.raises(TypeError):
sources((), "wf", lambda _directory: ["ci.yml"], reader({"wf/ci.yml": CONVENTIONS}))

def it_discovers_from_the_workflows_directory_by_default():
# The default is the whole point: #834 deleted the workflow the command
# used to name, and nothing pinned where it looks instead.
looked = []

sources(
(),
listdir=lambda directory: looked.append(directory) or ["ci.yml"],
read=reader({".github/workflows/ci.yml": CONVENTIONS}),
)

assert looked == [".github/workflows"]
138 changes: 4 additions & 134 deletions internals/checks/src/checks/preflight/gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,7 @@

Before the gates, each python root gets two drift guards (#782): `uv sync`, which
reconciles the venv with the manifest, and `declared-deps`, which asserts every
import is declared. A `uv pip install` leaves the local venv strictly more capable
than any real install, and no amount of running more gates catches that.

Three pairs cannot be run the naive way, and each is encoded rather than skipped:

* python `mutation` / `unit-coverage` -- these execute the suite, whose
`python3 -m pytest` must resolve to the package's own venv rather than an
ambient interpreter with no pytest (#706 saw it as a cosmic-ray baseline
failure naming no cause). Both run under `uv run` from the package root.
* rust `colocated-test` -- the co-change variant rides only the
python/typescript language set (rust units are inline, so no sibling can go
stale), and the CLI rejects `--base` for it.
* `e2e-verify` -- its PATH is the package root holding `e2e-attestations/`,
with the source dir as `--scope`; the `[e2e]` config table it needs is
passed as `--extra-scope` / `--exclude` flags, since it takes no `--config`.
import is declared.
"""

from __future__ import annotations
Expand All @@ -30,96 +16,10 @@
import subprocess
import tomllib
from collections.abc import Callable, Sequence
from dataclasses import dataclass

from .matrix import GATES, Root, pairs, parse_gate_matrix

MANIFESTS = ("pyproject.toml", "package.json", "Cargo.toml")

# The CLI, resolved the way CI resolves it. Two silent drifts made the local run
# enforce a different ruleset, and naming the tag closes both: the PyPI wheel
# `uvx` fetches lags npm by several releases, and a BARE `npx testing-conventions`
# resolves engine-aware, so a shim on node older than `engines.node` picks the
# last release below that floor -- and exits 0 with no banner to say so.
CLI = ["npx", "-y", "testing-conventions@latest"]

# How to launch a suite-executing gate, per language. Both run from the package
# root: python so the suite's `python3 -m pytest` resolves to that package's
# venv, typescript because only the npm CLI appends `--ts-mutation-adapter`,
# which the rule needs. Rust is absent -- it has no suite environment to enter.
#
# The python arm needs BOTH distributions. `--with` is not how the CLI is
# resolved; it puts the wheel's `testing_conventions` package on the interpreter
# the gate hands to `python3 -m`, which is the only form the mutation adapter
# ships in.
LAUNCHERS = {
"python": ["uv", "run", "--with", "testing-conventions", *CLI],
"typescript": CLI,
}


@dataclass
class Invocation:
argv: list[str]
# "." rather than None for the repo root: `subprocess.run(cwd=".")` is the
# same call, and it keeps every path in this module a plain str.
cwd: str


def package_root(source: str, exists: Callable[[str], bool]) -> str:
"""Nearest ancestor of `source` (inclusive) holding a package manifest."""
parts = source.split("/")
while parts:
candidate = "/".join(parts)
if any(exists(f"{candidate}/{name}") for name in MANIFESTS):
return candidate
parts.pop()
return "."


def e2e_flags(e2e: dict) -> list[str]:
flags = []
for scope in e2e.get("extra_scope", []):
flags += ["--extra-scope", scope]
for path in e2e.get("exclude", []):
flags += ["--exclude", path]
return flags


def invocation(
root: Root,
language: str,
gate_name: str,
base: str,
exists: Callable[[str], bool],
e2e: dict,
) -> Invocation:
gate = GATES[gate_name]
options = []
if gate.language:
options += ["--language", language]
if gate.base and (gate_name, language) != ("colocated-test", "rust"):
options += ["--base", base]
if gate.config and root.config:
options += ["--config", root.config]

if gate_name == "e2e-verify":
home = package_root(root.source, exists)
return Invocation(
[
*[*CLI, *gate.command, *options],
*["--scope", root.source, *e2e_flags(e2e), home],
],
".",
)
if gate.runs_suite and language in LAUNCHERS:
# Paths become relative to the package root we run from, not the repo.
cwd = package_root(root.source, exists)
if gate.config and root.config:
options[-1] = os.path.relpath(root.config, cwd)
source = os.path.relpath(root.source, cwd)
return Invocation([*LAUNCHERS[language], *gate.command, *options, source], cwd)
return Invocation([*CLI, *gate.command, *options, root.source], ".")
from .invocation import Invocation, invocation
from .matrix import GATES, pairs, parse_gate_matrix
from .prepare import prepare


def default_runner(argv: Sequence[str], cwd: str) -> int:
Expand All @@ -134,36 +34,6 @@ def read_e2e(config: str) -> dict:
return tomllib.load(handle).get("e2e", {})


def prepare(roots: list[Root], exists: Callable[[str], bool]) -> list[tuple[str, str, Invocation]]:
"""Per-python-root steps that guard against venv drift (#782).

`uv sync` reconciles the venv with the manifest, *removing* anything a
`uv pip install` left behind, so an undeclared dependency stops resolving
locally the way it never resolved in CI. `declared-deps` is the direct
assertion, independent of whatever the venv happens to hold.
"""
steps = []
for root in roots:
if "python" not in root.languages:
continue
home = package_root(root.source, exists)
steps.append((root.job, "uv-sync", Invocation(["uv", "sync", "--project", home], ".")))
steps.append(
(
root.job,
"declared-deps",
Invocation(
[
*["uv", "run", "--project", "internals/checks", "dirsql-checks"],
*["declared-deps", root.source],
],
".",
),
)
)
return steps


def run(
workflows: Sequence[str],
base: str,
Expand Down
Loading
Loading