deer-flow/backend/tests/test_makefile_shell_script_invocation.py
早上肚子疼 b41354d75f
fix(scripts): invoke repo shell scripts through an explicit interpreter (#5031)
Recipes and scripts ran sibling shell scripts bare (./scripts/x.sh), so
any checkout that lost the executable bit -- zip/tarball download,
core.fileMode=false, non-POSIX filesystem -- failed with:

    make: ./scripts/docker.sh: Permission denied
    make: *** [Makefile:181: docker-start] Error 127

The tracked modes are already 100755, so chmod cannot fix it. Name the
interpreter instead: the POSIX branch of RUN_SHELL_SCRIPT (renamed from
RUN_WITH_GIT_BASH) now expands to $(BASH) rather than nothing, and the
five script-to-script call sites are prefixed with bash.

Fixes #2903

Co-authored-by: zaoshangduziteng <309590849+zaoshangduziteng@users.noreply.github.com>
2026-08-29 14:54:02 +08:00

111 lines
5.2 KiB
Python

"""Regression test keeping repo shell scripts callable without the executable bit.
The root ``Makefile`` drives every shell script under ``scripts/`` through the
``RUN_SHELL_SCRIPT`` variable. On Windows that variable is the Git Bash shim; on
POSIX it used to expand to nothing, so recipes invoked the script directly::
make: ./scripts/docker.sh: Permission denied
make: *** [Makefile:181: docker-start] Error 127
Git tracks the executable bit, so a normal ``git clone`` is fine. It is lost in
the working tree whenever the checkout does not carry POSIX modes: source zip or
tarball downloads from the Releases/Code page, ``core.fileMode=false`` clones,
and non-POSIX filesystems (some Windows/WSL and network mounts). Nothing in the
repository can restore the bit for those users.
Naming the interpreter removes the dependency entirely -- ``bash ./x.sh`` ignores
the mode. Every script under ``scripts/`` uses ``#!/usr/bin/env bash``, so a
single ``$(BASH)`` default is correct for all of them, and the Windows branch is
untouched.
This test pins both halves of the invariant: no recipe may invoke a shell script
bare, and the POSIX definition of ``RUN_SHELL_SCRIPT`` must name an interpreter.
"""
from __future__ import annotations
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
MAKEFILE = REPO_ROOT / "Makefile"
# A recipe line is a tab-indented command, optionally silenced with "@".
_RECIPE_SHELL_SCRIPT = re.compile(r"^\t@?(?P<command>.*\./scripts/[\w.-]+\.sh.*)$")
def _recipe_lines_invoking_shell_scripts() -> list[tuple[int, str]]:
lines = MAKEFILE.read_text(encoding="utf-8").splitlines()
found = []
for number, line in enumerate(lines, start=1):
match = _RECIPE_SHELL_SCRIPT.match(line)
if match:
found.append((number, match.group("command")))
return found
def test_makefile_never_invokes_shell_scripts_bare() -> None:
"""Every ``scripts/*.sh`` recipe goes through an interpreter variable."""
invocations = _recipe_lines_invoking_shell_scripts()
assert invocations, "expected the Makefile to invoke scripts/*.sh somewhere"
bare = [f"Makefile:{number}: {command}" for number, command in invocations if not command.startswith("$(RUN_SHELL_SCRIPT)")]
assert not bare, "these recipes invoke a shell script without an interpreter, so they fail with 'Permission denied' in checkouts that lost the executable bit; prefix them with $(RUN_SHELL_SCRIPT):\n " + "\n ".join(bare)
def test_posix_run_shell_script_names_an_interpreter() -> None:
"""The non-Windows branch must expand to an interpreter, not to nothing."""
text = MAKEFILE.read_text(encoding="utf-8")
assert re.search(r"^BASH \?= bash$", text, re.MULTILINE), "expected 'BASH ?= bash' so operators can override the interpreter"
assignments = re.findall(r"^\s*RUN_SHELL_SCRIPT\s*=\s*(.*)$", text, re.MULTILINE)
assert len(assignments) == 2, f"expected one RUN_SHELL_SCRIPT definition per platform branch, got {assignments}"
windows, posix = assignments
assert "run-with-git-bash.cmd" in windows, windows
assert posix.strip() == "$(BASH)", f"the POSIX branch must name an interpreter; an empty value makes recipes depend on the executable bit, got {posix!r}"
SCRIPTS_DIR = REPO_ROOT / "scripts"
# Any reference to a shell script, however the path is spelled: "./scripts/x.sh",
# "$REPO_ROOT/scripts/x.sh", "$SCRIPT_DIR/x.sh".
_SCRIPT_REFERENCE = re.compile(r"\S*[\w.-]+\.sh\b")
# A script name inside a usage string or hint is prose, not an invocation.
_MESSAGE_COMMAND = re.compile(r"\b(echo|printf|cat)\b")
def _sibling_script_invocations() -> list[tuple[str, int, str]]:
"""Every line in scripts/*.sh that actually runs another shell script."""
found = []
for script in sorted(SCRIPTS_DIR.glob("*.sh")):
for number, raw in enumerate(script.read_text(encoding="utf-8").splitlines(), start=1):
line = raw.strip()
if not line or line.startswith("#"):
continue
for match in _SCRIPT_REFERENCE.finditer(line):
reference = match.group(0)
before = line[: match.start()]
if "://" in reference: # a download URL, not a local script
continue
if reference.endswith(script.name): # usage string / log prefix
continue
if _MESSAGE_COMMAND.search(before): # a hint printed to the user
continue
if not re.search(r"\bbash\s+$", before):
found.append((script.name, number, line))
break
return found
def test_scripts_never_invoke_sibling_scripts_bare() -> None:
"""A script calling another script must name the interpreter too.
``make docker-stop`` reaches ``scripts/cleanup-containers.sh`` through
``scripts/docker.sh``, so fixing only the Makefile would leave the same
``Permission denied`` failure one level deeper.
"""
bare = [f"scripts/{name}:{number}: {line}" for name, number, line in _sibling_script_invocations()]
assert not bare, "these lines run another shell script without an interpreter, so they fail with 'Permission denied' in checkouts that lost the executable bit; prefix them with 'bash':\n " + "\n ".join(bare)