github_actions_maintainer/
workflow.rs1use std::{
2 cmp::Reverse,
3 fs,
4 path::{Path, PathBuf},
5 sync::LazyLock,
6};
7
8use anyhow::{Context, Result, bail};
9use regex::Regex;
10use walkdir::WalkDir;
11
12use crate::model::{PinChange, ScriptType, ScriptUsage, WorkflowAction};
13
14static USES_LINE_RE: LazyLock<Regex> = LazyLock::new(|| {
15 Regex::new(
16 r"^(?P<indent>\s*)(?P<list>-\s*)?uses:\s*(?P<uses>[^#\s]+)\s*(?:#\s*(?P<comment>.*))?$",
17 )
18 .expect("valid workflow uses regex")
19});
20
21static RUN_LINE_RE: LazyLock<Regex> = LazyLock::new(|| {
22 Regex::new(r"^(?P<indent>\s*)(?P<list>-\s*)?run:\s*(?P<command>.*)$")
23 .expect("valid workflow run regex")
24});
25
26static SHELL_LINE_RE: LazyLock<Regex> = LazyLock::new(|| {
27 Regex::new(r"^\s*shell:\s*(?P<shell>[^#\s]+)").expect("valid workflow shell regex")
28});
29
30static YAML_KEY_RE: LazyLock<Regex> = LazyLock::new(|| {
31 Regex::new(r"^\s*(?:-\s*)?[A-Za-z_][A-Za-z0-9_-]*\s*:").expect("valid yaml key regex")
32});
33
34static BASH_USAGE_RE: LazyLock<Regex> = LazyLock::new(|| {
35 Regex::new(
36 r#"(^|[\s;|&({\["'`$])(?:/usr/bin/env\s+)?(?:/bin/)?(?:bash|sh)\b|(^|[\s;|&({\["'`$])(?:\./|/|[A-Za-z0-9_.-]+/)[^\s;|&({\["'`$]+\.sh\b"#,
37 )
38 .expect("valid bash usage regex")
39});
40
41static PYTHON_USAGE_RE: LazyLock<Regex> = LazyLock::new(|| {
42 Regex::new(
43 r#"(^|[\s;|&({\["'`$])(?:/usr/bin/env\s+)?python(?:3(?:\.\d+)?)?\b|(^|[\s;|&({\["'`$])(?:\./|/|[A-Za-z0-9_.-]+/)[^\s;|&({\["'`$]+\.py\b"#,
44 )
45 .expect("valid python usage regex")
46});
47
48#[derive(Debug, Clone, Eq, PartialEq)]
49struct ParsedActionTarget {
50 action_slug: String,
51 owner: String,
52 repository: String,
53 version: String,
54}
55
56pub fn discover_workflow_files(repo_root: &Path, workflows_path: &Path) -> Result<Vec<PathBuf>> {
57 let workflow_root = if workflows_path.is_absolute() {
58 workflows_path.to_path_buf()
59 } else {
60 repo_root.join(workflows_path)
61 };
62
63 if !workflow_root.exists() {
64 bail!("workflow directory '{}' does not exist", workflow_root.display());
65 }
66
67 let mut files = WalkDir::new(&workflow_root)
68 .into_iter()
69 .filter_map(std::result::Result::ok)
70 .filter(|entry| entry.file_type().is_file())
71 .filter(|entry| {
72 matches!(entry.path().extension().and_then(|ext| ext.to_str()), Some("yml" | "yaml"))
73 })
74 .map(walkdir::DirEntry::into_path)
75 .collect::<Vec<_>>();
76
77 files.sort();
78 Ok(files)
79}
80
81pub fn scan_workflow(path: &Path) -> Result<Vec<WorkflowAction>> {
82 let content = fs::read_to_string(path)
83 .with_context(|| format!("failed to read workflow '{}'", path.display()))?;
84
85 let mut actions = Vec::new();
86
87 for (index, line) in content.lines().enumerate() {
88 let Some(captures) = USES_LINE_RE.captures(line) else {
89 continue;
90 };
91
92 let uses_value = captures.name("uses").expect("uses capture is required").as_str();
93
94 let Some(parsed) = parse_action_target(uses_value) else {
95 continue;
96 };
97
98 let indentation =
99 captures.name("indent").map_or(String::new(), |capture| capture.as_str().to_owned());
100 let list_prefix =
101 captures.name("list").map_or(String::new(), |capture| capture.as_str().to_owned());
102 let inline_comment = captures.name("comment").map(|capture| capture.as_str().to_owned());
103
104 actions.push(WorkflowAction {
105 file: path.to_path_buf(),
106 line_number: index + 1,
107 indentation,
108 list_prefix,
109 action_slug: parsed.action_slug,
110 owner: parsed.owner,
111 repository: parsed.repository,
112 version: parsed.version,
113 inline_comment,
114 original_line: line.to_owned(),
115 });
116 }
117
118 Ok(actions)
119}
120
121pub fn scan_workflow_scripts(path: &Path) -> Result<Vec<ScriptUsage>> {
122 let content = fs::read_to_string(path)
123 .with_context(|| format!("failed to read workflow '{}'", path.display()))?;
124
125 Ok(scan_workflow_script_content(path, &content))
126}
127
128fn scan_workflow_script_content(path: &Path, content: &str) -> Vec<ScriptUsage> {
129 let mut usages = Vec::new();
130 let mut run_block_indent = None;
131
132 for (index, line) in content.lines().enumerate() {
133 let line_number = index + 1;
134 let mut candidates = Vec::new();
135
136 if let Some(captures) = RUN_LINE_RE.captures(line) {
137 let indent =
138 captures.name("indent").map_or(0, |capture| capture.as_str().chars().count());
139 let command = captures.name("command").expect("command capture is required").as_str();
140 let trimmed = command.trim();
141 if is_block_scalar(trimmed) {
142 run_block_indent = Some(indent);
143 } else if !trimmed.is_empty() {
144 run_block_indent = None;
145 candidates.push(trimmed.to_owned());
146 }
147 } else if let Some(indent) = run_block_indent {
148 if line.trim().is_empty() {
149 continue;
150 }
151
152 let current_indent = leading_spaces(line);
153 if current_indent <= indent && YAML_KEY_RE.is_match(line) {
154 run_block_indent = None;
155 } else {
156 candidates.push(line.trim().to_owned());
157 }
158 }
159
160 for command in candidates {
161 push_script_usages(path, line_number, &command, line, &mut usages);
162 }
163
164 if let Some(captures) = SHELL_LINE_RE.captures(line) {
165 let shell = captures.name("shell").expect("shell capture is required").as_str();
166 push_shell_usage(path, line_number, shell, line, &mut usages);
167 }
168 }
169
170 usages
171}
172
173fn push_script_usages(
174 path: &Path,
175 line_number: usize,
176 command: &str,
177 context: &str,
178 usages: &mut Vec<ScriptUsage>,
179) {
180 if BASH_USAGE_RE.is_match(command) {
181 usages.push(ScriptUsage {
182 file: path.to_path_buf(),
183 line_number,
184 script_type: ScriptType::Bash,
185 command: command.to_owned(),
186 context: context.to_owned(),
187 });
188 }
189
190 if PYTHON_USAGE_RE.is_match(command) {
191 usages.push(ScriptUsage {
192 file: path.to_path_buf(),
193 line_number,
194 script_type: ScriptType::Python,
195 command: command.to_owned(),
196 context: context.to_owned(),
197 });
198 }
199}
200
201fn push_shell_usage(
202 path: &Path,
203 line_number: usize,
204 shell: &str,
205 context: &str,
206 usages: &mut Vec<ScriptUsage>,
207) {
208 let normalized =
209 shell.trim_matches(|character| matches!(character, '"' | '\'')).to_ascii_lowercase();
210 let shell_name = normalized.split_whitespace().next().unwrap_or_default();
211 let script_type = if shell_name.starts_with("python") {
212 Some(ScriptType::Python)
213 } else if matches!(shell_name, "bash" | "sh")
214 || shell_name.ends_with("/bash")
215 || shell_name.ends_with("/sh")
216 {
217 Some(ScriptType::Bash)
218 } else {
219 None
220 };
221
222 if let Some(script_type) = script_type {
223 usages.push(ScriptUsage {
224 file: path.to_path_buf(),
225 line_number,
226 script_type,
227 command: shell.to_owned(),
228 context: context.to_owned(),
229 });
230 }
231}
232
233fn is_block_scalar(value: &str) -> bool {
234 matches!(value, "|" | ">" | "|-" | ">-" | "|+" | ">+")
235}
236
237fn leading_spaces(value: &str) -> usize {
238 value.chars().take_while(|character| *character == ' ').count()
239}
240
241pub fn apply_changes(changes: &[PinChange]) -> Result<()> {
242 let mut by_file = changes.iter().fold(
243 std::collections::BTreeMap::<&Path, Vec<&PinChange>>::new(),
244 |mut grouped, change| {
245 grouped.entry(change.file.as_path()).or_default().push(change);
246 grouped
247 },
248 );
249
250 for (path, file_changes) in &mut by_file {
251 let content = fs::read_to_string(path)
252 .with_context(|| format!("failed to read workflow '{}'", path.display()))?;
253 let rewritten = apply_changes_to_content(&content, file_changes)?;
254
255 fs::write(path, rewritten)
256 .with_context(|| format!("failed to write workflow '{}'", path.display()))?;
257 }
258
259 Ok(())
260}
261
262pub fn apply_changes_to_content(content: &str, changes: &[&PinChange]) -> Result<String> {
263 let mut lines = content.lines().map(str::to_owned).collect::<Vec<_>>();
264 let mut sorted_changes = changes.to_vec();
265
266 sorted_changes.sort_by_key(|change| Reverse(change.line_number));
267
268 for change in sorted_changes {
269 let line_index = change.line_number - 1;
270 if line_index >= lines.len() {
271 bail!("cannot rewrite content: line {} is outside the file", change.line_number);
272 }
273
274 lines[line_index].clone_from(&change.rewritten_line);
275 }
276
277 let rewritten =
278 if content.ends_with('\n') { format!("{}\n", lines.join("\n")) } else { lines.join("\n") };
279
280 Ok(rewritten)
281}
282
283fn parse_action_target(raw: &str) -> Option<ParsedActionTarget> {
284 if raw.contains("${{")
285 || raw.starts_with("./")
286 || raw.starts_with("../")
287 || raw.starts_with('/')
288 || raw.starts_with("docker://")
289 {
290 return None;
291 }
292
293 let (action_slug, version) = raw.rsplit_once('@')?;
294 let parts = action_slug.split('/').collect::<Vec<_>>();
295
296 if parts.len() < 2 {
297 return None;
298 }
299
300 Some(ParsedActionTarget {
301 action_slug: action_slug.to_owned(),
302 owner: parts[0].to_owned(),
303 repository: parts[1].to_owned(),
304 version: version.to_owned(),
305 })
306}
307
308#[cfg(test)]
309mod tests {
310 use std::{fs, path::Path};
311
312 use tempfile::tempdir;
313
314 use super::{apply_changes, discover_workflow_files, scan_workflow, scan_workflow_scripts};
315 use crate::model::{PinChange, ScriptType};
316
317 #[test]
318 fn scan_workflow_collects_github_actions() {
319 let temp_dir = tempdir().expect("tempdir");
320 let workflow = temp_dir.path().join("ci.yml");
321 fs::write(
322 &workflow,
323 r"jobs:
324 lint:
325 steps:
326 - uses: actions/checkout@v4
327 - uses: github/codeql-action/init@v3 # security
328 - uses: ./local-action
329 - uses: docker://ghcr.io/acme/tool:latest
330 - uses: ${{ matrix.action }}
331",
332 )
333 .expect("write workflow");
334
335 let actions = scan_workflow(&workflow).expect("scan workflow");
336
337 assert_eq!(actions.len(), 2);
338 assert_eq!(actions[0].action_slug, "actions/checkout");
339 assert_eq!(actions[0].version, "v4");
340 assert_eq!(actions[1].action_slug, "github/codeql-action/init");
341 assert_eq!(actions[1].inline_comment.as_deref(), Some("security"));
342 }
343
344 #[test]
345 fn discover_workflow_files_only_returns_yaml() {
346 let temp_dir = tempdir().expect("tempdir");
347 let workflow_dir = temp_dir.path().join(".github").join("workflows");
348 fs::create_dir_all(&workflow_dir).expect("create workflow directory");
349 fs::write(workflow_dir.join("ci.yml"), "name: CI\n").expect("write yml workflow");
350 fs::write(workflow_dir.join("release.yaml"), "name: Release\n")
351 .expect("write yaml workflow");
352 fs::write(workflow_dir.join("notes.txt"), "skip\n").expect("write non-workflow");
353
354 let files = discover_workflow_files(temp_dir.path(), Path::new(".github/workflows"))
355 .expect("discover workflows");
356
357 assert_eq!(files.len(), 2);
358 }
359
360 #[test]
361 fn scan_workflow_scripts_collects_single_line_and_block_usage() {
362 let temp_dir = tempdir().expect("tempdir");
363 let workflow = temp_dir.path().join("ci.yml");
364 fs::write(
365 &workflow,
366 r#"jobs:
367 lint:
368 steps:
369 - run: python3 scripts/check.py
370 - run: |
371 ./bin/bootstrap.sh
372 severity="$(python - <<'PY'
373 print('high')
374 PY
375 - run: cargo test
376 shell: bash
377"#,
378 )
379 .expect("write workflow");
380
381 let usages = scan_workflow_scripts(&workflow).expect("scan scripts");
382
383 assert_eq!(usages.len(), 4);
384 assert_eq!(usages[0].script_type, ScriptType::Python);
385 assert_eq!(usages[1].script_type, ScriptType::Bash);
386 assert_eq!(usages[2].script_type, ScriptType::Python);
387 assert_eq!(usages[3].script_type, ScriptType::Bash);
388 }
389
390 #[test]
391 fn scan_workflow_scripts_detects_shell_python() {
392 let temp_dir = tempdir().expect("tempdir");
393 let workflow = temp_dir.path().join("ci.yml");
394 fs::write(
395 &workflow,
396 r"jobs:
397 lint:
398 steps:
399 - run: print('hello')
400 shell: python
401",
402 )
403 .expect("write workflow");
404
405 let usages = scan_workflow_scripts(&workflow).expect("scan scripts");
406
407 assert_eq!(usages.len(), 1);
408 assert_eq!(usages[0].script_type, ScriptType::Python);
409 assert_eq!(usages[0].line_number, 5);
410 }
411
412 #[test]
413 fn apply_changes_rewrites_target_lines() {
414 let temp_dir = tempdir().expect("tempdir");
415 let workflow = temp_dir.path().join("ci.yml");
416 fs::write(&workflow, "steps:\n - uses: actions/checkout@v4\n - uses: actions/cache@v4\n")
417 .expect("write workflow");
418
419 apply_changes(&[
420 PinChange {
421 file: workflow.clone(),
422 line_number: 2,
423 action_slug: "actions/checkout".into(),
424 from_version: "v4".into(),
425 to_sha: "0123456789abcdef0123456789abcdef01234567".into(),
426 original_line: " - uses: actions/checkout@v4".into(),
427 rewritten_line:
428 " - uses: actions/checkout@0123456789abcdef0123456789abcdef01234567 # v4"
429 .into(),
430 },
431 PinChange {
432 file: workflow.clone(),
433 line_number: 3,
434 action_slug: "actions/cache".into(),
435 from_version: "v4".into(),
436 to_sha: "89abcdef0123456789abcdef0123456789abcdef".into(),
437 original_line: " - uses: actions/cache@v4".into(),
438 rewritten_line:
439 " - uses: actions/cache@89abcdef0123456789abcdef0123456789abcdef # v4".into(),
440 },
441 ])
442 .expect("apply changes");
443
444 let updated = fs::read_to_string(&workflow).expect("read rewritten workflow");
445 assert!(
446 updated.contains(
447 " - uses: actions/checkout@0123456789abcdef0123456789abcdef01234567 # v4"
448 )
449 );
450 assert!(
451 updated
452 .contains(" - uses: actions/cache@89abcdef0123456789abcdef0123456789abcdef # v4")
453 );
454 }
455}