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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ jobs:
# Check
pre-commit run --show-diff-on-failure --color=always --all-files || exit 1

# A fake uv executable makes the launcher contract deterministic:
# arguments, cwd, and environment are checked without network I/O.
bazelisk test //tools:python_tool_runner_test --test_output=errors

# The real target covers the integration boundary between
# rules_multitool, Bazel runfiles, uvx, and the Python package.
bazelisk run //tools:pre-commit -- --version

# Create builder for multi-arch builds
./scripts/create_builder.sh

Expand Down
4 changes: 3 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ repos:
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)$
# Both catalog formats feed the generated table; watching both keeps
# user-facing versions synchronized with their sources of truth.
files: ^tools/(README\.md|internal/(sync_readme\.py|devcontainer/install\.py)|lockfiles/(.*\.lock\.json|python_tools\.bzl))$
- repo: https://github.com/eclipse-score/tooling
rev: 31ff8eee214e4e97ef8f5cb46e443273515b63ec
hooks:
Expand Down
4 changes: 4 additions & 0 deletions MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ module(name = "score_devcontainer")

bazel_dep(name = "rules_multitool", version = "1.11.1")

# rules_shell provides a stable sh_binary and sh_test API across the supported
# Bazel versions.
bazel_dep(name = "rules_shell", version = "0.4.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")
Expand Down
26 changes: 25 additions & 1 deletion tools/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,19 @@
# SPDX-License-Identifier: Apache-2.0
# *******************************************************************************

load("@rules_shell//shell:sh_test.bzl", "sh_test")
load(":multitool.bzl", "multitool_aliases")
load(":python_tool.bzl", "python_tool")

package(default_visibility = ["//visibility:public"])

exports_files(glob(["lockfiles/*.lock.json"]))
# Native lockfiles are consumed by rules_multitool; the Starlark catalog is
# loaded directly by python_tool.bzl. Export both because they are public
# metadata shipped with the score_devcontainer module.
exports_files(glob([
"lockfiles/*.bzl",
"lockfiles/*.lock.json",
]))

multitool_aliases("actionlint")

Expand All @@ -38,3 +46,19 @@ multitool_aliases("yamlfmt")
multitool_aliases("apm")

multitool_aliases("opencode")

# Python distributions are resolved through pinned uvx because they do not
# provide the platform-specific release binaries consumed by rules_multitool.
python_tool("pre-commit")

# This contract test keeps Bazel's shell launcher and the container installer
# aligned with their shared catalog.
sh_test(
name = "python_tool_runner_test",
srcs = ["tests/python_tool_runner_test.sh"],
data = [
"internal/devcontainer/install.py",
"internal/run_python_tool.sh",
"lockfiles/python_tools.bzl",
],
)
4 changes: 3 additions & 1 deletion tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ access while Bazel downloads and caches the executable.
| `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 |
| `pre-commit` | `4.5.1` | Run repository pre-commit hooks |
| `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 |
Expand Down Expand Up @@ -76,7 +77,8 @@ image; otherwise the command set or versions can differ.

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.
access to the catalog. The Bazel targets bring their own pinned tool runtime;
Python is not required on the host to run Python-based commands.

Implementation and maintenance details are in
[internal/README.md](internal/README.md).
74 changes: 65 additions & 9 deletions tools/internal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,16 @@ 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. |
| `tools/lockfiles/*.lock.json` and `tools/lockfiles/python_tools.bzl` | Define versions and delivery metadata. |
| `MODULE.bazel` | Makes native tool lockfiles available to `rules_multitool`. |
| `tools/BUILD.bazel` | Exposes public Bazel targets 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.
A command is available when its catalog metadata, Bazel target, and applicable
DevContainer installer are registered together. The runner selects between
those delivery paths; the catalogs remain the command registries.

## Architecture

Expand All @@ -53,9 +53,22 @@ Native tools with upstream release artifacts use

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.
starting a tool, so configuration discovery and repository-relative paths
behave like a direct invocation.

