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, 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
21#[derive(Debug, Clone, Eq, PartialEq)]
22struct ParsedActionTarget {
23    action_slug: String,
24    owner: String,
25    repository: String,
26    version: String,
27}
28
29pub fn discover_workflow_files(repo_root: &Path, workflows_path: &Path) -> Result<Vec<PathBuf>> {
30    let workflow_root = if workflows_path.is_absolute() {
31        workflows_path.to_path_buf()
32    } else {
33        repo_root.join(workflows_path)
34    };
35
36    if !workflow_root.exists() {
37        bail!("workflow directory '{}' does not exist", workflow_root.display());
38    }
39
40    let mut files = WalkDir::new(&workflow_root)
41        .into_iter()
42        .filter_map(std::result::Result::ok)
43        .filter(|entry| entry.file_type().is_file())
44        .filter(|entry| {
45            matches!(entry.path().extension().and_then(|ext| ext.to_str()), Some("yml" | "yaml"))
46        })
47        .map(walkdir::DirEntry::into_path)
48        .collect::<Vec<_>>();
49
50    files.sort();
51    Ok(files)
52}
53
54pub fn scan_workflow(path: &Path) -> Result<Vec<WorkflowAction>> {
55    let content = fs::read_to_string(path)
56        .with_context(|| format!("failed to read workflow '{}'", path.display()))?;
57
58    let mut actions = Vec::new();
59
60    for (index, line) in content.lines().enumerate() {
61        let Some(captures) = USES_LINE_RE.captures(line) else {
62            continue;
63        };
64
65        let uses_value = captures.name("uses").expect("uses capture is required").as_str();
66
67        let Some(parsed) = parse_action_target(uses_value) else {
68            continue;
69        };
70
71        let indentation =
72            captures.name("indent").map_or(String::new(), |capture| capture.as_str().to_owned());
73        let list_prefix =
74            captures.name("list").map_or(String::new(), |capture| capture.as_str().to_owned());
75        let inline_comment = captures.name("comment").map(|capture| capture.as_str().to_owned());
76
77        actions.push(WorkflowAction {
78            file: path.to_path_buf(),
79            line_number: index + 1,
80            indentation,
81            list_prefix,
82            action_slug: parsed.action_slug,
83            owner: parsed.owner,
84            repository: parsed.repository,
85            version: parsed.version,
86            inline_comment,
87            original_line: line.to_owned(),
88        });
89    }
90
91    Ok(actions)
92}
93
94pub fn apply_changes(changes: &[PinChange]) -> Result<()> {
95    let mut by_file = changes.iter().fold(
96        std::collections::BTreeMap::<&Path, Vec<&PinChange>>::new(),
97        |mut grouped, change| {
98            grouped.entry(change.file.as_path()).or_default().push(change);
99            grouped
100        },
101    );
102
103    for (path, file_changes) in &mut by_file {
104        let content = fs::read_to_string(path)
105            .with_context(|| format!("failed to read workflow '{}'", path.display()))?;
106        let rewritten = apply_changes_to_content(&content, file_changes)?;
107
108        fs::write(path, rewritten)
109            .with_context(|| format!("failed to write workflow '{}'", path.display()))?;
110    }
111
112    Ok(())
113}
114
115pub fn apply_changes_to_content(content: &str, changes: &[&PinChange]) -> Result<String> {
116    let mut lines = content.lines().map(str::to_owned).collect::<Vec<_>>();
117    let mut sorted_changes = changes.to_vec();
118
119    sorted_changes.sort_by_key(|change| Reverse(change.line_number));
120
121    for change in sorted_changes {
122        let line_index = change.line_number - 1;
123        if line_index >= lines.len() {
124            bail!("cannot rewrite content: line {} is outside the file", change.line_number);
125        }
126
127        lines[line_index].clone_from(&change.rewritten_line);
128    }
129
130    let rewritten =
131        if content.ends_with('\n') { format!("{}\n", lines.join("\n")) } else { lines.join("\n") };
132
133    Ok(rewritten)
134}
135
136fn parse_action_target(raw: &str) -> Option<ParsedActionTarget> {
137    if raw.contains("${{")
138        || raw.starts_with("./")
139        || raw.starts_with("../")
140        || raw.starts_with('/')
141        || raw.starts_with("docker://")
142    {
143        return None;
144    }
145
146    let (action_slug, version) = raw.rsplit_once('@')?;
147    let parts = action_slug.split('/').collect::<Vec<_>>();
148
149    if parts.len() < 2 {
150        return None;
151    }
152
153    Some(ParsedActionTarget {
154        action_slug: action_slug.to_owned(),
155        owner: parts[0].to_owned(),
156        repository: parts[1].to_owned(),
157        version: version.to_owned(),
158    })
159}
160
161#[cfg(test)]
162mod tests {
163    use std::{fs, path::Path};
164
165    use tempfile::tempdir;
166
167    use super::{apply_changes, discover_workflow_files, scan_workflow};
168    use crate::model::PinChange;
169
170    #[test]
171    fn scan_workflow_collects_github_actions() {
172        let temp_dir = tempdir().expect("tempdir");
173        let workflow = temp_dir.path().join("ci.yml");
174        fs::write(
175            &workflow,
176            r"jobs:
177  lint:
178    steps:
179      - uses: actions/checkout@v4
180      - uses: github/codeql-action/init@v3 # security
181      - uses: ./local-action
182      - uses: docker://ghcr.io/acme/tool:latest
183      - uses: ${{ matrix.action }}
184",
185        )
186        .expect("write workflow");
187
188        let actions = scan_workflow(&workflow).expect("scan workflow");
189
190        assert_eq!(actions.len(), 2);
191        assert_eq!(actions[0].action_slug, "actions/checkout");
192        assert_eq!(actions[0].version, "v4");
193        assert_eq!(actions[1].action_slug, "github/codeql-action/init");
194        assert_eq!(actions[1].inline_comment.as_deref(), Some("security"));
195    }
196
197    #[test]
198    fn discover_workflow_files_only_returns_yaml() {
199        let temp_dir = tempdir().expect("tempdir");
200        let workflow_dir = temp_dir.path().join(".github").join("workflows");
201        fs::create_dir_all(&workflow_dir).expect("create workflow directory");
202        fs::write(workflow_dir.join("ci.yml"), "name: CI\n").expect("write yml workflow");
203        fs::write(workflow_dir.join("release.yaml"), "name: Release\n")
204            .expect("write yaml workflow");
205        fs::write(workflow_dir.join("notes.txt"), "skip\n").expect("write non-workflow");
206
207        let files = discover_workflow_files(temp_dir.path(), Path::new(".github/workflows"))
208            .expect("discover workflows");
209
210        assert_eq!(files.len(), 2);
211    }
212
213    #[test]
214    fn apply_changes_rewrites_target_lines() {
215        let temp_dir = tempdir().expect("tempdir");
216        let workflow = temp_dir.path().join("ci.yml");
217        fs::write(&workflow, "steps:\n  - uses: actions/checkout@v4\n  - uses: actions/cache@v4\n")
218            .expect("write workflow");
219
220        apply_changes(&[
221            PinChange {
222                file: workflow.clone(),
223                line_number: 2,
224                action_slug: "actions/checkout".into(),
225                from_version: "v4".into(),
226                to_sha: "0123456789abcdef0123456789abcdef01234567".into(),
227                original_line: "  - uses: actions/checkout@v4".into(),
228                rewritten_line:
229                    "  - uses: actions/checkout@0123456789abcdef0123456789abcdef01234567  # v4"
230                        .into(),
231            },
232            PinChange {
233                file: workflow.clone(),
234                line_number: 3,
235                action_slug: "actions/cache".into(),
236                from_version: "v4".into(),
237                to_sha: "89abcdef0123456789abcdef0123456789abcdef".into(),
238                original_line: "  - uses: actions/cache@v4".into(),
239                rewritten_line:
240                    "  - uses: actions/cache@89abcdef0123456789abcdef0123456789abcdef  # v4".into(),
241            },
242        ])
243        .expect("apply changes");
244
245        let updated = fs::read_to_string(&workflow).expect("read rewritten workflow");
246        assert!(
247            updated.contains(
248                "  - uses: actions/checkout@0123456789abcdef0123456789abcdef01234567  # v4"
249            )
250        );
251        assert!(
252            updated
253                .contains("  - uses: actions/cache@89abcdef0123456789abcdef0123456789abcdef  # v4")
254        );
255    }
256}