github_actions_maintainer/
update.rs1use std::{collections::BTreeMap, fs, path::PathBuf};
2
3use anyhow::{Context, Result};
4
5use crate::{
6 github::GitHubClient,
7 model::{FileUpdate, PinChange, UpdateChange, UpdateChangeKind},
8 workflow::{apply_changes_to_content, discover_workflow_files, scan_workflow},
9};
10
11#[derive(Debug, Clone, Copy, Eq, PartialEq)]
12pub enum UpdateMode {
13 Apply,
14 DryRun,
15 Status,
16}
17
18#[derive(Debug, Clone, Eq, PartialEq)]
19pub struct UpdateOptions {
20 pub repo_root: PathBuf,
21 pub workflows_path: PathBuf,
22 pub mode: UpdateMode,
23}
24
25#[derive(Debug, Clone, Eq, PartialEq)]
26pub struct VersionEntry {
27 pub file: PathBuf,
28 pub line_number: usize,
29 pub action_slug: String,
30 pub pinned: bool,
31 pub current_version: String,
32 pub current_sha: Option<String>,
33 pub latest_version: String,
34 pub latest_sha: String,
35 pub update_needed: bool,
36}
37
38#[derive(Debug, Clone, Eq, PartialEq)]
39pub struct UpdateReport {
40 pub workflow_files: usize,
41 pub references_scanned: usize,
42 pub already_pinned: usize,
43 pub entries: Vec<VersionEntry>,
44 pub changes: Vec<UpdateChange>,
45 pub file_updates: Vec<FileUpdate>,
46}
47
48impl UpdateReport {
49 #[must_use]
50 pub fn changed_files(&self) -> usize {
51 let mut files =
52 self.file_updates.iter().map(|update| update.file.as_path()).collect::<Vec<_>>();
53 files.sort();
54 files.dedup();
55 files.len()
56 }
57}
58
59#[derive(Debug, Clone)]
60pub struct WorkflowUpdater {
61 github: GitHubClient,
62}
63
64impl WorkflowUpdater {
65 #[must_use]
66 pub const fn new(github: GitHubClient) -> Self {
67 Self { github }
68 }
69
70 pub fn update(&self, options: &UpdateOptions) -> Result<UpdateReport> {
71 let repo_root = options.repo_root.canonicalize().with_context(|| {
72 format!("failed to resolve repository root '{}'", options.repo_root.display())
73 })?;
74 let workflow_files = discover_workflow_files(&repo_root, &options.workflows_path)?;
75
76 let mut references_scanned = 0usize;
77 let mut already_pinned = 0usize;
78 let mut entries = Vec::new();
79 let mut pin_changes = Vec::new();
80
81 for workflow_file in &workflow_files {
82 for action in scan_workflow(workflow_file)? {
83 references_scanned += 1;
84 if action.is_pinned() {
85 already_pinned += 1;
86 }
87
88 let latest = self.github.latest_reference(&action.owner, &action.repository)?;
89 let current_version = action.logical_version();
90 let current_sha = action.is_pinned().then(|| action.version.clone());
91 let update_needed = !action.is_pinned()
92 || current_sha.as_deref() != Some(latest.sha.as_str())
93 || current_version != latest.version;
94
95 entries.push(VersionEntry {
96 file: action.file.clone(),
97 line_number: action.line_number,
98 action_slug: action.action_slug.clone(),
99 pinned: action.is_pinned(),
100 current_version: current_version.clone(),
101 current_sha,
102 latest_version: latest.version.clone(),
103 latest_sha: latest.sha.clone(),
104 update_needed,
105 });
106
107 if update_needed && options.mode != UpdateMode::Status {
108 pin_changes.push(PinChange {
109 file: action.file.clone(),
110 line_number: action.line_number,
111 action_slug: action.action_slug.clone(),
112 from_version: current_version,
113 to_sha: latest.sha.clone(),
114 original_line: action.original_line.clone(),
115 rewritten_line: action.rendered_line(&latest.sha, &latest.version),
116 });
117 }
118 }
119 }
120
121 let file_updates = build_file_updates(&pin_changes)?;
122 if options.mode == UpdateMode::Apply && !file_updates.is_empty() {
123 write_file_updates(&file_updates)?;
124 }
125
126 let changes = pin_changes
127 .into_iter()
128 .map(|change| UpdateChange {
129 kind: UpdateChangeKind::GitHubAction,
130 file: change.file,
131 line_number: Some(change.line_number),
132 subject: change.action_slug,
133 from_version: change.from_version,
134 to_version: change.to_sha,
135 })
136 .collect();
137
138 Ok(UpdateReport {
139 workflow_files: workflow_files.len(),
140 references_scanned,
141 already_pinned,
142 entries,
143 changes,
144 file_updates,
145 })
146 }
147}
148
149fn build_file_updates(changes: &[PinChange]) -> Result<Vec<FileUpdate>> {
150 let grouped_changes = changes.iter().fold(
151 BTreeMap::<&std::path::Path, Vec<&PinChange>>::new(),
152 |mut grouped, change| {
153 grouped.entry(change.file.as_path()).or_default().push(change);
154 grouped
155 },
156 );
157
158 let mut file_updates = Vec::new();
159 for (file, file_changes) in grouped_changes {
160 let content = fs::read_to_string(file)
161 .with_context(|| format!("failed to read workflow '{}'", file.display()))?;
162 let updated_content = apply_changes_to_content(&content, &file_changes)?;
163 file_updates.push(FileUpdate { file: file.to_path_buf(), updated_content });
164 }
165
166 Ok(file_updates)
167}
168
169fn write_file_updates(file_updates: &[FileUpdate]) -> Result<()> {
170 for update in file_updates {
171 fs::write(&update.file, &update.updated_content)
172 .with_context(|| format!("failed to write workflow '{}'", update.file.display()))?;
173 }
174
175 Ok(())
176}
177
178#[cfg(test)]
179#[allow(clippy::significant_drop_tightening)]
180mod tests {
181 use std::fs;
182
183 use mockito::{Matcher, Server};
184 use tempfile::tempdir;
185
186 use crate::github::GitHubClient;
187
188 #[test]
189 fn discovers_latest_release_and_falls_back_to_tags() {
190 let mut server = Server::new();
191 let _latest_release = server
192 .mock("GET", "/repos/actions/checkout/releases/latest")
193 .with_status(200)
194 .with_body(r#"{"tag_name":"v5"}"#)
195 .create();
196 let _latest_release_commit = server
197 .mock("GET", "/repos/actions/checkout/commits/v5")
198 .with_status(200)
199 .with_body(r#"{"sha":"1111111111111111111111111111111111111111"}"#)
200 .create();
201 let _missing_release = server
202 .mock("GET", "/repos/custom/action/releases/latest")
203 .with_status(404)
204 .with_body(r#"{"message":"Not Found"}"#)
205 .create();
206 let _tag_fallback = server
207 .mock("GET", "/repos/custom/action/tags")
208 .match_query(Matcher::UrlEncoded("per_page".into(), "1".into()))
209 .with_status(200)
210 .with_body(r#"[{"name":"v1.2.3","commit":{"sha":"2222222222222222222222222222222222222222"}}]"#)
211 .create();
212
213 let client = GitHubClient::new(server.url(), None).expect("github client");
214
215 let release =
216 client.latest_reference("actions", "checkout").expect("latest release reference");
217 assert_eq!(release.version, "v5");
218 assert_eq!(release.sha, "1111111111111111111111111111111111111111");
219
220 let fallback = client.latest_reference("custom", "action").expect("latest tag fallback");
221 assert_eq!(fallback.version, "v1.2.3");
222 assert_eq!(fallback.sha, "2222222222222222222222222222222222222222");
223 }
224
225 #[test]
226 fn update_dry_run_reports_latest_versions_without_rewriting() {
227 let temp_dir = tempdir().expect("tempdir");
228 let workflow_dir = temp_dir.path().join(".github").join("workflows");
229 fs::create_dir_all(&workflow_dir).expect("create workflow directory");
230 let workflow = workflow_dir.join("ci.yml");
231 fs::write(
232 &workflow,
233 "steps:\n - uses: actions/checkout@v4\n - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4\n",
234 )
235 .expect("write workflow");
236
237 let mut server = Server::new();
238 let _checkout_release = server
239 .mock("GET", "/repos/actions/checkout/releases/latest")
240 .with_status(200)
241 .with_body(r#"{"tag_name":"v5"}"#)
242 .create();
243 let _checkout_commit = server
244 .mock("GET", "/repos/actions/checkout/commits/v5")
245 .with_status(200)
246 .with_body(r#"{"sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd"}"#)
247 .create();
248 let _cache_release = server
249 .mock("GET", "/repos/actions/cache/releases/latest")
250 .with_status(200)
251 .with_body(r#"{"tag_name":"v5.0.5"}"#)
252 .create();
253 let _cache_commit = server
254 .mock("GET", "/repos/actions/cache/commits/v5.0.5")
255 .with_status(200)
256 .with_body(r#"{"sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}"#)
257 .create();
258
259 let github = GitHubClient::new(server.url(), None).expect("github client");
260 let planner = crate::update::WorkflowUpdater::new(github);
261 let report = planner
262 .update(&crate::update::UpdateOptions {
263 repo_root: temp_dir.path().to_path_buf(),
264 workflows_path: ".github/workflows".into(),
265 mode: crate::update::UpdateMode::DryRun,
266 })
267 .expect("update dry run");
268
269 assert_eq!(report.entries.len(), 2);
270 assert_eq!(report.changes.len(), 2);
271 assert_eq!(report.entries[0].latest_version, "v5");
272 assert_eq!(report.entries[0].current_version, "v4");
273 assert!(report.entries[0].update_needed);
274
275 let content = fs::read_to_string(&workflow).expect("read workflow");
276 assert!(content.contains("actions/checkout@v4"));
277 }
278
279 #[test]
280 fn status_tracks_current_and_latest_versions() {
281 let temp_dir = tempdir().expect("tempdir");
282 let workflow_dir = temp_dir.path().join(".github").join("workflows");
283 fs::create_dir_all(&workflow_dir).expect("create workflow directory");
284 let workflow = workflow_dir.join("release.yml");
285 fs::write(
286 &workflow,
287 "steps:\n - uses: github/codeql-action/init@3d8036cf7fe7433e4a725cf513a6ea56c7fd0f14 # v2.25.0 | code scanning\n",
288 )
289 .expect("write workflow");
290
291 let mut server = Server::new();
292 let _release = server
293 .mock("GET", "/repos/github/codeql-action/releases/latest")
294 .with_status(200)
295 .with_body(r#"{"tag_name":"v2.26.0"}"#)
296 .create();
297 let _commit = server
298 .mock("GET", "/repos/github/codeql-action/commits/v2.26.0")
299 .with_status(200)
300 .with_body(r#"{"sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}"#)
301 .create();
302
303 let github = GitHubClient::new(server.url(), None).expect("github client");
304 let planner = crate::update::WorkflowUpdater::new(github);
305 let report = planner
306 .update(&crate::update::UpdateOptions {
307 repo_root: temp_dir.path().to_path_buf(),
308 workflows_path: ".github/workflows".into(),
309 mode: crate::update::UpdateMode::Status,
310 })
311 .expect("status report");
312
313 assert_eq!(report.entries.len(), 1);
314 assert_eq!(report.entries[0].current_version, "v2.25.0");
315 assert_eq!(report.entries[0].latest_version, "v2.26.0");
316 assert!(report.entries[0].pinned);
317 assert!(report.entries[0].update_needed);
318 assert!(report.changes.is_empty());
319 }
320}