use std::fs;
use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::Parser;
use ignore::WalkBuilder;
use ignore::overrides::OverrideBuilder;
use tracing::{debug, info, warn};
use crate::github_api::GithubApi;
use crate::parser::{
is_sha, is_short_sha, is_version_like_ref, parse_comment_ref, parse_quote, parse_repo_ref,
parse_uses_line, replace_ref,
};
#[derive(Debug, Parser)]
#[command(name = "act-up")]
#[command(author = clap::crate_authors!())]
#[command(version = clap::crate_version!())]
#[command(about = clap::crate_description!(), long_about = None)]
#[command(after_help = r#"Examples:
- Update all workflow files in current directory: `act-up`
- Update all workflow files other than deploy.yml: `act-up --files !.github/workflows/deploy.yml`
- Preview changes without writing to files: `act-up --dry-run`
Notes:
- If you hit GitHub API rate limit, you can set `GITHUB_TOKEN` environment variable to a personal access token.
- Non-semver refs (e.g. `main`) won't be updated unless --pin is set.
- If the first line contains `autogenerated` or `auto generated`, the file is skipped."#)]
struct Cli {
#[arg(long, value_delimiter = ',')]
files: Vec<String>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
pin: bool,
}
const DEFAULT_FILE_PATTERNS: [&str; 2] = [".github/workflows/*.yml", ".github/workflows/*.yaml"];
const GENERATED_FILE_INDICATORS: [&str; 3] = ["autogenerated", "auto generated", "@generated"];
#[derive(Debug)]
struct ChangeRecord {
line_no: usize,
repo: String,
old_ref: String,
new_ref: String,
pinned: bool,
}
pub fn run() -> Result<()> {
let cli = Cli::parse();
let files = collect_yaml_files(&cli.files)?;
if files.is_empty() {
warn!("no files matching given patterns");
return Ok(());
}
info!("checking {} files", files.len());
let mut api = GithubApi::new()?;
let mut all_changes = Vec::new();
for file in files {
let content =
fs::read_to_string(&file).with_context(|| format!("failed reading {file:?}"))?;
if is_generated_file(&content) {
debug!("skipped generated: {file:?}");
continue;
}
let (new_content, line_changes) = update_file_content(&mut api, &content, cli.pin)?;
if line_changes.is_empty() {
continue;
}
for change in line_changes {
info!(
"{}: {}:{} {}@{} -> {}{}",
if cli.dry_run {
"would update"
} else {
"updated"
},
file.display(),
change.line_no,
change.repo,
change.old_ref,
change.new_ref,
if change.pinned { " (pinned)" } else { "" }
);
all_changes.push(change);
}
if !cli.dry_run {
fs::write(&file, new_content).with_context(|| format!("failed writing {file:?}"))?;
}
}
if all_changes.is_empty() {
info!("completed: no updates");
} else {
info!(
"completed:\n{}",
all_changes
.iter()
.map(|c| {
format!(
" - {}@{} -> {}{}",
c.repo,
c.old_ref,
c.new_ref,
if c.pinned { " (pinned)" } else { "" }
)
})
.collect::<Vec<_>>()
.join("\n")
);
}
if cli.dry_run && !all_changes.is_empty() {
info!("re-run without --dry-run to apply changes");
}
Ok(())
}
fn collect_yaml_files(cli_files: &[String]) -> Result<Vec<PathBuf>> {
let mut builder = OverrideBuilder::new(".");
for p in DEFAULT_FILE_PATTERNS {
builder.add(p)?;
}
for p in cli_files {
builder.add(p.trim())?;
}
let overrides = builder.build().context("failed to build file overrides")?;
let mut files = WalkBuilder::new(".")
.follow_links(false)
.hidden(false)
.ignore(false)
.git_ignore(false)
.git_global(false)
.git_exclude(false)
.parents(false)
.overrides(overrides)
.build()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_some_and(|it| it.is_file()))
.map(|e| e.into_path())
.collect::<Vec<_>>();
files.sort();
files.dedup();
Ok(files)
}
fn is_generated_file(content: &str) -> bool {
content.lines().next().is_some_and(|line| {
let line = line.to_ascii_lowercase();
GENERATED_FILE_INDICATORS.iter().any(|i| line.contains(i))
})
}
fn update_file_content(
api: &mut GithubApi,
content: &str,
pin: bool,
) -> Result<(String, Vec<ChangeRecord>)> {
let mut changes = Vec::new();
let lines: Vec<String> = content
.lines()
.enumerate()
.map(|(idx, line)| match maybe_update_uses_line(api, line, pin) {
Ok(Some((new_line, Some(mut record)))) => {
record.line_no = idx + 1;
changes.push(record);
new_line
}
Ok(Some((new_line, None))) => new_line,
Ok(None) => line.to_string(),
Err(err) => {
warn!("failed to check line {}: {err:#}", idx + 1);
line.to_string()
}
})
.collect();
let mut new_content = lines.join("\n");
if content.ends_with('\n') {
new_content.push('\n');
}
Ok((new_content, changes))
}
fn maybe_update_uses_line(
api: &mut GithubApi,
line: &str,
pin: bool,
) -> Result<Option<(String, Option<ChangeRecord>)>> {
let Some(parsed) = parse_uses_line(line) else {
return Ok(None);
};
let q = parse_quote(&parsed.value_raw);
let Some(repo) = parse_repo_ref(q.value) else {
return Ok(Some((line.to_string(), None)));
};
if repo.hostname != "github.com" {
return Ok(Some((line.to_string(), None)));
}
let is_pinned_digest = is_sha(repo.current_ref) || is_short_sha(repo.current_ref);
let comment_body = parsed.comment_suffix.strip_prefix(" #").unwrap_or_default();
let (target_ref, target_sha, old_display_ref) = if is_pinned_digest {
let Some(c_ref) = parse_comment_ref(comment_body) else {
return Ok(Some((line.to_string(), None)));
};
let updated = if is_version_like_ref(&c_ref) {
api.resolve_updated_ref(repo.owner, repo.repo, &c_ref)?
.unwrap_or(c_ref)
} else {
c_ref
};
let sha = api.resolve_ref_sha(repo.owner, repo.repo, &updated)?;
(
updated,
sha,
parse_comment_ref(comment_body).unwrap_or_default(),
)
} else if pin {
let sha = api.resolve_ref_sha(repo.owner, repo.repo, repo.current_ref)?;
(
repo.current_ref.to_string(),
sha,
repo.current_ref.to_string(),
)
} else if is_version_like_ref(repo.current_ref) {
let updated = api.resolve_updated_ref(repo.owner, repo.repo, repo.current_ref)?;
match updated {
Some(u) if u != repo.current_ref => (u, String::new(), repo.current_ref.to_string()),
_ => return Ok(Some((line.to_string(), None))),
}
} else {
return Ok(Some((line.to_string(), None)));
};
let new_value_ref = if is_pinned_digest || pin {
&target_sha
} else {
&target_ref
};
if new_value_ref == repo.current_ref {
return Ok(Some((line.to_string(), None)));
}
let replaced = replace_ref(q.value, new_value_ref);
let val = if q.quote.is_empty() {
replaced
} else {
format!("{}{replaced}{}", q.quote, q.quote)
};
let (final_comment, display_new_ref, is_pinned) = if is_pinned_digest || pin {
(format!(" # {target_ref}"), target_ref.clone(), true)
} else {
(parsed.comment_suffix.clone(), target_ref.clone(), false)
};
let new_line = format!("{}{val}{final_comment}", parsed.prefix);
let record = ChangeRecord {
line_no: 0,
repo: format!("{}/{}", repo.owner, repo.repo),
old_ref: old_display_ref,
new_ref: if pin && !is_pinned_digest {
target_sha
} else {
display_new_ref
},
pinned: is_pinned,
};
Ok(Some((new_line, Some(record))))
}