diff --git a/.github/workflows/repo-hygiene.yml b/.github/workflows/repo-hygiene.yml index 1ea1bef83..734b02850 100644 --- a/.github/workflows/repo-hygiene.yml +++ b/.github/workflows/repo-hygiene.yml @@ -69,6 +69,14 @@ jobs: echo "checking ${#SCRIPTS[@]} scripts" shellcheck --severity=warning "${SCRIPTS[@]}" + # shellcheck cannot tell a shell function from a command on PATH, so a script that calls a + # scripts/lib function without sourcing the library reads as correct everywhere and dies at + # run time with exit 127. That is how v0.67.1's DMG job failed: create-dmg.sh called + # notarize_and_staple and never sourced lib/notarize.sh, and a release was the first thing + # to run it. + - name: Check every script sources the libraries it calls into + run: python3 scripts/ci/check-lib-sourcing.py + - name: Check the plugin manifest against the plugin classes run: python3 scripts/ci/check-plugin-manifest.py diff --git a/scripts/ci/check-lib-sourcing.py b/scripts/ci/check-lib-sourcing.py new file mode 100755 index 000000000..242958ab0 --- /dev/null +++ b/scripts/ci/check-lib-sourcing.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Fails when a script calls a scripts/lib function without sourcing the library that defines it. + +A missing `source` line is invisible to every tool the repo already runs. bash -n only parses, +shellcheck cannot know whether a bare word is a function or a command on PATH, and the call site +reads exactly like a working one. It surfaces at run time as exit 127, and for the one script that +only runs during a release, that means it surfaces during a release: v0.67.1's DMG job died with +"exit code 127" right after "DMG signed", because create-dmg.sh called notarize_and_staple and +never sourced scripts/lib/notarize.sh. + +Usage: check-lib-sourcing.py [scripts-dir] +""" + +import pathlib +import re +import sys + +FUNCTION = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*\(\)\s*\{", re.M) +# Any .sh named on a source line. Matched by basename so it works whether the script +# writes lib/common.sh or, as macos.sh does from inside lib/, just common.sh. +SOURCED = re.compile(r"^\s*(?:source|\.)\s+.*?([A-Za-z0-9_.-]+\.sh)", re.M) + + +def main(): + root = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "scripts") + lib_dir = root / "lib" + if not lib_dir.is_dir(): + sys.exit(f"check-lib-sourcing.py: no such directory: {lib_dir}") + + # function name -> library that defines it + owner = {} + for lib in sorted(lib_dir.glob("*.sh")): + for name in FUNCTION.findall(lib.read_text(encoding="utf-8")): + owner.setdefault(name, lib.name) + + # Sourcing is transitive: macos.sh sources common.sh, so a script that sources macos.sh gets + # both. Resolving that is the difference between a useful check and five false reports. + def sourced_libs(text): + return {name for name in SOURCED.findall(text) if (lib_dir / name).is_file()} + + closure = {} + for lib in sorted(lib_dir.glob("*.sh")): + seen, queue = set(), [lib.name] + while queue: + name = queue.pop() + if name in seen: + continue + seen.add(name) + queue.extend(sourced_libs((lib_dir / name).read_text(encoding="utf-8"))) + closure[lib.name] = seen + + problems = [] + for script in sorted(root.rglob("*.sh")): + if lib_dir in script.parents: + continue + source = script.read_text(encoding="utf-8") + sourced = set() + for name in sourced_libs(source): + sourced |= closure.get(name, {name}) + # Defined in the script itself, so it is not the library's to provide. + local = set(FUNCTION.findall(source)) + for name, lib in sorted(owner.items()): + if name in local or lib in sourced: + continue + call = re.search(rf"^\s*{re.escape(name)}(?:\s|$)", source, re.M) + if call: + line = source[: call.start()].count("\n") + 1 + problems.append(f"{script}:{line}: calls {name}, defined in lib/{lib}, without sourcing it") + + for problem in problems: + print(problem, file=sys.stderr) + if problems: + sys.exit(1) + print(f"{len(owner)} library functions, every caller sources its library.") + + +if __name__ == "__main__": + main() diff --git a/scripts/create-dmg.sh b/scripts/create-dmg.sh index a73abc0a2..04161e171 100755 --- a/scripts/create-dmg.sh +++ b/scripts/create-dmg.sh @@ -4,6 +4,9 @@ set -e +# shellcheck source=lib/notarize.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/notarize.sh" + # Configuration APP_NAME="TablePro" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"