Skip to main content

github_actions_maintainer/
pinning.rs

1use std::path::PathBuf;
2
3use anyhow::{Context, Result};
4
5use crate::{
6    github::GitHubClient,
7    model::{PinChange, PinReport},
8    workflow::{apply_changes, discover_workflow_files, scan_workflow},
9};
10
11#[derive(Debug, Clone, Copy, Eq, PartialEq)]
12pub enum PinMode {
13    Apply,
14    DryRun,
15}
16
17#[derive(Debug, Clone, Eq, PartialEq)]
18pub struct PinOptions {
19    pub repo_root: PathBuf,
20    pub workflows_path: PathBuf,
21    pub mode: PinMode,
22}
23
24#[derive(Debug, Clone)]
25pub struct WorkflowPinner {
26    github: GitHubClient,
27}
28
29impl WorkflowPinner {
30    #[must_use]
31    pub const fn new(github: GitHubClient) -> Self {
32        Self { github }
33    }
34
35    pub fn pin(&self, options: &PinOptions) -> Result<PinReport> {
36        let repo_root = options.repo_root.canonicalize().with_context(|| {
37            format!("failed to resolve repository root '{}'", options.repo_root.display())
38        })?;
39        let workflow_files = discover_workflow_files(&repo_root, &options.workflows_path)?;
40
41        let mut references_scanned = 0usize;
42        let mut already_pinned = 0usize;
43        let mut changes = Vec::new();
44
45        for workflow_file in &workflow_files {
46            for action in scan_workflow(workflow_file)? {
47                references_scanned += 1;
48
49                if action.is_pinned() {
50                    already_pinned += 1;
51                    continue;
52                }
53
54                let commit_sha = self.github.resolve_reference(
55                    &action.owner,
56                    &action.repository,
57                    &action.version,
58                )?;
59
60                changes.push(PinChange {
61                    file: action.file.clone(),
62                    line_number: action.line_number,
63                    action_slug: action.action_slug.clone(),
64                    from_version: action.version.clone(),
65                    to_sha: commit_sha.clone(),
66                    original_line: action.original_line.clone(),
67                    rewritten_line: action.rendered_line(&commit_sha, &action.version),
68                });
69            }
70        }
71
72        if options.mode == PinMode::Apply && !changes.is_empty() {
73            apply_changes(&changes)?;
74        }
75
76        Ok(PinReport {
77            workflow_files: workflow_files.len(),
78            references_scanned,
79            already_pinned,
80            changes,
81        })
82    }
83}
84
85#[cfg(test)]
86#[allow(clippy::significant_drop_tightening)]
87mod tests {
88    use std::fs;
89
90    use mockito::Server;
91    use tempfile::tempdir;
92
93    use super::{PinMode, PinOptions, WorkflowPinner};
94    use crate::github::GitHubClient;
95
96    #[test]
97    fn pin_dry_run_reports_changes_without_rewriting_files() {
98        let temp_dir = tempdir().expect("tempdir");
99        let workflow_dir = temp_dir.path().join(".github").join("workflows");
100        fs::create_dir_all(&workflow_dir).expect("create workflow directory");
101        let workflow = workflow_dir.join("ci.yml");
102        fs::write(
103            &workflow,
104            "steps:\n  - uses: actions/checkout@v4\n  - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7  # v5.0.4\n",
105        )
106        .expect("write workflow");
107
108        let mut server = Server::new();
109        let _checkout = server
110            .mock("GET", "/repos/actions/checkout/commits/v4")
111            .with_status(200)
112            .with_body(r#"{"sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd"}"#)
113            .create();
114
115        let github = GitHubClient::new(server.url(), None).expect("github client");
116        let report = WorkflowPinner::new(github)
117            .pin(&PinOptions {
118                repo_root: temp_dir.path().to_path_buf(),
119                workflows_path: ".github/workflows".into(),
120                mode: PinMode::DryRun,
121            })
122            .expect("pin workflows");
123
124        assert_eq!(report.workflow_files, 1);
125        assert_eq!(report.references_scanned, 2);
126        assert_eq!(report.already_pinned, 1);
127        assert_eq!(report.changes.len(), 1);
128
129        let content = fs::read_to_string(&workflow).expect("read workflow");
130        assert!(content.contains("actions/checkout@v4"));
131    }
132
133    #[test]
134    fn pin_apply_rewrites_workflow_files() {
135        let temp_dir = tempdir().expect("tempdir");
136        let workflow_dir = temp_dir.path().join(".github").join("workflows");
137        fs::create_dir_all(&workflow_dir).expect("create workflow directory");
138        let workflow = workflow_dir.join("release.yml");
139        fs::write(&workflow, "steps:\n  - uses: github/codeql-action/init@v3 # security scan\n")
140            .expect("write workflow");
141
142        let mut server = Server::new();
143        let _codeql = server
144            .mock("GET", "/repos/github/codeql-action/commits/v3")
145            .with_status(200)
146            .with_body(r#"{"sha":"3d8036cf7fe7433e4a725cf513a6ea56c7fd0f14"}"#)
147            .create();
148
149        let github = GitHubClient::new(server.url(), None).expect("github client");
150        let report = WorkflowPinner::new(github)
151            .pin(&PinOptions {
152                repo_root: temp_dir.path().to_path_buf(),
153                workflows_path: ".github/workflows".into(),
154                mode: PinMode::Apply,
155            })
156            .expect("pin workflows");
157
158        assert_eq!(report.changes.len(), 1);
159
160        let content = fs::read_to_string(&workflow).expect("read workflow");
161        assert!(content.contains(
162            "  - uses: github/codeql-action/init@3d8036cf7fe7433e4a725cf513a6ea56c7fd0f14  # v3 | security scan"
163        ));
164    }
165}