# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Declarative registry of the external tools `repomatic run` manages.
Each `ToolSpec` entry pins a tool's version and, for binary-distributed tools,
its per-platform download URLs and SHA-256 digests in `CHECKSUMS`; the paired
`VERSIONS` map records the version each checksum set was computed for. The
`ArchiveFormat`, `NativeFormat`, `BinarySpec`, and `NpmSpec` types describe how
each tool is fetched and how its `[tool.X]` section is translated to the tool's
native config format. The `repomatic run` engine in `tooling/runner.py`
consumes this data to install and invoke each tool, and `tooling/binaries.py`
installs the binaries.
```{note}
`sync-tool-versions` and `update-checksums` rewrite this module's `version=`,
`VERSIONS`, and `CHECKSUMS` literals in place by string substitution, so their
formatting must stay stable. The lint, autofix, and docs workflows key their
tool caches on a hash of this file, so only a genuine version or checksum bump
invalidates a cached tool download.
```
```{todo}
Drop the YAML-block fixup in the `mdformat` post-process callback once
[executablebooks/mdformat-myst#49](https://github.com/executablebooks/mdformat-myst/pull/49)
merges upstream. See `_DIRECTIVE_YAML_OPTIONS_RE` for the shape it repairs.
```
```{todo}
Drop the colon-fence fixup in the same callback once `mdformat-myst` ships
colon-fence support, via either
[executablebooks/mdformat-myst#36](https://github.com/executablebooks/mdformat-myst/pull/36)
or
[executablebooks/mdformat-myst#48](https://github.com/executablebooks/mdformat-myst/pull/48).
See `_ESCAPED_COLON_FENCE_RE` for the shape it repairs.
```
```{todo}
Once `lychee` ships `0.25.0`, the release expected to carry
[lycheeverse/lychee#2250](https://github.com/lycheeverse/lychee/pull/2250),
run the ``{#id}`` reproduction from
[lycheeverse/lychee#2249](https://github.com/lycheeverse/lychee/issues/2249#issuecomment-5760051417)
through `repomatic run lychee`, and report the result there, as that comment
commits to. The `file://` docs-fragment entry in `[tool.lychee]` stays either
way: `lychee` reads the Markdown source, which holds neither MyST `(target)=`
labels nor the anchors directives generate at build time.
```
"""
from __future__ import annotations
import json
import logging
import re
import sys
from dataclasses import dataclass, replace
from enum import Enum
from inspect import cleandoc
from pathlib import Path, PurePosixPath
import tomlrt
import yaml
from click_extra import ColumnSpec, TableFormat, render_table
from extra_platforms import (
AARCH64,
ALL_PLATFORMS,
LINUX,
MACOS,
UNKNOWN_PLATFORM,
WINDOWS,
X86_64,
Architecture,
Group,
Platform,
current_architecture,
current_platform,
)
from packaging.requirements import Requirement
from ..file_ops import generated_header
from ..github.urls import parse_github_repo
from ..npm import NPM_PACKAGE_URL
from ..pypi import PYPI_PACKAGE_URL
from .nuitka import nuitka_params, prepare_nuitka_build
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
from typing import Any, Literal
from ..metadata.core import Metadata
[docs]
class BinaryInstallError(ToolInstallError):
"""Raised when a binary tool cannot be downloaded, verified or extracted.
Every failure of the binary install path ends as this error or one of its
subclasses, so a caller that degrades on failure catches one type. The
`gh` resolver of {mod}`repomatic.github.gh` is such a caller: it falls back
to the `gh` on `PATH`.
"""
PlatformKey = tuple[Platform | Group, Architecture]
"""A `(platform_or_group, architecture)` pair used as binary lookup key.
The platform element can be a single {class}`~extra_platforms.Platform` (like
`MACOS`) or a {class}`~extra_platforms.Group` (like `LINUX`, which matches
any Linux distribution). The architecture is always a concrete
{class}`~extra_platforms.Architecture`.
Resolution order in {meth}`BinarySpec.resolve_platform`:
1. Exact Platform match (`current_platform() == key_platform`).
2. Group membership (`current_platform() in key_group`), preferring the
group with fewest members (most specific).
3. The `LINUX` family, only when `current_platform()` is `UNKNOWN_PLATFORM`,
so a distribution extra-platforms cannot name still reaches a family-wide
key.
"""
[docs]
@dataclass(frozen=True)
class BinarySpec:
"""Platform-specific binary download specification.
Keys are {data}`PlatformKey` tuples pairing an extra-platforms
{class}`~extra_platforms.Platform` or {class}`~extra_platforms.Group`
with an {class}`~extra_platforms.Architecture`. This lets callers use
broad groups (`LINUX` matches any distro) or specific platforms
(`DEBIAN`) with full detection heuristics from extra-platforms.
```{hint}
Structural integrity checks (key types, checksum format, URL
placeholders, strip_components consistency) are enforced in
`test_tool_spec_integrity`.
```
```{todo}
Move those integrity checks to `__post_init__` if the registry ever becomes
user-configurable: a test only covers the specs shipped here.
```
"""
urls: dict[PlatformKey, str]
"""Platform key to URL template mapping. URLs use ``{version}`` placeholders."""
checksums: dict[PlatformKey, str]
"""Platform key to SHA-256 hex digest mapping."""
archive_format: ArchiveFormat | dict[PlatformKey | Platform | Group, ArchiveFormat]
"""Archive format of the downloaded file.
A single {class}`ArchiveFormat` applies to every platform. A dict maps
platform specifiers to formats, allowing mixed archives in one spec::
archive_format={ALL_PLATFORMS: ArchiveFormat.TAR_GZ, WINDOWS: ArchiveFormat.ZIP}
Dict keys follow the same resolution as {meth}`resolve_platform`:
exact {data}`PlatformKey` tuple first, then bare
{class}`~extra_platforms.Platform` equality, then
{class}`~extra_platforms.Group` membership (smallest group wins).
"""
archive_executable: str | None = None
"""Path of the executable inside the archive. `None` defaults to the
tool name. For `RAW` format, used as the final filename.
"""
strip_components: int | dict[PlatformKey | Platform | Group, int] = 0
"""Number of leading path components to strip when extracting.
A single `int` applies to every platform. A dict maps platform specifiers to
counts, using the same resolution as {meth}`get_archive_format`, for a
project whose archives are not laid out identically across platforms::
strip_components={ALL_PLATFORMS: 1, WINDOWS: 0}
`gh` is the motivating case: its Linux and macOS archives nest everything
under a `gh_{version}_{platform}_{arch}/` directory, while the Windows zip
puts `bin/gh.exe` at the root. The nesting cannot be absorbed by
{attr}`archive_executable` instead, since that is one string for all
platforms and the directory name carries the version and platform.
"""
@staticmethod
def _resolve_per_platform(
mapping: dict[PlatformKey | Platform | Group, Any],
key: PlatformKey,
what: str,
) -> Any:
"""Pick a per-platform mapping's value for the given platform key.
Resolves in order: exact {data}`PlatformKey` tuple, bare Platform
equality, then Group membership with the smallest group winning, so a
narrow `WINDOWS` entry overrides a broad `ALL_PLATFORMS` one.
:param mapping: Platform specifier to value mapping.
:param key: The platform key to resolve for.
:param what: Field name, for the error message.
:return: The matching value.
:raises ValueError: If no entry matches the platform key.
"""
# Exact tuple match.
if key in mapping:
return mapping[key]
# Bare Platform or Group match.
key_plat = key[0]
group_hits: list[tuple[Group, Any]] = []
for map_key, value in mapping.items():
if isinstance(map_key, tuple):
continue
if map_key == key_plat:
return value
if isinstance(map_key, Group) and (
isinstance(key_plat, Platform)
and key_plat in map_key
or isinstance(key_plat, Group)
and (key_plat & map_key)
):
group_hits.append((map_key, value))
if group_hits:
# Most-specific group: fewest members.
return min(group_hits, key=lambda h: len(h[0]))[1]
msg = f"No {what} for {key[0].name} {key[1].name}"
raise ValueError(msg)
[docs]
def get_strip_components(self, key: PlatformKey) -> int:
"""Return the leading path components to strip for a platform key.
When {attr}`strip_components` is a plain `int`, returns it directly.
When it is a dict, resolves through `_resolve_per_platform`.
"""
if isinstance(self.strip_components, int):
return self.strip_components
count: int = self._resolve_per_platform(
self.strip_components, key, "strip_components"
)
return count
[docs]
def resolve(
self, tool_name: str, version: str, key: PlatformKey | None = None
) -> ResolvedBinary:
"""Pick the download of one platform, at one version.
:param tool_name: Registry key of the tool, the default executable name.
:param version: Version to fill into the URL template.
:param key: Platform to resolve. Defaults to the running platform.
:raises UnsupportedPlatformError: When *key* is not given and no
binary matches the running platform.
"""
if key is None:
key = self.resolve_platform()
return ResolvedBinary(
key=key,
url=self.urls[key].format(version=version),
checksum=self.checksums.get(key),
archive_format=self.get_archive_format(key),
strip_components=self.get_strip_components(key),
executable=self.archive_executable or tool_name,
)
[docs]
@dataclass(frozen=True, kw_only=True)
class ResolvedBinary:
"""The download of a binary tool for one platform, at one version."""
key: PlatformKey
"""The platform this download serves."""
url: str
"""Download URL, with the version filled in."""
checksum: str | None
"""SHA-256 of the download, or `None` when the registry records none."""
archive_format: ArchiveFormat
"""Format of the download."""
strip_components: int
"""Leading path components to drop from archive members."""
executable: str
"""Path of the executable inside the archive, after stripping.
The final filename of a `RAW` download.
"""
@property
def cache_key(self) -> str:
"""Filesystem-safe name of the platform, like `linux-aarch64`."""
return BinarySpec.platform_cache_key(self.key)
@property
def archive_name(self) -> str:
"""Filename of the download, as its URL names it."""
return self.url.rsplit("/", 1)[-1]
@property
def archive_members(self) -> tuple[str, str]:
"""Names the executable can carry inside a ZIP archive.
A Windows build adds the `.exe` suffix to the executable.
"""
return self.executable, f"{self.executable}.exe"
@property
def cached_names(self) -> tuple[str, str]:
"""Names the cache can hold the extracted executable under.
The cache keys an entry on the extracted file's own name: a path inside
the archive (`bin/gh`) loses its directories, and a Windows build keeps
its `.exe` suffix.
"""
stem = PurePosixPath(self.executable).name
return stem, f"{stem}.exe"
TOOL_LIST_HEADER_DEFS: tuple[ColumnSpec, ...] = (
ColumnSpec("tool", "Tool"),
ColumnSpec("version", "Version"),
ColumnSpec("config-source", "Config source"),
)
"""Column definitions for the `repomatic run --list` table.
Lives beside the registry it renders; the CLI derives its `--sort-by`
choices from it.
"""
NPM_MIN_VERSION_FOR_COOLDOWN = "11.10.0"
"""First npm release honoring `min-release-age`, the cooldown gate for npm tools.
Older npm silently ignores the `--min-release-age` flag, so `_install_npm`
warns when it cannot enforce the cooldown. This is a fixed floor (the release that
introduced the option), distinct from the pinned {data}`NPM_BOOTSTRAP_TOOL`,
which `sync-tool-versions` keeps on the latest npm past the cooldown.
"""
NPM_BOOTSTRAP_TOOL = "npm"
"""Registry name of the npm that installs every npm-backed tool.
The npm on `PATH` is whatever the machine carries: unpinned, and too old to
honor the cooldown on many images. It only installs this pinned npm, which then
installs the tool. The npm package bundles its own dependencies, so that first
install resolves nothing an old npm could leave outside the cooldown.
"""
[docs]
@dataclass(frozen=True)
class NpmSpec:
"""npm-registry backend marker for a {class}`ToolSpec`.
Presence (`ToolSpec.npm is not None`) selects the npm backend, the way a
{class}`BinarySpec` selects the download backend. The package name,
executable, and version all derive from the `ToolSpec` fields, so no per-tool
npm config is needed today; the class exists as a typed discriminator and a
home for future npm-specific options.
```{note}
npm tools need Node.js and npm on `PATH` at run time: the one backend that
depends on a runtime repomatic neither bundles nor provisions (binary tools
are self-contained; the uv backends use uv). Integrity is npm's own
per-tarball verification on install, so unlike {class}`BinarySpec` there is
no repomatic-pinned checksum; the `minimum-release-age` cooldown (npm's
`min-release-age`, npm 11.10.0+) gates the transitive tree instead. Older npm
ignores the gate, so the runner warns rather than silently skipping it.
```
"""
# ---------------------------------------------------------------------------
# Post-process callbacks
# ---------------------------------------------------------------------------
_DIRECTIVE_YAML_OPTIONS_RE = re.compile(
r"^((?:`{3,}|:{3,})\{[^}]+\}[^\n]*\n)"
r"---\n"
r"((?:[^\n]+\n)+?)"
r"---\n",
re.MULTILINE,
)
"""Match YAML-block directive options immediately after a MyST fence opening.
```{note}
Workaround for [executablebooks/mdformat-myst#21](https://github.com/executablebooks/mdformat-myst/issues/21)
where `mdformat-myst` unconditionally converts `:key: value` directive
options to YAML blocks (`---` / `key: value` / `---`).
```
Retiring this pattern is tracked in the module docstring, where `autodoc`
publishes it onto the todo list; a private name renders nowhere.
"""
def _yaml_block_to_field_list(match: re.Match[str]) -> str:
"""Convert a single YAML-block directive option to field-list syntax."""
directive_line = match.group(1)
yaml_lines = match.group(2)
# Prepend ":" to each non-empty line: `key: value` → `:key: value`.
field_lines = re.sub(r"^(?=\S)", ":", yaml_lines, flags=re.MULTILINE)
return directive_line + field_lines
_ESCAPED_COLON_FENCE_RE = re.compile(
r"^\\(:{3,})\\\{[^}]+\}.*" # Opener: escaped ::: run, escaped {name}, title.
r"(?:\n(?:.*\n)*?)?" # Inner option and body lines.
r"^\\\1[ \t]*$", # Closer: the matching escaped ::: run.
re.MULTILINE,
)
r"""Match a colon-fence directive whose delimiters mdformat has escaped.
```{note}
Workaround for
[executablebooks/mdformat-myst#13](https://github.com/executablebooks/mdformat-myst/issues/13):
`mdformat-myst` does not treat ``:::{name}`` colon fences as directives, so
`mdformat-deflist` escapes their leading colons and `mdformat-myst` escapes the
opening brace, leaving an uneditable ``\:::\{name}`` / ``\:option:`` / ``\:::`` block.
```
Retiring this pattern is tracked in the module docstring, where `autodoc`
publishes it onto the todo list; a private name renders nowhere.
"""
def _unescape_colon_fence(match: re.Match[str]) -> str:
"""Strip mdformat's backslash escaping from a colon-fence directive block.
Handles nested fences: every fence and option line in the matched block is
un-escaped, not just the outermost opener.
"""
block = match.group(0)
# Drop the leading-colon escape on every fence and option line.
block = re.sub(r"^\\:", ":", block, flags=re.MULTILINE)
# Drop the brace escape on directive openers: :::\{name} -> :::{name}.
return re.sub(r"^(:{3,})\\\{", r"\1{", block, flags=re.MULTILINE)
def _fix_myst_directives(extra_args: Sequence[str]) -> None:
"""Undo mdformat's MyST-hostile directive rewrites, in place.
Two fixups run on every file in *extra_args* that exists on disk: YAML-block
directive options are restored to field-list syntax (see
{data}`_DIRECTIVE_YAML_OPTIONS_RE`), and escaped colon-fence directives are
un-escaped (see {data}`_ESCAPED_COLON_FENCE_RE`). Files without matching
patterns are left untouched, and so is a file that is not UTF-8 text.
"""
for arg in extra_args:
path = Path(arg)
if not path.is_file():
continue
try:
content = path.read_text(encoding="UTF-8")
except UnicodeDecodeError:
# mdformat writes UTF-8, so this file is not one of its own. The
# runner also hands over whatever else changed below a directory.
continue
fixed = _DIRECTIVE_YAML_OPTIONS_RE.sub(_yaml_block_to_field_list, content)
fixed = _ESCAPED_COLON_FENCE_RE.sub(_unescape_colon_fence, fixed)
if fixed != content:
path.write_text(fixed, encoding="UTF-8")
logging.debug(f"Fixed MyST directives in {path}")
# ---------------------------------------------------------------------------
# Binary checksums
# ---------------------------------------------------------------------------
CHECKSUMS: dict[str, dict[PlatformKey, str]] = {
"actionlint": {
(
LINUX,
AARCH64,
): "325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6",
(
LINUX,
X86_64,
): "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8",
(
MACOS,
AARCH64,
): "aba9ced2dee8d27fecca3dc7feb1a7f9a52caefa1eb46f3271ea66b6e0e6953f",
(
MACOS,
X86_64,
): "5b44c3bc2255115c9b69e30efc0fecdf498fdb63c5d58e17084fd5f16324c644",
(
WINDOWS,
AARCH64,
): "cadcf7ea4efe3a68728893813643cebe1185e5b1d4be5b96245f65c9a4d5ea41",
(
WINDOWS,
X86_64,
): "6e7241b51e6817ea6a047693d8e6fed13b31819c9a0dd6c5a726e1592d22f6e9",
},
"biome": {
(
LINUX,
AARCH64,
): "a56bcd73ddbfdb0f57a82bb67b7ea698eed4027aff602b95aa18fafe919d59e0",
(
LINUX,
X86_64,
): "5d867a0b2ccea1755b7e508b01d836a8c64e31a1e4d11ec24b43b6b4eec8b3b6",
(
MACOS,
AARCH64,
): "3636796d8c78bfcfe0d45946dc93e21e12d27a49eba7401e18af1ea854d25503",
(
MACOS,
X86_64,
): "86fc618563d19f373ac24eb5b8f1e41bd753a506d5df1f198c152ec365141490",
(
WINDOWS,
AARCH64,
): "6244a2bb366c76ad869b94f999ee60ce41b94112a11dc23c4f05fd1a7053256a",
(
WINDOWS,
X86_64,
): "f2beadee753b78fe76081158123538be886a31b5e67a34863e42535f3f491926",
},
"gh": {
(
LINUX,
AARCH64,
): "7862c86c72f43df3a2d93ddde6f473285b4e2af61b494849846827e513ef6484",
(
LINUX,
X86_64,
): "bb766f710eef8ede859c18578c72c327597cd4c8a85b06001b1f3843c6019386",
(
MACOS,
AARCH64,
): "da922c20d1792e5b2cbf375593d7a658acf034c12c84e007e71c76ef959c337e",
(
MACOS,
X86_64,
): "b245f24eb2bf5f75b426b4c26da3651a107f8d5b6f4fddfbfccc5679041378b3",
(
WINDOWS,
AARCH64,
): "5dcf12aa8525eabd0c46ec414f323ab6cf65229fc2cd46543cc705001bbaf223",
(
WINDOWS,
X86_64,
): "ae64e556ecc240b200f7eba60d550e4bb60d78e860e69dd88c449405b86067f4",
},
"gitleaks": {
(
LINUX,
AARCH64,
): "e4a487ee7ccd7d3a7f7ec08657610aa3606637dab924210b3aee62570fb4b080",
(
LINUX,
X86_64,
): "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb",
(
MACOS,
AARCH64,
): "b40ab0ae55c505963e365f271a8d3846efbc170aa17f2607f13df610a9aeb6a5",
(
MACOS,
X86_64,
): "dfe101a4db2255fc85120ac7f3d25e4342c3c20cf749f2c20a18081af1952709",
(
WINDOWS,
AARCH64,
): "b95f5e4f5c425cedca7ee203d9afd29597e692c4924a12ed42f970537c72cc0f",
(
WINDOWS,
X86_64,
): "d29144deff3a68aa93ced33dddf84b7fdc26070add4aa0f4513094c8332afc4e",
},
"labelmaker": {
(
LINUX,
AARCH64,
): "4685e142da55150904d16624fe1052161de5dba1a859cddef19ab41833c37728",
(
LINUX,
X86_64,
): "d76f8e64f9671884dac1758fe54a28a6680c5d9bf0ffd593a2c68ba558cc49a2",
(
MACOS,
AARCH64,
): "a52a4e102f0760ce1632da5fdaee2b0debe0e6ddea577b88a94a60172fe85751",
(
MACOS,
X86_64,
): "dc8374d6a9bec4ebf143fb42e3024aeffabe8585bb9bd6f134cfaf0693be7688",
(
WINDOWS,
X86_64,
): "939195930f9d5fd2b15a5cf43497019a52083e6c6713807d3379de49395c2e10",
},
"lychee": {
(
LINUX,
AARCH64,
): "91a7bd65685da41b90ccb9bc867a3d649a7818042dae04ff405e55a25bddee4c",
(
LINUX,
X86_64,
): "1f4e0ef7f6554a6ed33dd7ac144fb2e1bbed98598e7af973042fc5cd43951c9a",
(
MACOS,
AARCH64,
): "c9d3740ea2d891854d37116c9fba840f37b6e7c89d330e7db84ac333631c4977",
(
WINDOWS,
X86_64,
): "32975d1493ee1a975d6bb41e4fb56fe419cb442ded628bb772ba2e614acfacad",
},
"oxipng": {
(
LINUX,
AARCH64,
): "f51bb7c9836202da52a8181549641940662aa87ec2e3ae68115d822d893015b3",
(
LINUX,
X86_64,
): "46e3c4beb9aae57290ad809dd3374b07153579d3322a3778c53900633618b7c6",
(
MACOS,
AARCH64,
): "7039fcfc78e8aa1ed2b57d848057a0296f082e92b3e1807ac65402d10d926764",
(
MACOS,
X86_64,
): "111883bbe42b25e01cb1ca41f39f8094e4830b173f0e42be342ecc4ca48f3131",
(
WINDOWS,
X86_64,
): "7e940f83ee46874b73f53031f96a15834cb70b220af27391fb06fe7b4dd798e1",
},
"shellcheck": {
(
LINUX,
AARCH64,
): "12b331c1d2db6b9eb13cfca64306b1b157a86eb69db83023e261eaa7e7c14588",
(
LINUX,
X86_64,
): "8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198",
(
MACOS,
AARCH64,
): "56affdd8de5527894dca6dc3d7e0a99a873b0f004d7aabc30ae407d3f48b0a79",
(
MACOS,
X86_64,
): "3c89db4edcab7cf1c27bff178882e0f6f27f7afdf54e859fa041fca10febe4c6",
(
WINDOWS,
X86_64,
): "8a4e35ab0b331c85d73567b12f2a444df187f483e5079ceffa6bda1faa2e740e",
},
"shfmt": {
(
LINUX,
AARCH64,
): "5f2db09dae91fca848f7adbdd014632e921a383863a2ad7e0450ad3aba0c6489",
(
LINUX,
X86_64,
): "76e77641faa025814b77f153b29796b8e6fa2fca03e0c76a691608b86c7ea7bf",
(
MACOS,
AARCH64,
): "b7c872db63553ccffc7253aba3ed7d4885a27d83f1ba567b1138c6315a5847e5",
(
MACOS,
X86_64,
): "d33eee0da0f92835b3562e9767a05cee7e4eaeef47daa03bfd09da17b4b590a6",
(
WINDOWS,
X86_64,
): "13629ce28442ca80b6b5a819f7574ab39e1c28c6e26734ca816c9714e04851df",
},
"typos": {
(
LINUX,
AARCH64,
): "2ac5e37b3236b3c07dec5cdf1527acf143aec4674a6089b0bf424d021b35e099",
(
LINUX,
X86_64,
): "93c301fd4120ff076e2cc2a6d6a61cab4a4bdb81899287c0938b452e56d1f454",
(
MACOS,
AARCH64,
): "316d50a32c3726a80ab39c6d12141b70080498973c5e8a9b068b12cde78b10c1",
(
MACOS,
X86_64,
): "f3451079971633a7f769fb958fe06b0d64b5f1dc46df0854aa1ddb9a6f4031d6",
(
WINDOWS,
X86_64,
): "518f759fba43205f17a717656054c1d25bc883455a6296989e9d021c6edb7c59",
},
}
"""Tool name to platform-keyed SHA-256 hex digest mapping.
Recomputed in place by `repomatic update-checksums` and `sync-tool-versions`.
Kept as a flat sidecar dict (rather than inline in each `BinarySpec`) so the
checksum recompute can replace a hash by exact string match without re-parsing
the registry, and so `VERSIONS` can anchor the offline staleness test.
"""
VERSIONS: dict[str, str] = {
"actionlint": "1.7.12",
"biome": "2.5.15",
"gh": "2.102.0",
"gitleaks": "8.30.1",
"labelmaker": "0.6.4",
"lychee": "0.24.2",
"oxipng": "10.2.1",
"shellcheck": "0.11.0",
"shfmt": "3.14.1",
"typos": "1.51.1",
}
"""Tool name to the version each checksum set was computed for.
`test_tool_spec_integrity` asserts this equals the matching `ToolSpec.version`,
so a bump whose checksums were never refreshed (a stale `CHECKSUMS` entry) fails
CI offline, without downloading anything.
"""
# ---------------------------------------------------------------------------
# Tool registry
# ---------------------------------------------------------------------------
TOOL_REGISTRY: dict[str, ToolSpec] = {
"actionlint": ToolSpec(
name="actionlint",
version="1.7.12",
native_config_files=(".github/actionlint.yaml", ".github/actionlint.yml"),
config_flag="--config-file",
native_format=NativeFormat.YAML,
default_config="actionlint.yaml",
default_flags=("-color",),
# actionlint lints the `run:` scripts with the shellcheck it finds on
# PATH, and skips them when there is none.
path_tools=("shellcheck",),
# A copy of `.github/actionlint-matcher.json` from the actionlint
# repository, at the tag of the pinned version.
problem_matcher="actionlint-matcher.json",
source_url="https://github.com/rhysd/actionlint",
config_docs_url="https://github.com/rhysd/actionlint/blob/main/docs/config.md",
cli_docs_url="https://github.com/rhysd/actionlint/blob/main/docs/usage.md",
binary=BinarySpec(
urls={
(
LINUX,
AARCH64,
): "https://github.com/rhysd/actionlint/releases/download/v{version}/actionlint_{version}_linux_arm64.tar.gz",
(
LINUX,
X86_64,
): "https://github.com/rhysd/actionlint/releases/download/v{version}/actionlint_{version}_linux_amd64.tar.gz",
(
MACOS,
AARCH64,
): "https://github.com/rhysd/actionlint/releases/download/v{version}/actionlint_{version}_darwin_arm64.tar.gz",
(
MACOS,
X86_64,
): "https://github.com/rhysd/actionlint/releases/download/v{version}/actionlint_{version}_darwin_amd64.tar.gz",
(
WINDOWS,
AARCH64,
): "https://github.com/rhysd/actionlint/releases/download/v{version}/actionlint_{version}_windows_arm64.zip",
(
WINDOWS,
X86_64,
): "https://github.com/rhysd/actionlint/releases/download/v{version}/actionlint_{version}_windows_amd64.zip",
},
checksums=CHECKSUMS["actionlint"],
archive_format={
ALL_PLATFORMS: ArchiveFormat.TAR_GZ,
WINDOWS: ArchiveFormat.ZIP,
},
),
docs_notes=cleandoc(r"""
**Try it:**
```shell-session
$ repomatic run actionlint
```
**Minimal `[tool.actionlint]`:**
```toml
[tool.actionlint.self-hosted-runner]
labels = ["my-linux-runner"]
```
With no arguments actionlint lints every workflow under `.github/workflows`.
The `[tool.actionlint]` section is bridged to a temporary YAML config:
declaring self-hosted runner labels stops custom `runs-on:` values being
flagged as unknown.
The pinned ShellCheck goes on `PATH` for the run, which is what lints the
shell scripts of the `run:` steps. In GitHub Actions, repomatic also
registers actionlint's problem matcher, so each finding annotates the line
it names.
"""),
),
"autopep8": ToolSpec(
name="autopep8",
default_paths="python_files",
version="2.3.2",
source_url="https://github.com/hhatto/autopep8",
cli_docs_url="https://pypi.org/project/autopep8/",
reads_pyproject=True,
default_flags=(
"--recursive",
"--in-place",
"--max-line-length",
"88",
"--select",
"E501",
),
docs_notes=cleandoc(r"""
**Try it:**
```shell-session
$ repomatic run autopep8 -- .
```
autopep8 takes its configuration from CLI flags only. repomatic passes
`--recursive --in-place --max-line-length 88 --select E501` by default;
append more flags after `--`.
"""),
),
"awesome-lint": ToolSpec(
name="awesome-lint",
version="2.3.0",
npm=NpmSpec(),
cli_docs_url="https://github.com/sindresorhus/awesome-lint#usage",
),
"biome": ToolSpec(
name="biome",
default_args=(
"format",
"--write",
"--no-errors-on-unmatched",
# JSONC files: biome auto-detects the well-known ones, these
# cover the rest.
"--json-parse-allow-comments=true",
"--json-parse-allow-trailing-commas=true",
),
default_paths="json_files",
display_name="Biome",
version="2.5.15",
source_url="https://github.com/biomejs/biome",
tag_pattern=r"^@biomejs/biome@(?P<version>.+)$",
config_docs_url="https://biomejs.dev/reference/configuration/",
cli_docs_url="https://biomejs.dev/reference/cli/",
native_config_files=(
"biome.json",
"biome.jsonc",
".biome.json",
".biome.jsonc",
),
config_flag="--config-path",
config_after_subcommand=True,
native_format=NativeFormat.JSON,
binary=BinarySpec(
urls={
(
LINUX,
AARCH64,
): "https://github.com/biomejs/biome/releases/download/%40biomejs%2Fbiome%40{version}/biome-linux-arm64",
(
LINUX,
X86_64,
): "https://github.com/biomejs/biome/releases/download/%40biomejs%2Fbiome%40{version}/biome-linux-x64",
(
MACOS,
AARCH64,
): "https://github.com/biomejs/biome/releases/download/%40biomejs%2Fbiome%40{version}/biome-darwin-arm64",
(
MACOS,
X86_64,
): "https://github.com/biomejs/biome/releases/download/%40biomejs%2Fbiome%40{version}/biome-darwin-x64",
(
WINDOWS,
AARCH64,
): "https://github.com/biomejs/biome/releases/download/%40biomejs%2Fbiome%40{version}/biome-win32-arm64.exe",
(
WINDOWS,
X86_64,
): "https://github.com/biomejs/biome/releases/download/%40biomejs%2Fbiome%40{version}/biome-win32-x64.exe",
},
checksums=CHECKSUMS["biome"],
archive_format=ArchiveFormat.RAW,
),
docs_notes=cleandoc(r"""
**Try it:**
```shell-session
$ repomatic run biome -- check .
```
**Minimal `[tool.biome]`:**
```toml
[tool.biome.formatter]
indentStyle = "space"
```
`biome check` reports formatting and lint issues; add `--write` after `--`
to apply fixes. The `[tool.biome]` section is bridged to a temporary
`biome.json`, so keys keep Biome's camelCase spelling.
"""),
),
"bump-my-version": ToolSpec(
name="bump-my-version",
version="1.6.0",
reads_pyproject=True,
pyproject_table="bumpversion",
source_url="https://github.com/callowayproject/bump-my-version",
config_docs_url="https://callowayproject.github.io/bump-my-version/reference/configuration/",
cli_docs_url="https://callowayproject.github.io/bump-my-version/reference/cli/",
native_config_files=(".bumpversion.toml",),
config_flag="--config-file",
native_format=NativeFormat.TOML,
docs_notes=cleandoc(r"""
**Try it:**
```shell-session
$ repomatic run bump-my-version -- show-bump
```
**Minimal `[tool.bumpversion]`:**
```toml
[tool.bumpversion]
current_version = "1.2.3"
```
The configuration table is `[tool.bumpversion]`, not
`[tool.bump-my-version]`: the section name predates the project's rename.
`show-bump` previews the next versions without writing; `repomatic run
bump-my-version -- bump minor` performs the bump.
"""),
),
"gh": ToolSpec(
name="gh",
display_name="GitHub CLI",
version="2.102.0",
source_url="https://github.com/cli/cli",
cli_docs_url="https://cli.github.com/manual/",
binary=BinarySpec(
urls={
(
LINUX,
AARCH64,
): "https://github.com/cli/cli/releases/download/v{version}/gh_{version}_linux_arm64.tar.gz",
(
LINUX,
X86_64,
): "https://github.com/cli/cli/releases/download/v{version}/gh_{version}_linux_amd64.tar.gz",
(
MACOS,
AARCH64,
): "https://github.com/cli/cli/releases/download/v{version}/gh_{version}_macOS_arm64.zip",
(
MACOS,
X86_64,
): "https://github.com/cli/cli/releases/download/v{version}/gh_{version}_macOS_amd64.zip",
(
WINDOWS,
AARCH64,
): "https://github.com/cli/cli/releases/download/v{version}/gh_{version}_windows_arm64.zip",
(
WINDOWS,
X86_64,
): "https://github.com/cli/cli/releases/download/v{version}/gh_{version}_windows_amd64.zip",
},
checksums=CHECKSUMS["gh"],
archive_format={
ALL_PLATFORMS: ArchiveFormat.TAR_GZ,
MACOS: ArchiveFormat.ZIP,
WINDOWS: ArchiveFormat.ZIP,
},
archive_executable="bin/gh",
# Linux and macOS archives nest everything under a
# `gh_{version}_{platform}_{arch}/` directory; the Windows zip puts
# `bin/gh.exe` at the root.
strip_components={ALL_PLATFORMS: 1, WINDOWS: 0},
),
docs_notes=cleandoc(r"""
**Try it:**
```shell-session
$ repomatic run gh -- --version
```
Pinned so the release lane gets the same `gh` everywhere. The manylinux
container the Linux binaries compile in ships no `gh`, and the runner images
that do ship one leave its version to the image. `gh` reads no project
configuration: it authenticates from `GH_TOKEN` in the environment.
"""),
),
"gitleaks": ToolSpec(
name="gitleaks",
# The history of the repository, as the `lint-secrets` job scans it.
default_args=("git", "."),
display_name="Gitleaks",
version="8.30.1",
source_url="https://github.com/gitleaks/gitleaks",
config_docs_url="https://github.com/gitleaks/gitleaks#configuration",
cli_docs_url="https://github.com/gitleaks/gitleaks#usage",
native_config_files=(".gitleaks.toml",),
config_flag="--config",
native_format=NativeFormat.TOML,
binary=BinarySpec(
urls={
(
LINUX,
AARCH64,
): "https://github.com/gitleaks/gitleaks/releases/download/v{version}/gitleaks_{version}_linux_arm64.tar.gz",
(
LINUX,
X86_64,
): "https://github.com/gitleaks/gitleaks/releases/download/v{version}/gitleaks_{version}_linux_x64.tar.gz",
(
MACOS,
AARCH64,
): "https://github.com/gitleaks/gitleaks/releases/download/v{version}/gitleaks_{version}_darwin_arm64.tar.gz",
(
MACOS,
X86_64,
): "https://github.com/gitleaks/gitleaks/releases/download/v{version}/gitleaks_{version}_darwin_x64.tar.gz",
(
WINDOWS,
AARCH64,
): "https://github.com/gitleaks/gitleaks/releases/download/v{version}/gitleaks_{version}_windows_arm64.zip",
(
WINDOWS,
X86_64,
): "https://github.com/gitleaks/gitleaks/releases/download/v{version}/gitleaks_{version}_windows_x64.zip",
},
checksums=CHECKSUMS["gitleaks"],
archive_format={
ALL_PLATFORMS: ArchiveFormat.TAR_GZ,
WINDOWS: ArchiveFormat.ZIP,
},
),
docs_notes=cleandoc(r"""
**Try it:**
```shell-session
$ repomatic run gitleaks
```
**Minimal `[tool.gitleaks]`:**
```toml
[tool.gitleaks.extend]
useDefault = true
[tool.gitleaks.allowlist]
paths = ['''\.env\.sample$''']
```
With no arguments, gitleaks scans the git history of the repository
(`gitleaks git .`), as the `lint-secrets` job does. `repomatic run gitleaks
-- dir .` scans the working tree instead. The `[tool.gitleaks]` section is
bridged to a temporary `.gitleaks.toml`: keep `extend.useDefault = true`, or
a custom config silently replaces the built-in rule set.
"""),
),
"labelmaker": ToolSpec(
name="labelmaker",
version="0.6.4",
source_url="https://github.com/jwodder/labelmaker",
cli_docs_url="https://github.com/jwodder/labelmaker",
binary=BinarySpec(
urls={
(
LINUX,
AARCH64,
): "https://github.com/jwodder/labelmaker/releases/download/v{version}/labelmaker-aarch64-unknown-linux-gnu.tar.xz",
(
LINUX,
X86_64,
): "https://github.com/jwodder/labelmaker/releases/download/v{version}/labelmaker-x86_64-unknown-linux-gnu.tar.xz",
(
MACOS,
AARCH64,
): "https://github.com/jwodder/labelmaker/releases/download/v{version}/labelmaker-aarch64-apple-darwin.tar.xz",
(
MACOS,
X86_64,
): "https://github.com/jwodder/labelmaker/releases/download/v{version}/labelmaker-x86_64-apple-darwin.tar.xz",
(
WINDOWS,
X86_64,
): "https://github.com/jwodder/labelmaker/releases/download/v{version}/labelmaker-x86_64-pc-windows-msvc.zip",
},
checksums=CHECKSUMS["labelmaker"],
archive_format={
ALL_PLATFORMS: ArchiveFormat.TAR_XZ,
WINDOWS: ArchiveFormat.ZIP,
},
strip_components=1,
),
docs_notes=cleandoc(r"""
labelmaker syncs a repository's issue and PR labels from a label-definition
file, so unlike the linters it needs a target repository and a
`GITHUB_TOKEN`, not a path in the working tree. There is no
`[tool.labelmaker]` section: the label file is the configuration. See the
[upstream usage docs](https://github.com/jwodder/labelmaker) for its flags
and file schema.
"""),
),
"lychee": ToolSpec(
name="lychee",
# The crawl the `check-broken-links` job runs through `repomatic
# broken-links`, which adds the flags of its markdown report.
default_args=(
"--hidden",
"--suggest",
"--no-progress",
"--include-fragments",
"--exclude-all-private",
),
default_paths="doc_files",
display_name="Lychee",
version="0.24.2",
source_url="https://github.com/lycheeverse/lychee",
tag_pattern=r"^lychee-v(?P<version>.+)$",
config_docs_url="https://lychee.cli.rs/guides/config/",
cli_docs_url="https://lychee.cli.rs/guides/cli/",
native_config_files=("lychee.toml",),
config_flag="--config",
output_flag="--output",
native_format=NativeFormat.TOML,
# Since v0.24.0 (https://github.com/lycheeverse/lychee/issues/1930,
# https://github.com/lycheeverse/lychee/pull/2104), lychee natively reads
# [tool.lychee] from pyproject.toml, so repomatic skips the translation bridge.
reads_pyproject=True,
binary=BinarySpec(
urls={
(
LINUX,
AARCH64,
): "https://github.com/lycheeverse/lychee/releases/download/lychee-v{version}/lychee-aarch64-unknown-linux-gnu.tar.gz",
(
LINUX,
X86_64,
): "https://github.com/lycheeverse/lychee/releases/download/lychee-v{version}/lychee-x86_64-unknown-linux-gnu.tar.gz",
(
MACOS,
AARCH64,
): "https://github.com/lycheeverse/lychee/releases/download/lychee-v{version}/lychee-aarch64-apple-darwin.tar.gz",
(
WINDOWS,
X86_64,
): "https://github.com/lycheeverse/lychee/releases/download/lychee-v{version}/lychee-x86_64-pc-windows-msvc.zip",
},
checksums=CHECKSUMS["lychee"],
archive_format={
ALL_PLATFORMS: ArchiveFormat.TAR_GZ,
WINDOWS: ArchiveFormat.ZIP,
},
strip_components=1,
),
docs_notes=cleandoc(r"""
**Try it:**
```shell-session
$ repomatic run lychee
```
**Minimal `[tool.lychee]`:**
```toml
[tool.lychee]
max_redirects = 5
```
With no arguments, lychee checks the links of every documentation file, as
the `check-broken-links` job does through `repomatic broken-links`. Pass
paths after `--` to check other files. Since v0.24 it reads `[tool.lychee]`
from `pyproject.toml` natively, so repomatic does not translate it.
"""),
),
"mdformat": ToolSpec(
name="mdformat",
default_paths="markdown_files",
per_file=True,
version="1.0.0",
source_url="https://github.com/hukkin/mdformat",
config_docs_url="https://mdformat.readthedocs.io/en/stable/users/configuration_file.html",
cli_docs_url="https://mdformat.readthedocs.io/en/stable/users/installation_and_usage.html",
native_config_files=(".mdformat.toml",),
native_format=NativeFormat.TOML,
default_config="mdformat.toml",
reads_pyproject=True,
default_flags=("--strict-front-matter",),
# No Python-formatting plugin here: ruff formats fenced Python blocks in
# Markdown natively, and the `format-python` job already runs it over doc
# files. A `mdformat-ruff` plugin would only cover the `python` info
# string (ruff also handles `py`, `py3`, `python3`, `pyi` and `pycon`) and
# would pin a second, independently-drifting ruff version in this
# environment, letting the two jobs fight over the same code blocks.
with_packages=(
"mdformat_admon==2.1.1",
"mdformat-config==0.2.1",
"mdformat_deflist==0.1.4",
"mdformat_footnote==0.1.3",
"mdformat-front-matters==2.0.0",
"mdformat-gfm==1.0.0",
"mdformat_gfm_alerts==2.1.0",
"mdformat_myst==0.3.0",
"mdformat-pelican==1.0.0",
"mdformat_pyproject==0.1.1",
"mdformat-recover-urls==0.0.2",
"mdformat-shfmt==0.2.0",
"mdformat_simple_breaks==0.1.0",
"mdformat-toc==0.5.0",
"mdformat-web==0.2.0",
),
# mdformat-shfmt shells out to `shfmt` rather than importing it, so the
# binary has to be on PATH. Routed through the registry to get the same
# pinned, checksum-verified build `repomatic run shfmt` uses.
path_tools=("shfmt",),
post_process=_fix_myst_directives,
check_flags=("--check",),
docs_notes=cleandoc(r"""
**Try it:**
```shell-session
$ repomatic run mdformat -- .
```
**Minimal `[tool.mdformat]`:**
```toml
[tool.mdformat]
wrap = "no"
```
mdformat rewrites Markdown in place. repomatic bundles a plugin set (GFM,
MyST, front-matter, and others) and a baseline `mdformat.toml`;
`[tool.mdformat]` in your `pyproject.toml` overrides it.
"""),
),
"mypy": ToolSpec(
name="mypy",
default_paths="python_files",
version="2.4.0",
source_url="https://github.com/python/mypy",
config_docs_url="https://mypy.readthedocs.io/en/stable/config_file.html",
cli_docs_url="https://mypy.readthedocs.io/en/stable/command_line.html",
# mypy also auto-discovers standalone `mypy.ini`/`.mypy.ini`, but those use
# INI syntax, which `NativeFormat` cannot represent — so `native_config_files`
# is intentionally left empty. `reads_pyproject=True` (for `[tool.mypy]`) plus
# `config_flag` cover how repomatic resolves mypy's configuration.
config_flag="--config-file",
reads_pyproject=True,
needs_venv=True,
default_flags=("--color-output",),
computed_params=lambda m: m.mypy_params or [],
docs_notes=cleandoc(r"""
**Try it:**
```shell-session
$ repomatic run mypy -- .
```
**Minimal `[tool.mypy]`:**
```toml
[tool.mypy]
strict = true
```
mypy runs inside the project virtualenv (via `uv run`) so it can import your
dependencies. repomatic derives `--python-version` from `requires-python`,
so the check matches your lowest supported interpreter. In a repository
without a `uv.lock` there is no project virtualenv to freeze, so mypy runs
in an isolated environment instead and only resolves the standard library:
fine for standalone scripts, but dependency imports then report
`import-not-found`.
`uv run` provisions only the default dependency groups, so a module that
imports a dep declared solely in a non-default group (`docs`, `typing`, …)
sees it as missing and mypy reports `import-not-found`. Either move the
stub/dependency somewhere mypy resolves, or silence it with an override:
```toml
[[tool.mypy.overrides]]
module = "the_docs_only_package.*"
ignore_missing_imports = true
```
"""),
),
"npm": ToolSpec(
name="npm",
version="12.2.0",
npm=NpmSpec(),
source_url="https://github.com/npm/cli",
cli_docs_url="https://docs.npmjs.com/cli/",
docs_notes=cleandoc(r"""
**Try it:**
```shell-session
$ repomatic run npm -- --version
```
The npm every npm-backed tool installs through, like awesome-lint. The npm
on `PATH` only installs this pinned one into the scratch prefix of the run,
which then installs the tool: an npm older than 11.10.0 ignores the
`minimum-release-age` cooldown, and the one a runner image carries is not
pinned at all. Node.js still comes from `PATH`.
"""),
),
"nuitka": ToolSpec(
name="nuitka",
display_name="Nuitka",
version="4.3.1",
package="nuitka[onefile]",
source_url="https://github.com/Nuitka/Nuitka",
config_docs_url="https://nuitka.net/doc/user-manual.html",
cli_docs_url="https://nuitka.net/doc/user-manual.html",
needs_venv=True,
# Nuitka's install path ends in its compile cache keys: see
# ToolSpec.install_into_venv.
install_into_venv=True,
module="nuitka",
native_format=NativeFormat.FLAGS,
# Nuitka looks up its newest release on PyPI and warns when the running
# one is older. The pin above trails that release by the cooldown
# window on purpose, so the check is turned off.
default_flags=(
"--mode=onefile",
"--assume-yes-for-downloads",
"--update-check=never",
),
computed_params=nuitka_params,
prepare=prepare_nuitka_build,
docs_notes=cleandoc(r"""
**Try it:**
```shell-session
$ repomatic run nuitka -- --project-name=papaya \
--main-entry-point=papaya=papaya.__main__:main
```
`--main-entry-point` takes a `[project.scripts]` entry, written
`name=module:function`, and compiles the binary that console script would
start. It needs `--project-name`, which names the build folders.
**Minimal `[tool.nuitka]`:**
```toml
[tool.nuitka]
onefile = true
output-dir = "build"
```
repomatic reads every key from `[tool.nuitka]` and forwards it as a CLI
flag: `true` becomes a bare `--flag`, a string or number becomes
`--key=value`, and a list repeats the flag once per item. Nuitka `4.2` reads
the section itself, but only under `--project`
([Nuitka#3909](https://github.com/Nuitka/Nuitka/issues/3909)). That mode
takes its entry points from the project configuration, not from the command
line, so repomatic keeps its own bridge.
Binaries skip `tkinter` by default, via the `nuitka.nofollow-imports`
setting of [`[tool.repomatic]`](configuration.md): set it to `[]` to bundle
Tcl/Tk in a GUI project.
In a project with a `uv.lock`, repomatic installs Nuitka into the project
virtualenv before the build, and Nuitka stays there until the next `uv
sync`. The compile cache depends on that fixed path: see [Compile
caching](nuitka.md#compile-caching).
"""),
),
"oxipng": ToolSpec(
name="oxipng",
display_name="Oxipng",
version="10.2.1",
source_url="https://github.com/shssoichiro/oxipng",
cli_docs_url="https://github.com/shssoichiro/oxipng#usage",
binary=BinarySpec(
urls={
(
LINUX,
AARCH64,
): "https://github.com/shssoichiro/oxipng/releases/download/v{version}/oxipng-{version}-aarch64-unknown-linux-gnu.tar.gz",
(
LINUX,
X86_64,
): "https://github.com/shssoichiro/oxipng/releases/download/v{version}/oxipng-{version}-x86_64-unknown-linux-gnu.tar.gz",
(
MACOS,
AARCH64,
): "https://github.com/shssoichiro/oxipng/releases/download/v{version}/oxipng-{version}-aarch64-apple-darwin.tar.gz",
(
MACOS,
X86_64,
): "https://github.com/shssoichiro/oxipng/releases/download/v{version}/oxipng-{version}-x86_64-apple-darwin.tar.gz",
(
WINDOWS,
X86_64,
): "https://github.com/shssoichiro/oxipng/releases/download/v{version}/oxipng-{version}-x86_64-pc-windows-msvc.zip",
},
checksums=CHECKSUMS["oxipng"],
archive_format={
ALL_PLATFORMS: ArchiveFormat.TAR_GZ,
WINDOWS: ArchiveFormat.ZIP,
},
# Every archive nests its payload under `oxipng-{version}-{triple}/`,
# with the executable directly inside rather than in a `bin/`.
strip_components=1,
),
docs_notes=cleandoc(r"""
**Try it:**
```shell-session
$ repomatic run oxipng -- --opt 4 --strip safe image.png
```
Lossless PNG optimizer. `repomatic format-images` reaches it through
{func}`repomatic.tooling.binaries.ensure_binary`, so the pinned,
checksum-verified build is used instead of whatever the runner image or the
distro archive supplies.
"""),
),
"pyproject-fmt": ToolSpec(
name="pyproject-fmt",
default_paths="pyproject_files",
version="2.30.3",
source_url="https://github.com/tox-dev/pyproject-fmt",
config_docs_url="https://pyproject-fmt.readthedocs.io/en/latest/",
cli_docs_url="https://pyproject-fmt.readthedocs.io/en/latest/",
native_config_files=("pyproject-fmt.toml",),
config_flag="--config",
native_format=NativeFormat.TOML,
reads_pyproject=True,
rewrite_exit_code=1,
docs_notes=cleandoc(r"""
**Try it:**
```shell-session
$ repomatic run pyproject-fmt -- pyproject.toml
```
**Minimal `[tool.pyproject-fmt]`:**
```toml
[tool.pyproject-fmt]
indent = 4
```
pyproject-fmt normalizes and reorders `pyproject.toml` in place. It reads
its own `[tool.pyproject-fmt]` section natively.
"""),
),
"ruff": ToolSpec(
name="ruff",
display_name="Ruff",
version="0.17.0",
source_url="https://github.com/astral-sh/ruff",
config_docs_url="https://docs.astral.sh/ruff/configuration/",
cli_docs_url="https://docs.astral.sh/ruff/configuration/#command-line-interface",
native_config_files=(".ruff.toml", "ruff.toml"),
config_flag="--config",
native_format=NativeFormat.TOML,
default_config="ruff.toml",
reads_pyproject=True,
# ruff applies `exclude` and `extend-exclude` only while it walks a
# directory: a path named on the command line bypasses both. Its
# `--force-exclude` flag exists only after the subcommand, so the same
# setting goes in through the global `--config`, as an inline override.
default_flags=("--config=force-exclude=true",),
docs_notes=cleandoc(r"""
**Try it:**
```shell-session
$ repomatic run ruff -- check .
```
**Minimal `[tool.ruff]`:**
```toml
[tool.ruff]
line-length = 100
```
`ruff check .` lints; `ruff format .` reformats. Both read `[tool.ruff]`
natively. With no project config, repomatic falls back to its bundled
`ruff.toml` baseline.
The default `--config=force-exclude=true` applies `exclude` and
`extend-exclude` to a path named on the command line too, as in `repomatic
run ruff -- check {path}`. ruff alone honours those lists only while it
walks a directory, and lints or reformats an excluded file handed to it by
name. To process such a file once, pass `--no-force-exclude` after the
subcommand.
"""),
),
"shellcheck": ToolSpec(
name="shellcheck",
display_name="ShellCheck",
version="0.11.0",
source_url="https://github.com/koalaman/shellcheck",
config_docs_url="https://github.com/koalaman/shellcheck/wiki/Directive",
cli_docs_url="https://github.com/koalaman/shellcheck/blob/master/shellcheck.1.md",
binary=BinarySpec(
urls={
(
LINUX,
AARCH64,
): "https://github.com/koalaman/shellcheck/releases/download/v{version}/shellcheck-v{version}.linux.aarch64.tar.xz",
(
LINUX,
X86_64,
): "https://github.com/koalaman/shellcheck/releases/download/v{version}/shellcheck-v{version}.linux.x86_64.tar.xz",
(
MACOS,
AARCH64,
): "https://github.com/koalaman/shellcheck/releases/download/v{version}/shellcheck-v{version}.darwin.aarch64.tar.xz",
(
MACOS,
X86_64,
): "https://github.com/koalaman/shellcheck/releases/download/v{version}/shellcheck-v{version}.darwin.x86_64.tar.xz",
(
WINDOWS,
X86_64,
): "https://github.com/koalaman/shellcheck/releases/download/v{version}/shellcheck-v{version}.zip",
},
checksums=CHECKSUMS["shellcheck"],
archive_format={
ALL_PLATFORMS: ArchiveFormat.TAR_XZ,
WINDOWS: ArchiveFormat.ZIP,
},
# The tarballs nest the executable under a `shellcheck-v{version}/`
# directory; the Windows zip puts `shellcheck.exe` at the root.
strip_components={ALL_PLATFORMS: 1, WINDOWS: 0},
),
docs_notes=cleandoc(r"""
**Try it:**
```shell-session
$ repomatic run shellcheck -- script.sh
```
ShellCheck lints shell scripts. repomatic puts it on `PATH` for `repomatic
run actionlint`, which lints the `run:` scripts of the workflows with it. It
reads its own `.shellcheckrc` and the `# shellcheck` directives of each
script, so there is no `[tool.shellcheck]` section.
"""),
),
"shfmt": ToolSpec(
name="shfmt",
default_paths="shfmt_files",
version="3.14.1",
source_url="https://github.com/mvdan/sh",
config_docs_url="https://github.com/mvdan/sh/blob/master/cmd/shfmt/shfmt.1.scd",
cli_docs_url="https://github.com/mvdan/sh#shfmt",
native_config_files=(".editorconfig",),
native_format=NativeFormat.EDITORCONFIG,
default_flags=("--write",),
binary=BinarySpec(
urls={
(
LINUX,
AARCH64,
): "https://github.com/mvdan/sh/releases/download/v{version}/shfmt_v{version}_linux_arm64",
(
LINUX,
X86_64,
): "https://github.com/mvdan/sh/releases/download/v{version}/shfmt_v{version}_linux_amd64",
(
MACOS,
AARCH64,
): "https://github.com/mvdan/sh/releases/download/v{version}/shfmt_v{version}_darwin_arm64",
(
MACOS,
X86_64,
): "https://github.com/mvdan/sh/releases/download/v{version}/shfmt_v{version}_darwin_amd64",
(
WINDOWS,
X86_64,
): "https://github.com/mvdan/sh/releases/download/v{version}/shfmt_v{version}_windows_amd64.exe",
},
checksums=CHECKSUMS["shfmt"],
archive_format=ArchiveFormat.RAW,
),
docs_notes=cleandoc(r"""
**Try it:**
```shell-session
$ repomatic run shfmt -- .
```
shfmt formats shell scripts in place. It has no `[tool.shfmt]` section:
indentation and style come from `.editorconfig` (`indent_size`,
`shell_variant`, and the `shfmt`-specific keys).
"""),
),
"typos": ToolSpec(
name="typos",
version="1.51.1",
source_url="https://github.com/crate-ci/typos",
config_docs_url="https://github.com/crate-ci/typos/blob/master/docs/reference.md",
cli_docs_url="https://github.com/crate-ci/typos/blob/master/docs/reference.md",
native_config_files=("typos.toml", "_typos.toml", ".typos.toml"),
config_flag="--config",
native_format=NativeFormat.TOML,
reads_pyproject=True,
# typos applies `files.extend-exclude` only while it walks a directory: a
# path named on the command line bypasses the list, and `--write-changes`
# then rewrites a file the repository excluded. `--force-exclude` holds
# the list for those paths too.
default_flags=("--force-exclude", "--write-changes"),
binary=BinarySpec(
urls={
(
LINUX,
AARCH64,
): "https://github.com/crate-ci/typos/releases/download/v{version}/typos-v{version}-aarch64-unknown-linux-musl.tar.gz",
(
LINUX,
X86_64,
): "https://github.com/crate-ci/typos/releases/download/v{version}/typos-v{version}-x86_64-unknown-linux-musl.tar.gz",
(
MACOS,
AARCH64,
): "https://github.com/crate-ci/typos/releases/download/v{version}/typos-v{version}-aarch64-apple-darwin.tar.gz",
(
MACOS,
X86_64,
): "https://github.com/crate-ci/typos/releases/download/v{version}/typos-v{version}-x86_64-apple-darwin.tar.gz",
(
WINDOWS,
X86_64,
): "https://github.com/crate-ci/typos/releases/download/v{version}/typos-v{version}-x86_64-pc-windows-msvc.zip",
},
checksums=CHECKSUMS["typos"],
archive_format={
ALL_PLATFORMS: ArchiveFormat.TAR_GZ,
WINDOWS: ArchiveFormat.ZIP,
},
),
docs_notes=cleandoc(r"""
**Try it:**
```shell-session
$ repomatic run typos -- .
```
**Minimal `[tool.typos]`:**
```toml
[tool.typos.files]
extend-exclude = ["*.lock"]
```
typos scans the tree and, with repomatic's default `--write-changes`, fixes
what it finds. It reads `[tool.typos]` natively; use
`[tool.typos.default.extend-words]` to map project-specific terms to their
intended spelling.
The default `--force-exclude` applies `extend-exclude` to a path named on
the command line too, as in `repomatic run typos -- {path}`. typos alone
honours that list only while it walks a directory, and rewrites an excluded
file handed to it by name.
Because the `fix-typos` workflow job ships whatever typos rewrites as an
unattended pull request, guard content where a "correction" is a corruption
with `[tool.typos.default.extend-ignore-re]` patterns. The two known traps
are encoded hashes, whose random letter runs typos happily respells (a Guix
`(base32 "...")` source hash losing its value to an `an`-to-`and` fix), and
intentional-typo examples that docs or tests exercise on purpose:
```toml
[tool.typos.default]
extend-ignore-re = [
'base32 "[0-9a-z]{52}"',
"\\{query\\}",
]
```
"""),
),
"yamllint": ToolSpec(
name="yamllint",
default_args=(".",),
version="1.38.0",
source_url="https://github.com/adrienverge/yamllint",
config_docs_url="https://yamllint.readthedocs.io/en/stable/configuration.html",
cli_docs_url="https://yamllint.readthedocs.io/en/stable/quickstart.html",
native_config_files=(
".yamllint",
".yamllint.yaml",
".yamllint.yml",
),
config_flag="--config-file",
default_config="yamllint.yaml",
default_flags=("--strict",),
ci_flags=("--format", "github"),
docs_notes=cleandoc(r"""
**Try it:**
```shell-session
$ repomatic run yamllint -- .
```
**Minimal `[tool.yamllint]`:**
```toml
[tool.yamllint.rules.line-length]
max = 120
```
yamllint has no native `pyproject.toml` support, so repomatic bridges
`[tool.yamllint]` to a temporary YAML config passed via `--config-file`.
With no project config it uses repomatic's strict bundled `yamllint.yaml`.
"""),
),
"zizmor": ToolSpec(
name="zizmor",
default_args=(".",),
version="1.30.1",
source_url="https://github.com/zizmorcore/zizmor",
config_docs_url="https://docs.zizmor.sh/configuration/",
cli_docs_url="https://docs.zizmor.sh/usage/",
native_config_files=(
".github/zizmor.yml",
".github/zizmor.yaml",
"zizmor.yml",
"zizmor.yaml",
),
config_flag="--config",
default_config="zizmor.yaml",
default_flags=("--offline",),
ci_flags=("--format", "github"),
docs_notes=cleandoc(r"""
**Try it:**
```shell-session
$ repomatic run zizmor -- .
```
zizmor audits GitHub Actions workflows for security issues, offline by
default. repomatic bridges `[tool.zizmor]` to a temporary YAML config
(passed via `--config`); with none, it uses the bundled `zizmor.yaml`. See
the [configuration reference](https://docs.zizmor.sh/configuration/) for
available keys.
"""),
),
}
"""Tool name to the specification driving its install, config and invocation.
The single source of truth for `repomatic run`: every command, workflow job and
documentation renderer reads the roster from here rather than hard-coding a tool
name, a version or a flag.
```{todo}
Give the `mdformat` entry a `config_flag="--config"` once mdformat accepts an
explicit config path, so it stops relying on discovery from the working
directory:
[hukkin/mdformat#432](https://github.com/hukkin/mdformat/issues/432) and
[hukkin/mdformat#562](https://github.com/hukkin/mdformat/issues/562).
```
"""
# ---------------------------------------------------------------------------
# Documentation renderers
# ---------------------------------------------------------------------------
#
# Each renderer turns the registry above into a Markdown fragment consumed by a
# `{python:render}` block in `docs/tool-runner.md`, so the page documents the
# live registry on every Sphinx build. Nothing they emit is checked in: adding
# a tool to the registry is all it takes for the page to cover it. They live
# beside the registry rather than in a module of their own because they read
# nothing else, and a spec field gains its rendering in the same file it is
# declared in.
def _tool_badges(spec: ToolSpec) -> str:
"""Render the Stars and Last release badges of a tool's section.
Stars need a GitHub repository. The release badge follows the registry the
tool installs from: the GitHub release date for a binary, the npm version
for an npm tool, and the PyPI version for the uv backends. Badges keep
their default shields.io labels, so they describe themselves inline.
"""
badges = []
parsed = parse_github_repo(spec.source_url or "")
repo = "/".join(parsed) if parsed else None
if repo:
badges.append(
f""
)
if spec.backend is ToolBackend.BINARY:
if repo:
badges.append(
f""
)
elif spec.backend is ToolBackend.NPM:
badges.append(
f""
)
else:
badges.append(
f""
)
return " ".join(badges)