fix(validator): add support for GitHub Actions reusable workflow validation

Enhance the workflow validator to correctly handle GitHub Actions reusable workflows:
- Skip validation of 'runs-on' field for jobs with a 'uses' field
- Skip validation of the 'steps' field for reusable workflow jobs
- Add specific format validation for reusable workflow references
- Make workflow name optional for files that only contain reusable workflow jobs
- Add comprehensive test workflow files for various reusable workflow scenarios

This fixes validation errors when using GitHub's workflow_call feature that
allows reusing workflows from other repositories or local files.
This commit is contained in:
bahdotsh
2025-04-30 15:36:38 +05:30
parent 8975519c03
commit 7bd7cc3b2b
12 changed files with 303 additions and 21 deletions

View File

@@ -23,7 +23,23 @@ pub fn evaluate_workflow_file(path: &Path, verbose: bool) -> Result<ValidationRe
// Check if name exists
if workflow.get("name").is_none() {
result.add_issue("Workflow is missing a name".to_string());
// Check if this might be a reusable workflow caller before reporting missing name
let has_reusable_workflow_job = if let Some(Value::Mapping(jobs)) = workflow.get("jobs") {
jobs.values().any(|job| {
if let Some(job_config) = job.as_mapping() {
job_config.contains_key(Value::String("uses".to_string()))
} else {
false
}
})
} else {
false
};
// Only report missing name if it's not a workflow with reusable workflow jobs
if !has_reusable_workflow_job {
result.add_issue("Workflow is missing a name".to_string());
}
}
// Check if jobs section exists

View File

@@ -8,6 +8,7 @@ mod matrix;
mod matrix_test;
mod models;
mod parser;
mod reusable_workflow_test;
mod runtime;
mod ui;
mod utils;

View File

@@ -0,0 +1,67 @@
#[cfg(test)]
mod tests {
use crate::evaluator::evaluate_workflow_file;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_reusable_workflow_validation() {
let temp_dir = tempdir().unwrap();
let workflow_path = temp_dir.path().join("test-workflow.yml");
// Create a workflow file that uses reusable workflows
let content = r#"
on:
pull_request:
branches:
- main
jobs:
call-workflow-1-in-local-repo:
uses: octo-org/this-repo/.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89
call-workflow-2-in-local-repo:
uses: ./path/to/workflow.yml
with:
username: mona
secrets:
token: ${{ secrets.TOKEN }}
"#;
fs::write(&workflow_path, content).unwrap();
// Validate the workflow
let result = evaluate_workflow_file(&workflow_path, false).unwrap();
// Should be valid since we've fixed the validation to handle reusable workflows
assert!(
result.is_valid,
"Workflow should be valid, but got issues: {:?}",
result.issues
);
assert!(result.issues.is_empty());
// Create an invalid reusable workflow (bad format for 'uses')
let invalid_content = r#"
on:
pull_request:
branches:
- main
jobs:
call-workflow-invalid:
uses: invalid-format
"#;
fs::write(&workflow_path, invalid_content).unwrap();
// Validate the workflow
let result = evaluate_workflow_file(&workflow_path, false).unwrap();
// Should be invalid due to the bad format
assert!(!result.is_valid);
assert!(result
.issues
.iter()
.any(|issue| issue.contains("Invalid reusable workflow reference format")));
}
}

View File

@@ -12,34 +12,55 @@ pub fn validate_jobs(jobs: &Value, result: &mut ValidationResult) {
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())) {
// Check if this is a reusable workflow job (has 'uses' field)
let is_reusable_workflow =
job_config.contains_key(Value::String("uses".to_string()));
// Only check for 'runs-on' if it's not a reusable workflow
if !is_reusable_workflow
&& !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() {
// Only check for steps if it's not a reusable workflow
if !is_reusable_workflow {
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 '{}' has empty 'steps' section",
"Job '{}': 'steps' section is not a sequence",
job_name
));
}
None => {
result.add_issue(format!(
"Job '{}' is missing '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
));
} else {
// For reusable workflows, validate the 'uses' field format
if let Some(Value::String(uses)) =
job_config.get(Value::String("uses".to_string()))
{
// Simple validation for reusable workflow reference format
if !uses.contains('/') || !uses.contains('.') {
result.add_issue(format!(
"Job '{}': Invalid reusable workflow reference format '{}'",
job_name, uses
));
}
}
}

