diff --git a/.devcontainer/post_create_command.sh b/.devcontainer/post_create_command.sh index cb4e8f6..e87b8cc 100755 --- a/.devcontainer/post_create_command.sh +++ b/.devcontainer/post_create_command.sh @@ -18,7 +18,7 @@ npm install -g @devcontainers/cli SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" REPOSITORY_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd -P)" -sudo "${REPOSITORY_ROOT}/tools/tool_installer.py" install actionlint bazelisk buildifier ruff shellcheck yamlfmt +sudo "${REPOSITORY_ROOT}/tools/internal/devcontainer/install.py" install actionlint bazelisk buildifier ruff shellcheck yamlfmt pre-commit install diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e4ed0a8..aeea13c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,35 +38,41 @@ repos: # Formatters - id: yamlfmt name: yamlfmt - entry: tools/run_tool.sh yamlfmt + entry: tools/run-tool yamlfmt language: system types: [yaml] - id: ruff-format name: ruff format - entry: tools/run_tool.sh ruff format + entry: tools/run-tool ruff format language: system types: [python] - id: buildifier name: buildifier - entry: tools/run_tool.sh buildifier + entry: tools/run-tool buildifier language: system types: [bazel] # Static Code Analysis Tools - id: actionlint name: actionlint - entry: tools/run_tool.sh actionlint + entry: tools/run-tool actionlint language: system files: ^\.github/workflows/.*\.(yml|yaml)$ - id: shellcheck name: shellcheck - entry: tools/run_tool.sh shellcheck + entry: tools/run-tool shellcheck language: system types: [shell] - id: ruff-check name: ruff check - entry: tools/run_tool.sh ruff check + entry: tools/run-tool ruff check language: system types: [python] + - id: tool-documentation + name: check tool documentation + entry: python3 tools/internal/sync_readme.py + language: system + pass_filenames: false + files: ^tools/(README\.md|internal/(sync_readme\.py|devcontainer/install\.py)|lockfiles/.*\.lock\.json)$ - repo: https://github.com/eclipse-score/tooling rev: 31ff8eee214e4e97ef8f5cb46e443273515b63ec hooks: diff --git a/MODULE.bazel b/MODULE.bazel index fe3a8b9..3d7d0fb 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -17,15 +17,15 @@ bazel_dep(name = "rules_multitool", version = "1.11.1") multitool = use_extension("@rules_multitool//multitool:extension.bzl", "multitool") multitool.hub(lockfile = "//tools:lockfiles/actionlint.lock.json") +multitool.hub(lockfile = "//tools:lockfiles/apm.lock.json") +multitool.hub(lockfile = "//tools:lockfiles/bazelisk.lock.json") +multitool.hub(lockfile = "//tools:lockfiles/buildifier.lock.json") +multitool.hub(lockfile = "//tools:lockfiles/opencode.lock.json") multitool.hub(lockfile = "//tools:lockfiles/ruff.lock.json") multitool.hub(lockfile = "//tools:lockfiles/shellcheck.lock.json") -multitool.hub(lockfile = "//tools:lockfiles/yamlfmt.lock.json") -multitool.hub(lockfile = "//tools:lockfiles/uv.lock.json") -multitool.hub(lockfile = "//tools:lockfiles/buildifier.lock.json") multitool.hub(lockfile = "//tools:lockfiles/starpls.lock.json") -multitool.hub(lockfile = "//tools:lockfiles/bazelisk.lock.json") -multitool.hub(lockfile = "//tools:lockfiles/apm.lock.json") -multitool.hub(lockfile = "//tools:lockfiles/opencode.lock.json") +multitool.hub(lockfile = "//tools:lockfiles/uv.lock.json") +multitool.hub(lockfile = "//tools:lockfiles/yamlfmt.lock.json") use_repo(multitool, "multitool") register_toolchains("@multitool//toolchains:all") diff --git a/README.md b/README.md index 4fd03de..50fe4fb 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ SPDX-License-Identifier: Apache-2.0 # Common DevContainer for Eclipse S-CORE This repository contains the common [development container](https://containers.dev) for [Eclipse S-CORE](https://github.com/eclipse-score). It contains all tools required to develop (modify, build, ...) Eclipse S-CORE. -All tool version are well-defined, and all tools are pre-configured to work as expected for Eclipse S-CORE development. +All tool versions are well-defined, and all tools are pre-configured to work as expected for Eclipse S-CORE development. The container is [pre-built](https://containers.dev/guide/prebuild) in GitHub Actions as part of this repository, [tested, published](./docs/ci.md), and ready for use. Using the pre-built container in an Eclipse S-CORE repository is described in the [Usage](#usage) section. @@ -79,7 +79,14 @@ Afterwards, Visual Studio Code should show this in the lower left corner of your ### Inside the Container -Open a Terminal, and - for example - type `bazel build ...` to execute the default build of the repository. +Open a terminal and, for example, run `bazel build ...` to execute the +repository's default build. + +For pinned command-line tools that are also available outside the container, +use the repository's `.devcontainer/run-tool` wrapper. It selects the +container installation here and the matching Bazel target on the host. See +[Pinned command-line tools](tools/README.md) for the supported commands and +setup. After you have build the code, create [compilation databases](https://clang.llvm.org/docs/JSONCompilationDatabase.html) via Visual Studio Code [Task](https://code.visualstudio.com/docs/debugtest/tasks): diff --git a/src/s-core-devcontainer/.devcontainer/bazel-feature/install.sh b/src/s-core-devcontainer/.devcontainer/bazel-feature/install.sh index fca90c3..ea34c32 100755 --- a/src/s-core-devcontainer/.devcontainer/bazel-feature/install.sh +++ b/src/s-core-devcontainer/.devcontainer/bazel-feature/install.sh @@ -29,7 +29,7 @@ rm -f "${COPY_TARGET}/devcontainer-features.env" "${COPY_TARGET}/devcontainer-fe DEBIAN_FRONTEND=noninteractive # Read tool versions + metadata into environment variables -. /usr/local/share/score-tools/versions.sh /devcontainer/features/bazel/versions.yaml +. /usr/local/share/score-tools/internal/devcontainer/load_feature_versions.sh /devcontainer/features/bazel/versions.yaml ARCHITECTURE=$(dpkg --print-architecture) @@ -40,7 +40,7 @@ apt-get update apt-get install apt-transport-https -y # Lockfile-managed Bazel tooling -/usr/local/share/score-tools/tool_installer.py install bazelisk buildifier starpls +/usr/local/share/score-tools/internal/devcontainer/install.py install bazelisk buildifier starpls # Bazelisk + Bazel ln -sf /usr/local/bin/bazelisk /usr/local/bin/bazel diff --git a/src/s-core-devcontainer/.devcontainer/bazel-feature/tests/test_default.sh b/src/s-core-devcontainer/.devcontainer/bazel-feature/tests/test_default.sh index bb85e25..c6447ab 100755 --- a/src/s-core-devcontainer/.devcontainer/bazel-feature/tests/test_default.sh +++ b/src/s-core-devcontainer/.devcontainer/bazel-feature/tests/test_default.sh @@ -16,11 +16,11 @@ set -euo pipefail # Read tool versions + metadata into environment variables -. /usr/local/share/score-tools/versions.sh /devcontainer/features/bazel/versions.yaml +. /usr/local/share/score-tools/internal/devcontainer/load_feature_versions.sh /devcontainer/features/bazel/versions.yaml -bazelisk_lockfile_version="$(/usr/local/share/score-tools/tool_installer.py version bazelisk)" -buildifier_lockfile_version="$(/usr/local/share/score-tools/tool_installer.py version buildifier)" -starpls_lockfile_version="$(/usr/local/share/score-tools/tool_installer.py version starpls)" +bazelisk_lockfile_version="$(/usr/local/share/score-tools/internal/devcontainer/install.py version bazelisk)" +buildifier_lockfile_version="$(/usr/local/share/score-tools/internal/devcontainer/install.py version buildifier)" +starpls_lockfile_version="$(/usr/local/share/score-tools/internal/devcontainer/install.py version starpls)" # Bazel-related tools ## This is the bazel version preinstalled in the devcontainer. ## A solid test would disable the network interface first to prevent a different version from being downloaded, diff --git a/src/s-core-devcontainer/.devcontainer/devcontainer-lock.json b/src/s-core-devcontainer/.devcontainer/devcontainer-lock.json index 84a5452..8baccd5 100644 --- a/src/s-core-devcontainer/.devcontainer/devcontainer-lock.json +++ b/src/s-core-devcontainer/.devcontainer/devcontainer-lock.json @@ -16,4 +16,4 @@ "integrity": "sha256:00fd45550f578d9d515044d9e2226e908dbc3d7aa6fcb9dee4d8bdb60be114cf" } } -} \ No newline at end of file +} diff --git a/src/s-core-devcontainer/.devcontainer/s-core-local/install.sh b/src/s-core-devcontainer/.devcontainer/s-core-local/install.sh index 202b5f8..8464cae 100755 --- a/src/s-core-devcontainer/.devcontainer/s-core-local/install.sh +++ b/src/s-core-devcontainer/.devcontainer/s-core-local/install.sh @@ -31,7 +31,7 @@ rm -f "${COPY_TARGET}/devcontainer-features.env" "${COPY_TARGET}/devcontainer-fe DEBIAN_FRONTEND=noninteractive # Read tool versions + metadata into environment variables -. /usr/local/share/score-tools/versions.sh /devcontainer/features/s-core-local/versions.yaml +. /usr/local/share/score-tools/internal/devcontainer/load_feature_versions.sh /devcontainer/features/s-core-local/versions.yaml ARCHITECTURE=$(dpkg --print-architecture) KERNEL=$(uname -s) @@ -58,7 +58,7 @@ apt-get install -y "python${python_version}" python3-pip python3-venv apt-get install -y flake8 python3-autopep8 black python3-yapf mypy pydocstyle pycodestyle bandit pipenv virtualenv pylint # Lockfile-managed local developer tools -/usr/local/share/score-tools/tool_installer.py install shellcheck ruff actionlint yamlfmt uv uvx apm opencode +/usr/local/share/score-tools/internal/devcontainer/install.py install shellcheck ruff actionlint yamlfmt uv uvx apm opencode # GraphViz # The Ubuntu Noble package of GraphViz diff --git a/src/s-core-devcontainer/.devcontainer/s-core-local/tests/test_default.sh b/src/s-core-devcontainer/.devcontainer/s-core-local/tests/test_default.sh index d14eacc..82561ee 100755 --- a/src/s-core-devcontainer/.devcontainer/s-core-local/tests/test_default.sh +++ b/src/s-core-devcontainer/.devcontainer/s-core-local/tests/test_default.sh @@ -19,16 +19,16 @@ ARCHITECTURE=$(dpkg --print-architecture) KERNEL=$(uname -s) # Read tool versions + metadata into environment variables -. /usr/local/share/score-tools/versions.sh /devcontainer/features/s-core-local/versions.yaml - -shellcheck_lockfile_version="$(/usr/local/share/score-tools/tool_installer.py version shellcheck)" -ruff_lockfile_version="$(/usr/local/share/score-tools/tool_installer.py version ruff)" -actionlint_lockfile_version="$(/usr/local/share/score-tools/tool_installer.py version actionlint)" -yamlfmt_lockfile_version="$(/usr/local/share/score-tools/tool_installer.py version yamlfmt)" -uv_lockfile_version="$(/usr/local/share/score-tools/tool_installer.py version uv)" -uvx_lockfile_version="$(/usr/local/share/score-tools/tool_installer.py version uvx)" -apm_lockfile_version="$(/usr/local/share/score-tools/tool_installer.py version apm)" -opencode_lockfile_version="$(/usr/local/share/score-tools/tool_installer.py version opencode)" +. /usr/local/share/score-tools/internal/devcontainer/load_feature_versions.sh /devcontainer/features/s-core-local/versions.yaml + +shellcheck_lockfile_version="$(/usr/local/share/score-tools/internal/devcontainer/install.py version shellcheck)" +ruff_lockfile_version="$(/usr/local/share/score-tools/internal/devcontainer/install.py version ruff)" +actionlint_lockfile_version="$(/usr/local/share/score-tools/internal/devcontainer/install.py version actionlint)" +yamlfmt_lockfile_version="$(/usr/local/share/score-tools/internal/devcontainer/install.py version yamlfmt)" +uv_lockfile_version="$(/usr/local/share/score-tools/internal/devcontainer/install.py version uv)" +uvx_lockfile_version="$(/usr/local/share/score-tools/internal/devcontainer/install.py version uvx)" +apm_lockfile_version="$(/usr/local/share/score-tools/internal/devcontainer/install.py version apm)" +opencode_lockfile_version="$(/usr/local/share/score-tools/internal/devcontainer/install.py version opencode)" # pre-commit, it is available via $PATH in login shells, but not in non-login shells check "validate pre-commit is working and has the correct version" bash -c "pre-commit --version | grep '4.5.1'" diff --git a/tools/README.md b/tools/README.md index df64898..b50bea5 100644 --- a/tools/README.md +++ b/tools/README.md @@ -14,315 +14,69 @@ SPDX-License-Identifier: Apache-2.0 ******************************************************************************* --> -# Tooling Strategy: Reproducible CLI Tools Across Development Environments +# Pinned command-line tools -> This document complements the general infrastructure direction defined in -> [DR-001 Infrastructure Design Decision](https://eclipse-score.github.io/score/main/design_decisions/DR-001-infra.html) -> and specifies how CLI tooling is provided across environments. +S-CORE repositories run the tools listed below through one interface: +`.devcontainer/run-tool`. Use it inside and outside the DevContainer so local +development, hooks, and CI select the same pinned version. -## Purpose +## Quick start -We provide selected CLI tools such as `actionlint` and `shellcheck` in a reproducible way across supported development environments. - -The goal is simple: - -- same tool versions -- same behavior -- same results - -independent of how developers choose to work. - ---- - -## Strategy - -We support two ways to access the same tooling: - -- **DevContainer** -- **Bazel via `rules_multitool`** - -Both are supported intentionally. - -Not all developers work the same way. Some prefer a fully managed environment, others prefer to stay on their host system. Both workflows exist in practice, and both need to produce identical results. - -![Tooling architecture](arch.svg) - ---- - -## Design Principle - -> Reproducibility is required. -> The execution path is a developer choice. - -This means: - -- no reliance on system-installed tools -- no hidden dependencies -- no environment-specific behavior - ---- - -## DevContainer - -The DevContainer provides: - -- a ready-to-use environment -- minimal setup effort -- predictable tooling - -For many developers, this is the most straightforward option. - ---- - -## Bazel-Based Tool Access - -We additionally expose tools via Bazel using [`rules_multitool`](https://github.com/bazel-contrib/rules_multitool). - -Example usage: -- `bazel run //tools:actionlint` -- `bazel run //tools:shellcheck` - -This exists primarily to support workflows outside the DevContainer. - -It allows: - -- reproducible tool execution on the host -- consistent versions across platforms -- alignment with CI execution - -At the same time, invoking standalone tools through a build system is not always the most ergonomic experience. The setup therefore focuses on making this path reliable rather than minimal. - ---- - -## Why We Support Both - -In practice: - -- some developers use the DevContainer -- some developers do not -- some switch between both depending on the task - -Relying on only one of these paths would either: - -- reduce adoption (DevContainer-only), or -- introduce inconsistencies (native-only) - -Supporting both allows flexibility without sacrificing consistency. - ---- - -## Why `rules_multitool` - -We use [`rules_multitool`](https://github.com/bazel-contrib/rules_multitool) to provide: - -- pinned tool versions -- checksum verification -- platform-specific binaries (Linux x64, macOS arm64) -- a uniform way to expose CLI tools via Bazel - -This is particularly useful for standalone tools such as: - -- `actionlint` -- `shellcheck` - -The alternative would be to manually maintain platform mappings, download logic, and wrappers for each tool. At scale, that quickly turns into a parallel infrastructure effort. - ---- - -## Why This Approach - -This setup reflects the actual constraints: - -- large number of users -- multiple host platforms -- mixed development workflows -- need for consistent results across local and CI - -A single enforced workflow would simplify the model, but would not match how the system is used in reality. - ---- - -## Alternatives Considered - -### DevContainer only - -Conceptually simple, but assumes universal adoption. In practice, that assumption does not hold, leading to gaps in reproducibility. - ---- - -### Bazel toolchains - -Technically correct and very powerful, but introduce significantly more complexity than needed for standalone CLI tools. - ---- - -## Why Use a Niche Solution - -`rules_multitool` is not widely used, and that is expected. - -Most teams: - -- operate on a single platform (usually Linux) -- rely on CI-only validation -- accept minor inconsistencies in local setups - -Under those conditions, simpler approaches are sufficient. - -Our setup differs: - -- cross-platform development (Linux + macOS ARM) -- large team size -- frequent local execution of tools -- low tolerance for inconsistencies - -In this context, reproducibility becomes more important than minimizing tooling layers. - ---- - -## Source of Truth - -For tools downloaded directly from upstream release artifacts that participate -in the shared lockfile-based setup, the authoritative metadata lives in the -`tools/lockfiles/*.lock.json` files. - -These lockfiles define: - -- supported platforms -- download URLs -- checksums -- archive or package layout - -Both Bazel via `rules_multitool` and the DevContainer installation scripts -consume the same lockfiles. - -Feature installation scripts must not duplicate version, URL, or checksum data -for these tools. - -Tools that are currently still managed directly inside a feature script, or via -the distribution package manager, remain managed elsewhere. - ---- - -## Using From Another Repository - -There are two supported Bazel usage patterns for consumers outside this -repository. - -### Option 1: Reuse the exported tool targets directly - -If another repository wants to use the exact targets defined here, it can depend -on this module and run the tools through external labels. - -Consumer `MODULE.bazel`: - -```starlark -module(name = "consumer") - -bazel_dep(name = "score_devcontainer", version = "1.4.1") +```console +$ .devcontainer/run-tool shellcheck scripts/example.sh +$ .devcontainer/run-tool ruff check . ``` -Then run the tools through the exported targets from this repository: - -- `bazel run @score_devcontainer//tools:actionlint -- --version` -- `bazel run @score_devcontainer//tools:shellcheck -- --version` -- `bazel run @score_devcontainer//tools:ruff -- --version` - -If the consumer wants local target names, it can keep option 1 and add local -aliases on top: - -Consumer `BUILD.bazel`: - -```starlark -alias( - name = "shellcheck", - actual = "@score_devcontainer//tools:shellcheck", -) - -alias( - name = "actionlint", - actual = "@score_devcontainer//tools:actionlint", -) -``` - -Then run: - -- `bazel run //:shellcheck -- --version` -- `bazel run //:actionlint -- --version` - -This is the simplest option if the consumer wants the targets defined here, but -prefers local labels in its own repository. - -### Option 2: Reuse the lockfiles, but define local targets in the consumer - -If another repository wants to keep its own target names, it can import the -lockfiles exported by this repository and create its own `rules_multitool` hub. +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 lockfiles are exported as files from the top-level `tools` package, so the -external labels look like this: +## Available tools -- `@score_devcontainer//tools:lockfiles/actionlint.lock.json` -- `@score_devcontainer//tools:lockfiles/shellcheck.lock.json` + + -Consumer `MODULE.bazel`: +| Command | Version | Purpose | +| --- | --- | --- | +| `actionlint` | `1.7.7` | Check GitHub Actions workflow files | +| `apm` | `0.27.0` | Manage APM packages | +| `bazelisk` | `1.27.0` | Run the Bazel version selected by a repository | +| `buildifier` | `8.2.1` | Format and lint Bazel files | +| `opencode` | `1.18.15` | Run the OpenCode CLI | +| `ruff` | `0.11.13` | Check and format Python code | +| `shellcheck` | `0.10.0` | Check shell scripts | +| `starpls` | `0.1.22` | Provide language-server support for Starlark | +| `uv` | `0.10.4` | Manage Python projects and packages | +| `uvx` | `0.10.4` | Run Python tools in isolated environments | +| `yamlfmt` | `0.17.0` | Format YAML files | -```starlark -module(name = "consumer") + -bazel_dep(name = "rules_multitool", version = "1.11.1") -bazel_dep(name = "score_devcontainer", version = "1.4.1") +Pass `--help` through the runner to see a command's own documentation: -multitool = use_extension("@rules_multitool//multitool:extension.bzl", "multitool") +```console +$ .devcontainer/run-tool shellcheck --help +``` -multitool.hub(lockfile = "@score_devcontainer//tools:lockfiles/shellcheck.lock.json") -multitool.hub(lockfile = "@score_devcontainer//tools:lockfiles/actionlint.lock.json") +## Add the runner to a repository -use_repo(multitool, "multitool") -register_toolchains("@multitool//toolchains:all") -``` +Copy the maintained [`run-tool`](run-tool) to `.devcontainer/run-tool` in the +consumer repository and make it executable. -Consumer `BUILD.bazel`: +Pin that release as a module dependency in the repository's `MODULE.bazel`: ```starlark -alias( - name = "shellcheck", - actual = "@multitool//tools/shellcheck:cwd", -) - -alias( - name = "actionlint", - actual = "@multitool//tools/actionlint:cwd", -) +bazel_dep(name = "score_devcontainer", version = "") ``` -Then run: - -- `bazel run //:shellcheck -- --version` -- `bazel run //:actionlint -- --version` - -This option is useful if the consumer wants to share the pinned tool metadata -but expose its own wrapper targets. - -### Version alignment - -The `score_devcontainer` Bazel module version corresponds to the DevContainer -image version. Repositories that use both the DevContainer and the Bazel module -must pin the same version to ensure identical tool versions in both paths. - -### Notes - -- The lockfile labels above are intended as the cross-repository API for Bazel - consumers. -- The lockfile shell installer in this directory is internal support code for - the DevContainer image build. It is not intended as a stable cross-repository - API. - ---- - -## Summary - -We provide: +Replace `` with the S-CORE DevContainer release used by the +repository. Use the same release for the Bazel module and the DevContainer +image; otherwise the command set or versions can differ. -- a **DevContainer** for convenience and quick setup -- **Bazel-based tooling** for reproducible execution outside the container +Outside the container, a `bazel` executable must be on `PATH`. Install Bazel +or Bazelisk as `bazel` before the first host-side invocation; it bootstraps +access to the catalog. -This combination allows developers to choose their workflow while ensuring consistent and predictable results across the project. +Implementation and maintenance details are in +[internal/README.md](internal/README.md). diff --git a/tools/internal/README.md b/tools/internal/README.md new file mode 100644 index 0000000..45b8cf1 --- /dev/null +++ b/tools/internal/README.md @@ -0,0 +1,156 @@ + + +# Maintaining command-line tools + +This guide describes how the pinned command-line tools are provided and +maintained. For normal usage, see [Pinned command-line tools](../README.md). + +The approach complements the general direction in +[DR-001 Infrastructure Design Decision](https://eclipse-score.github.io/score/main/design_decisions/DR-001-infra.html). + +## Delivery contract + +One catalog is delivered through two execution paths: + +| Component | Responsibility | +| --- | --- | +| `tools/lockfiles/*.lock.json` | Define versions, supported platforms, download locations, checksums, and archive layouts. | +| `MODULE.bazel` | Makes each lockfile available to `rules_multitool`. | +| `tools/BUILD.bazel` | Exposes the public Bazel aliases for each command. | +| Feature installers | Install the commands exposed on the DevContainer's `PATH`. | +| `tools/run-tool` | Uses the command on `PATH` in a container when available; otherwise uses the public Bazel alias. | +| `tools/README.md` | Documents every command and its version from the catalog. | + +Adding a lockfile alone does not make a command available through either +delivery path. Add its Bazel alias and its applicable DevContainer installer +as well. The runner is an interface selector, not a registry of commands. + +## Architecture + +The public interface is `.devcontainer/run-tool` in each consumer +repository. Inside a container, it uses the command installed on `PATH` when +available. Otherwise, it invokes the matching public Bazel target. Developers +use the same command line without choosing the execution path. + +![Command-line tool delivery](tool-delivery.svg) + +Native tools with upstream release artifacts use +[`rules_multitool`](https://github.com/bazel-contrib/rules_multitool). + +The `multitool_aliases` helper exposes the `rules_multitool` `cwd` target as +the public command. That wrapper restores `BUILD_WORKING_DIRECTORY` before +starting a tool, so +configuration discovery and repository-relative paths behave like a direct +invocation. + +The maintained runner source is [`run-tool`](../run-tool). It remains under +`tools/` in this implementation repository; consumer repositories copy it to +`.devcontainer/run-tool`. This repository invokes that source directly from +its pre-commit configuration. Consumer documentation only presents the copied +`.devcontainer/run-tool` path. + +## Sources of truth + +Native tool metadata lives in +[`tools/lockfiles/*.lock.json`](../lockfiles). Each lockfile records versions, +supported platforms, download URLs, checksums, and archive layouts. +`rules_multitool` consumes the files for Bazel; +[`devcontainer/install.py`](devcontainer/install.py) consumes them while +building the DevContainer. + +The published `score_devcontainer` Bazel module and DevContainer image share a +release version. Consumers using both must pin the same release. + +## Adding or updating a tool + +For a tool distributed as a native release artifact, make the following +changes together: + +1. Add or update the multitool-compatible + `tools/lockfiles/.lock.json`, including a `description` field + for each tool entry (used to generate the README table). +2. Register the lockfile with `multitool.hub` in the root + [`MODULE.bazel`](../../MODULE.bazel). +3. Add `multitool_aliases("")` to + [`tools/BUILD.bazel`](../BUILD.bazel). +4. Add the command to the appropriate feature installer: + [`s-core-local/install.sh`](../../src/s-core-devcontainer/.devcontainer/s-core-local/install.sh) + for general tools or + [`bazel-feature/install.sh`](../../src/s-core-devcontainer/.devcontainer/bazel-feature/install.sh) + for Bazel-specific tools. +5. Read the lockfile version and test the installed command in the matching + feature test: + [`s-core-local/tests/test_default.sh`](../../src/s-core-devcontainer/.devcontainer/s-core-local/tests/test_default.sh) + or + [`bazel-feature/tests/test_default.sh`](../../src/s-core-devcontainer/.devcontainer/bazel-feature/tests/test_default.sh). +6. Regenerate the documented command table: + + ```console + $ python3 tools/internal/sync_readme.py + ``` + +7. If this repository's own DevContainer needs the command, add it to + [`.devcontainer/post_create_command.sh`](../../.devcontainer/post_create_command.sh). + +Keep related commands such as `uv` and `uvx` in one lockfile when upstream +publishes them together. The installer can locate a command in a differently +named lockfile, but an explicit `--lockfile` remains available for ambiguous +cases. + +To remove a command, remove it from the same integration points, including +its `description` field. Then run `sync_readme.py` (see below); it rejects +lockfile entries without a description. + +## Validation and release alignment + +Regenerate the user-facing command table after changing a lockfile or a +description: + +```console +$ python3 tools/internal/sync_readme.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. + +Run the feature test for every installer changed. The feature tests read their +expected versions from the catalog, which verifies that the DevContainer and +lockfiles remain aligned. Publish the Bazel module and the DevContainer image +with the same release version so consumer repositories can pin one version for +both delivery paths. + +## Bazel target conventions + +`multitool_aliases` exposes two native-tool targets: + +- `` uses the caller's working directory and is intended for + `bazel run`. +- `_binary` is the raw executable for use as a tool dependency in other + Bazel rules. + +## Rationale and boundaries + +The runner supports mixed workflows without exposing two user interfaces. A +container-only implementation would not cover developers who do not run the +DevContainer at all, while arbitrary system-installed tools would lose +version alignment with those who do. + +Full Bazel toolchains remain appropriate for tools that participate in build +actions or platform transitions. They add unnecessary complexity for the +standalone CLIs covered here. Bazel targets, exported lockfiles, and +`internal/devcontainer/install.py` are implementation details; the runner is +the only documented consumer invocation method. diff --git a/tools/tool_installer.py b/tools/internal/devcontainer/install.py similarity index 73% rename from tools/tool_installer.py rename to tools/internal/devcontainer/install.py index dd1288b..a518df9 100755 --- a/tools/tool_installer.py +++ b/tools/internal/devcontainer/install.py @@ -11,14 +11,14 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -"""Install pinned tools from the `tools/lockfiles/*.lock.json` catalog. +"""Install pinned tools from the shared `tools/lockfiles` catalog. Dependency-free (stdlib only) so devcontainer feature installers can use it without extra packages. Usage: - tool_installer.py install shellcheck yamlfmt - tool_installer.py version shellcheck + install.py install shellcheck yamlfmt + install.py version shellcheck """ # pyright: reportAny=false, reportUnusedCallResult=false, reportExplicitAny=false @@ -37,6 +37,7 @@ import zipfile from pathlib import Path from typing import NotRequired, TypedDict +from collections.abc import Iterator class Binary(TypedDict): @@ -56,10 +57,66 @@ class ToolData(TypedDict): """Tool metadata from a lockfile entry.""" version: NotRequired[str] + description: NotRequired[str] binaries: list[Binary] -LOCKFILE_ROOT = Path(__file__).resolve().parent / "lockfiles" +LOCKFILE_ROOT = Path(__file__).resolve().parents[2] / "lockfiles" + + +def _iter_catalog() -> Iterator[tuple[str, str, ToolData]]: + """Yield (tool, lockfile filename, definition) for every catalog entry.""" + for path in sorted(LOCKFILE_ROOT.glob("*.lock.json")): + with path.open(encoding="utf-8") as handle: + data = json.load(handle) + + for tool, definition in data.items(): + if tool.startswith("$"): + continue + if not isinstance(definition, dict): + raise SystemExit(f"Unexpected entry '{tool}' in '{path.name}'") + yield tool, path.name, definition + + +def load_catalog_versions() -> dict[str, str]: + """Return every tool version declared by the lockfile catalog.""" + versions: dict[str, str] = {} + + for tool, filename, definition in _iter_catalog(): + version = definition.get("version") + if not isinstance(version, str): + raise SystemExit( + f"Tool '{tool}' in '{filename}' does not define a string version" + ) + if tool in versions: + raise SystemExit(f"Tool '{tool}' is defined by multiple lockfiles") + versions[tool] = version + + return versions + + +def load_catalog_descriptions() -> dict[str, str]: + """Return every tool description declared by the lockfile catalog. + + Tools without a description are omitted so callers can report a + friendly list of undocumented tools instead of failing on the first + one encountered. + """ + descriptions: dict[str, str] = {} + + for tool, filename, definition in _iter_catalog(): + description = definition.get("description") + if description is None: + continue + if not isinstance(description, str): + raise SystemExit( + f"Tool '{tool}' in '{filename}' has a non-string description" + ) + if tool in descriptions: + raise SystemExit(f"Tool '{tool}' is defined by multiple lockfiles") + descriptions[tool] = description + + return descriptions def _detect_os() -> str: @@ -133,6 +190,16 @@ def _select_binary(tool_data: ToolData, os_name: str, cpu: str) -> Binary: def _cmd_version(args: argparse.Namespace) -> int: """Print the declared version for one tool.""" + if args.lockfile is None: + versions = load_catalog_versions() + try: + print(versions[args.tool]) + except KeyError as exc: + raise SystemExit( + f"Tool '{args.tool}' not found in lockfile catalog" + ) from exc + return 0 + args.lockfile = _resolve_lockfile(args.tool, args.lockfile) tool_data = _load_tool(args.lockfile, args.tool) version = tool_data.get("version") @@ -232,16 +299,19 @@ def _cmd_install(args: argparse.Namespace) -> int: if kind == "file": _place_binary(download, destination) elif kind == "archive": - extracted = tmp / "extracted" - _extract_member(binary, download, extracted, tool) - if extracted.exists(): - _place_binary(extracted, destination) - elif kind == "archive-dir": - extracted_dir = tmp / "extracted_dir" - extracted_dir.mkdir() - _extract_dir(binary, download, extracted_dir, tool) - shutil.copytree(str(extracted_dir), str(dest_dir), dirs_exist_ok=True) - (dest_dir / tool).chmod(0o755) + if "dir" in binary: + extracted_dir = tmp / "extracted_dir" + extracted_dir.mkdir() + _extract_dir(binary, download, extracted_dir, tool) + shutil.copytree( + str(extracted_dir), str(dest_dir), dirs_exist_ok=True + ) + destination.chmod(0o755) + else: + extracted = tmp / "extracted" + _extract_member(binary, download, extracted, tool) + if extracted.exists(): + _place_binary(extracted, destination) else: raise SystemExit(f"Unsupported kind '{kind}' for {tool}") diff --git a/tools/versions.sh b/tools/internal/devcontainer/load_feature_versions.sh similarity index 68% rename from tools/versions.sh rename to tools/internal/devcontainer/load_feature_versions.sh index f2a845c..bc2ce33 100755 --- a/tools/versions.sh +++ b/tools/internal/devcontainer/load_feature_versions.sh @@ -23,15 +23,12 @@ if [ "${ARCHITECTURE}" = "arm64" ]; then SHA256_FIELD="05df1f6aed334f223bb3e6a967db259f7185e33650c3b6447625e16fea0ed31f" fi -# if /tmp/yq does not exist, download yq -if [ ! -f /tmp/yq ]; then - curl -L "https://github.com/mikefarah/yq/releases/download/${VERSION}/yq_linux_${ARCHITECTURE}" -o /tmp/yq - echo "${SHA256_FIELD} /tmp/yq" | sha256sum -c - || exit 1 - chmod +x /tmp/yq -fi +yq_binary="$(mktemp)" +trap 'rm -f "${yq_binary}"' EXIT -# Read tool versions and metadata into environment variables -export $(/tmp/yq eval '.. | select((tag == "!!map" or tag == "!!seq") | not) | (path | join("_")) + "=" + .' "$1" | awk '!/=$/{print }' | xargs) +curl --fail --location "https://github.com/mikefarah/yq/releases/download/${VERSION}/yq_linux_${ARCHITECTURE}" -o "${yq_binary}" +echo "${SHA256_FIELD} ${yq_binary}" | sha256sum -c - || exit 1 +chmod +x "${yq_binary}" -# Clean up -trap 'rm -f /tmp/yq' EXIT +# Read tool versions and metadata into environment variables +export $("${yq_binary}" eval '.. | select((tag == "!!map" or tag == "!!seq") | not) | (path | join("_")) + "=" + .' "$1" | awk '!/=$/{print }' | xargs) diff --git a/tools/internal/sync_readme.py b/tools/internal/sync_readme.py new file mode 100644 index 0000000..e98f681 --- /dev/null +++ b/tools/internal/sync_readme.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 the documented tool table with the lockfile catalog.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from devcontainer.install import load_catalog_descriptions, load_catalog_versions + + +TOOLS_DIR = Path(__file__).resolve().parent.parent +README_PATH = TOOLS_DIR / "README.md" +TABLE_START = "" +TABLE_END = "" + + +def _render_table(versions: dict[str, str], descriptions: dict[str, str]) -> str: + undocumented = sorted(versions.keys() - descriptions.keys()) + if undocumented: + raise SystemExit("Missing tool descriptions for: " + ", ".join(undocumented)) + + rows = [ + TABLE_START, + "", + "", + "| Command | Version | Purpose |", + "| --- | --- | --- |", + ] + rows.extend( + f"| `{command}` | `{versions[command]}` | {descriptions[command]} |" + for command in sorted(versions) + ) + rows.extend(["", TABLE_END]) + return "\n".join(rows) + + +def _updated_readme(content: str, table: str) -> str: + if content.count(TABLE_START) != 1 or content.count(TABLE_END) != 1: + raise SystemExit( + f"{README_PATH.name} must contain exactly one generated tool table" + ) + + start = content.index(TABLE_START) + end = content.index(TABLE_END, start) + len(TABLE_END) + return content[:start] + table + content[end:] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Synchronize README tool versions with lockfiles.", + ) + parser.parse_args(argv) + + current = README_PATH.read_text(encoding="utf-8") + table = _render_table(load_catalog_versions(), load_catalog_descriptions()) + expected = _updated_readme(current, table) + + if current == expected: + return 0 + + README_PATH.write_text(expected, encoding="utf-8") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/arch.svg b/tools/internal/tool-delivery.svg similarity index 97% rename from tools/arch.svg rename to tools/internal/tool-delivery.svg index 1abf659..5296920 100644 --- a/tools/arch.svg +++ b/tools/internal/tool-delivery.svg @@ -4,7 +4,7 @@ Copyright (c) 2026 Contributors to the Eclipse Foundation SPDX-License-Identifier: Apache-2.0 --> - Tooling architecture + Pinned command-line tool delivery A shared lockfile catalog feeds DevContainer installation scripts and Bazel rules_multitool targets, giving both execution paths the same tool versions and checksums. diff --git a/tools/lockfiles/actionlint.lock.json b/tools/lockfiles/actionlint.lock.json index 9c37306..1f31e29 100644 --- a/tools/lockfiles/actionlint.lock.json +++ b/tools/lockfiles/actionlint.lock.json @@ -2,6 +2,7 @@ "$schema": "https://raw.githubusercontent.com/bazel-contrib/rules_multitool/main/lockfile.schema.json", "actionlint": { "version": "1.7.7", + "description": "Check GitHub Actions workflow files", "binaries": [ { "kind": "archive", diff --git a/tools/lockfiles/apm.lock.json b/tools/lockfiles/apm.lock.json index 924f366..ad01b22 100644 --- a/tools/lockfiles/apm.lock.json +++ b/tools/lockfiles/apm.lock.json @@ -2,34 +2,43 @@ "$schema": "https://raw.githubusercontent.com/bazel-contrib/rules_multitool/main/lockfile.schema.json", "apm": { "version": "0.27.0", + "description": "Manage APM packages", "binaries": [ { - "kind": "archive-dir", + "kind": "archive", + "type": "tar.gz", "dir": "apm-darwin-arm64", + "file": "apm-darwin-arm64/apm", "url": "https://github.com/microsoft/apm/releases/download/v0.27.0/apm-darwin-arm64.tar.gz", "sha256": "4c68e5eaa3cfdb0b25734c316deb532835eaf3c3e2f7379a4c7c06918043a641", "os": "macos", "cpu": "arm64" }, { - "kind": "archive-dir", + "kind": "archive", + "type": "tar.gz", "dir": "apm-darwin-x86_64", + "file": "apm-darwin-x86_64/apm", "url": "https://github.com/microsoft/apm/releases/download/v0.27.0/apm-darwin-x86_64.tar.gz", "sha256": "846b30055d96cbc6fa0fcf451f50d13f632b540ffdff344873a025bba607e25a", "os": "macos", "cpu": "x86_64" }, { - "kind": "archive-dir", + "kind": "archive", + "type": "tar.gz", "dir": "apm-linux-x86_64", + "file": "apm-linux-x86_64/apm", "url": "https://github.com/microsoft/apm/releases/download/v0.27.0/apm-linux-x86_64.tar.gz", "sha256": "be2d8a97ca8816636117ec26da85482d647ae3353213ea022fb1130c2dd3d3b0", "os": "linux", "cpu": "x86_64" }, { - "kind": "archive-dir", + "kind": "archive", + "type": "tar.gz", "dir": "apm-linux-arm64", + "file": "apm-linux-arm64/apm", "url": "https://github.com/microsoft/apm/releases/download/v0.27.0/apm-linux-arm64.tar.gz", "sha256": "7df6e64ca9540665367f07af0226077ba92820f6cc759c10a5ca37e038a500e4", "os": "linux", diff --git a/tools/lockfiles/bazelisk.lock.json b/tools/lockfiles/bazelisk.lock.json index 6fd42a5..50dfabc 100644 --- a/tools/lockfiles/bazelisk.lock.json +++ b/tools/lockfiles/bazelisk.lock.json @@ -2,6 +2,7 @@ "$schema": "https://raw.githubusercontent.com/bazel-contrib/rules_multitool/main/lockfile.schema.json", "bazelisk": { "version": "1.27.0", + "description": "Run the Bazel version selected by a repository", "binaries": [ { "kind": "file", diff --git a/tools/lockfiles/buildifier.lock.json b/tools/lockfiles/buildifier.lock.json index 680c647..a5560b0 100644 --- a/tools/lockfiles/buildifier.lock.json +++ b/tools/lockfiles/buildifier.lock.json @@ -2,6 +2,7 @@ "$schema": "https://raw.githubusercontent.com/bazel-contrib/rules_multitool/main/lockfile.schema.json", "buildifier": { "version": "8.2.1", + "description": "Format and lint Bazel files", "binaries": [ { "kind": "file", diff --git a/tools/lockfiles/opencode.lock.json b/tools/lockfiles/opencode.lock.json index fbcb3be..811d21e 100644 --- a/tools/lockfiles/opencode.lock.json +++ b/tools/lockfiles/opencode.lock.json @@ -2,6 +2,7 @@ "$schema": "https://raw.githubusercontent.com/bazel-contrib/rules_multitool/main/lockfile.schema.json", "opencode": { "version": "1.18.15", + "description": "Run the OpenCode CLI", "binaries": [ { "kind": "archive", diff --git a/tools/lockfiles/ruff.lock.json b/tools/lockfiles/ruff.lock.json index 39a3338..6e34997 100644 --- a/tools/lockfiles/ruff.lock.json +++ b/tools/lockfiles/ruff.lock.json @@ -2,6 +2,7 @@ "$schema": "https://raw.githubusercontent.com/bazel-contrib/rules_multitool/main/lockfile.schema.json", "ruff": { "version": "0.11.13", + "description": "Check and format Python code", "binaries": [ { "kind": "archive", diff --git a/tools/lockfiles/shellcheck.lock.json b/tools/lockfiles/shellcheck.lock.json index 7022955..d4de0cc 100644 --- a/tools/lockfiles/shellcheck.lock.json +++ b/tools/lockfiles/shellcheck.lock.json @@ -2,6 +2,7 @@ "$schema": "https://raw.githubusercontent.com/bazel-contrib/rules_multitool/main/lockfile.schema.json", "shellcheck": { "version": "0.10.0", + "description": "Check shell scripts", "binaries": [ { "kind": "archive", diff --git a/tools/lockfiles/starpls.lock.json b/tools/lockfiles/starpls.lock.json index 0806e21..03d29a4 100644 --- a/tools/lockfiles/starpls.lock.json +++ b/tools/lockfiles/starpls.lock.json @@ -2,6 +2,7 @@ "$schema": "https://raw.githubusercontent.com/bazel-contrib/rules_multitool/main/lockfile.schema.json", "starpls": { "version": "0.1.22", + "description": "Provide language-server support for Starlark", "binaries": [ { "kind": "file", diff --git a/tools/lockfiles/uv.lock.json b/tools/lockfiles/uv.lock.json index 8317747..5ab2f8f 100644 --- a/tools/lockfiles/uv.lock.json +++ b/tools/lockfiles/uv.lock.json @@ -2,6 +2,7 @@ "$schema": "https://raw.githubusercontent.com/bazel-contrib/rules_multitool/main/lockfile.schema.json", "uv": { "version": "0.10.4", + "description": "Manage Python projects and packages", "binaries": [ { "kind": "archive", @@ -34,6 +35,7 @@ }, "uvx": { "version": "0.10.4", + "description": "Run Python tools in isolated environments", "binaries": [ { "kind": "archive", diff --git a/tools/lockfiles/yamlfmt.lock.json b/tools/lockfiles/yamlfmt.lock.json index f00e3c6..f3bfe14 100644 --- a/tools/lockfiles/yamlfmt.lock.json +++ b/tools/lockfiles/yamlfmt.lock.json @@ -2,6 +2,7 @@ "$schema": "https://raw.githubusercontent.com/bazel-contrib/rules_multitool/main/lockfile.schema.json", "yamlfmt": { "version": "0.17.0", + "description": "Format YAML files", "binaries": [ { "kind": "archive", diff --git a/tools/run_tool.sh b/tools/run-tool similarity index 55% rename from tools/run_tool.sh rename to tools/run-tool index 504547d..894c5d6 100755 --- a/tools/run_tool.sh +++ b/tools/run-tool @@ -13,9 +13,9 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -# Unified entry point for running a CLI tool by name. -# Inside a container the tool is expected on PATH; outside, it is resolved via Bazel. -# See tools/README.md for the rationale behind supporting both paths. +# 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. +# See https://github.com/eclipse-score/devcontainer/tree/main/tools. set -euo pipefail @@ -27,18 +27,12 @@ fi tool_name="$1" shift -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" -repository_root="$(cd "${script_dir}/.." && pwd -P)" - if { [[ -f /.dockerenv ]] || [[ -f /run/.containerenv ]] || [[ -d /devcontainer ]]; } && - command -v "${tool_name}" >/dev/null 2>&1; then - exec "${tool_name}" "$@" -fi - -if command -v bazel >/dev/null 2>&1; then -cd "${repository_root}" -exec bazel run "//tools:${tool_name}" -- "$@" + command -v "${tool_name}" >/dev/null 2>&1; then + exec "${tool_name}" "$@" +elif command -v bazel >/dev/null 2>&1; then + exec bazel run "@score_devcontainer//tools:${tool_name}" -- "$@" +else + echo "Could not run '${tool_name}': no container command or Bazel executable is available." >&2 + exit 127 fi - -echo "Could not run '${tool_name}': not available on PATH in a container, and bazel was not found." >&2 -exit 127