Skip to main content

github_actions_maintainer/
workflow.rs

1use 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}