From 17a17d8717ca95d7a35eb7167268b14ee02589dc Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 13 Oct 2026 10:37:46 +0000 Subject: [PATCH] fix(ci): pin cargo-geiger where the action actually reads the version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pin added in #2715 did not work. `cache-cargo-install-action` has no `version` input, and GitHub Actions treats an unknown input as a warning rather than an error, so `version: 0.13.0` was dropped on the floor and the step went on installing the newest release. Nightly run 260, on the commit that was supposed to fix this, failed exactly as run 259 did: + cargo install -f cargo-geiger --root ... --locked --version 0.14.0 error: cannot install package `cargo-geiger 0.14.0`, it requires rustc 1.97 or newer, while the currently active rustc version is 1.95.0 ##[warning]Unexpected input(s) 'version', valid inputs are ['tool', 'locked', 'git', 'tag', 'rev', 'features', ...] The version belongs in `tool:` as `name@version`. The earlier log's `INPUT_VERSION: 0.14.0` is the action's own resolved value, not an input it accepts, which is what made the wrong spelling look plausible. Add a test for both halves of the mistake: a source-built tool on the pinned toolchain with no `@version`, and any input this action silently drops. A pin that reads as correct and does nothing is worse than no pin, because the job keeps reporting the failure it was meant to fix. Jobs on the nightly toolchain are exempt from the pin requirement, since nightly outruns any released MSRV — that is why cargo-fuzz needs none. --- .github/workflows/nightly.yml | 9 +- scripts/tests/test_pinned_ci_tools.py | 132 ++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 scripts/tests/test_pinned_ci_tools.py diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 89bd06716..d1beac4f2 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -95,15 +95,18 @@ jobs: - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 # Built from source against the pinned toolchain, so the tool version is - # pinned too: an unpinned `tool:` resolves to the newest release, and + # pinned too: an unpinned tool resolves to the newest release, and # cargo-geiger 0.14.0 raised its MSRV to 1.97 on the day it shipped, # which broke this job on a tree that had not changed. Bump deliberately # alongside rust-toolchain.toml; 0.13.0 builds on 1.85+. + # + # The version belongs in `tool:` as `name@version`. This action has no + # `version` input, and an unknown input is only a warning, so spelling it + # that way silently installed the newest release anyway. - name: Install cargo-geiger uses: taiki-e/cache-cargo-install-action@9ee83daaa7b96a6fab930949ecf1122bba04a389 # v3 with: - tool: cargo-geiger - version: 0.13.0 + tool: cargo-geiger@0.13.0 locked: true - name: Run cargo-geiger (unsafe code audit) diff --git a/scripts/tests/test_pinned_ci_tools.py b/scripts/tests/test_pinned_ci_tools.py new file mode 100644 index 000000000..7a452bc1d --- /dev/null +++ b/scripts/tests/test_pinned_ci_tools.py @@ -0,0 +1,132 @@ +"""A source-built CI tool on the pinned toolchain must pin a version, in a +spelling the action actually reads. + +`cache-cargo-install-action` runs `cargo install` against whatever toolchain +the job set up. Where that is the pinned one (rust-toolchain.toml), an unpinned +tool resolves to the newest release and an upstream MSRV bump breaks a tree +that has not changed: cargo-geiger 0.14.0 shipped on 2026-10-12 requiring rustc +1.97, and nightly run 259 went red the next morning with the unsafe-code audit +skipped. A job that installs the nightly toolchain is exempt — nightly outruns +any released MSRV — which is why `cargo-fuzz` in the fuzz job needs no pin. + +The first attempt at pinning geiger passed `version: 0.13.0`. That action has +no `version` input, and GitHub Actions treats an unknown input as a warning +rather than an error, so the pin silently did nothing and run 260 installed +0.14.0 again with only this buried in the log: + + ##[warning]Unexpected input(s) 'version', valid inputs are + ['tool', 'locked', 'git', 'tag', 'rev', 'features', ...] + +Hence both checks below. The version has to be in `tool:` as `name@version`, +where the action reads it, and no step may pass an input the action drops. A +pin that looks right and does nothing is worse than no pin, because the job +goes on reporting the failure it was supposed to fix. +""" + +from pathlib import Path +import re +import unittest + +import yaml + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOWS = ROOT / '.github/workflows' + +# The action builds from source, so its tool version rides the job's toolchain. +SOURCE_BUILD_ACTION = 'taiki-e/cache-cargo-install-action' +TOOLCHAIN_ACTION = 'dtolnay/rust-toolchain' + +# Documented inputs, from the action's own error message. Anything else is +# accepted silently by the runner and ignored by the action. +VALID_INPUTS = { + 'tool', + 'locked', + 'git', + 'tag', + 'rev', + 'features', + 'no-default-features', + 'all-features', +} + +# `dtolnay/rust-toolchain@ # nightly`. The channel lives in the comment, +# which YAML drops, so collect those SHAs from the raw text. Dependabot moves +# the SHA and the comment together, so this keeps working across bumps. +NIGHTLY_REF = re.compile( + rf'{re.escape(TOOLCHAIN_ACTION)}@([0-9a-f]{{40}})\s*#\s*nightly\b' +) + + +def nightly_toolchain_shas(): + shas = set() + for path in WORKFLOWS.glob('*.yml'): + shas.update(NIGHTLY_REF.findall(path.read_text())) + return shas + + +def job_uses_nightly(job, nightly_shas): + for step in job.get('steps') or []: + uses = step.get('uses') or '' + if TOOLCHAIN_ACTION in uses: + _, _, ref = uses.partition('@') + if ref in nightly_shas: + return True + return False + + +def source_build_steps(): + """(workflow, job, step, on_nightly) per source-build action step.""" + nightly_shas = nightly_toolchain_shas() + for path in sorted(WORKFLOWS.glob('*.yml')): + spec = yaml.safe_load(path.read_text()) + for job_name, job in (spec.get('jobs') or {}).items(): + on_nightly = job_uses_nightly(job, nightly_shas) + for step in job.get('steps') or []: + if SOURCE_BUILD_ACTION in (step.get('uses') or ''): + yield path.name, job_name, step, on_nightly + + +class PinnedCiToolTests(unittest.TestCase): + def test_discovery_finds_both_kinds_of_job(self): + # A selector that matched nothing, or that lost the nightly + # distinction, would make the checks below vacuous. + steps = list(source_build_steps()) + self.assertTrue(steps, f'no {SOURCE_BUILD_ACTION} steps found') + self.assertTrue( + nightly_toolchain_shas(), + f'no `{TOOLCHAIN_ACTION}@ # nightly` ref found; the comment ' + 'spelling this parser relies on may have changed', + ) + + def test_tools_on_the_pinned_toolchain_pin_a_version(self): + for workflow, job, step, on_nightly in source_build_steps(): + if on_nightly: + continue + tool = (step.get('with') or {}).get('tool', '') + with self.subTest(workflow=workflow, job=job, tool=tool): + name, at, version = tool.partition('@') + self.assertTrue( + at and name and version, + f'{workflow}:{job} builds {tool!r} from source on the ' + 'pinned toolchain with no version; pin it as ' + '`tool: name@version` so an upstream MSRV bump cannot ' + 'break a tree that has not changed', + ) + + def test_no_step_passes_an_input_the_action_ignores(self): + # The failure mode this exists for: `version:` here is dropped with a + # warning, so the pin reads as correct and does nothing. + for workflow, job, step, _ in source_build_steps(): + for key in step.get('with') or {}: + with self.subTest(workflow=workflow, job=job, input=key): + self.assertIn( + key, + VALID_INPUTS, + f'{workflow}:{job} passes {key!r} to ' + f'{SOURCE_BUILD_ACTION}, which does not accept it; ' + 'the runner only warns, so it is silently ignored', + ) + + +if __name__ == '__main__': + unittest.main()