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
12 changes: 12 additions & 0 deletions .github/renovate.json5
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,18 @@
"depNameTemplate": "anomalyco/opencode",
"versioningTemplate": "semver"
},
{
"customType": "regex",
"fileMatch": [
"^tools/lockfiles/uv\\.lock\\.json$"
],
"matchStrings": [
"\"version\":\\s*\"(?<currentValue>[^\"]+)\""
],
"datasourceTemplate": "github-releases",
"depNameTemplate": "astral-sh/uv",
"versioningTemplate": "semver"
},
{
"customType": "regex",
"fileMatch": [
Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ To support proxy environments, environment variables are set in the [`Dockerfile

## Tests

After an image build, tests check that each tool expected to be in the image is installed with the specified version for [pre-existing features](../src/s-core-devcontainer/test-project/test.sh), the [S-CORE feature](../src/s-core-devcontainer/.devcontainer/s-core-local/tests/test_default.sh) and the [bazel feature](../src/s-core-devcontainer/.devcontainer/bazel-feature/tests/test_default.sh).
After an image build, tests check that each tool expected to be in the image is installed with the specified version for [pre-existing features](../src/s-core-devcontainer/test-project/test.sh), the [system CA trust store](../src/s-core-devcontainer/test-project/ca_certificates.sh), the [S-CORE feature](../src/s-core-devcontainer/.devcontainer/s-core-local/tests/test_default.sh) and the [bazel feature](../src/s-core-devcontainer/.devcontainer/bazel-feature/tests/test_default.sh).
This may seem overly complex at first, but prevents (1) accidentially wrong versions, (2) completely broken tools that cannot even execute.
Both cases can happen and have happened in the past already, e.g. due to unexpected interactions between devcontainer features.

Expand Down
11 changes: 11 additions & 0 deletions src/s-core-devcontainer/.devcontainer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ FROM buildpack-deps:noble-curl

LABEL dev.containers.features="common"

# Refresh the system trust store before anything else downloads over TLS. The
# base image carries the CA bundle from its own build date, and the
# common-utils feature deliberately runs with `upgradePackages: false`, so a
# distrusted CA would otherwise stay trusted for the life of the image.
# Deliberately unpinned: a pinned trust store is a stale trust store.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& update-ca-certificates --fresh \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*

COPY tools /usr/local/share/score-tools

RUN userdel -f -r ubuntu
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ ENV no_proxy=${no_proxy}

LABEL dev.containers.features="common"

# Refresh the system trust store before anything else downloads over TLS. The
# base image carries the CA bundle from its own build date, and the
# common-utils feature deliberately runs with `upgradePackages: false`, so a
# distrusted CA would otherwise stay trusted for the life of the image.
# Deliberately unpinned: a pinned trust store is a stale trust store.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& update-ca-certificates --fresh \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*

# Unset proxy variables for all login shells
COPY src/s-core-devcontainer/.devcontainer/unset-proxy.sh /etc/bash_completion.d/unset-proxy.sh
COPY tools /usr/local/share/score-tools
Expand Down
44 changes: 44 additions & 0 deletions src/s-core-devcontainer/test-project/ca_certificates.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/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
# *******************************************************************************

# Sourced by test.sh, which provides the `check` helper.

# System trust store
#
# The October 2026 eMudhra incident compromised intermediate CA private keys
# chaining to "emSign ECC Root CA - G3"; Ubuntu distrusts that root as of
# ca-certificates 20260816ubuntu0.24.04.1. The Dockerfile upgrades the
# package, but image builds run with `--cache-from`, so a layer assembled
# before that upgrade could still be reused. Asserting the outcome here means
# a stale trust store fails CI instead of being published.
distrusted_root_cn="emSign ECC Root CA - G3"
distrusted_root_sha256="86:A1:EC:BA:08:9C:4A:8D:3B:BE:27:34:C6:12:BA:34:1D:81:3E:04:3C:F9:E8:A8:62:CD:5C:57:A3:6B:BE:6B"

# Prove the bundle is readable first; otherwise the absence check below would
# pass simply because nothing could be parsed out of it.
check "validate the system CA bundle is present and parsable" bash -c \
"[ \"\$(openssl crl2pkcs7 -nocrl -certfile /etc/ssl/certs/ca-certificates.crt | openssl pkcs7 -print_certs -noout | grep -c '^subject=')\" -gt 50 ]"
check "validate the compromised ${distrusted_root_cn} is not in the system CA bundle" bash -c \
"! openssl crl2pkcs7 -nocrl -certfile /etc/ssl/certs/ca-certificates.crt | openssl pkcs7 -print_certs -noout | grep -qF '${distrusted_root_cn}'"

# Bazel runs its JVM against this keystore (see the bazel feature's
# /etc/bazel.bazelrc), so it needs the same guarantee as the OpenSSL bundle.
# Matched on fingerprint because the keytool alias is derived from a filename.
# As above, prove the keystore is readable first, so a missing or corrupt
# keystore cannot make the absence check pass.
check "validate the Java keystore is present and parsable" bash -c \
"[ \"\$(keytool -list -keystore /etc/ssl/certs/java/cacerts -storepass changeit | grep -c 'trustedCertEntry')\" -gt 50 ]"
check "validate the compromised ${distrusted_root_cn} is not in the Java keystore" bash -c \
"! keytool -list -keystore /etc/ssl/certs/java/cacerts -storepass changeit | grep -qiF '${distrusted_root_sha256}'"
4 changes: 4 additions & 0 deletions src/s-core-devcontainer/test-project/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ check "validate clang-format is working and has the correct version" bash -c "cl
check "validate clang-tidy is working and has the correct version" bash -c "clang-tidy --version | grep '20.1.8'"
check "validate clang is working and has the correct version" bash -c "clang --version | grep '20.1.8'"

# System trust store. Verifies the Dockerfile's CA refresh, so it is sourced
# explicitly here rather than being discovered; forgetting this line skips it.
source "${SCRIPT_DIR}/ca_certificates.sh"

# Tests from the local s-core-local feature
source /devcontainer/features/s-core-local/tests/test_default.sh

Expand Down
6 changes: 3 additions & 3 deletions tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,12 @@ host-side Bazel invocation may require network access to download the binary.
| `bazelisk` | `1.27.0` | Run the Bazel version selected by a repository |
| `buildifier` | `8.2.1` | Format and lint Bazel files |
| `opencode` | `1.18.31` | Run the OpenCode CLI |
| `pre-commit` | `4.5.1` | Run repository pre-commit hooks |
| `pre-commit` | `4.7.0` | 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 |
| `uv` | `0.10.4` | Manage Python projects and packages |
| `uvx` | `0.10.4` | Run Python tools in isolated environments |
| `uv` | `0.13.5` | Manage Python projects and packages |
| `uvx` | `0.13.5` | Run Python tools in isolated environments |
| `yamlfmt` | `0.17.0` | Format YAML files |

<!-- END GENERATED TOOL TABLE -->
Expand Down
58 changes: 41 additions & 17 deletions tools/internal/refresh_lockfile_digests.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,35 @@ def _api_get(url, token=None):
return json.load(resp)


def _asset_name(url):
"""Extract the asset filename from a GitHub release download URL."""
return url.rsplit("/", 1)[-1]
def _parse_release_url(url):
"""Split a GitHub release download URL into its components.

Layout: https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>
Returns ``(owner, repo, tag, asset)`` or ``None`` if the URL does not match.
"""
parts = url.split("/")
if len(parts) != 9 or parts[2] != "github.com" or parts[5] != "releases":
return None
return parts[3], parts[4], parts[7], parts[8]


def _release_digests(owner, repo, tag, token, cache):
"""Return ``{asset name: digest}`` for a release, fetching each one once."""
key = (owner, repo, tag)
if key not in cache:
release = _api_get(
f"{GITHUB_API}/repos/{owner}/{repo}/releases/tags/{tag}", token
)
cache[key] = {a["name"]: a.get("digest", "") for a in release.get("assets", [])}
return cache[key]


def refresh_lockfile(path, token=None):
def refresh_lockfile(path, token=None, cache=None):
"""Refresh one lockfile in place. Returns a list of changed binary names."""
with open(path, encoding="utf-8") as f:
data = json.load(f)

cache = {} if cache is None else cache
changed = []
for tool_name, tool in data.items():
if not isinstance(tool, dict) or "binaries" not in tool:
Expand All @@ -67,36 +86,40 @@ def refresh_lockfile(path, token=None):
if not version:
continue

# Derive the GitHub repo from the first binary URL:
# https://github.com/<owner>/<repo>/releases/download/v<ver>/<asset>
first_url = tool["binaries"][0].get("url", "")
parts = first_url.split("/")
if len(parts) < 8 or parts[2] != "github.com" or parts[5] != "releases":
parsed = _parse_release_url(first_url)
if parsed is None:
print(
f" ! {tool_name}: cannot derive repo from {first_url!r}",
file=sys.stderr,
)
continue
owner, repo = parts[3], parts[4]
owner, repo, first_tag, _ = parsed

release = _api_get(
f"{GITHUB_API}/repos/{owner}/{repo}/releases/tags/v{version}", token
)
digests = {a["name"]: a.get("digest", "") for a in release.get("assets", [])}
# Release tags are not uniformly prefixed: most projects publish
# `v1.27.0`, while the astral-sh ones (uv, ruff) publish a bare
# `0.13.5`. Reuse the prefix the pinned URL already encodes; assuming
# `v` here would rewrite those tools to a nonexistent tag.
tag = ("v" if first_tag.startswith("v") else "") + version

digests = _release_digests(owner, repo, tag, token, cache)

for binary in tool["binaries"]:
asset = _asset_name(binary.get("url", ""))
binary_parts = _parse_release_url(binary.get("url", ""))
asset = binary_parts[3] if binary_parts else None
digest = digests.get(asset)
if not digest:
print(
f" ! {tool_name}: no digest for asset {asset!r} in v{version}",
f" ! {tool_name}: no digest for asset {asset!r} in {tag}",
file=sys.stderr,
)
continue
sha = digest.split(":", 1)[1] if digest.startswith("sha256:") else digest
# Rebuild the URL from the version so the file is consistent
# whether or not Renovate already updated the URL.
new_url = f"https://github.com/{owner}/{repo}/releases/download/v{version}/{asset}"
new_url = (
f"https://github.com/{owner}/{repo}/releases/download/{tag}/{asset}"
)
if binary.get("sha256") != sha or binary.get("url") != new_url:
binary["url"] = new_url
binary["sha256"] = sha
Expand All @@ -120,11 +143,12 @@ def main(argv):
if name.endswith(".lock.json")
]

cache = {}
failed = False
for path in argv[1:]:
print(f"Refreshing {path}")
try:
changed = refresh_lockfile(path, token)
changed = refresh_lockfile(path, token, cache)
except Exception as exc:
print(f" ! failed: {exc}", file=sys.stderr)
failed = True
Expand Down
2 changes: 1 addition & 1 deletion tools/lockfiles/python_tools.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ PYTHON_TOOLS = {
# users. They are separate because Python distributions may name them
# differently.
"package": "pre-commit",
"version": "4.5.1",
"version": "4.7.0",
"entrypoint": "pre-commit",
# Documentation is generated from the catalog so version and purpose
# are reviewed together.
Expand Down
28 changes: 14 additions & 14 deletions tools/lockfiles/uv.lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion tools/tests/python_tool_runner_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ chmod +x "${fake_uvx}"
# The explicit assertion verifies the requested release. Subsequent checks use
# the parsed value to keep every expected uv argument aligned with the catalog.
catalog_version="$("${installer}" version pre-commit)"
[[ "${catalog_version}" = "4.5.1" ]]
[[ "${catalog_version}" = "4.7.0" ]]

# The Bazel launcher receives an execroot-relative uvx path, then changes to the
# caller's workspace. This catches regressions where that path breaks after cd.
Expand Down
Loading