workflow validator

This commit is contained in:
bahdotsh
2025-03-28 22:43:14 +05:30
commit 784c3e82e9
12 changed files with 819 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

336
Cargo.lock generated Normal file
View File

@@ -0,0 +1,336 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anstream"
version = "0.6.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9"
[[package]]
name = "anstyle-parse"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e"
dependencies = [
"anstyle",
"once_cell",
"windows-sys",
]
[[package]]
name = "clap"
version = "4.5.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e958897981290da2a852763fe9cdb89cd36977a5d729023127095fa94d95e2ff"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83b0f35019843db2160b5bb19ae09b4e6411ac33fc6a712003c33e03090e2489"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
[[package]]
name = "colorchoice"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
[[package]]
name = "colored"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [
"lazy_static",
"windows-sys",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "hashbrown"
version = "0.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "indexmap"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3954d50fe15b02142bf25d3b8bdadb634ec3948f103d04ffe3031bc8fe9d7058"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
name = "itoa"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "once_cell"
version = "1.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2806eaa3524762875e21c3dcd057bc4b7bfa01ce4da8d46be1cd43649e1cc6b"
[[package]]
name = "proc-macro2"
version = "1.0.94"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
dependencies = [
"proc-macro2",
]
[[package]]
name = "ryu"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
name = "serde"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "wrkflw"
version = "0.1.0"
dependencies = [
"clap",
"colored",
"serde",
"serde_yaml",
]

11
Cargo.toml Normal file
View File

@@ -0,0 +1,11 @@
[package]
name = "wrkflw"
version = "0.1.0"
edition = "2021"
description = "GitHub Workflow evaluator for validating workflow files"
[dependencies]
serde = "1.0"
serde_yaml = "0.9"
colored = "2.0"
clap = { version = "4.4", features = ["derive"] }

61
src/evaluator.rs Normal file
View File

@@ -0,0 +1,61 @@
use colored::*;
use serde_yaml::{self, Value};
use std::fs;
use std::path::Path;
use crate::models::ValidationResult;
use crate::validators::{validate_jobs, validate_triggers};
pub fn evaluate_workflow_file(path: &Path, verbose: bool) -> Result<ValidationResult, String> {
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
// Parse YAML content
let workflow: Value =
serde_yaml::from_str(&content).map_err(|e| format!("Invalid YAML: {}", e))?;
let mut result = ValidationResult::new();
// Check for required structure
if !workflow.is_mapping() {
result.add_issue("Workflow file is not a valid YAML mapping".to_string());
return Ok(result);
}
// Check if name exists
if !workflow.get("name").is_some() {
result.add_issue("Workflow is missing a name".to_string());
}
// Check if jobs section exists
match workflow.get("jobs") {
Some(jobs) if jobs.is_mapping() => {
validate_jobs(jobs, &mut result);
}
Some(_) => {
result.add_issue("'jobs' section is not a mapping".to_string());
}
None => {
result.add_issue("Workflow is missing 'jobs' section".to_string());
}
}
// Check for valid triggers
match workflow.get("on") {
Some(on) => {
validate_triggers(on, &mut result);
}
None => {
result.add_issue("Workflow is missing 'on' section (triggers)".to_string());
}
}
if verbose && result.is_valid {
println!(
"{} Validated structure of workflow: {}",
"".green(),
path.display()
);
}
Ok(result)
}

127
src/main.rs Normal file
View File

@@ -0,0 +1,127 @@
mod evaluator;
mod models;
mod utils;
mod validators;
use clap::Parser;
use colored::*;
use evaluator::evaluate_workflow_file;
use std::path::PathBuf;
use std::process;
use utils::is_workflow_file;
#[derive(Debug, Parser)]
#[command(name = "wrkflw", about = "GitHub Workflow evaluator")]
struct Wrkflw {
/// Path to the workflow file or directory containing workflow files
path: PathBuf,
/// Run in verbose mode with detailed output
#[arg(short, long)]
verbose: bool,
}
fn main() {
let opt = Wrkflw::parse();
let path = &opt.path;
if !path.exists() {
eprintln!("{}", "Error: Path does not exist".red());
process::exit(1);
}
if path.is_dir() {
evaluate_directory(path, opt.verbose);
} else {
match evaluate_workflow_file(path, opt.verbose) {
Ok(result) => {
if result.is_valid {
println!(
"{} {}",
"".green(),
format!("Workflow file is valid: {}", path.display()).green()
);
} else {
println!(
"{} {}",
"".red(),
format!("Workflow file has issues: {}", path.display()).red()
);
for (i, issue) in result.issues.iter().enumerate() {
println!(" {}. {}", i + 1, issue);
}
}
}
Err(e) => {
eprintln!(
"{} {}: {}",
"".red(),
format!("Error processing file {}", path.display()).red(),
e
);
process::exit(1);
}
}
}
}
fn evaluate_directory(dir_path: &PathBuf, verbose: bool) {
use std::fs;
let mut valid_count = 0;
let mut invalid_count = 0;
println!("Evaluating workflows in directory: {}", dir_path.display());
let entries = match fs::read_dir(dir_path) {
Ok(entries) => entries,
Err(e) => {
eprintln!("{}", format!("Error reading directory: {}", e).red());
process::exit(1);
}
};
for entry in entries {
if let Ok(entry) = entry {
let path = entry.path();
if path.is_file() && is_workflow_file(&path) {
match evaluate_workflow_file(&path, verbose) {
Ok(result) => {
if result.is_valid {
println!(
"{} {}",
"".green(),
format!("Valid: {}", path.display()).green()
);
valid_count += 1;
} else {
println!(
"{} {}",
"".red(),
format!("Invalid: {}", path.display()).red()
);
for (i, issue) in result.issues.iter().enumerate() {
println!(" {}. {}", i + 1, issue);
}
invalid_count += 1;
}
}
Err(e) => {
eprintln!(
"{} {}: {}",
"".red(),
format!("Error: {}", path.display()).red(),
e
);
invalid_count += 1;
}
}
}
}
}
println!(
"\nSummary: {} valid, {} invalid workflow files",
valid_count, invalid_count
);
}

18
src/models.rs Normal file
View File

@@ -0,0 +1,18 @@
pub struct ValidationResult {
pub is_valid: bool,
pub issues: Vec<String>,
}
impl ValidationResult {
pub fn new() -> Self {
ValidationResult {
is_valid: true,
issues: Vec::new(),
}
}
pub fn add_issue(&mut self, issue: String) {
self.is_valid = false;
self.issues.push(issue);
}
}

15
src/utils.rs Normal file
View File

@@ -0,0 +1,15 @@
use std::path::Path;
pub fn is_workflow_file(path: &Path) -> bool {
if let Some(ext) = path.extension() {
if ext == "yml" || ext == "yaml" {
// Check if the file is in a .github/workflows directory
// Or accept any YAML file if specifically chosen
if let Some(parent) = path.parent() {
return parent.ends_with(".github/workflows")
|| path.to_string_lossy().contains("workflow");
}
}
}
false
}

40
src/validators/actions.rs Normal file
View File

@@ -0,0 +1,40 @@
use crate::models::ValidationResult;
pub fn validate_action_reference(
action_ref: &str,
job_name: &str,
step_idx: usize,
result: &mut ValidationResult,
) {
// Check for valid action reference formats
if !action_ref.contains('/') && !action_ref.contains('.') {
result.add_issue(format!(
"Job '{}', step {}: Invalid action reference format '{}'",
job_name,
step_idx + 1,
action_ref
));
return;
}
// Check for version tag or commit SHA
if action_ref.contains('@') {
let parts: Vec<&str> = action_ref.split('@').collect();
if parts.len() != 2 || parts[1].is_empty() {
result.add_issue(format!(
"Job '{}', step {}: Action '{}' has invalid version/ref format",
job_name,
step_idx + 1,
action_ref
));
}
} else {
// Missing version tag is not recommended
result.add_issue(format!(
"Job '{}', step {}: Action '{}' is missing version tag (@v2, @main, etc.)",
job_name,
step_idx + 1,
action_ref
));
}
}

76
src/validators/jobs.rs Normal file
View File

@@ -0,0 +1,76 @@
use crate::models::ValidationResult;
use crate::validators::validate_steps;
use serde_yaml::Value;
pub fn validate_jobs(jobs: &Value, result: &mut ValidationResult) {
if let Value::Mapping(jobs_map) = jobs {
if jobs_map.is_empty() {
result.add_issue("'jobs' section is empty".to_string());
return;
}
for (job_name, job_config) in jobs_map {
if let Some(job_name) = job_name.as_str() {
if let Some(job_config) = job_config.as_mapping() {
// Check for required 'runs-on'
if !job_config.contains_key(&Value::String("runs-on".to_string())) {
result.add_issue(format!("Job '{}' is missing 'runs-on' field", job_name));
}
// Check for steps
match job_config.get(&Value::String("steps".to_string())) {
Some(Value::Sequence(steps)) => {
if steps.is_empty() {
result.add_issue(format!(
"Job '{}' has empty 'steps' section",
job_name
));
} else {
validate_steps(steps, job_name, result);
}
}
Some(_) => {
result.add_issue(format!(
"Job '{}': 'steps' section is not a sequence",
job_name
));
}
None => {
result.add_issue(format!(
"Job '{}' is missing 'steps' section",
job_name
));
}
}
// Check for job dependencies
if let Some(Value::Sequence(needs)) =
job_config.get(&Value::String("needs".to_string()))
{
for need in needs {
if let Some(need_str) = need.as_str() {
if !jobs_map.contains_key(&Value::String(need_str.to_string())) {
result.add_issue(format!(
"Job '{}' depends on non-existent job '{}'",
job_name, need_str
));
}
}
}
} else if let Some(Value::String(need)) =
job_config.get(&Value::String("needs".to_string()))
{
if !jobs_map.contains_key(&Value::String(need.clone())) {
result.add_issue(format!(
"Job '{}' depends on non-existent job '{}'",
job_name, need
));
}
}
} else {
result.add_issue(format!("Job '{}' configuration is not a mapping", job_name));
}
}
}
}
}

9
src/validators/mod.rs Normal file
View File

@@ -0,0 +1,9 @@
mod actions;
mod jobs;
mod steps;
mod triggers;
pub use actions::validate_action_reference;
pub use jobs::validate_jobs;
pub use steps::validate_steps;
pub use triggers::validate_triggers;

42
src/validators/steps.rs Normal file
View File

@@ -0,0 +1,42 @@
use crate::models::ValidationResult;
use crate::validators::validate_action_reference;
use serde_yaml::Value;
pub fn validate_steps(steps: &Vec<Value>, job_name: &str, result: &mut ValidationResult) {
for (i, step) in steps.iter().enumerate() {
if let Some(step_map) = step.as_mapping() {
if !step_map.contains_key(&Value::String("name".to_string()))
&& !step_map.contains_key(&Value::String("uses".to_string()))
&& !step_map.contains_key(&Value::String("run".to_string()))
{
result.add_issue(format!(
"Job '{}', step {}: Missing 'name', 'uses', or 'run' field",
job_name,
i + 1
));
}
// Check for both 'uses' and 'run' in the same step
if step_map.contains_key(&Value::String("uses".to_string()))
&& step_map.contains_key(&Value::String("run".to_string()))
{
result.add_issue(format!(
"Job '{}', step {}: Contains both 'uses' and 'run' (should only use one)",
job_name,
i + 1
));
}
// Validate action reference if 'uses' is present
if let Some(Value::String(uses)) = step_map.get(&Value::String("uses".to_string())) {
validate_action_reference(uses, job_name, i, result);
}
} else {
result.add_issue(format!(
"Job '{}', step {}: Not a valid mapping",
job_name,
i + 1
));
}
}
}

View File

@@ -0,0 +1,83 @@
use crate::models::ValidationResult;
use serde_yaml::Value;
pub fn validate_triggers(on: &Value, result: &mut ValidationResult) {
let valid_events = vec![
"push",
"pull_request",
"workflow_dispatch",
"schedule",
"repository_dispatch",
"issue_comment",
"issues",
"label",
"milestone",
"page_build",
"project",
"project_card",
"public",
"release",
"status",
"watch",
"workflow_run",
];
match on {
Value::String(event) => {
if !valid_events.contains(&event.as_str()) {
result.add_issue(format!("Unknown trigger event: '{}'", event));
}
}
Value::Sequence(events) => {
for event in events {
if let Some(event_str) = event.as_str() {
if !valid_events.contains(&event_str) {
result.add_issue(format!("Unknown trigger event: '{}'", event_str));
}
}
}
}
Value::Mapping(event_map) => {
for (event, _) in event_map {
if let Some(event_str) = event.as_str() {
if !valid_events.contains(&event_str) {
result.add_issue(format!("Unknown trigger event: '{}'", event_str));
}
}
}
// Check schedule syntax if present
if let Some(Value::Sequence(schedules)) =
event_map.get(&Value::String("schedule".to_string()))
{
for schedule in schedules {
if let Some(schedule_map) = schedule.as_mapping() {
if let Some(Value::String(cron)) =
schedule_map.get(&Value::String("cron".to_string()))
{
validate_cron_syntax(cron, result);
} else {
result.add_issue("Schedule is missing 'cron' expression".to_string());
}
}
}
}
}
_ => {
result.add_issue("'on' section has invalid format".to_string());
}
}
}
fn validate_cron_syntax(cron: &str, result: &mut ValidationResult) {
// Basic validation of cron syntax
let parts: Vec<&str> = cron.split_whitespace().collect();
if parts.len() != 5 {
result.add_issue(format!(
"Invalid cron syntax '{}': should have 5 components",
cron
));
}
// More detailed validation could be added here
}