Skip to content

Commit c68630e

Browse files
committed
Tooling: workflows-rustup check forbids rustup target/component add in workflows
`rust-toolchain.toml` at the workspace root is the single source of truth for which rustup targets and components the project needs. A workflow that also runs `rustup target add X` duplicates that source — and the duplicate gets out of sync silently. This is the bug class we just hit in the v0.20.0 release: `apps/desktop/rust-toolchain.toml` was out of scope when the release workflow ran `rustup target add x86_64-apple-darwin` from the repo root, so the target landed on a different toolchain than `pnpm tauri build` actually used. The structural fix (rust-toolchain.toml at workspace root, `targets = [...]` declared there) landed in commit `41e999ab`. This check is the regression guard: any new `rustup target add` or `rustup component add` line in any workflow fails the check with a hint to declare it in rust-toolchain.toml instead. Allowed by design: - `rustup install`, `rustup update`, `rustup toolchain install`, `rustup show`: install / sync whole channels, not orthogonal to the toolchain file. - `# allowed-rustup-add: <reason>` end-of-line opt-out with a non-empty reason. The reason renders in `--verbose` so future readers see the justification. - Full-line YAML comments mentioning the phrase in prose. The scanner skips lines whose first non-whitespace char is `#`. Tests: - 10 table-driven cases pin the rules: target-add flagged, component-add flagged, chained-command flagged, install / update / toolchain-install / show all accepted, opt-out with reason accepted, empty opt-out reason still flagged, prose-only comment not flagged. Wired in as `workflows-rustup` (nickname `rustup-add`), `IsFast: true`, no deps. Passes today (cmdr has no `rustup target add` left in any workflow).
1 parent 8525835 commit c68630e

4 files changed

Lines changed: 280 additions & 1 deletion

File tree

scripts/check/CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ before tests.
226226
| Website | Docker | docker-build |
227227
| API server | TS | oxfmt, eslint, typecheck, tests |
228228
| Scripts | Go | gofmt, go-vet, staticcheck, ineffassign, misspell, gocyclo, nilaway, deadcode, go-tests, govulncheck |
229-
| Other | Metrics | file-length (warn-only), CLAUDE.md-reminder (warn-only), changelog-commit-links |
229+
| Other | Metrics | file-length (warn-only), CLAUDE.md-reminder (warn-only), changelog-commit-links, workflows-rustup (forbids `rustup target/component add` in workflows) |
230230
| Other | Security | workflows-hardening (SHA-pinning, no `pull_request_target`, job-scoped `id-token: write`) |
231231

