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
32 changes: 6 additions & 26 deletions .github/workflows/_lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ jobs:
lint:
runs-on: ${{ inputs.runs-on }}
timeout-minutes: 30
env:
SOLDR_FORCE_MANAGED_CARGO_SUBCOMMANDS: "1"
defaults:
run:
shell: bash
Expand Down Expand Up @@ -49,7 +51,7 @@ jobs:
sudo apt-get update -qq
sudo apt-get install -y -qq libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev

- uses: zackees/setup-soldr@v0.9.62
- uses: zackees/setup-soldr@dfbe9627f6cb0226716b61625b99a58949162720 # v0.9.80
id: soldr
env:
GITHUB_TOKEN: ${{ github.token }}
Expand All @@ -76,37 +78,15 @@ jobs:
# the dylint driver dir, exposing dylint-cache-hit so we can skip the
# expensive cargo installs below (#170).
dylint-cache: true
dylint-toolchain: nightly-2026-03-26
dylint-toolchain: nightly-2026-05-28
cargo-dylint-version: "6.0.3"
dylint-link-version: "6.0.3"

- name: Sync Python deps
run: uv sync --group dev

# The dylint nightly toolchain must land in the job's RUSTUP_HOME
# (setup-soldr exports a managed rustup home to all later steps), so use
# bare rustup here. Routing this through `soldr rustup` installs into
# soldr's private toolchain home instead, and the dylint driver build
# then fails with E0463 (can't find crate rustc_driver) because the
# rustc-dev components are invisible (#170 regression from #172).
# Unconditional: the nightly is not part of the dylint cache paths.
- name: Install Dylint nightly toolchain
run: rustup toolchain install nightly-2026-03-26 --profile minimal --component llvm-tools-preview,rust-src,rustc-dev

# Building cargo-dylint / dylint-link from source is the dominant fixed
# cost of every lint job (10+ min per platform). A restored cache should
# expose both binaries, but verify the commands themselves: a cache hit
# without a PATH-visible binary must not make the lint job fail.
- name: Install Dylint tools
run: |
DYLINT_VERSION="6.0.3"
if ! uv run python ci/lint_python/dylint_version_checker.py cargo-dylint "$DYLINT_VERSION"; then
soldr cargo install cargo-dylint --version "$DYLINT_VERSION" --locked --force
fi
if ! command -v dylint-link >/dev/null 2>&1; then
soldr cargo install dylint-link --version "$DYLINT_VERSION" --locked --force
fi
uv run python ci/lint_python/dylint_version_checker.py cargo-dylint "$DYLINT_VERSION"
- name: Prepare published Dylint tools
run: env -u RUSTUP_TOOLCHAIN soldr dylint prepare

- name: Lint
run: ./lint
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/linux-arm-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ concurrency:

jobs:
call:
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full'))
uses: ./.github/workflows/_lint.yml
with:
source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/macos-arm-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ concurrency:

jobs:
call:
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full'))
uses: ./.github/workflows/_lint.yml
with:
source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/macos-x86-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ concurrency:

jobs:
call:
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full'))
uses: ./.github/workflows/_lint.yml
with:
source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/windows-x86-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ concurrency:

jobs:
call:
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full'))
uses: ./.github/workflows/_lint.yml
with:
source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
Expand Down
4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ members = [
"crates/fastled-cli",
]

[workspace.metadata.dylint]
libraries = [{ path = "dylints/*" }]

[workspace.package]
version = "2.0.22"
edition = "2021"
Expand All @@ -25,4 +28,3 @@ codegen-units = 1

[profile.test.package.kernal-api]
codegen-units = 1

4 changes: 4 additions & 0 deletions dylints/ban_std_pathbuf/.cargo/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Dylint requires the toolchain-suffixed cdylib name. Its linker wrapper
# produces that name on every host, without a post-build copy or retry.
[target.'cfg(all())']
rustflags = ["-C", "linker=dylint-link"]
17 changes: 7 additions & 10 deletions dylints/ban_std_pathbuf/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,27 +20,24 @@ regression from coming back into a different module.

## Toolchain

