scripts/ had ~80 flat files. Entry points (ci_*.sh, codegen.sh, run_tests.sh, format_native.sh, setup_hooks.sh, upload-symbols.*) and the shared pubspec_version.py stay at the root; checkers, generators, maestro tooling and release tooling move into subdirectories with their tests. Updated every reference: workflow steps, guard-test glob, Docker COPY paths and .dockerignore whitelist, website audit path, dart test imports, and regenerated the five outputs whose headers embed generator paths.
246 lines
9.4 KiB
Python
246 lines
9.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Guard distro dependencies for host libraries linked by the Linux runner.
|
|
|
|
The runner's CMake link graph maps through pkg-config modules to the hand-maintained
|
|
package lists. This catches undeclared runtime libraries before they fail on users'
|
|
machines; the built-bundle check covers plugin links unavailable before `pub get`.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
import ast
|
|
import re
|
|
import sys
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
if len(sys.argv) > 2:
|
|
raise SystemExit(f"Usage: {Path(sys.argv[0]).name} [linux-dir]")
|
|
LINUX = Path(sys.argv[1]).resolve() if len(sys.argv) == 2 else ROOT / "linux"
|
|
|
|
RUNNER_CMAKE = LINUX / "runner/CMakeLists.txt"
|
|
PACKAGES_PY = LINUX / "packaging/build-packages.py"
|
|
BUNDLE_SH = LINUX / "packaging/bundle-libs.sh"
|
|
# Runner-linked pkg_check_modules may be declared in any of these files.
|
|
CMAKE_FILES = (RUNNER_CMAKE, LINUX / "CMakeLists.txt", LINUX / "flutter/CMakeLists.txt")
|
|
|
|
# Bundled modules have no host dependency; their excluded runtime links are
|
|
# checked separately against the built bundle.
|
|
BUNDLED_MODULES = {"mpv"}
|
|
|
|
# pkg-config module -> runtime package name for each distro.
|
|
RUNTIME_PACKAGES = {
|
|
"gtk+-3.0": {"deb": "libgtk-3-0", "rpm": "gtk3", "pacman": "gtk3"},
|
|
"epoxy": {"deb": "libepoxy0", "rpm": "libepoxy", "pacman": "libepoxy"},
|
|
# Reached through the `flutter` INTERFACE target.
|
|
"glib-2.0": {"deb": "libglib2.0-0", "rpm": "glib2", "pacman": "glib2"},
|
|
"gio-2.0": {"deb": "libglib2.0-0", "rpm": "glib2", "pacman": "glib2"},
|
|
"wayland-client": {
|
|
"deb": "libwayland-client0",
|
|
"rpm": "libwayland-client",
|
|
# Arch ships all libwayland-* libraries in `wayland`.
|
|
"pacman": "wayland",
|
|
},
|
|
"wayland-egl": {
|
|
"deb": "libwayland-egl1",
|
|
"rpm": "libwayland-egl",
|
|
"pacman": "wayland",
|
|
},
|
|
# libglvnd provides libEGL.so.1.
|
|
"egl": {"deb": "libegl1", "rpm": "libglvnd-egl", "pacman": "libglvnd"},
|
|
}
|
|
|
|
errors: list[str] = []
|
|
|
|
|
|
def require(condition: bool, message: str) -> None:
|
|
if not condition:
|
|
errors.append(message)
|
|
|
|
|
|
def read(path: Path) -> str:
|
|
try:
|
|
return path.read_text(encoding="utf-8")
|
|
except OSError as error:
|
|
errors.append(f"{path}: cannot read: {error}")
|
|
return ""
|
|
|
|
|
|
LINK_KEYWORDS = {"PRIVATE", "PUBLIC", "INTERFACE", "optimized", "debug", "general"}
|
|
# Link keywords and pkg_check_modules options are not module names.
|
|
PKG_OPTIONS = ("REQUIRED", "QUIET", "GLOBAL", "NO_CMAKE_PATH", "NO_CMAKE_ENVIRONMENT_PATH")
|
|
|
|
|
|
def strip_comments(text: str) -> str:
|
|
"""A `#` comment containing `)` would otherwise truncate a call body.
|
|
|
|
That is the fail-open direction: every target after the comment vanishes and
|
|
the guard still exits 0, which is the whole bug class it exists to catch.
|
|
"""
|
|
return re.sub(r"#[^\n]*", "", text)
|
|
|
|
|
|
def link_token(raw: str) -> str:
|
|
"""`$<LINK_ONLY:PkgConfig::X>` and `"PkgConfig::X"` both name PkgConfig::X."""
|
|
return re.sub(r"^\$<[^:]*:", "", raw.strip('"')).rstrip(">")
|
|
|
|
|
|
def link_graph(text: str) -> dict[str, list[str]]:
|
|
"""target -> everything target_link_libraries() gives it, in order."""
|
|
graph: dict[str, list[str]] = {}
|
|
for match in re.finditer(r"target_link_libraries\(\s*([^\s)]+)\s*([^)]*)\)", strip_comments(text)):
|
|
name = match.group(1).replace("${BINARY_NAME}", "BINARY")
|
|
tokens = [link_token(t) for t in match.group(2).split()]
|
|
graph.setdefault(name, []).extend(t for t in tokens if t not in LINK_KEYWORDS)
|
|
return graph
|
|
|
|
|
|
def linked_pkgconfig_targets(text: str) -> set[str]:
|
|
"""Every PkgConfig:: target that reaches the runner's link line.
|
|
|
|
A library hands its dependencies to whatever links it - CMake puts even
|
|
PRIVATE ones of a static library on the consumer's link line, and an
|
|
INTERFACE target exists only to propagate them - so an internal target has to
|
|
be followed rather than treated as a leaf. `wayland_protocols PUBLIC
|
|
PkgConfig::WAYLAND_CLIENT` and `flutter INTERFACE PkgConfig::GTK` are both
|
|
invisible otherwise, the latter across a file boundary.
|
|
"""
|
|
graph = link_graph(text)
|
|
targets: set[str] = set()
|
|
seen: set[str] = set()
|
|
queue = ["BINARY"]
|
|
while queue:
|
|
current = queue.pop()
|
|
if current in seen:
|
|
continue
|
|
seen.add(current)
|
|
for token in graph.get(current, []):
|
|
if token.startswith("PkgConfig::"):
|
|
targets.add(token[len("PkgConfig::") :])
|
|
elif token in graph:
|
|
queue.append(token)
|
|
return targets
|
|
|
|
|
|
def pkgconfig_modules() -> dict[str, list[str]]:
|
|
"""CMake variable prefix -> every pkg-config module the call names.
|
|
|
|
A single call may name several - `pkg_check_modules(X REQUIRED
|
|
IMPORTED_TARGET a b c)` makes one PkgConfig::X that links all three - and
|
|
taking only the first is the fail-open direction: the extra libraries reach
|
|
the binary while the guard reports a clean run. wayland-cursor and
|
|
xkbcommon are the natural companions of a subsurface and grouping them into
|
|
the existing call is the natural way to add them, so this is the next edit
|
|
to this file rather than a hypothetical.
|
|
"""
|
|
modules: dict[str, list[str]] = {}
|
|
options = "|".join(PKG_OPTIONS)
|
|
for path in CMAKE_FILES:
|
|
for match in re.finditer(
|
|
# Skip options before module names.
|
|
r"pkg_check_modules\(\s*(\w+)\b[^)]*?IMPORTED_TARGET\s+((?:(?:" + options + r")\s+)*[^)]*)\)",
|
|
strip_comments(read(path)),
|
|
):
|
|
# Strip version constraints before package lookup.
|
|
names = [
|
|
re.split(r"[<>=!]", t, maxsplit=1)[0] for t in match.group(2).split() if t not in PKG_OPTIONS
|
|
]
|
|
names = [n for n in names if n]
|
|
if names:
|
|
modules.setdefault(match.group(1), names)
|
|
return modules
|
|
|
|
|
|
def declared_depends() -> dict[str, list[str]]:
|
|
"""distro -> depends list, read from the DISTROS literal by AST."""
|
|
tree = ast.parse(read(PACKAGES_PY), filename=str(PACKAGES_PY))
|
|
for node in tree.body:
|
|
if not isinstance(node, ast.Assign):
|
|
continue
|
|
if not any(isinstance(t, ast.Name) and t.id == "DISTROS" for t in node.targets):
|
|
continue
|
|
table = ast.literal_eval(node.value)
|
|
return {name: list(config.get("depends", [])) for name, config in table.items()}
|
|
errors.append(f"{PACKAGES_PY}: no DISTROS assignment to read the depends lists from")
|
|
return {}
|
|
|
|
|
|
# Include every owned CMake input; missing files fail closed instead of shrinking
|
|
# the graph and silently passing.
|
|
absent = [path for path in CMAKE_FILES if not path.is_file()]
|
|
if absent:
|
|
for path in absent:
|
|
print(
|
|
f"ERROR: {path}: expected CMake input is missing, so the dependency walk "
|
|
"would silently cover less than it claims",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
cmake_text = "\n".join(read(path) for path in CMAKE_FILES)
|
|
modules = pkgconfig_modules()
|
|
depends = declared_depends()
|
|
|
|
require(bool(depends), "no distro depends lists were found, so nothing was checked")
|
|
|
|
# These libraries must remain host-provided; bundling them would invalidate this guard.
|
|
bundle = read(BUNDLE_SH)
|
|
for pattern in (r"libEGL\.so", r"libwayland.*\.so"):
|
|
require(
|
|
pattern in bundle,
|
|
f"bundle-libs.sh no longer excludes {pattern}: if those are bundled now, "
|
|
"the depends entries this guard demands may be wrong",
|
|
)
|
|
|
|
linked = linked_pkgconfig_targets(cmake_text)
|
|
require(
|
|
bool(linked),
|
|
"found no PkgConfig:: link reaching ${BINARY_NAME}; the link-line parse is broken, not the build",
|
|
)
|
|
|
|
checked_modules = 0
|
|
for target in sorted(linked):
|
|
target_modules = modules.get(target)
|
|
if not target_modules:
|
|
errors.append(
|
|
f"PkgConfig::{target} is linked into the runner but no pkg_check_modules "
|
|
f"declares it in {', '.join(p.name for p in CMAKE_FILES)}"
|
|
)
|
|
continue
|
|
for module in target_modules:
|
|
checked_modules += 1
|
|
if module in BUNDLED_MODULES:
|
|
# Bundled modules have no host dependency but remain part of the summary.
|
|
continue
|
|
packages = RUNTIME_PACKAGES.get(module)
|
|
if packages is None:
|
|
errors.append(
|
|
f"pkg-config module '{module}' (PkgConfig::{target}) is linked into the runner "
|
|
f"but has no entry in RUNTIME_PACKAGES: name the package that ships its "
|
|
f"runtime library on each distro, then declare it in {PACKAGES_PY.name}"
|
|
)
|
|
continue
|
|
for distro, declared in sorted(depends.items()):
|
|
package = packages.get(distro)
|
|
if package is None:
|
|
errors.append(
|
|
f"RUNTIME_PACKAGES['{module}'] has no '{distro}' package name, so the "
|
|
f"{distro} package cannot declare a library the runner links"
|
|
)
|
|
continue
|
|
require(
|
|
package in declared,
|
|
f"the runner links {module} but the {distro} package does not depend on "
|
|
f"'{package}'; bundle-libs.sh will not bundle it, so an installed package "
|
|
f"can fail to start",
|
|
)
|
|
|
|
if errors:
|
|
for error in errors:
|
|
print(f"ERROR: {error}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
print(
|
|
f"linux/runner CMake dependency checks passed ({len(linked)} pkg-config links, {checked_modules} modules); "
|
|
"Flutter plugin links are out of reach here - check-bundle-host-deps.py covers those from the built bundle"
|
|
)
|