lint: switch to ruff for formatting and linting
What changed, and why it matters
This commit changes how the Bitcoin Core project runs its Python code style checker. It switches from installing the 'ruff' tool via a Python package manager inside the CI script to copying a pre-built 'ruff' binary directly into the Docker lint image. It also moves the list of lint rules from the Rust test-runner source code into a dedicated configuration file called ruff.toml. There is no change to the actual Bitcoin node software, consensus rules, wallet handling, or network behavior. This is purely a tooling and configuration cleanup.
No security action required. Treat as a normal build-system hygiene change. Reviewers may optionally verify that the ruff.toml rule list matches the previous inline list exactly and that the ghcr.io/astral-sh/ruff:0.15 image reference is trustworthy and reproducible.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The commit refactors the CI lint setup: (1) removes ‘ruff==0.15.5’ from the uv pip install list in ci/lint/01_install.sh; (2) adds a COPY –from=ghcr.io/astral-sh/ruff:0.15 /ruff /bin/ layer in ci/lint_imagefile so ruff is baked into the Docker image; (3) creates ruff.toml containing the exact same rule selection previously hard-coded in lint_py.rs; and (4) updates lint_py.rs to invoke ‘ruff check’ without the inline –select list, relying on ruff.toml instead. The rule set is unchanged, and no source code under linting is modified.
Changed components
ci/lint/01_install.shci/lint_imagefileruff.tomltest/lint/test_runner/src/lint_py.rsInspect captured patch +47 / −49
diff --git a/ci/lint/01_install.sh b/ci/lint/01_install.sh
index 50615030..0d9ccfbb 100755
--- a/ci/lint/01_install.sh
+++ b/ci/lint/01_install.sh
@@ -32,8 +32,7 @@ python3 --version
uv pip install --python /python_env \
lief==0.17.5 \
mypy==1.19.1 \
- pyzmq==27.1.0 \
- ruff==0.15.5
+ pyzmq==27.1.0
SHELLCHECK_VERSION=v0.11.0
curl --fail -L "https://github.com/koalaman/shellcheck/releases/download/${SHELLCHECK_VERSION}/shellcheck-${SHELLCHECK_VERSION}.linux.$(uname --machine).tar.xz" | \
diff --git a/ci/lint_imagefile b/ci/lint_imagefile
index d050153f..7dc21203 100644
--- a/ci/lint_imagefile
+++ b/ci/lint_imagefile
@@ -6,9 +6,11 @@
FROM mirror.gcr.io/ubuntu:24.04
-# Pin uv to minor version to avoid breaking changes:
+# Pin uv and ruff to minor version to avoid breaking changes
# https://docs.astral.sh/uv/reference/policies/versioning/
+# https://docs.astral.sh/ruff/versioning/
COPY --from=ghcr.io/astral-sh/uv:0.10 /uv /uvx /bin/
+COPY --from=ghcr.io/astral-sh/ruff:0.15 /ruff /bin/
COPY ./ci/retry/retry /ci_retry
COPY ./.python-version /.python-version
diff --git a/ruff.toml b/ruff.toml
new file mode 100644
index 00000000..10df372c
--- /dev/null
+++ b/ruff.toml
@@ -0,0 +1,42 @@
+[lint]
+select = [
+ "B006", # mutable-argument-default
+ "B008", # function-call-in-default-argument
+ "E101", # indentation contains mixed spaces and tabs
+ "E401", # multiple imports on one line
+ "E402", # module level import not at top of file
+ "E701", # multiple statements on one line (colon)
+ "E702", # multiple statements on one line (semicolon)
+ "E703", # statement ends with a semicolon
+ "E711", # comparison to None should be 'if cond is None:'
+ "E713", # test for membership should be "not in"
+ "E714", # test for object identity should be "is not"
+ "E721", # do not compare types, use "isinstance()"
+ "E722", # do not use bare 'except'
+ "E742", # do not define classes named "l", "O", or "I"
+ "E743", # do not define functions named "l", "O", or "I"
+ "F401", # module imported but unused
+ "F402", # import module from line N shadowed by loop variable
+ "F403", # 'from foo_module import *' used; unable to detect undefined names
+ "F404", # future import(s) name after other statements
+ "F405", # foo_function may be undefined, or defined from star imports: bar_module
+ "F406", # "from module import *" only allowed at module level
+ "F407", # an undefined __future__ feature name was imported
+ "F541", # f-string without any placeholders
+ "F601", # dictionary key name repeated with different values
+ "F602", # dictionary key variable name repeated with different values
+ "F621", # too many expressions in an assignment with star-unpacking
+ "F631", # assertion test is a tuple, which are always True
+ "F632", # use ==/!= to compare str, bytes, and int literals
+ "F811", # redefinition of unused name from line N
+ "F821", # undefined name 'Foo'
+ "F822", # undefined name name in __all__
+ "F823", # local variable name referenced before assignment
+ "F841", # local variable 'foo' is assigned to but never used
+ "PLE", # Pylint errors
+ "W191", # indentation contains tabs
+ "W291", # trailing whitespace
+ "W292", # no newline at end of file
+ "W293", # blank line contains whitespace
+ "W605", # invalid escape sequence "x"
+]
diff --git a/test/lint/test_runner/src/lint_py.rs b/test/lint/test_runner/src/lint_py.rs
index d9177c86..1d0ebed9 100644
--- a/test/lint/test_runner/src/lint_py.rs
+++ b/test/lint/test_runner/src/lint_py.rs
@@ -9,51 +9,6 @@ use crate::util::{check_output, get_pathspecs_default_excludes, git, LintResult}
pub fn lint_py_lint() -> LintResult {
let bin_name = "ruff";
- let checks = format!(
- "--select={}",
- [
- "B006", // mutable-argument-default
- "B008", // function-call-in-default-argument
- "E101", // indentation contains mixed spaces and tabs
- "E401", // multiple imports on one line
- "E402", // module level import not at top of file
- "E701", // multiple statements on one line (colon)
- "E702", // multiple statements on one line (semicolon)
- "E703", // statement ends with a semicolon
- "E711", // comparison to None should be 'if cond is None:'
- "E713", // test for membership should be "not in"
- "E714", // test for object identity should be "is not"
- "E721", // do not compare types, use "isinstance()"
- "E722", // do not use bare 'except'
- "E742", // do not define classes named "l", "O", or "I"
- "E743", // do not define functions named "l", "O", or "I"
- "F401", // module imported but unused
- "F402", // import module from line N shadowed by loop variable
- "F403", // 'from foo_module import *' used; unable to detect undefined names
- "F404", // future import(s) name after other statements
- "F405", // foo_function may be undefined, or defined from star imports: bar_module
- "F406", // "from module import *" only allowed at module level
- "F407", // an undefined __future__ feature name was imported
- "F541", // f-string without any placeholders
- "F601", // dictionary key name repeated with different values
- "F602", // dictionary key variable name repeated with different values
- "F621", // too many expressions in an assignment with star-unpacking
- "F631", // assertion test is a tuple, which are always True
- "F632", // use ==/!= to compare str, bytes, and int literals
- "F811", // redefinition of unused name from line N
- "F821", // undefined name 'Foo'
- "F822", // undefined name name in __all__
- "F823", // local variable name … referenced before assignment
- "F841", // local variable 'foo' is assigned to but never used
- "PLE", // Pylint errors
- "W191", // indentation contains tabs
- "W291", // trailing whitespace
- "W292", // no newline at end of file
- "W293", // blank line contains whitespace
- "W605", // invalid escape sequence "x"
- ]
- .join(",")
- );
let files = check_output(
git()
.args(["ls-files", "--", "*.py"])
@@ -61,7 +16,7 @@ pub fn lint_py_lint() -> LintResult {
)?;
let mut cmd = Command::new(bin_name);
- cmd.args(["check", &checks]).args(files.lines());
+ cmd.arg("check").args(files.lines());
match cmd.status() {
Ok(status) if status.success() => Ok(()),
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.