Python packages do not provide the platform-specific, checksum-addressed
release binaries expected by `rules_multitool`. Their public Bazel targets
therefore use the `uvx` binary from `rules_multitool` to create an isolated
environment for the exact catalogued package version. A shell launcher restores
the caller's working directory before invoking `uvx`; using shell here is
intentional so Python-based tools do not require a system Python installation
on the host.

The DevContainer takes the other delivery path: it installs each Python tool
once with the catalogued `uv` binary and exposes the resulting entrypoint on
`PATH`. Python is already part of the image before feature tools are installed,
so the installer can use its standard library without adding a container or
host dependency.

The maintained runner source is [`run-tool`](../run-tool). It remains under
`tools/` in this implementation repository; consumer repositories copy it to
Expand All @@ -72,6 +85,20 @@ supported platforms, download URLs, checksums, and archive layouts.
[`devcontainer/install.py`](devcontainer/install.py) consumes them while
building the DevContainer.

Python command-line tool metadata lives in
[`tools/lockfiles/python_tools.bzl`](../lockfiles/python_tools.bzl). The file is
a data-only Starlark dictionary because Bazel must read the pin during analysis,
before the command can run. Its restricted literal form is also valid Python
syntax. The privileged DevContainer installer parses it with
`ast.literal_eval`, preserving a data-only privilege boundary. This shared
format keeps the package, console entrypoint, version, and description in one

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does privileged mean here?

source of truth while Bazel execution remains independent of host Python.

The catalogs have distinct ownership: `devcontainer-lock.json` records external
DevContainer features, `python_tools.bzl` records Python package releases, and
`uv.lock.json` records the installer runtime. Each version is therefore owned
by the component that resolves it.

The published `score_devcontainer` Bazel module and DevContainer image share a
release version. Consumers using both must pin the same release.

Expand Down Expand Up @@ -106,6 +133,19 @@ changes together:
7. If this repository's own DevContainer needs the command, add it to
[`.devcontainer/post_create_command.sh`](../../.devcontainer/post_create_command.sh).

For a Python command-line tool, add an entry to
`tools/lockfiles/python_tools.bzl`, add `python_tool("<command>")` to
`tools/BUILD.bazel`, and install it through `install.py install-python` in
each applicable DevContainer installer. Keep the catalog as a single literal
`PYTHON_TOOLS` assignment: this is what makes it safe for the installer and
directly loadable by Bazel. The `package` selects the distribution passed to
`uv`, while `entrypoint` names the console command because those names need not
be identical.

Bazel invokes Python tools on demand with the pinned `uvx`; DevContainers
install the same package with the pinned `uv`. Add launcher and installer
coverage to `python_tool_runner_test` when either delivery contract changes.

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
Expand Down Expand Up @@ -133,6 +173,12 @@ 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.

`python_tool_runner_test` uses a fake uv executable to verify arguments,
environment variables, and working-directory behavior without network access.
CI additionally runs the real `//tools:pre-commit` target as a smoke test
because only that target exercises `rules_multitool`, Bazel runfiles, and uvx
together.

## Bazel target conventions

`multitool_aliases` exposes two native-tool targets:
Expand All @@ -142,6 +188,16 @@ both delivery paths.
- `<command>_binary` is the raw executable for use as a tool dependency in other
Bazel rules.

Python tools expose only the runnable `<command>` target. They are intended for
interactive and CI use, not as executable dependencies in Bazel actions. The
target embeds the catalog metadata as arguments to a shell launcher and carries
only the pinned `uvx` executable in its runfiles, which keeps execution
independent of host Python and avoids runtime catalog path resolution.

`rules_shell` supplies the `sh_binary` and `sh_test` APIs consistently across
the supported Bazel generations, so one BUILD definition serves every
supported consumer.

## Rationale and boundaries

The runner supports mixed workflows without exposing two user interfaces. A
Expand Down
Loading