use anyhow::{Context, Result};
use regex::Regex;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
#[derive(Debug, Clone)]
pub struct ActionRef {
pub owner: String,
pub repo: String,
pub subpath: Option<String>,
pub ref_string: String,
pub ref_type: RefType,
pub tag_comment: Option<String>,
pub line_number: usize,
pub raw_line: String,
}
impl ActionRef {
pub fn full_name(&self) -> String {
match &self.subpath {
Some(sub) => format!("{}/{}/{}", self.owner, self.repo, sub),
None => format!("{}/{}", self.owner, self.repo),
}
}
pub fn owner_repo(&self) -> String {
format!("{}/{}", self.owner, self.repo)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum RefType {
Branch,
Sha,
SlidingTag,
Tag,
}
static USES_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(\s*-?\s*uses:\s*)([^\s@]+)@(\S+?)(\s*#\s*(.+?))?\s*$").unwrap()
});
static RUN_BLOCK_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(\s*)(?:-\s+)?run\s*:\s*[|>][0-9+\-]*\s*$").unwrap());
pub fn parse_uses_line(line: &str, line_number: usize) -> Option<ActionRef> {
let caps = USES_RE.captures(line)?;
let action_path = caps.get(2)?.as_str();
let ref_string = caps.get(3)?.as_str().to_string();
let tag_comment = caps.get(5).map(|m| m.as_str().trim().to_string());
if action_path.starts_with('.') {
return None;
}
let parts: Vec<&str> = action_path.splitn(3, '/').collect();
if parts.len() < 2 {
return None;
}
let owner = parts[0].to_string();
let repo = parts[1].to_string();
let subpath = if parts.len() == 3 {
Some(parts[2].to_string())
} else {
None
};
let ref_type = classify_ref(&ref_string);
Some(ActionRef {
owner,
repo,
subpath,
ref_string,
ref_type,
tag_comment,
line_number,
raw_line: line.to_string(),
})
}
fn classify_ref(r: &str) -> RefType {
if r.len() == 40 && r.chars().all(|c| c.is_ascii_hexdigit()) {
return RefType::Sha;
}
let version_part = r.strip_prefix('v').unwrap_or(r);
if !version_part.is_empty() && version_part.chars().all(|c| c.is_ascii_digit() || c == '.') {
return if version_part.contains('.') {
RefType::Tag
} else {
RefType::SlidingTag
};
}
RefType::Branch
}
pub fn build_pinned_line(line: &str, sha: &str, original_tag: &str) -> Option<String> {
let caps = USES_RE.captures(line)?;
let prefix = caps.get(1)?.as_str();
let action_path = caps.get(2)?.as_str();
Some(format!("{prefix}{action_path}@{sha} # {original_tag}"))
}
pub fn scan_content(content: &str) -> Vec<ActionRef> {
let mut refs = Vec::new();
let mut block_parent_col: Option<usize> = None;
for (i, line) in content.lines().enumerate() {
let line_num = i + 1;
if let Some(start_col) = block_parent_col {
let indent = line.chars().take_while(|c| *c == ' ').count();
if line.trim().is_empty() || indent > start_col {
continue;
}
block_parent_col = None;
}
if let Some(caps) = RUN_BLOCK_RE.captures(line) {
block_parent_col = Some(caps.get(1).unwrap().as_str().len());
continue;
}
if let Some(r) = parse_uses_line(line, line_num) {
refs.push(r);
}
}
refs
}
pub fn scan_workflow(path: &Path) -> Result<Vec<ActionRef>> {
let content =
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
Ok(scan_content(&content))
}
pub fn find_workflows(repo_root: &Path) -> Result<Vec<PathBuf>> {
let workflows_dir = repo_root.join(".github").join("workflows");
if !workflows_dir.is_dir() {
anyhow::bail!(
"No .github/workflows/ directory found in {}",
repo_root.display()
);
}
let mut files = Vec::new();
for entry in std::fs::read_dir(&workflows_dir)
.with_context(|| format!("reading {}", workflows_dir.display()))?
{
let entry = entry?;
let path = entry.path();
if let Some(ext) = path.extension()
&& (ext == "yml" || ext == "yaml")
{
files.push(path);
}
}
files.sort();
Ok(files)
}
pub fn display_path(path: &Path, root: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.display()
.to_string()
}
pub fn rewrite_actions(
path: &Path,
replacements: &[(usize, String)], ) -> Result<usize> {
let content =
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
let mut lines: Vec<String> = content.lines().map(String::from).collect();
let mut seen: std::collections::HashSet<usize> =
std::collections::HashSet::with_capacity(replacements.len());
let mut count = 0;
for (line_num, new_line) in replacements {
if !seen.insert(*line_num) {
debug_assert!(
false,
"duplicate rewrite target for line {line_num} in {}",
path.display()
);
continue;
}
let idx = line_num - 1; if idx < lines.len() {
lines[idx] = new_line.clone();
count += 1;
}
}
let mut output = lines.join("\n");
if content.ends_with('\n') {
output.push('\n');
}
std::fs::write(path, &output).with_context(|| format!("writing {}", path.display()))?;
Ok(count)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_sliding_tag() {
let r = parse_uses_line(" - uses: actions/checkout@v4", 1).unwrap();
assert_eq!(r.owner, "actions");
assert_eq!(r.repo, "checkout");
assert_eq!(r.ref_string, "v4");
assert_eq!(r.ref_type, RefType::SlidingTag);
assert!(r.subpath.is_none());
assert!(r.tag_comment.is_none());
}
#[test]
fn parse_exact_tag() {
let r = parse_uses_line(" - uses: actions/checkout@v4.3.1", 1).unwrap();
assert_eq!(r.ref_type, RefType::Tag);
}
#[test]
fn parse_sha_ref_with_tag_comment() {
let line =
" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2";
let r = parse_uses_line(line, 5).unwrap();
assert_eq!(r.ref_type, RefType::Sha);
assert_eq!(r.tag_comment.as_deref(), Some("v6.0.2"));
assert_eq!(r.line_number, 5);
}
#[test]
fn parse_branch_ref() {
let r = parse_uses_line(" - uses: Homebrew/actions/setup-homebrew@main", 1).unwrap();
assert_eq!(r.ref_type, RefType::Branch);
assert_eq!(r.owner, "Homebrew");
assert_eq!(r.repo, "actions");
assert_eq!(r.subpath.as_deref(), Some("setup-homebrew"));
}
#[test]
fn parse_subpath_action() {
let r =
parse_uses_line(" uses: github/codeql-action/init@abc123def456abc123def456abc123def456abcd # v4.35.1", 1)
.unwrap();
assert_eq!(r.owner, "github");
assert_eq!(r.repo, "codeql-action");
assert_eq!(r.subpath.as_deref(), Some("init"));
assert_eq!(r.full_name(), "github/codeql-action/init");
}
#[test]
fn parse_numeric_sliding_tag() {
let r = parse_uses_line(" - uses: some/action@4", 1).unwrap();
assert_eq!(r.ref_type, RefType::SlidingTag);
}
#[test]
fn parse_numeric_exact_tag() {
let r = parse_uses_line(" - uses: some/action@4.1", 1).unwrap();
assert_eq!(r.ref_type, RefType::Tag);
}
#[test]
fn skip_local_action() {
assert!(parse_uses_line(" - uses: ./.github/actions/my-action@v1", 1).is_none());
}
#[test]
fn skip_non_uses_line() {
assert!(parse_uses_line(" - run: echo hello", 1).is_none());
assert!(parse_uses_line("name: CI", 1).is_none());
assert!(parse_uses_line("", 1).is_none());
}
#[test]
fn preserves_raw_line() {
let line = " - uses: actions/checkout@v4";
let r = parse_uses_line(line, 1).unwrap();
assert_eq!(r.raw_line, line);
}
#[test]
fn pin_simple_tag() {
let line = " - uses: actions/checkout@v4";
let result = build_pinned_line(line, "abc123def456", "v4").unwrap();
assert_eq!(result, " - uses: actions/checkout@abc123def456 # v4");
}
#[test]
fn pin_replaces_existing_comment() {
let line = " - uses: actions/checkout@v3 # old comment";
let result = build_pinned_line(line, "abc123", "v4").unwrap();
assert_eq!(result, " - uses: actions/checkout@abc123 # v4");
}
#[test]
fn pin_preserves_indentation() {
let line = " uses: actions/checkout@v4";
let result = build_pinned_line(line, "sha123", "v4").unwrap();
assert!(result.starts_with(" uses:"));
}
#[test]
fn pin_with_subpath() {
let line = " - uses: github/codeql-action/init@v3";
let result = build_pinned_line(line, "sha123", "v3").unwrap();
assert_eq!(
result,
" - uses: github/codeql-action/init@sha123 # v3"
);
}
#[test]
fn full_name_without_subpath() {
let r = parse_uses_line(" - uses: actions/checkout@v4", 1).unwrap();
assert_eq!(r.full_name(), "actions/checkout");
assert_eq!(r.owner_repo(), "actions/checkout");
}
#[test]
fn full_name_with_subpath() {
let r = parse_uses_line(" - uses: github/codeql-action/init@v3", 1).unwrap();
assert_eq!(r.full_name(), "github/codeql-action/init");
assert_eq!(r.owner_repo(), "github/codeql-action");
}
#[test]
fn scan_skips_uses_inside_run_block() {
let yaml = r#"
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Generate workflow
run: |
cat > test.yml <<YAML
steps:
- uses: ${OWNER}/${REPO}@${SHA}
YAML
- uses: actions/setup-node@v4
"#;
let refs = scan_content(yaml);
assert_eq!(refs.len(), 2);
assert_eq!(refs[0].full_name(), "actions/checkout");
assert_eq!(refs[1].full_name(), "actions/setup-node");
}
#[test]
fn scan_handles_block_scalar_chomping_indicators() {
for marker in ["|", "|-", "|+", ">", ">-", ">+", "|2", "|2-", ">-2"] {
let yaml = format!(
"steps:\n - run: {marker}\n - uses: evil/action@v1\n - uses: good/action@v2\n"
);
let refs = scan_content(&yaml);
assert_eq!(
refs.len(),
1,
"marker {marker:?} should skip the inner uses"
);
assert_eq!(refs[0].full_name(), "good/action");
}
}
#[test]
fn scan_inline_run_does_not_trigger_skip() {
let yaml = "steps:\n - run: echo hello\n - uses: actions/checkout@v4\n";
let refs = scan_content(yaml);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].full_name(), "actions/checkout");
}
#[test]
fn scan_handles_blank_lines_inside_run_block() {
let yaml =
"steps:\n - run: |\n echo one\n\n echo two\n - uses: actions/checkout@v4\n";
let refs = scan_content(yaml);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].full_name(), "actions/checkout");
}
#[test]
fn scan_exits_block_scalar_on_dedent() {
let yaml = "steps:\n - run: |\n echo shell\n - uses: real/action@v1\n";
let refs = scan_content(yaml);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].full_name(), "real/action");
}
#[test]
fn scan_multiple_run_blocks_in_one_file() {
let yaml = r#"
jobs:
a:
steps:
- run: |
echo uses: fake/a@v1
- uses: real/a@v1
b:
steps:
- run: |
echo uses: fake/b@v1
- uses: real/b@v1
"#;
let refs = scan_content(yaml);
assert_eq!(refs.len(), 2);
assert_eq!(refs[0].full_name(), "real/a");
assert_eq!(refs[1].full_name(), "real/b");
}
#[test]
fn classify_39_hex_chars_is_branch() {
let short = "a".repeat(39);
assert!(matches!(classify_ref(&short), RefType::Branch));
}
#[test]
fn classify_41_hex_chars_is_branch() {
let long = "a".repeat(41);
assert!(matches!(classify_ref(&long), RefType::Branch));
}
#[test]
fn classify_40_hex_chars_is_sha() {
let sha = "a".repeat(40);
assert!(matches!(classify_ref(&sha), RefType::Sha));
}
#[test]
fn classify_mixed_case_hex_is_sha() {
let sha = "aAbBcCdDeEfF0011223344556677889900112233";
assert!(matches!(classify_ref(sha), RefType::Sha));
}
#[test]
fn classify_prerelease_tag_is_branch() {
assert!(matches!(classify_ref("v1.2.3-alpha"), RefType::Branch));
}
#[test]
fn classify_main_is_branch() {
assert!(matches!(classify_ref("main"), RefType::Branch));
}
#[test]
fn build_pinned_line_non_uses_returns_none() {
assert!(build_pinned_line(" - run: echo hello", "abc123", "v1").is_none());
}
#[test]
fn display_path_relative() {
let root = Path::new("/repo");
let path = Path::new("/repo/.github/workflows/ci.yml");
assert_eq!(display_path(path, root), ".github/workflows/ci.yml");
}
#[test]
fn display_path_outside_root() {
let root = Path::new("/repo");
let path = Path::new("/other/ci.yml");
assert_eq!(display_path(path, root), "/other/ci.yml");
}
#[test]
fn rewrite_preserves_trailing_newline() {
let dir = tempfile::TempDir::new().unwrap();
let file = dir.path().join("test.yml");
std::fs::write(&file, "line1\nline2\n").unwrap();
let count = rewrite_actions(&file, &[(1, "replaced".to_string())]).unwrap();
assert_eq!(count, 1);
let result = std::fs::read_to_string(&file).unwrap();
assert!(result.ends_with('\n'));
assert_eq!(result, "replaced\nline2\n");
}
#[test]
fn rewrite_no_trailing_newline() {
let dir = tempfile::TempDir::new().unwrap();
let file = dir.path().join("test.yml");
std::fs::write(&file, "line1\nline2").unwrap();
let count = rewrite_actions(&file, &[(1, "replaced".to_string())]).unwrap();
assert_eq!(count, 1);
let result = std::fs::read_to_string(&file).unwrap();
assert!(!result.ends_with('\n'));
}
#[test]
fn rewrite_out_of_bounds_skipped() {
let dir = tempfile::TempDir::new().unwrap();
let file = dir.path().join("test.yml");
std::fs::write(&file, "line1\n").unwrap();
let count = rewrite_actions(&file, &[(99, "nope".to_string())]).unwrap();
assert_eq!(count, 0);
}
#[test]
fn rewrite_empty_replacements() {
let dir = tempfile::TempDir::new().unwrap();
let file = dir.path().join("test.yml");
std::fs::write(&file, "line1\n").unwrap();
let count = rewrite_actions(&file, &[]).unwrap();
assert_eq!(count, 0);
}
#[test]
#[cfg(not(debug_assertions))]
fn rewrite_duplicate_line_skipped_in_release() {
let dir = tempfile::TempDir::new().unwrap();
let file = dir.path().join("test.yml");
std::fs::write(&file, "line1\nline2\n").unwrap();
let count = rewrite_actions(
&file,
&[
(1, "first".to_string()),
(1, "second-should-be-ignored".to_string()),
],
)
.unwrap();
assert_eq!(count, 1);
let result = std::fs::read_to_string(&file).unwrap();
assert_eq!(result, "first\nline2\n");
}
#[test]
#[cfg(debug_assertions)]
#[should_panic(expected = "duplicate rewrite target")]
fn rewrite_duplicate_line_panics_in_debug() {
let dir = tempfile::TempDir::new().unwrap();
let file = dir.path().join("test.yml");
std::fs::write(&file, "line1\nline2\n").unwrap();
let _ = rewrite_actions(
&file,
&[(1, "first".to_string()), (1, "second".to_string())],
);
}
}