From c6fbf2a78bbe712b90e05aa8ee830c867388d2d4 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Thu, 13 Aug 2026 00:23:53 +0200 Subject: [PATCH 1/9] refactor: organize pinned tool infrastructure --- .devcontainer/post_create_command.sh | 2 +- .pre-commit-config.yaml | 10 +- MODULE.bazel | 12 +- README.md | 11 +- .../.devcontainer/bazel-feature/install.sh | 4 +- .../bazel-feature/load_feature_versions.sh | 0 .../bazel-feature/tests/test_default.sh | 8 +- .../.devcontainer/devcontainer-lock.json | 2 +- .../.devcontainer/s-core-local/install.sh | 4 +- .../s-core-local/load_feature_versions.sh | 37 ++ .../s-core-local/tests/test_default.sh | 20 +- tools/BUILD.bazel | 4 +- tools/README.md | 338 +++--------------- tools/internal/README.md | 159 ++++++++ .../devcontainer/install.py} | 50 ++- tools/internal/sync_readme.py | 112 ++++++ .../{arch.svg => internal/tool-delivery.svg} | 2 +- tools/{run_tool.sh => run-tool} | 26 +- 18 files changed, 452 insertions(+), 349 deletions(-) rename tools/versions.sh => src/s-core-devcontainer/.devcontainer/bazel-feature/load_feature_versions.sh (100%) create mode 100755 src/s-core-devcontainer/.devcontainer/s-core-local/load_feature_versions.sh create mode 100644 tools/internal/README.md rename tools/{tool_installer.py => internal/devcontainer/install.py} (85%) create mode 100644 tools/internal/sync_readme.py rename tools/{arch.svg => internal/tool-delivery.svg} (97%) rename tools/{run_tool.sh => run-tool} (55%) diff --git a/.devcontainer/post_create_command.sh b/.devcontainer/post_create_command.sh index 4185353..c6e85b4 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 bazelisk shellcheck yamlfmt +sudo "${REPOSITORY_ROOT}/tools/internal/devcontainer/install.py" install bazelisk shellcheck yamlfmt uv uvx pre-commit install diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e8016e2..a170b4f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,14 +37,20 @@ repos: pass_filenames: false - id: yamlfmt name: yamlfmt - entry: tools/run_tool.sh yamlfmt + entry: tools/run-tool yamlfmt language: system types: [yaml] - id: shellcheck name: shellcheck - entry: tools/run_tool.sh shellcheck + entry: tools/run-tool shellcheck language: system types: [shell] + - id: tool-documentation + name: check tool documentation + entry: python3 tools/internal/sync_readme.py --check + language: system + pass_filenames: false + files: ^tools/(README\.md|internal/sync_readme\.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..86f66e3 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 +. /devcontainer/features/bazel/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/tools/versions.sh b/src/s-core-devcontainer/.devcontainer/bazel-feature/load_feature_versions.sh similarity index 100% rename from tools/versions.sh rename to src/s-core-devcontainer/.devcontainer/bazel-feature/load_feature_versions.sh 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..b96fea4 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 +. /devcontainer/features/bazel/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 6e745a2..2f85a15 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 +. /devcontainer/features/s-core-local/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/load_feature_versions.sh b/src/s-core-devcontainer/.devcontainer/s-core-local/load_feature_versions.sh new file mode 100755 index 0000000..f2a845c --- /dev/null +++ b/src/s-core-devcontainer/.devcontainer/s-core-local/load_feature_versions.sh @@ -0,0 +1,37 @@ +#!/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 + +ARCHITECTURE=$(dpkg --print-architecture) +VERSION="v4.47.2" + +SHA256_FIELD="1bb99e1019e23de33c7e6afc23e93dad72aad6cf2cb03c797f068ea79814ddb0" # Default to amd64 +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 + +# 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) + +# Clean up +trap 'rm -f /tmp/yq' EXIT 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 dea564b..964f864 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)" +. /devcontainer/features/s-core-local/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/BUILD.bazel b/tools/BUILD.bazel index 0864323..241ac20 100644 --- a/tools/BUILD.bazel +++ b/tools/BUILD.bazel @@ -18,13 +18,13 @@ package(default_visibility = ["//visibility:public"]) exports_files(glob(["lockfiles/*.lock.json"])) multitool_aliases("actionlint") +multitool_aliases("apm") multitool_aliases("bazelisk") multitool_aliases("buildifier") +multitool_aliases("opencode") multitool_aliases("ruff") multitool_aliases("shellcheck") multitool_aliases("starpls") multitool_aliases("uv") multitool_aliases("uvx") multitool_aliases("yamlfmt") -multitool_aliases("apm") -multitool_aliases("opencode") 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..ca55f13 --- /dev/null +++ b/tools/internal/README.md @@ -0,0 +1,159 @@ + + +# 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`. +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. Add the command description to `PURPOSES` in + [`sync_readme.py`](sync_readme.py), then regenerate the 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. Then run the +documentation check below; it rejects command descriptions without a catalog +entry and catalog 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 +``` + +Use `--check` in automation to reject stale generated content: + +```console +$ python3 tools/internal/sync_readme.py --check +``` + +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 working on Linux or +macOS hosts, while arbitrary system-installed tools would lose version +alignment. + +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 85% rename from tools/tool_installer.py rename to tools/internal/devcontainer/install.py index 0404bc5..06d9736 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 @@ -59,7 +59,33 @@ class ToolData(TypedDict): binaries: list[Binary] -LOCKFILE_ROOT = Path(__file__).resolve().parent / "lockfiles" +LOCKFILE_ROOT = Path(__file__).resolve().parents[2] / "lockfiles" + + +def load_catalog_versions() -> dict[str, str]: + """Return every tool version declared by the lockfile catalog.""" + versions: dict[str, str] = {} + + 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}'") + + version = definition.get("version") + if not isinstance(version, str): + raise SystemExit( + f"Tool '{tool}' in '{path.name}' 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 _detect_os() -> str: @@ -133,6 +159,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") @@ -173,9 +209,7 @@ def _extract_member( raise SystemExit(f"Unsupported archive type '{archive_type}' for {tool}") -def _extract_dir( - binary: Binary, archive_path: Path, out_dir: Path, tool: str -) -> None: +def _extract_dir(binary: Binary, archive_path: Path, out_dir: Path, tool: str) -> None: """Extract a directory from a tar archive, stripping the top-level prefix.""" dir_prefix = binary.get("dir") if dir_prefix is None: @@ -187,7 +221,7 @@ def _extract_dir( for member in tf.getmembers(): if not member.name.startswith(prefix): continue - rel = member.name[len(prefix):] + rel = member.name[len(prefix) :] if not rel: continue dest = out_dir / rel diff --git a/tools/internal/sync_readme.py b/tools/internal/sync_readme.py new file mode 100644 index 0000000..0b19e49 --- /dev/null +++ b/tools/internal/sync_readme.py @@ -0,0 +1,112 @@ +# ******************************************************************************* +# 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_versions + + +TOOLS_DIR = Path(__file__).resolve().parent.parent +README_PATH = TOOLS_DIR / "README.md" +TABLE_START = "" +TABLE_END = "" + +PURPOSES = { + "actionlint": "Check GitHub Actions workflow files", + "apm": "Manage APM packages", + "bazelisk": "Run the Bazel version selected by a repository", + "buildifier": "Format and lint Bazel files", + "opencode": "Run the OpenCode CLI", + "ruff": "Check and format Python code", + "shellcheck": "Check shell scripts", + "starpls": "Provide language-server support for Starlark", + "uv": "Manage Python projects and packages", + "uvx": "Run Python tools in isolated environments", + "yamlfmt": "Format YAML files", +} + + +def _render_table(versions: dict[str, str]) -> str: + undocumented = sorted(versions.keys() - PURPOSES.keys()) + stale_purposes = sorted(PURPOSES.keys() - versions.keys()) + if undocumented: + raise SystemExit( + "Missing tool descriptions for: " + ", ".join(undocumented) + ) + if stale_purposes: + raise SystemExit( + "Descriptions exist without lockfile entries for: " + + ", ".join(stale_purposes) + ) + + rows = [ + TABLE_START, + "", + "", + "| Command | Version | Purpose |", + "| --- | --- | --- |", + ] + rows.extend( + f"| `{command}` | `{versions[command]}` | {PURPOSES[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.add_argument( + "--check", + action="store_true", + help="Fail instead of updating README.md when the generated table differs.", + ) + args = parser.parse_args(argv) + + current = README_PATH.read_text(encoding="utf-8") + expected = _updated_readme(current, _render_table(load_catalog_versions())) + + if current == expected: + return 0 + if args.check: + print( + "tools/README.md is out of date; run " + "'python3 tools/internal/sync_readme.py'", + file=sys.stderr, + ) + return 1 + + 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/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 From 7ce99790ee2ad30b0d5d4385acb78cc021f597bb Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Thu, 13 Aug 2026 10:26:08 +0200 Subject: [PATCH 2/9] ci: add ruff format and check pre-commit hooks Add local pre-commit hooks that run the pinned ruff tool via tools/run-tool, mirroring the existing yamlfmt/shellcheck hooks: - ruff-format: formats Python files - ruff-check: lints Python files with --fix Also apply ruff format to tools/internal/sync_readme.py. --- .pre-commit-config.yaml | 10 ++++++++++ tools/internal/sync_readme.py | 4 +--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a170b4f..833f063 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,6 +45,16 @@ repos: entry: tools/run-tool shellcheck language: system types: [shell] + - id: ruff-format + name: ruff format + entry: tools/run-tool ruff format + language: system + types: [python] + - id: ruff-check + name: ruff check + entry: tools/run-tool ruff check --fix + language: system + types: [python] - id: tool-documentation name: check tool documentation entry: python3 tools/internal/sync_readme.py --check diff --git a/tools/internal/sync_readme.py b/tools/internal/sync_readme.py index 0b19e49..41aefcd 100644 --- a/tools/internal/sync_readme.py +++ b/tools/internal/sync_readme.py @@ -45,9 +45,7 @@ def _render_table(versions: dict[str, str]) -> str: undocumented = sorted(versions.keys() - PURPOSES.keys()) stale_purposes = sorted(PURPOSES.keys() - versions.keys()) if undocumented: - raise SystemExit( - "Missing tool descriptions for: " + ", ".join(undocumented) - ) + raise SystemExit("Missing tool descriptions for: " + ", ".join(undocumented)) if stale_purposes: raise SystemExit( "Descriptions exist without lockfile entries for: " From 3a64a5a7e1dc279e177bfd4019e9884d2e46db3a Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Thu, 13 Aug 2026 13:28:27 +0200 Subject: [PATCH 3/9] fix: keep post_create_command.sh installs scoped to docs PR Remove the uv/uvx installation added to the outer (repository) DevContainer's post_create_command.sh. This PR is scoped to documentation and a lockfile newline fix; uv/uvx are already installed separately inside the generated S-CORE DevContainer (src/s-core-devcontainer/.devcontainer/s-core-local/install.sh), and no functional need for them in the outer DevContainer was identified in review (see PR #139 discussion). --- .devcontainer/post_create_command.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/post_create_command.sh b/.devcontainer/post_create_command.sh index c6e85b4..d9032fd 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/internal/devcontainer/install.py" install bazelisk shellcheck yamlfmt uv uvx +sudo "${REPOSITORY_ROOT}/tools/internal/devcontainer/install.py" install bazelisk shellcheck yamlfmt pre-commit install From 8d9fc3f080de15d87937e1b385b5f79ea705c94e Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Thu, 13 Aug 2026 13:31:55 +0200 Subject: [PATCH 4/9] refactor: de-duplicate load_feature_versions.sh Move load_feature_versions.sh into tools/internal/devcontainer/ so it is shared via the existing "COPY tools /usr/local/share/score-tools" mechanism, the same way install.py already is. This restores the pre-existing behavior where both the s-core-local and bazel-feature DevContainer features sourced a single shared script (previously tools/versions.sh), instead of keeping two identical copies of the file, one per feature. Both install.sh and test_default.sh scripts in each feature now source the script from /usr/local/share/score-tools/internal/ devcontainer/load_feature_versions.sh instead of a per-feature copy. --- .../.devcontainer/bazel-feature/install.sh | 2 +- .../bazel-feature/tests/test_default.sh | 2 +- .../.devcontainer/s-core-local/install.sh | 2 +- .../s-core-local/load_feature_versions.sh | 37 ------------------- .../s-core-local/tests/test_default.sh | 2 +- .../devcontainer}/load_feature_versions.sh | 0 6 files changed, 4 insertions(+), 41 deletions(-) delete mode 100755 src/s-core-devcontainer/.devcontainer/s-core-local/load_feature_versions.sh rename {src/s-core-devcontainer/.devcontainer/bazel-feature => tools/internal/devcontainer}/load_feature_versions.sh (100%) diff --git a/src/s-core-devcontainer/.devcontainer/bazel-feature/install.sh b/src/s-core-devcontainer/.devcontainer/bazel-feature/install.sh index 86f66e3..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 -. /devcontainer/features/bazel/load_feature_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) 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 b96fea4..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,7 +16,7 @@ set -euo pipefail # Read tool versions + metadata into environment variables -. /devcontainer/features/bazel/load_feature_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/internal/devcontainer/install.py version bazelisk)" buildifier_lockfile_version="$(/usr/local/share/score-tools/internal/devcontainer/install.py version buildifier)" 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 2f85a15..c128af4 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 -. /devcontainer/features/s-core-local/load_feature_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) diff --git a/src/s-core-devcontainer/.devcontainer/s-core-local/load_feature_versions.sh b/src/s-core-devcontainer/.devcontainer/s-core-local/load_feature_versions.sh deleted file mode 100755 index f2a845c..0000000 --- a/src/s-core-devcontainer/.devcontainer/s-core-local/load_feature_versions.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/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 - -ARCHITECTURE=$(dpkg --print-architecture) -VERSION="v4.47.2" - -SHA256_FIELD="1bb99e1019e23de33c7e6afc23e93dad72aad6cf2cb03c797f068ea79814ddb0" # Default to amd64 -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 - -# 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) - -# Clean up -trap 'rm -f /tmp/yq' EXIT 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 964f864..df5e116 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,7 +19,7 @@ ARCHITECTURE=$(dpkg --print-architecture) KERNEL=$(uname -s) # Read tool versions + metadata into environment variables -. /devcontainer/features/s-core-local/load_feature_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 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)" diff --git a/src/s-core-devcontainer/.devcontainer/bazel-feature/load_feature_versions.sh b/tools/internal/devcontainer/load_feature_versions.sh similarity index 100% rename from src/s-core-devcontainer/.devcontainer/bazel-feature/load_feature_versions.sh rename to tools/internal/devcontainer/load_feature_versions.sh From 71dd6233cfba022b8c9b8f3a8514c526559f83b6 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Thu, 13 Aug 2026 14:06:30 +0200 Subject: [PATCH 5/9] refactor: drop redundant --check flag from sync_readme.py pre-commit already detects and rejects commits when a hook modifies tracked files, the same mechanism relied on by the yamlfmt and ruff-format hooks in this repo. sync_readme.py's --check mode was therefore redundant: even without it, running the script always updates tools/README.md in place when it drifts from the lockfile catalog, and pre-commit fails the commit on that modification just like any other formatter hook. Simplify sync_readme.py to always update README.md and drop --check from the pre-commit entry and its documentation. --- .pre-commit-config.yaml | 2 +- tools/README.md | 2 +- tools/internal/README.md | 13 +++++-------- tools/internal/sync_readme.py | 14 +------------- 4 files changed, 8 insertions(+), 23 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 833f063..102f30b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -57,7 +57,7 @@ repos: types: [python] - id: tool-documentation name: check tool documentation - entry: python3 tools/internal/sync_readme.py --check + entry: python3 tools/internal/sync_readme.py language: system pass_filenames: false files: ^tools/(README\.md|internal/sync_readme\.py|lockfiles/.*\.lock\.json)$ diff --git a/tools/README.md b/tools/README.md index b50bea5..bd29175 100644 --- a/tools/README.md +++ b/tools/README.md @@ -45,7 +45,7 @@ access while Bazel downloads and caches the executable. | `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 | +| `shellcheck` | `9.9.9` | 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 | diff --git a/tools/internal/README.md b/tools/internal/README.md index ca55f13..1d3b57c 100644 --- a/tools/internal/README.md +++ b/tools/internal/README.md @@ -111,9 +111,9 @@ 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. Then run the -documentation check below; it rejects command descriptions without a catalog -entry and catalog entries without a description. +To remove a command, remove it from the same integration points. Then run +`sync_readme.py` (see below); it rejects command descriptions without a +catalog entry and catalog entries without a description. ## Validation and release alignment @@ -124,11 +124,8 @@ description: $ python3 tools/internal/sync_readme.py ``` -Use `--check` in automation to reject stale generated content: - -```console -$ python3 tools/internal/sync_readme.py --check -``` +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 diff --git a/tools/internal/sync_readme.py b/tools/internal/sync_readme.py index 41aefcd..17e57db 100644 --- a/tools/internal/sync_readme.py +++ b/tools/internal/sync_readme.py @@ -82,25 +82,13 @@ def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( description="Synchronize README tool versions with lockfiles.", ) - parser.add_argument( - "--check", - action="store_true", - help="Fail instead of updating README.md when the generated table differs.", - ) - args = parser.parse_args(argv) + parser.parse_args(argv) current = README_PATH.read_text(encoding="utf-8") expected = _updated_readme(current, _render_table(load_catalog_versions())) if current == expected: return 0 - if args.check: - print( - "tools/README.md is out of date; run " - "'python3 tools/internal/sync_readme.py'", - file=sys.stderr, - ) - return 1 README_PATH.write_text(expected, encoding="utf-8") return 0 From 3e47c338c8494669e0a6e6ad225900de68ea2a66 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Thu, 13 Aug 2026 15:00:30 +0200 Subject: [PATCH 6/9] docs: clarify runner rationale for non-DevContainer users Reword the rationale in tools/internal/README.md: DevContainers already work on Linux and macOS hosts, so that wasn't the actual gap. The runner exists to keep developers who don't run the DevContainer at all on the same pinned tool versions as those who do. --- tools/internal/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/internal/README.md b/tools/internal/README.md index 1d3b57c..f9bda1c 100644 --- a/tools/internal/README.md +++ b/tools/internal/README.md @@ -145,9 +145,9 @@ both delivery paths. ## Rationale and boundaries The runner supports mixed workflows without exposing two user interfaces. A -container-only implementation would not cover developers working on Linux or -macOS hosts, while arbitrary system-installed tools would lose version -alignment. +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 From 141a75cdf601ac29462c126ecacb7a5899ba6fa2 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Thu, 13 Aug 2026 15:05:17 +0200 Subject: [PATCH 7/9] fix: restore correct shellcheck version in tools/README.md The generated table accidentally recorded shellcheck's version as 9.9.9 instead of 0.10.0. This was introduced while manually testing pre-commit's file-modification detection for the previous commit (the test copy of tools/README.md was left staged and re-checked-out from the index instead of HEAD, propagating the corruption). --- tools/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/README.md b/tools/README.md index bd29175..b50bea5 100644 --- a/tools/README.md +++ b/tools/README.md @@ -45,7 +45,7 @@ access while Bazel downloads and caches the executable. | `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` | `9.9.9` | Check shell scripts | +| `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 | From bd2a0f8b1f8ececcac9e684c6ed878297890b611 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Thu, 13 Aug 2026 15:06:07 +0200 Subject: [PATCH 8/9] refactor: move tool descriptions from sync_readme.py into lockfiles Add a "description" field to every tool entry in tools/lockfiles/*.lock.json and drop the separate PURPOSES dict from sync_readme.py. Descriptions now live next to the version and binaries they document, so adding or removing a pinned tool only requires touching its lockfile instead of also editing sync_readme.py. install.py gains load_catalog_descriptions(), mirroring the existing load_catalog_versions(), backed by a shared _iter_catalog() helper. sync_readme.py uses both to render the README table and still fails loudly if a lockfile entry is missing its description. Update the "Adding or updating a tool" instructions in tools/internal/README.md accordingly. --- tools/internal/README.md | 12 +++--- tools/internal/devcontainer/install.py | 55 ++++++++++++++++++++------ tools/internal/sync_readme.py | 33 ++++------------ tools/lockfiles/actionlint.lock.json | 1 + tools/lockfiles/apm.lock.json | 1 + tools/lockfiles/bazelisk.lock.json | 1 + tools/lockfiles/buildifier.lock.json | 1 + tools/lockfiles/opencode.lock.json | 1 + tools/lockfiles/ruff.lock.json | 1 + tools/lockfiles/shellcheck.lock.json | 1 + tools/lockfiles/starpls.lock.json | 1 + tools/lockfiles/uv.lock.json | 2 + tools/lockfiles/yamlfmt.lock.json | 1 + 13 files changed, 67 insertions(+), 44 deletions(-) diff --git a/tools/internal/README.md b/tools/internal/README.md index f9bda1c..45b8cf1 100644 --- a/tools/internal/README.md +++ b/tools/internal/README.md @@ -81,7 +81,8 @@ 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`. + `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 @@ -96,8 +97,7 @@ changes together: [`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. Add the command description to `PURPOSES` in - [`sync_readme.py`](sync_readme.py), then regenerate the table: +6. Regenerate the documented command table: ```console $ python3 tools/internal/sync_readme.py @@ -111,9 +111,9 @@ 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. Then run -`sync_readme.py` (see below); it rejects command descriptions without a -catalog entry and catalog entries without a description. +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 diff --git a/tools/internal/devcontainer/install.py b/tools/internal/devcontainer/install.py index 06d9736..d1c5b7b 100755 --- a/tools/internal/devcontainer/install.py +++ b/tools/internal/devcontainer/install.py @@ -37,6 +37,7 @@ import zipfile from pathlib import Path from typing import NotRequired, TypedDict +from collections.abc import Iterator class Binary(TypedDict): @@ -56,16 +57,15 @@ class ToolData(TypedDict): """Tool metadata from a lockfile entry.""" version: NotRequired[str] + description: NotRequired[str] binaries: list[Binary] LOCKFILE_ROOT = Path(__file__).resolve().parents[2] / "lockfiles" -def load_catalog_versions() -> dict[str, str]: - """Return every tool version declared by the lockfile catalog.""" - versions: dict[str, str] = {} - +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) @@ -75,19 +75,50 @@ def load_catalog_versions() -> dict[str, str]: continue if not isinstance(definition, dict): raise SystemExit(f"Unexpected entry '{tool}' in '{path.name}'") + yield tool, path.name, definition - version = definition.get("version") - if not isinstance(version, str): - raise SystemExit( - f"Tool '{tool}' in '{path.name}' does not define a string version" - ) - if tool in versions: - raise SystemExit(f"Tool '{tool}' is defined by multiple lockfiles") - versions[tool] = version + +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: """Map Python's platform string to the lockfile schema's OS names.""" system = platform.system() diff --git a/tools/internal/sync_readme.py b/tools/internal/sync_readme.py index 17e57db..e98f681 100644 --- a/tools/internal/sync_readme.py +++ b/tools/internal/sync_readme.py @@ -18,7 +18,7 @@ import sys from pathlib import Path -from devcontainer.install import load_catalog_versions +from devcontainer.install import load_catalog_descriptions, load_catalog_versions TOOLS_DIR = Path(__file__).resolve().parent.parent @@ -26,31 +26,11 @@ TABLE_START = "" TABLE_END = "" -PURPOSES = { - "actionlint": "Check GitHub Actions workflow files", - "apm": "Manage APM packages", - "bazelisk": "Run the Bazel version selected by a repository", - "buildifier": "Format and lint Bazel files", - "opencode": "Run the OpenCode CLI", - "ruff": "Check and format Python code", - "shellcheck": "Check shell scripts", - "starpls": "Provide language-server support for Starlark", - "uv": "Manage Python projects and packages", - "uvx": "Run Python tools in isolated environments", - "yamlfmt": "Format YAML files", -} - - -def _render_table(versions: dict[str, str]) -> str: - undocumented = sorted(versions.keys() - PURPOSES.keys()) - stale_purposes = sorted(PURPOSES.keys() - versions.keys()) + +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)) - if stale_purposes: - raise SystemExit( - "Descriptions exist without lockfile entries for: " - + ", ".join(stale_purposes) - ) rows = [ TABLE_START, @@ -60,7 +40,7 @@ def _render_table(versions: dict[str, str]) -> str: "| --- | --- | --- |", ] rows.extend( - f"| `{command}` | `{versions[command]}` | {PURPOSES[command]} |" + f"| `{command}` | `{versions[command]}` | {descriptions[command]} |" for command in sorted(versions) ) rows.extend(["", TABLE_END]) @@ -85,7 +65,8 @@ def main(argv: list[str] | None = None) -> int: parser.parse_args(argv) current = README_PATH.read_text(encoding="utf-8") - expected = _updated_readme(current, _render_table(load_catalog_versions())) + table = _render_table(load_catalog_versions(), load_catalog_descriptions()) + expected = _updated_readme(current, table) if current == expected: return 0 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..32c4e4e 100644 --- a/tools/lockfiles/apm.lock.json +++ b/tools/lockfiles/apm.lock.json @@ -2,6 +2,7 @@ "$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", 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", From 7189b1525f36e3a264b9bd4d978107be0ceb6d2a Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Thu, 13 Aug 2026 17:34:11 +0200 Subject: [PATCH 9/9] refactor: remove obsolete archive installer branch --- tools/internal/devcontainer/install.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tools/internal/devcontainer/install.py b/tools/internal/devcontainer/install.py index 8a45d81..a518df9 100755 --- a/tools/internal/devcontainer/install.py +++ b/tools/internal/devcontainer/install.py @@ -312,12 +312,6 @@ def _cmd_install(args: argparse.Namespace) -> int: _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) else: raise SystemExit(f"Unsupported kind '{kind}' for {tool}")