github-actions-maintainer 0.6.2

General-purpose GitHub Actions maintenance toolkit with secure workflow pinning
Documentation
use std::{
    cmp::Reverse,
    fs,
    path::{Path, PathBuf},
    sync::LazyLock,
};

use anyhow::{Context, Result, bail};
use regex::Regex;
use walkdir::WalkDir;

use crate::model::{PinChange, WorkflowAction};

static USES_LINE_RE: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(
        r"^(?P<indent>\s*)(?P<list>-\s*)?uses:\s*(?P<uses>[^#\s]+)\s*(?:#\s*(?P<comment>.*))?$",
    )
    .expect("valid workflow uses regex")
});

#[derive(Debug, Clone, Eq, PartialEq)]
struct ParsedActionTarget {
    action_slug: String,
    owner: String,
    repository: String,
    version: String,
}

pub fn discover_workflow_files(repo_root: &Path, workflows_path: &Path) -> Result<Vec<PathBuf>> {
    let workflow_root = if workflows_path.is_absolute() {
        workflows_path.to_path_buf()
    } else {
        repo_root.join(workflows_path)
    };

    if !workflow_root.exists() {
        bail!("workflow directory '{}' does not exist", workflow_root.display());
    }

    let mut files = WalkDir::new(&workflow_root)
        .into_iter()
        .filter_map(std::result::Result::ok)
        .filter(|entry| entry.file_type().is_file())
        .filter(|entry| {
            matches!(entry.path().extension().and_then(|ext| ext.to_str()), Some("yml" | "yaml"))
        })
        .map(walkdir::DirEntry::into_path)
        .collect::<Vec<_>>();

    files.sort();
    Ok(files)
}

pub fn scan_workflow(path: &Path) -> Result<Vec<WorkflowAction>> {
    let content = fs::read_to_string(path)
        .with_context(|| format!("failed to read workflow '{}'", path.display()))?;

    let mut actions = Vec::new();

    for (index, line) in content.lines().enumerate() {
        let Some(captures) = USES_LINE_RE.captures(line) else {
            continue;
        };

        let uses_value = captures.name("uses").expect("uses capture is required").as_str();

        let Some(parsed) = parse_action_target(uses_value) else {
            continue;
        };

        let indentation =
            captures.name("indent").map_or(String::new(), |capture| capture.as_str().to_owned());
        let list_prefix =
            captures.name("list").map_or(String::new(), |capture| capture.as_str().to_owned());
        let inline_comment = captures.name("comment").map(|capture| capture.as_str().to_owned());

        actions.push(WorkflowAction {
            file: path.to_path_buf(),
            line_number: index + 1,
            indentation,
            list_prefix,
            action_slug: parsed.action_slug,
            owner: parsed.owner,
            repository: parsed.repository,
            version: parsed.version,
            inline_comment,
            original_line: line.to_owned(),
        });
    }

    Ok(actions)
}

pub fn apply_changes(changes: &[PinChange]) -> Result<()> {
    let mut by_file = changes.iter().fold(
        std::collections::BTreeMap::<&Path, Vec<&PinChange>>::new(),
        |mut grouped, change| {
            grouped.entry(change.file.as_path()).or_default().push(change);
            grouped
        },
    );

    for (path, file_changes) in &mut by_file {
        let content = fs::read_to_string(path)
            .with_context(|| format!("failed to read workflow '{}'", path.display()))?;
        let rewritten = apply_changes_to_content(&content, file_changes)?;

        fs::write(path, rewritten)
            .with_context(|| format!("failed to write workflow '{}'", path.display()))?;
    }

    Ok(())
}

pub fn apply_changes_to_content(content: &str, changes: &[&PinChange]) -> Result<String> {
    let mut lines = content.lines().map(str::to_owned).collect::<Vec<_>>();
    let mut sorted_changes = changes.to_vec();

    sorted_changes.sort_by_key(|change| Reverse(change.line_number));

    for change in sorted_changes {
        let line_index = change.line_number - 1;
        if line_index >= lines.len() {
            bail!("cannot rewrite content: line {} is outside the file", change.line_number);
        }

        lines[line_index].clone_from(&change.rewritten_line);
    }

    let rewritten =
        if content.ends_with('\n') { format!("{}\n", lines.join("\n")) } else { lines.join("\n") };

    Ok(rewritten)
}