Builds against `nightly-2026-03-26` (see `rust-toolchain.toml`). The main
Builds against `nightly-2026-05-28` (see `rust-toolchain.toml`). The main
workspace stays on stable; this sub-crate is intentionally not in the
workspace. The local `[workspace]` table in `Cargo.toml` keeps Cargo from
implicitly attaching this crate to the parent stable workspace.

## Running

The repository `./lint` script runs this lint after `cargo clippy`. It builds
the lint with the pinned nightly in a short target directory, copies the shared
library to Dylint's required `@toolchain` filename, then passes that file to
Dylint with `--lib-path`. The Dylint invocation keeps rustup shims ahead of any
direct toolchain binaries so Dylint's internal driver build can resolve the
nightly toolchain:
The repository `./lint` script runs this lint after `cargo clippy`. Soldr
prepares the published Dylint tools and driver for the pinned nightly;
`.cargo/config.toml` directs the lint crate through `dylint-link` to emit
the toolchain-suffixed library Dylint expects:

```bash
./lint
```

The root `rust-toolchain.toml` remains stable. Only the Dylint build/check
invocation sets `RUSTUP_TOOLCHAIN=nightly-2026-03-26`, which is required by
`rustc_private`.
The root `rust-toolchain.toml` remains stable. The Dylint build/check clears
the ambient stable `RUSTUP_TOOLCHAIN` so Soldr selects the pinned nightly.

## Reference

Expand Down
2 changes: 1 addition & 1 deletion dylints/ban_std_pathbuf/rust-toolchain.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
[toolchain]
channel = "nightly-2026-03-26"
channel = "nightly-2026-05-28"
components = ["llvm-tools-preview", "rust-src", "rustc-dev"]
98 changes: 5 additions & 93 deletions lint
Original file line number Diff line number Diff line change
Expand Up @@ -29,101 +29,13 @@ soldr cargo fmt --all --check
echo Running cargo clippy
soldr cargo clippy --workspace --all-targets -- -D warnings

DYLINT_VERSION="6.0.3"
DYLINT_TOOLCHAIN="nightly-2026-03-26"
DYLINT_DIR="$SCRIPT_DIR/dylints/ban_std_pathbuf"
DYLINT_TARGET_DIR="${FASTLED_DYLINT_TARGET_DIR:-${RUNNER_TEMP:-${TMPDIR:-/tmp}}/fastled-dylint-target}"
DYLINT_LIBRARY_DIR="$DYLINT_TARGET_DIR/dylint/libraries/$DYLINT_TOOLCHAIN"

DYLINT_SKIP_REASON=""
if ! command -v cargo-dylint &> /dev/null; then
DYLINT_SKIP_REASON="cargo-dylint $DYLINT_VERSION is not installed (soldr cargo install cargo-dylint --version $DYLINT_VERSION --locked)"
elif ! uv run python "$SCRIPT_DIR/ci/lint_python/dylint_version_checker.py" cargo-dylint "$DYLINT_VERSION"; then
DYLINT_SKIP_REASON="cargo-dylint $DYLINT_VERSION is required (soldr cargo install cargo-dylint --version $DYLINT_VERSION --locked --force)"
elif ! command -v rustup &> /dev/null; then
DYLINT_SKIP_REASON="rustup is required for cargo-dylint's internal driver build"
fi

if [ -n "$DYLINT_SKIP_REASON" ] && [ -n "${CI:-}" ]; then
echo "Error: dylint prerequisites are missing in CI: $DYLINT_SKIP_REASON" >&2
echo Running ban_std_pathbuf dylint
export SOLDR_FORCE_MANAGED_CARGO_SUBCOMMANDS=1
if ! env -u RUSTUP_TOOLCHAIN soldr dylint prepare; then
echo "Error: dylint prerequisites are missing: soldr dylint prepare failed" >&2
exit 1
fi
env -u RUSTUP_TOOLCHAIN soldr dylint --all -- --workspace --all-targets