View File

@@ -0,0 +1,21 @@
name: Basic Workflow
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '16'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test

View File

@@ -0,0 +1,20 @@
name: Reusable Workflow Caller
on:
push:
branches: [main]
jobs:
call-workflow-1:
uses: octo-org/example-repo/.github/workflows/workflow-A.yml@v1
call-workflow-2:
uses: ./local-workflows/build.yml
with:
config-path: ./config/test.yml
secrets:
token: ${{ secrets.GITHUB_TOKEN }}
call-workflow-3:
uses: octo-org/example-repo/.github/workflows/workflow-B.yml@main
needs: [call-workflow-1]

View File

@@ -0,0 +1,32 @@
name: Reusable Workflow Definition
on:
workflow_call:
inputs:
config-path:
required: true
type: string
description: "Path to the configuration file"
environment:
required: false
type: string
default: "production"
description: "Environment to run in"
secrets:
token:
required: true
description: "GitHub token for authentication"
jobs:
reusable-job:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Load configuration
run: echo "Loading configuration from ${{ inputs.config-path }}"
- name: Run in environment
run: echo "Running in ${{ inputs.environment }} environment"
- name: Use secret
run: echo "Using secret with length ${#TOKEN}"
env:
TOKEN: ${{ secrets.token }}

View File

@@ -0,0 +1,25 @@
name: Mixed Regular and Reusable Jobs
on:
push:
branches: [main]
jobs:
regular-job:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run regular task
run: echo "This is a regular job"
reusable-job:
uses: octo-org/example-repo/.github/workflows/reusable.yml@main
with:
parameter: "value"
dependent-job:
runs-on: ubuntu-latest
needs: [regular-job, reusable-job]
steps:
- name: Run dependent task
run: echo "This job depends on both a regular and reusable job"

View File

@@ -0,0 +1,12 @@
on:
push:
branches: [main]
jobs:
call-workflow-1:
uses: octo-org/example-repo/.github/workflows/workflow-A.yml@v1
call-workflow-2:
uses: ./local-workflows/build.yml
with:
config-path: ./config/test.yml

View File

@@ -0,0 +1,17 @@
name: Invalid Reusable Format
on:
push:
branches: [main]
jobs:
valid-job:
runs-on: ubuntu-latest
steps:
- name: Test step
run: echo "This is a valid job"
invalid-reusable-job:
uses: invalid-format
with:
param: "value"

View File

@@ -0,0 +1,19 @@
name: Invalid Regular Job
on:
push:
branches: [main]
jobs:
job-missing-runs-on:
# Missing runs-on field
steps:
- name: Test step
run: echo "This job is missing runs-on field"
job-missing-steps:
runs-on: ubuntu-latest
# Missing steps section
valid-reusable-job:
uses: octo-org/example-repo/.github/workflows/reusable.yml@main

View File

@@ -0,0 +1,31 @@
name: Cyclic Dependencies
on:
push:
branches: [main]
jobs:
job-a:
runs-on: ubuntu-latest
needs: [job-c]
steps:
- name: Job A
run: echo "Job A"
job-b:
runs-on: ubuntu-latest
needs: [job-a]
steps:
- name: Job B
run: echo "Job B"
job-c:
runs-on: ubuntu-latest
needs: [job-b]
steps:
- name: Job C
run: echo "Job C"
reusable-job:
uses: octo-org/example-repo/.github/workflows/reusable.yml@main
needs: [job-a]