232232
## Output format
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package checks
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"regexp"
9+
"sort"
10+
"strings"
11+
)
12+
13+
// RunWorkflowsRustup ensures `rust-toolchain.toml` stays the single source of
14+
// truth for which rustup targets / components the project needs. Workflows that
15+
// also `rustup target add X` (or `rustup component add X`) duplicate that
16+
// source — and the duplicate gets out of sync silently.
17+
//
18+
// The bug this guards against (May 2026, v0.20.0 release): the release workflow
19+
// ran `rustup target add x86_64-apple-darwin` from the repo root, but
20+
// `rust-toolchain.toml` was at `apps/desktop/`. So `rustup target add` touched
21+
// the runner's default toolchain instead of the pinned channel
22+
// `pnpm tauri build` used inside `apps/desktop/`. Result: every non-aarch64
23+
// release build failed at "Target x86_64-apple-darwin is not installed".
24+
//
25+
// The structural fix is to move `rust-toolchain.toml` to the workspace root
26+
// (done in commit `41e999ab`) AND declare `targets = [...]` there. This check
27+
// adds the regression guard: any new `rustup target add` / `rustup component
28+
// add` in a workflow re-introduces the divergence risk, so the check fails.
29+
//
30+
// `rustup install`, `rustup update`, `rustup toolchain install`, `rustup show`
31+
// stay allowed — they don't add anything orthogonal to the toolchain file.
32+
//
33+
// Opt-out: append `# allowed-rustup-add: <reason>` to the line. Empty reasons
34+
// are rejected.
35+
func RunWorkflowsRustup(ctx *CheckContext) (CheckResult, error) {
36+
wfDir := filepath.Join(ctx.RootDir, ".github", "workflows")
37+
entries, err := os.ReadDir(wfDir)
38+
if err != nil {
39+
if os.IsNotExist(err) {
40+
return Skipped("no .github/workflows/"), nil
41+
}
42+
return CheckResult{}, fmt.Errorf("failed to read workflows dir: %w", err)
43+
}
44+
45+
var files []string
46+
for _, e := range entries {
47+
if e.IsDir() {
48+
continue
49+
}
50+
name := e.Name()
51+
if strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml") {
52+
files = append(files, filepath.Join(wfDir, name))
53+
}
54+
}
55+
sort.Strings(files)
56+
57+
var violations []string
58+
scanned := 0
59+
for _, f := range files {
60+
v, err := scanWorkflowForRustup(f, ctx.RootDir)
61+
if err != nil {
62+
return CheckResult{}, err
63+
}
64+
violations = append(violations, v...)
65+
scanned++
66+
}
67+
68+
if len(violations) > 0 {
69+
return CheckResult{}, fmt.Errorf(
70+
"`rustup target add` / `rustup component add` found in workflows; declare in rust-toolchain.toml instead\n%s",
71+
indentOutput(strings.Join(violations, "\n")))
72+
}
73+
74+
result := Success(fmt.Sprintf("%d %s, no `rustup target/component add` lines",
75+
scanned, Pluralize(scanned, "workflow", "workflows")))
76+
result.Total = scanned
77+
return result, nil
78+
}
79+
80+
// Matches `rustup target add` or `rustup component add` anywhere on a line,
81+
// catching both `run: rustup target add X` and shell continuations / `&&`
82+
// chains. Whitespace between tokens is forgiving. The `#` lookahead is not
83+
// strict — we strip trailing comments before the match in the scanner.
84+
var rustupAddRE = regexp.MustCompile(`\brustup\s+(target|component)\s+add\b`)
85+
86+
// Opt-out marker. Must include a non-empty reason after the colon.
87+
var rustupAddAllowedRE = regexp.MustCompile(`#\s*allowed-rustup-add:\s*(\S.*)`)
88+
89+
func scanWorkflowForRustup(path, repoRoot string) ([]string, error) {
90+
rel, _ := filepath.Rel(repoRoot, path)
91+
f, err := os.Open(path)
92+
if err != nil {
93+
return nil, fmt.Errorf("open %s: %w", rel, err)
94+
}
95+
defer f.Close()
96+
97+
var violations []string
98+
scanner := bufio.NewScanner(f)
99+
// Workflow yaml lines can be long (compose-style chained runs); raise the
100+
// buffer above bufio's 64 KB default to cover the worst case.
101+
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
102+
lineNo := 0
103+
for scanner.Scan() {
104+
lineNo++
105+
line := scanner.Text()
106+
// Skip whole-line YAML comments. A line whose first non-whitespace
107+
// character is `#` is a comment and can mention `rustup target add` in
108+
// prose without being a command (for example, "the previous rustup
109+
// target add step was removed; see rust-toolchain.toml").
110+
trimmed := strings.TrimLeft(line, " \t")
111+
if strings.HasPrefix(trimmed, "#") {
112+
continue
113+
}
114+
if !rustupAddRE.MatchString(line) {
115+
continue
116+
}
117+
// Opt-out: line ends with `# allowed-rustup-add: <reason>`.
118+
if m := rustupAddAllowedRE.FindStringSubmatch(line); m != nil && strings.TrimSpace(m[1]) != "" {
119+
continue
120+
}
121+
violations = append(violations, fmt.Sprintf(
122+
"%s:%d: %s\n declare the target/component in rust-toolchain.toml instead, OR add `# allowed-rustup-add: <reason>`",
123+
rel, lineNo, strings.TrimSpace(line)))
124+
}
125+
if err := scanner.Err(); err != nil {
126+
return nil, fmt.Errorf("scan %s: %w", rel, err)
127+
}
128+
return violations, nil
129+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package checks
2+
3+
import (
4+
"path/filepath"
5+
"strings"
6+
"testing"
7+
)
8+
9+
func TestScanWorkflowForRustup(t *testing.T) {
10+
tmp := t.TempDir()
11+
wfDir := filepath.Join(tmp, ".github", "workflows")
12+
13+
cases := []struct {
14+
name string
15+
content string
16+
want []string // substrings expected in each violation
17+
}{
18+
{
19+
name: "rustup target add flagged",
20+
content: `name: x
21+
jobs:
22+
build:
23+
steps:
24+
- run: rustup target add x86_64-apple-darwin
25+
`,
26+
want: []string{"rustup target add x86_64-apple-darwin"},
27+
},
28+
{
29+
name: "rustup component add flagged",
30+
content: `name: x
31+
jobs:
32+
build:
33+
steps:
34+
- run: rustup component add clippy
35+
`,
36+
want: []string{"rustup component add clippy"},
37+
},
38+
{
39+
name: "rustup target add inside chained command flagged",
40+
content: `name: x
41+
jobs:
42+
build:
43+
steps:
44+
- run: |
45+
rustup update stable && rustup target add x86_64-unknown-linux-gnu
46+
`,
47+
want: []string{"rustup target add x86_64-unknown-linux-gnu"},
48+
},
49+
{
50+
name: "rustup install accepted (whole toolchains, not redundant with toml)",
51+
content: `name: x
52+
jobs:
53+
build:
54+
steps:
55+
- run: rustup install 1.95.0
56+
`,
57+
want: nil,
58+
},
59+
{
60+
name: "rustup update accepted",
61+
content: `name: x
62+
jobs:
63+
build:
64+
steps:
65+
- run: rustup update stable
66+
`,
67+
want: nil,
68+
},
69+
{
70+
name: "rustup toolchain install accepted",
71+
content: `name: x
72+
jobs:
73+
build:
74+
steps:
75+
- run: rustup toolchain install nightly
76+
`,
77+
want: nil,
78+
},
79+
{
80+
name: "rustup show accepted",
81+
content: `name: x
82+
jobs:
83+
build:
84+
steps:
85+
- run: rustup show
86+
`,
87+
want: nil,
88+
},
89+
{
90+
name: "opt-out with reason accepted",
91+
content: `name: x
92+
jobs:
93+
build:
94+
steps:
95+
- run: rustup target add wasm32-unknown-unknown # allowed-rustup-add: wasm builds only happen here, separate from main toolchain
96+
`,
97+
want: nil,
98+
},
99+
{
100+
name: "opt-out with empty reason still flagged",
101+
content: `name: x
102+
jobs:
103+
build:
104+
steps:
105+
- run: rustup target add wasm32-unknown-unknown # allowed-rustup-add:
106+
`,
107+
want: []string{"rustup target add wasm32-unknown-unknown"},
108+
},
109+
{
110+
name: "comment mentioning rustup target add (not an actual command) not flagged",
111+
content: `name: x
112+
jobs:
113+
build:
114+
steps:
115+
# The previous rustup target add step was removed; see rust-toolchain.toml
116+
- run: echo ok
117+
`,
118+
want: nil,
119+
},
120+
}
121+
122+
for _, tc := range cases {
123+
t.Run(tc.name, func(t *testing.T) {
124+
writeWorkflow(t, wfDir, "test.yml", tc.content)
125+
got, err := scanWorkflowForRustup(filepath.Join(wfDir, "test.yml"), tmp)
126+
if err != nil {
127+
t.Fatalf("unexpected error: %v", err)
128+
}
129+
if len(got) != len(tc.want) {
130+
t.Fatalf("got %d violations, want %d:\n got: %v\n want: %v",
131+
len(got), len(tc.want), got, tc.want)
132+
}
133+
for i, expected := range tc.want {
134+
if !strings.Contains(got[i], expected) {
135+
t.Errorf("violation %d does not contain %q:\n %s", i, expected, got[i])
136+
}
137+
}
138+
})
139+
}
140+
}

scripts/check/checks/registry.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,16 @@ var AllChecks = []CheckDefinition{
559559
IsFast: true,
560560
Run: RunWorkflowsHardening,
561561
},
562+
{
563+
ID: "workflows-rustup",
564+
Nickname: "rustup-add",
565+
DisplayName: "workflows / rustup add",
566+
App: AppOther,
567+
Tech: "📏 Metrics",
568+
DependsOn: nil,
569+
IsFast: true,
570+
Run: RunWorkflowsRustup,
571+
},
562572
}
563573

564574
// GetCheckByID returns a check definition by its ID or nickname.

0 commit comments

Comments
 (0)