fn parse_action_target(raw: &str) -> Option<ParsedActionTarget> {
    if raw.contains("${{")
        || raw.starts_with("./")
        || raw.starts_with("../")
        || raw.starts_with('/')
        || raw.starts_with("docker://")
    {
        return None;
    }

    let (action_slug, version) = raw.rsplit_once('@')?;
    let parts = action_slug.split('/').collect::<Vec<_>>();

    if parts.len() < 2 {
        return None;
    }

    Some(ParsedActionTarget {
        action_slug: action_slug.to_owned(),
        owner: parts[0].to_owned(),
        repository: parts[1].to_owned(),
        version: version.to_owned(),
    })
}

#[cfg(test)]
mod tests {
    use std::{fs, path::Path};

    use tempfile::tempdir;

    use super::{apply_changes, discover_workflow_files, scan_workflow};
    use crate::model::PinChange;

    #[test]
    fn scan_workflow_collects_github_actions() {
        let temp_dir = tempdir().expect("tempdir");
        let workflow = temp_dir.path().join("ci.yml");
        fs::write(
            &workflow,
            r"jobs:
  lint:
    steps:
      - uses: actions/checkout@v4
      - uses: github/codeql-action/init@v3 # security
      - uses: ./local-action
      - uses: docker://ghcr.io/acme/tool:latest
      - uses: ${{ matrix.action }}
",
        )
        .expect("write workflow");

        let actions = scan_workflow(&workflow).expect("scan workflow");

        assert_eq!(actions.len(), 2);
        assert_eq!(actions[0].action_slug, "actions/checkout");
        assert_eq!(actions[0].version, "v4");
        assert_eq!(actions[1].action_slug, "github/codeql-action/init");
        assert_eq!(actions[1].inline_comment.as_deref(), Some("security"));
    }

    #[test]
    fn discover_workflow_files_only_returns_yaml() {
        let temp_dir = tempdir().expect("tempdir");
        let workflow_dir = temp_dir.path().join(".github").join("workflows");
        fs::create_dir_all(&workflow_dir).expect("create workflow directory");
        fs::write(workflow_dir.join("ci.yml"), "name: CI\n").expect("write yml workflow");
        fs::write(workflow_dir.join("release.yaml"), "name: Release\n")
            .expect("write yaml workflow");
        fs::write(workflow_dir.join("notes.txt"), "skip\n").expect("write non-workflow");

        let files = discover_workflow_files(temp_dir.path(), Path::new(".github/workflows"))
            .expect("discover workflows");

        assert_eq!(files.len(), 2);
    }

    #[test]
    fn apply_changes_rewrites_target_lines() {
        let temp_dir = tempdir().expect("tempdir");
        let workflow = temp_dir.path().join("ci.yml");
        fs::write(&workflow, "steps:\n  - uses: actions/checkout@v4\n  - uses: actions/cache@v4\n")
            .expect("write workflow");

        apply_changes(&[
            PinChange {
                file: workflow.clone(),
                line_number: 2,
                action_slug: "actions/checkout".into(),
                from_version: "v4".into(),
                to_sha: "0123456789abcdef0123456789abcdef01234567".into(),
                original_line: "  - uses: actions/checkout@v4".into(),
                rewritten_line:
                    "  - uses: actions/checkout@0123456789abcdef0123456789abcdef01234567  # v4"
                        .into(),
            },
            PinChange {
                file: workflow.clone(),
                line_number: 3,
                action_slug: "actions/cache".into(),
                from_version: "v4".into(),
                to_sha: "89abcdef0123456789abcdef0123456789abcdef".into(),
                original_line: "  - uses: actions/cache@v4".into(),
                rewritten_line:
                    "  - uses: actions/cache@89abcdef0123456789abcdef0123456789abcdef  # v4".into(),
            },
        ])
        .expect("apply changes");

        let updated = fs::read_to_string(&workflow).expect("read rewritten workflow");
        assert!(
            updated.contains(
                "  - uses: actions/checkout@0123456789abcdef0123456789abcdef01234567  # v4"
            )
        );
        assert!(
            updated
                .contains("  - uses: actions/cache@89abcdef0123456789abcdef0123456789abcdef  # v4")
        );
    }
}