if [ -z "$DYLINT_SKIP_REASON" ]; then
echo Running ban_std_pathbuf dylint
RUSTUP_BIN="$(command -v rustup || true)"
# cargo-dylint 6.x's driver toolchain check requires RUSTUP_HOME to be set
# explicitly; CI exports it but local shells typically rely on rustup's
# implicit default and fail with "environment variable not found: RUSTUP_HOME".
export RUSTUP_HOME="${RUSTUP_HOME:-$("$RUSTUP_BIN" show home)}"
RUSTUP_BIN_DIR="$(dirname "$RUSTUP_BIN")"
DYLINT_CARGO_SHIM_DIR="$(mktemp -d)"
DYLINT_PREVIOUS_DEFAULT="$(rustup default 2>/dev/null | sed 's/ (default)$//')"

cleanup_dylint_toolchain() {
rm -rf "$DYLINT_CARGO_SHIM_DIR"
if [ -n "$DYLINT_PREVIOUS_DEFAULT" ]; then
rustup default "$DYLINT_PREVIOUS_DEFAULT" >/dev/null || true
fi
}
trap cleanup_dylint_toolchain EXIT

cat > "$DYLINT_CARGO_SHIM_DIR/cargo" <<EOF
#!/bin/sh
exec "$RUSTUP_BIN" run "$DYLINT_TOOLCHAIN" cargo "\$@"
EOF
chmod +x "$DYLINT_CARGO_SHIM_DIR/cargo"
rustup default "$DYLINT_TOOLCHAIN" >/dev/null

(
cd "$DYLINT_DIR"
RUSTUP_TOOLCHAIN="$DYLINT_TOOLCHAIN" CARGO_TARGET_DIR="$DYLINT_LIBRARY_DIR" soldr cargo build --release
)

