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, 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}