Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions tools/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
)
10 changes: 7 additions & 3 deletions tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
81 changes: 72 additions & 9 deletions tools/run-tool
Comment thread
lurtz marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Comment on lines +23 to +24

strict=0
if [[ "${1:-}" == "--strict" ]]; then
strict=1
shift
fi

if [[ "$#" -lt 1 ]]; then
echo "Usage: $0 <tool> [args...]" >&2
echo "Usage: $0 [--strict] <tool> [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 ;;
Comment on lines +52 to +53
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}" </dev/null 2>&1); then
Comment thread
lurtz marked this conversation as resolved.
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
Comment on lines +77 to +80
}

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}" -- "$@"
Expand Down
128 changes: 128 additions & 0 deletions tools/tests/run_tool_test.sh
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +70 to +71
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}" ]]
Loading