case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*)
DYLINT_BUILT_LIBRARY="$(find "$DYLINT_LIBRARY_DIR" -path "*/release/ban_std_pathbuf.dll" ! -path "*/deps/*" -print -quit)"
DYLINT_LIBRARY_NAME="ban_std_pathbuf@$DYLINT_TOOLCHAIN.dll"
;;
Darwin*)
DYLINT_BUILT_LIBRARY="$(find "$DYLINT_LIBRARY_DIR" -path "*/release/libban_std_pathbuf.dylib" ! -path "*/deps/*" -print -quit)"
DYLINT_LIBRARY_NAME="libban_std_pathbuf@$DYLINT_TOOLCHAIN.dylib"
;;
*)
DYLINT_BUILT_LIBRARY="$(find "$DYLINT_LIBRARY_DIR" -path "*/release/libban_std_pathbuf.so" ! -path "*/deps/*" -print -quit)"
DYLINT_LIBRARY_NAME="libban_std_pathbuf@$DYLINT_TOOLCHAIN.so"
;;
esac

if [ -z "$DYLINT_BUILT_LIBRARY" ]; then
echo "Error: built ban_std_pathbuf dylint library was not found under $DYLINT_LIBRARY_DIR" >&2
exit 1
fi

DYLINT_NAMED_LIBRARY="$DYLINT_LIBRARY_DIR/release/$DYLINT_LIBRARY_NAME"
mkdir -p "$DYLINT_LIBRARY_DIR/release"
cp "$DYLINT_BUILT_LIBRARY" "$DYLINT_NAMED_LIBRARY"
# cargo-dylint builds an internal driver and intentionally unsets RUSTUP_TOOLCHAIN
# for that nested build. Use a short-lived cargo shim plus a temporary rustup
# default so the nested driver build uses the pinned nightly in its temp dir.
PATH="$DYLINT_CARGO_SHIM_DIR:$RUSTUP_BIN_DIR:$PATH" RUSTUP_TOOLCHAIN="$DYLINT_TOOLCHAIN" CARGO_TARGET_DIR="$DYLINT_TARGET_DIR" \
cargo-dylint dylint --workspace --lib-path "$DYLINT_NAMED_LIBRARY" -- --all-targets
cleanup_dylint_toolchain
trap - EXIT
fi

if [ -n "$DYLINT_SKIP_REASON" ]; then
# Local hosts without the dylint prerequisites still get a green lint: every
# other stage ran. CI never reaches here -- the guard above fails hard when
# $CI is set, so the dylint stage cannot be silently dropped in CI (#262).
echo "" >&2
echo "==============================================================" >&2
echo "dylint not run: $DYLINT_SKIP_REASON" >&2
echo "Every other lint stage ran and passed." >&2
echo "Install the prerequisite and re-run ./lint for full coverage." >&2
echo "==============================================================" >&2
echo "Linting complete (dylint skipped)!"
exit 0
fi
echo Linting complete!
exit 0
12 changes: 7 additions & 5 deletions tests/unit/test_dylint_version_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,11 @@ def test_lint_entrypoints_enforce_the_exact_pin() -> None:
encoding="utf-8"
)

assert 'DYLINT_VERSION="6.0.3"' in lint_script
invocation = r'dylint_version_checker\.py"? cargo-dylint "\$DYLINT_VERSION"'
assert re.search(invocation, lint_script)
assert "soldr dylint prepare" in lint_script
assert "soldr dylint --all -- --workspace --all-targets" in lint_script
assert 'cargo-dylint-version: "6.0.3"' in lint_workflow
assert 'DYLINT_VERSION="6.0.3"' in lint_workflow
assert len(re.findall(invocation, lint_workflow)) >= 2
assert "dylint-toolchain: nightly-2026-05-28" in lint_workflow
assert "soldr dylint prepare" in lint_workflow
assert "soldr cargo install cargo-dylint" not in lint_workflow
linker_config = (REPO_ROOT / "dylints/ban_std_pathbuf/.cargo/config.toml").read_text(encoding="utf-8")
assert 'rustflags = ["-C", "linker=dylint-link"]' in linker_config
34 changes: 27 additions & 7 deletions tests/unit/test_lint_script.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@

_STUB_SCRIPT = """#!/bin/sh
printf '%s\\n' "$(basename "$0") $*" >> "$LINT_STUB_LOG"
if [ "$(basename "$0") $1 $2" = "soldr dylint prepare" ]; then
exit 1
fi
exit 0
"""

Expand Down Expand Up @@ -79,10 +82,8 @@ def _log_has_match(log_lines: list[str], pattern: str) -> bool:
def test_python_stages_run_and_cover_ci_without_dylint(tmp_path: Path) -> None:
result, log_lines = _run_lint(tmp_path, ci=False)

# Outside CI a missing dylint prerequisite is a loud skip, not a failure:
# the other stages all ran, so the script still succeeds.
assert result.returncode == 0, (result.stdout, result.stderr)
assert "dylint not run" in result.stderr
assert result.returncode != 0
assert "dylint prerequisites are missing" in result.stderr

# Every Python lint stage must cover src, tests, and ci -- the stub uv
# collapses "uv run <tool> ..." into a single logged argv line, so match
Expand All @@ -104,15 +105,16 @@ def test_python_stages_run_and_cover_ci_without_dylint(tmp_path: Path) -> None:
for line in log_lines
)

# No cargo-dylint invocation should have been attempted at all.
assert not any("cargo-dylint" in line for line in log_lines)
# Preparation fails closed, so the Dylint pass cannot run.
assert "soldr dylint prepare" in log_lines
assert "soldr dylint --all -- --workspace --all-targets" not in log_lines


def test_missing_dylint_is_a_hard_failure_in_ci(tmp_path: Path) -> None:
result, log_lines = _run_lint(tmp_path, ci=True)

assert result.returncode != 0
assert "Error: dylint prerequisites are missing in CI:" in result.stderr
assert "Error: dylint prerequisites are missing:" in result.stderr
# The failure is reported only after the Python stages have run, so a CI
# host missing cargo-dylint still reports every Python finding.
assert _log_has_match(log_lines, r"^uv (run )?pyright src tests ci$")
Expand All @@ -131,3 +133,21 @@ def test_python_stages_precede_the_rust_stages(tmp_path: Path) -> None:
)

assert pyright_index < rust_index


@pytest.mark.parametrize(
"workflow",
[
"linux-arm-lint.yml",
"macos-arm-lint.yml",
"macos-x86-lint.yml",
"windows-x86-lint.yml",
],
)
def test_native_dylint_runs_on_unlabelled_pr(workflow: str) -> None:
source = (REPO_ROOT / ".github" / "workflows" / workflow).read_text(
encoding="utf-8"
)
assert "pull_request:" in source
assert "uses: ./.github/workflows/_lint.yml" in source
assert "contains(github.event.pull_request.labels.*.name, 'ci-full')" not in source
Loading