diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2b6472e..dbb66cb 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -66,6 +66,7 @@ jobs: # A fake uv executable makes the launcher contract deterministic: # arguments, cwd, and environment are checked without network I/O. bazelisk test //tools:python_tool_runner_test --test_output=errors + bazelisk test //tools:run_tool_test --test_output=errors # The real target covers the integration boundary between # rules_multitool, Bazel runfiles, uvx, and the Python package. diff --git a/tools/BUILD.bazel b/tools/BUILD.bazel index dd5b49a..57599c8 100644 --- a/tools/BUILD.bazel +++ b/tools/BUILD.bazel @@ -62,3 +62,14 @@ sh_test( "lockfiles/python_tools.bzl", ], ) + +sh_test( + name = "run_tool_test", + size = "small", + srcs = ["tests/run_tool_test.sh"], + data = [ + "run-tool", + "internal/devcontainer/install.py", + "lockfiles/shellcheck.lock.json", + ], +) diff --git a/tools/README.md b/tools/README.md index 0642aff..3ddbbda 100644 --- a/tools/README.md +++ b/tools/README.md @@ -25,12 +25,16 @@ development, hooks, and CI select the same pinned version. ```console $ .devcontainer/run-tool shellcheck scripts/example.sh $ .devcontainer/run-tool ruff check . +$ .devcontainer/run-tool --strict bazelisk version ``` Everything after the command is passed to that command. In the DevContainer, -the runner executes its installed executable. Outside the container, it runs -the matching Bazel target. The first host-side invocation may require network -access while Bazel downloads and caches the executable. +the runner executes its installed executable. Outside the container, it checks +for a local installation matching the pinned version; if found, it uses that. +Otherwise it runs the matching Bazel target. The first Bazel invocation may +require network access while it downloads and caches the executable. + +Use the `--strict` flag to skip local tools and always run through Bazel. ## Available tools diff --git a/tools/run-tool b/tools/run-tool index bc6d800..c569e55 100755 --- a/tools/run-tool +++ b/tools/run-tool @@ -14,26 +14,89 @@ # ******************************************************************************* # Consumer repositories install this runner as .devcontainer/run-tool. -# It runs a pinned CLI tool from PATH in a container or through Bazel on the host. +# It runs a pinned CLI tool from PATH in a container, from PATH on the host if +# its version matches the pinned catalog, or through Bazel otherwise. # See https://github.com/eclipse-score/devcontainer/tree/main/tools. set -euo pipefail +SCRIPT_PATH=$(readlink -f "$0") +SCRIPT_DIR=$(dirname -- "${SCRIPT_PATH}") + +strict=0 +if [[ "${1:-}" == "--strict" ]]; then + strict=1 + shift +fi + if [[ "$#" -lt 1 ]]; then - echo "Usage: $0 [args...]" >&2 + echo "Usage: $0 [--strict] [args...]" >&2 exit 2 fi tool_name="$1" shift -# A host PATH may contain an arbitrary, unpinned version, so PATH execution is -# deliberately limited to containers built from this catalog. If a container -# lacks a command, falling through to Bazel still gives it the pinned version. -if { [[ -f /.dockerenv ]] || [[ -f /run/.containerenv ]] || [[ -d /devcontainer ]]; } && - command -v "${tool_name}" >/dev/null 2>&1; then - exec "${tool_name}" "$@" -elif command -v bazel >/dev/null 2>&1; then +# Pulls the first dotted version number out of a tool's free-form CLI output. +extract_version() { + grep -oE '[0-9]+(\.[0-9]+){1,3}[0-9A-Za-z.-]*' <<< "$1" | head -n1 +} + +# Most tools report their own version for one of the usual flags, so those are +# swept in turn. Tools that need something else are listed explicitly, because +# a sweep cannot correct a flag that answers with the wrong version: `bazelisk +# --version` prints the Bazel release bazelisk downloaded, not bazelisk's own, +# and would win the sweep before the correct `version` subcommand is reached. +version_args() { + case "$1" in + bazelisk | starpls) printf '%s\n' version ;; + *) printf '%s\n' -v -version --version ;; + esac +} + +# The first argument that yields a version string wins. stdin is closed so a +# tool that reads it when it fails to parse an argument cannot hang the check. +installed_version() { + local tool="$1" arg output version + while IFS= read -r arg; do + if output=$("${tool}" "${arg}" &1); then + version=$(extract_version "${output}") + if [[ -n "${version}" ]]; then + printf '%s\n' "${version}" + return 0 + fi + fi + done < <(version_args "${tool}") + return 1 +} + +# Only this repository's own checkout ships the catalog next to run-tool; +# consumer repositories that copied just this file fall through to Bazel. +pinned_version() { + local tool="$1" + local installer="${SCRIPT_DIR}/internal/devcontainer/install.py" + command -v python3 >/dev/null 2>&1 || return 1 + [[ -f "${installer}" ]] || return 1 + python3 "${installer}" version "${tool}" 2>/dev/null +} + +if [[ "${strict}" -eq 0 ]] && command -v "${tool_name}" >/dev/null 2>&1; then + # A container built from this catalog only ever has the pinned version on + # PATH, so no version check is needed there. + if [[ -f /.dockerenv ]] || [[ -f /run/.containerenv ]] || [[ -d /devcontainer ]]; then + exec "${tool_name}" "$@" + fi + + # A host PATH may contain an arbitrary, unpinned version; only use it when + # it exactly matches the catalog, otherwise fall through to Bazel. + # shellcheck disable=SC2310 + if installed="$(installed_version "${tool_name}")" && pinned="$(pinned_version "${tool_name}")" && + [[ "${installed}" == "${pinned}" ]]; then + exec "${tool_name}" "$@" + fi +fi + +if command -v bazel >/dev/null 2>&1; then # Consumer repositories expose this module as @score_devcontainer; `--` # prevents tool flags from being interpreted as Bazel flags. exec bazel run "@score_devcontainer//tools:${tool_name}" -- "$@" diff --git a/tools/tests/run_tool_test.sh b/tools/tests/run_tool_test.sh new file mode 100644 index 0000000..a75a88f --- /dev/null +++ b/tools/tests/run_tool_test.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash + +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +set -euo pipefail + +runfiles_root="${TEST_SRCDIR}/${TEST_WORKSPACE}" +runner="${runfiles_root}/tools/run-tool" +fake_bin="${TEST_TMPDIR}/bin" +tool_output="${TEST_TMPDIR}/tool.args" +version_args_output="${TEST_TMPDIR}/version.args" +bazel_output="${TEST_TMPDIR}/bazel.args" +mkdir -p "${fake_bin}" + +cat > "${fake_bin}/shellcheck" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ "$#" -eq 1 && "${INVALID_VERSION_ARGS:-}" == *"|$1|"* ]]; then + printf '%s\n' "$1" >> "${VERSION_ARGS_OUTPUT}" + exit 2 +fi +if [[ "$#" -eq 1 && "$1" == "${SUCCESS_VERSION_ARG}" ]]; then + printf '%s\n' "$1" >> "${VERSION_ARGS_OUTPUT}" + printf 'ShellCheck - %s\n' "${FAKE_VERSION}" + exit 0 +fi +printf '%s\n' "$@" > "${TOOL_OUTPUT}" +EOF + +cat > "${fake_bin}/bazel" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$@" > "${BAZEL_OUTPUT}" +EOF + +cat > "${fake_bin}/unknown-tool" <<'EOF' +#!/usr/bin/env bash +printf 'Unknown Tool 1.0.0\n' +EOF +chmod +x "${fake_bin}/shellcheck" "${fake_bin}/bazel" "${fake_bin}/unknown-tool" + +assert_lines() { + local actual_file="$1" + shift + local expected_file="${TEST_TMPDIR}/expected-lines" + printf '%s\n' "$@" > "${expected_file}" + diff -u "${expected_file}" "${actual_file}" +} + +export PATH="${fake_bin}:${PATH}" +export TOOL_OUTPUT="${tool_output}" +export VERSION_ARGS_OUTPUT="${version_args_output}" +export BAZEL_OUTPUT="${bazel_output}" +export INVALID_VERSION_ARGS="|-v|-version|" +export SUCCESS_VERSION_ARG="--version" + +# A tool may reject -v and -version; the runner must continue to --version. +export FAKE_VERSION="0.10.0" +"${runner}" shellcheck --help +assert_lines "${version_args_output}" -v -version --version +assert_lines "${tool_output}" --help +[[ ! -e "${bazel_output}" ]] + +# When no version flag returns a parseable version, installed_version returns 1 +# and the runner must fall back to Bazel. +rm -f "${tool_output}" "${version_args_output}" "${bazel_output}" +export INVALID_VERSION_ARGS="|-v|-version|--version|" +"${runner}" shellcheck check.sh +assert_lines "${version_args_output}" -v -version --version +assert_lines "${bazel_output}" \ + "run" \ + "@score_devcontainer//tools:shellcheck" \ + "--" \ + "check.sh" +[[ ! -e "${tool_output}" ]] + +# An installed tool absent from the catalog must also use the Bazel target. +rm -f "${tool_output}" "${version_args_output}" "${bazel_output}" +"${runner}" unknown-tool check.sh +assert_lines "${bazel_output}" \ + "run" \ + "@score_devcontainer//tools:unknown-tool" \ + "--" \ + "check.sh" +[[ ! -e "${tool_output}" ]] + +# Strict mode always uses the Bazel target. +rm -f "${tool_output}" "${version_args_output}" "${bazel_output}" +export INVALID_VERSION_ARGS="|-v|-version|" +"${runner}" --strict shellcheck check.sh +assert_lines "${bazel_output}" \ + "run" \ + "@score_devcontainer//tools:shellcheck" \ + "--" \ + "check.sh" +[[ ! -e "${tool_output}" ]] + +# A mismatched local version uses the Bazel target. +rm -f "${tool_output}" "${version_args_output}" "${bazel_output}" +export FAKE_VERSION="0.9.0" +"${runner}" shellcheck check.sh +assert_lines "${bazel_output}" \ + "run" \ + "@score_devcontainer//tools:shellcheck" \ + "--" \ + "check.sh" +[[ ! -e "${tool_output}" ]] + +# An unavailable local tool uses the Bazel target. +rm -f "${tool_output}" "${version_args_output}" "${bazel_output}" +"${runner}" missing-tool check.sh +assert_lines "${bazel_output}" \ + "run" \ + "@score_devcontainer//tools:missing-tool" \ + "--" \ + "check.sh" +[[ ! -e "${tool_output}" ]]