Skip to content

check_workflow_make_targets

rhiza_hooks.check_workflow_make_targets

Check that every make target CI invokes actually exists.

A workflow calling a target the Makefile no longer defines fails only when that workflow next runs — which, for a scheduled or path-filtered job, can be weeks after the commit that broke it. The template has already produced this failure: make validate existed up to rhiza v1.1.3 and was removed by v1.2.1, so anything still naming it reported healthy repos as broken.

check-makefile-targets asserts that a handful of recommended targets exist. This hook checks the opposite direction — that the targets actually invoked are defined — which is what catches a removal or a rename.

Both sides come from files in the repo, so the check is offline and mechanical:

  • targets — the root Makefile plus everything it includes, transitively, globs expanded (rhiza's own layout is Makefilelocal.mk, and was Makefile.rhiza/rhiza.mk.rhiza/make.d/*.mk before v1.4.0); read by :mod:rhiza_hooks._makefile, shared with check-makefile-targets;
  • invocations — the shell snippets of every CI definition: run: in GitHub workflows, script:/before_script:/after_script: in .gitlab-ci.yml.

A catch-all rule silences this check, and honestly so: with %: in the Makefile every name resolves, so an invocation of a target that does not exist is no longer distinguishable from one that does — make itself cannot tell either, which is why the rhiza-task shim leaves "unknown task" to the CLI. The comparison is skipped rather than guessed at, and the run says so in its summary; the alternative, reading the CLI's task list, would mean running it from a pre-commit hook.

Invocations are read out of parsed YAML rather than raw text, so name: make sure the cache is warm cannot be mistaken for an invocation of a target called sure. An invocation naming a target through a variable or a matrix expression (make ${{ matrix.task }}) is unresolvable, and is skipped rather than reported: a false positive here blocks every commit, which is worse than a missed check.

Only inline shell steps are read. A workflow that delegates to a reusable one::

jobs:
  ci:
    uses: jebel-quant/rhiza/.github/workflows/rhiza_ci.yml@v1.5.1

keeps every command in another repository, behind a pinned ref. Nothing in the checkout holds them, so this hook cannot see them — and since that is the shape of every rhiza-managed repo, the check can pass while inspecting nothing at all. That is indistinguishable, from the outside, from passing on merit.

Two things make the difference visible. The run always reports what it worked from (inspected N CI file(s), found M resolvable make target invocation(s)), and --require-invocations turns a zero into a failure, for a repo that believes it has inline invocations to check. The flag is opt-in because zero is the correct, permanent answer for a repo whose CI is entirely delegated: making it fatal by default would report every such repo as broken, which is the mistake this hook already refuses to make for unresolvable target names.

Following uses: into the reusable workflow would restore real coverage, but it means network access from a pre-commit hook against a pinned ref — a much larger change, and one better answered in the template's own CI, where a removed target is reachable without leaving the repo that removed it.

Exit codes

0 - every resolvable invocation names a defined target 1 - at least one invocation names a target nothing defines, or --require-invocations was given and there were no invocations to check

check_workflow_make_targets(repo_root)

Report every CI invocation of a make target that nothing defines.

Parameters:

Name Type Description Default
repo_root Path

Root directory of the repository.

required

Returns:

Type Description
list[str]

List of error messages, one per (file, missing target) pair. Empty when

list[str]

there is no Makefile — with no targets to compare against, every invocation

list[str]

would be reported, which is noise rather than signal.

Source code in rhiza_hooks/check_workflow_make_targets.py
def check_workflow_make_targets(repo_root: Path) -> list[str]:
    """Report every CI invocation of a make target that nothing defines.

    Args:
        repo_root: Root directory of the repository.

    Returns:
        List of error messages, one per (file, missing target) pair. Empty when
        there is no Makefile — with no targets to compare against, every invocation
        would be reported, which is noise rather than signal.
    """
    return _undefined_targets(collect_targets(repo_root), _ci_invocations(_ci_files(repo_root), repo_root))

invoked_targets(snippet)

Return the resolvable make targets a shell snippet invokes.

sorted(invoked_targets("make fmt && make test")) ['fmt', 'test']

Flags are dropped, including the value of a flag that takes one, so the argument of -C is never read as a target:

sorted(invoked_targets("make -C sub build")) ['build']

VAR=value overrides are not targets either:

sorted(invoked_targets("make CFLAGS=-O2 release")) ['release']

A word only make or a shell can resolve abandons the whole command, rather than contributing invented target names:

sorted(invoked_targets("make ${{ matrix.task }}")) []

Source code in rhiza_hooks/check_workflow_make_targets.py
def invoked_targets(snippet: str) -> set[str]:
    """Return the resolvable make targets a shell snippet invokes.

    >>> sorted(invoked_targets("make fmt && make test"))
    ['fmt', 'test']

    Flags are dropped, including the value of a flag that takes one, so the
    argument of ``-C`` is never read as a target:

    >>> sorted(invoked_targets("make -C sub build"))
    ['build']

    ``VAR=value`` overrides are not targets either:

    >>> sorted(invoked_targets("make CFLAGS=-O2 release"))
    ['release']

    A word only make or a shell can resolve abandons the whole command, rather
    than contributing invented target names:

    >>> sorted(invoked_targets("make ${{ matrix.task }}"))
    []
    """
    targets: set[str] = set()
    for line in snippet.splitlines():
        words = line.replace(";", " ; ").replace("&&", " && ").split()
        for index, word in enumerate(words):
            if word == "make":
                targets.update(_targets_in_command(words[index + 1 :]))
    return targets

main(argv=None)

Run the hook and return a process exit code.

Source code in rhiza_hooks/check_workflow_make_targets.py
def main(argv: list[str] | None = None) -> int:
    """Run the hook and return a process exit code."""
    parser = argparse.ArgumentParser(description="Check every make target invoked by CI exists")
    parser.add_argument(
        "filenames",
        nargs="*",
        help="Filenames (ignored: a target removal must be caught as well as a workflow edit)",
    )
    parser.add_argument(
        "--require-invocations",
        action="store_true",
        help="Fail when the repo has CI files but none of them invokes make, rather than passing vacuously",
    )
    args = parser.parse_args(argv)  # filenames are consumed for pre-commit's sake and unused

    repo_root = find_repo_root()
    ci_files = _ci_files(repo_root)
    invocations = _ci_invocations(ci_files, repo_root)
    defined = collect_targets(repo_root)
    errors = _undefined_targets(defined, invocations)

    # Always, and before the errors: on a failing run this is the context for them, and
    # on a passing one it is the only thing distinguishing a real check from an empty
    # one. pre-commit hides a passing hook's output unless the hook sets `verbose: true`,
    # which is why --require-invocations exists rather than this line alone.
    print(summarize(len(ci_files), invocations, catch_all=defined.catch_all), file=sys.stderr)

    # A repo with no CI files at all is not claiming to have invocations, so it is not
    # held to the flag; one that ships CI files and yields nothing is what the flag is for.
    if args.require_invocations and ci_files and not invocations:
        errors.append(
            "no CI file invokes `make`, and --require-invocations was given. "
            "A workflow that delegates to a reusable one (`uses:`) keeps its commands in "
            "another repository, which this hook does not read; drop the flag if that is "
            "the intended shape of this repo's CI."
        )

    for error in errors:
        print(f"ERROR: {error}", file=sys.stderr)

    return 1 if errors else 0

summarize(ci_file_count, invocations, *, catch_all=False)

Return the one-line account of what a run actually inspected.

A guard that finds no input passes exactly like one that passes on merit, so the run says what it read. The count is of distinct (file, target) pairs, which is what the check compares — make fmt test contributes two, and a target named twice in one file contributes one:

summarize(4, {"ci.yml": {"fmt", "test"}, ".gitlab-ci.yml": {"test"}}) 'inspected 4 CI file(s), found 3 resolvable make target invocation(s)'

Zero is the answer worth being able to see. It is what a repo whose CI is entirely delegated to reusable workflows gets, and it means the check compared nothing:

summarize(8, {}) 'inspected 8 CI file(s), found 0 resolvable make target invocation(s)'

A catch-all rule is the other way to compare nothing, and the more surprising one, because the invocations are right there and every one of them passes:

line = summarize(2, {"ci.yml": {"test"}}, catch_all=True) line.startswith("inspected 2 CI file(s), found 1 resolvable") True line.endswith("a catch-all rule (%:) defines every name, so none was compared") True

Source code in rhiza_hooks/check_workflow_make_targets.py
def summarize(ci_file_count: int, invocations: dict[str, set[str]], *, catch_all: bool = False) -> str:
    """Return the one-line account of what a run actually inspected.

    A guard that finds no input passes exactly like one that passes on merit, so the
    run says what it read. The count is of distinct (file, target) pairs, which is
    what the check compares — ``make fmt test`` contributes two, and a target named
    twice in one file contributes one:

    >>> summarize(4, {"ci.yml": {"fmt", "test"}, ".gitlab-ci.yml": {"test"}})
    'inspected 4 CI file(s), found 3 resolvable `make` target invocation(s)'

    Zero is the answer worth being able to see. It is what a repo whose CI is
    entirely delegated to reusable workflows gets, and it means the check compared
    nothing:

    >>> summarize(8, {})
    'inspected 8 CI file(s), found 0 resolvable `make` target invocation(s)'

    A catch-all rule is the other way to compare nothing, and the more surprising
    one, because the invocations are right there and every one of them passes:

    >>> line = summarize(2, {"ci.yml": {"test"}}, catch_all=True)
    >>> line.startswith("inspected 2 CI file(s), found 1 resolvable")
    True
    >>> line.endswith("a catch-all rule (`%:`) defines every name, so none was compared")
    True
    """
    found = sum(len(targets) for targets in invocations.values())
    line = f"inspected {ci_file_count} CI file(s), found {found} resolvable `make` target invocation(s)"
    if catch_all:
        line += "; a catch-all rule (`%:`) defines every name, so none was compared"
    return line