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/.pre-commit-config.yaml b/.pre-commit-config.yaml index fa8745b..a94925e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -75,6 +75,14 @@ repos: # Both catalog formats feed the generated table; watching both keeps # user-facing versions synchronized with their sources of truth. files: ^tools/(README\.md|internal/(sync_readme\.py|devcontainer/install\.py)|lockfiles/(.*\.lock\.json|python_tools\.bzl))$ + - id: sync-run-tool + name: check run-tool pinned versions + entry: python3 tools/internal/sync_run_tool.py + language: system + pass_filenames: false + # Both catalog formats feed the generated runner; watching both keeps + # runner versions synchronized with their sources of truth. + files: ^tools/(run-tool|internal/(sync_run_tool\.py|devcontainer/install\.py)|lockfiles/(.*\.lock\.json|python_tools\.bzl))$ - repo: https://github.com/eclipse-score/tooling rev: 31ff8eee214e4e97ef8f5cb46e443273515b63ec hooks: diff --git a/tools/BUILD.bazel b/tools/BUILD.bazel index dd5b49a..6a18a96 100644 --- a/tools/BUILD.bazel +++ b/tools/BUILD.bazel @@ -62,3 +62,16 @@ sh_test( "lockfiles/python_tools.bzl", ], ) + +sh_test( + name = "run_tool_test", + size = "small", + srcs = ["tests/run_tool_test.sh"], + data = [ + "internal/devcontainer/install.py", + "run-tool", + ] + glob([ + "lockfiles/*.bzl", + "lockfiles/*.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/internal/README.md b/tools/internal/README.md index 5ced83a..2c69b4b 100644 --- a/tools/internal/README.md +++ b/tools/internal/README.md @@ -160,15 +160,17 @@ lockfile entries without a description. ## Validation and release alignment -Regenerate the user-facing command table after changing a lockfile or a -description: +Regenerate the user-facing command table and runner versions after changing a +lockfile or a description: ```console $ python3 tools/internal/sync_readme.py +$ python3 tools/internal/sync_run_tool.py ``` -The pre-commit hook runs the same command; pre-commit rejects the commit if -running it changes `tools/README.md`, so a stale table cannot be committed. +The pre-commit hooks run these commands; pre-commit rejects the commit if +running them changes `tools/README.md` or `tools/run-tool`, so a stale table or +runner catalog cannot be committed. Run the feature test for every installer changed. The feature tests read their expected versions from the catalog, which verifies that the DevContainer and diff --git a/tools/internal/sync_run_tool.py b/tools/internal/sync_run_tool.py new file mode 100644 index 0000000..875c1a4 --- /dev/null +++ b/tools/internal/sync_run_tool.py @@ -0,0 +1,79 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Synchronize embedded version catalog in run-tool with lockfiles.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from devcontainer.install import load_catalog_versions + +TOOLS_DIR = Path(__file__).resolve().parent.parent +RUN_TOOL_PATH = TOOLS_DIR / "run-tool" +RUN_TOOL_START = "# BEGIN GENERATED PINNED VERSIONS" +RUN_TOOL_END = "# END GENERATED PINNED VERSIONS" + + +def _render_pinned_versions(versions: dict[str, str]) -> str: + lines = [ + RUN_TOOL_START, + "# Generated by internal/sync_run_tool.py; do not edit manually.", + "pinned_version() {", + ' case "$1" in', + ] + for command in sorted(versions): + lines.append(f" {command}) printf '%s\\n' \"{versions[command]}\" ;;") + lines.extend( + [ + " *) return 1 ;;", + " esac", + "}", + RUN_TOOL_END, + ] + ) + return "\n".join(lines) + + +def _updated_run_tool(content: str, block: str) -> str: + if content.count(RUN_TOOL_START) != 1 or content.count(RUN_TOOL_END) != 1: + raise SystemExit( + f"{RUN_TOOL_PATH.name} must contain exactly one generated pinned versions block" + ) + + start = content.index(RUN_TOOL_START) + end = content.index(RUN_TOOL_END, start) + len(RUN_TOOL_END) + return content[:start] + block + content[end:] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Synchronize run-tool embedded versions with lockfiles.", + ) + parser.parse_args(argv) + + versions = load_catalog_versions() + current = RUN_TOOL_PATH.read_text(encoding="utf-8") + block = _render_pinned_versions(versions) + expected = _updated_run_tool(current, block) + + if current == expected: + return 0 + + RUN_TOOL_PATH.write_text(expected, encoding="utf-8") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/run-tool b/tools/run-tool index bc6d800..7c318e1 100755 --- a/tools/run-tool +++ b/tools/run-tool @@ -14,26 +14,115 @@ # ******************************************************************************* # 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 +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 +} + +# Tests can force either side of the container boundary without changing the +# runtime's automatic detection behavior. +in_container() { + case "${RUN_TOOL_CONTAINER_MODE:-auto}" in + host) return 1 ;; + auto) + [[ -f /.dockerenv ]] || [[ -f /run/.containerenv ]] || [[ -d /devcontainer ]] + ;; + *) + echo "Invalid RUN_TOOL_CONTAINER_MODE: ${RUN_TOOL_CONTAINER_MODE}" >&2 + return 2 + ;; + esac +} + +# Embedded catalog of pinned versions keeps the runner self-contained when +# copied into consumer repositories as .devcontainer/run-tool. +# BEGIN GENERATED PINNED VERSIONS +# Generated by internal/sync_run_tool.py; do not edit manually. +pinned_version() { + case "$1" in + actionlint) printf '%s\n' "1.7.7" ;; + apm) printf '%s\n' "0.27.0" ;; + bazelisk) printf '%s\n' "1.27.0" ;; + buildifier) printf '%s\n' "8.2.1" ;; + opencode) printf '%s\n' "1.18.15" ;; + pre-commit) printf '%s\n' "4.5.1" ;; + ruff) printf '%s\n' "0.11.13" ;; + shellcheck) printf '%s\n' "0.10.0" ;; + starpls) printf '%s\n' "0.1.22" ;; + uv) printf '%s\n' "0.10.4" ;; + uvx) printf '%s\n' "0.10.4" ;; + yamlfmt) printf '%s\n' "0.17.0" ;; + *) return 1 ;; + esac +} +# END GENERATED PINNED VERSIONS + +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. + # shellcheck disable=SC2310 + if in_container; 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 100755 index 0000000..af21384 --- /dev/null +++ b/tools/tests/run_tool_test.sh @@ -0,0 +1,220 @@ +#!/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}" +installer="${runfiles_root}/tools/internal/devcontainer/install.py" +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 + +cat > "${fake_bin}/special-tool" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +tool_name=$(basename "$0") +case "${tool_name}" in + bazelisk) version="1.27.0" ;; + starpls) version="0.1.22" ;; + *) exit 1 ;; +esac +if [[ "$#" -eq 1 && "$1" == version ]]; then + printf '%s\n' "$1" >> "${VERSION_ARGS_OUTPUT}" + printf '%s %s\n' "${tool_name}" "${version}" + exit 0 +fi +if [[ "$#" -gt 0 ]]; then + printf '%s\n' "$@" > "${TOOL_OUTPUT}" +fi +EOF +chmod +x "${fake_bin}/shellcheck" "${fake_bin}/bazel" \ + "${fake_bin}/unknown-tool" "${fake_bin}/special-tool" +ln -s special-tool "${fake_bin}/bazelisk" +ln -s special-tool "${fake_bin}/starpls" + +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 RUN_TOOL_CONTAINER_MODE=host +export INVALID_VERSION_ARGS="|-v|-version|" +export SUCCESS_VERSION_ARG="--version" + +reset_outputs() { + rm -f "${tool_output}" "${version_args_output}" "${bazel_output}" +} + +# A tool may reject -v and -version; the runner must continue to --version. +export FAKE_VERSION="0.10.0" +reset_outputs +"${runner}" shellcheck --help +assert_lines "${version_args_output}" -v -version --version +assert_lines "${tool_output}" --help +[[ ! -e "${bazel_output}" ]] + +# Bazelisk and Starpls must use only their explicit version subcommand; their +# --version output describes the Bazel or language-server release instead. +reset_outputs +"${runner}" bazelisk check.sh +assert_lines "${version_args_output}" version +assert_lines "${tool_output}" check.sh +[[ ! -e "${bazel_output}" ]] + +reset_outputs +"${runner}" starpls check.sh +assert_lines "${version_args_output}" version +assert_lines "${tool_output}" check.sh +[[ ! -e "${bazel_output}" ]] + +# When no version flag returns a parseable version, installed_version returns 1 +# and the runner must fall back to Bazel. +reset_outputs +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. +reset_outputs +"${runner}" unknown-tool check.sh +assert_lines "${bazel_output}" \ + "run" \ + "@score_devcontainer//tools:unknown-tool" \ + "--" \ + "check.sh" +[[ ! -e "${tool_output}" ]] + +# Without Bazel, an unavailable tool must report exit status 127. +no_bazel_bin="${TEST_TMPDIR}/no-bazel-bin" +mkdir -p "${no_bazel_bin}" +if PATH="${no_bazel_bin}" /bin/bash "${runner}" missing-tool check.sh \ + > "${TEST_TMPDIR}/no-bazel.output" 2>&1; then + echo "runner unexpectedly succeeded without Bazel" >&2 + exit 1 +else + no_bazel_status=$? +fi +[[ "${no_bazel_status}" -eq 127 ]] +grep -Fx "Could not run 'missing-tool': no container command or Bazel executable is available." \ + "${TEST_TMPDIR}/no-bazel.output" + +# Strict mode always uses the Bazel target. +reset_outputs +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. +reset_outputs +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. +reset_outputs +"${runner}" missing-tool check.sh +assert_lines "${bazel_output}" \ + "run" \ + "@score_devcontainer//tools:missing-tool" \ + "--" \ + "check.sh" +[[ ! -e "${tool_output}" ]] + +# The runner embeds the catalog as a shell function; extract and evaluate only +# that generated function so the test can compare it with the source catalog. +# shellcheck disable=SC1090 +eval "$(sed -n '/^pinned_version() {/,/^}/p' "${runner}")" + +catalog_versions="${TEST_TMPDIR}/catalog-versions" +runner_versions="${TEST_TMPDIR}/runner-versions" + +python3 -c ' +import sys +from pathlib import Path +sys.path.insert(0, str(Path("'"${installer}"'").parent)) +from install import load_catalog_versions +for tool, ver in sorted(load_catalog_versions().items()): + print(f"{tool}={ver}") +' > "${catalog_versions}" + +while IFS='=' read -r tool expected_ver; do + actual_ver="$(pinned_version "${tool}")" + [[ "${actual_ver}" == "${expected_ver}" ]] || { + printf 'pinned_version mismatch for %s: expected %s, got %s\n' \ + "${tool}" "${expected_ver}" "${actual_ver}" >&2 + exit 1 + } + printf '%s=%s\n' "${tool}" "${actual_ver}" >> "${runner_versions}" +done < "${catalog_versions}" + +diff -u "${catalog_versions}" "${runner_versions}" + +# An uncatalogued tool must return non-zero from pinned_version. +if pinned_version "unknown-tool" >/dev/null 2>&1; then + echo "pinned_version unexpectedly succeeded for unknown-tool" >&2 + exit 1 +fi