CtrlK
BlogDocsLog inGet started
Tessl Logo

pantheon-ai/jenkinsfile-validator

Comprehensive toolkit for validating, linting, testing, and automating Jenkinsfile pipelines (both Declarative and Scripted). Use this skill when working with Jenkins pipeline files, validating pipeline syntax, checking best practices, debugging pipeline issues, or working with custom plugins.

Overall
score

93%

Does it follow best practices?

Validation for skill structure

Overview
Skills
Evals
Files

validate_declarative.shscripts/

#!/bin/bash

# Declarative Pipeline Validator
# Validates Jenkins Declarative Pipeline syntax and structure

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# Colors for output
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color

# Counters
ERRORS=0
WARNINGS=0
INFO=0

# Validation result arrays
declare -a ERROR_MESSAGES=()
declare -a WARNING_MESSAGES=()
declare -a INFO_MESSAGES=()

usage() {
    echo "Usage: $0 <jenkinsfile>"
    echo "Validates a Declarative Jenkins Pipeline"
    exit 1
}

log_error() {
    local line=$1
    local message=$2
    ERROR_MESSAGES+=("ERROR [Line $line]: $message")
    ((ERRORS++))
}

log_warning() {
    local line=$1
    local message=$2
    WARNING_MESSAGES+=("WARNING [Line $line]: $message")
    ((WARNINGS++))
}

log_info() {
    local line=$1
    local message=$2
    INFO_MESSAGES+=("INFO [Line $line]: $message")
    ((INFO++))
}

# Check if file starts with 'pipeline {'
validate_pipeline_block() {
    local file=$1

    # Remove comments and empty lines for checking
    local first_meaningful=$(grep -v '^\s*//' "$file" | grep -v '^\s*$' | head -1)

    if ! echo "$first_meaningful" | grep -q '^\s*pipeline\s*{'; then
        log_error 1 "Declarative pipeline must start with 'pipeline {' block"
        return 1
    fi

    return 0
}

# Check for required top-level sections
validate_required_sections() {
    local file=$1

    # Check for agent section
    if ! grep -q '^\s*agent\s' "$file"; then
        local line=$(grep -n 'pipeline\s*{' "$file" | head -1 | cut -d: -f1)
        # Default to line 1 if pipeline block not found
        line=${line:-1}
        log_error "$line" "Missing required section 'agent' - must be defined at pipeline or stage level"
        log_error "$line" "  → Add 'agent any' or specific agent configuration"
    fi

    # Check for stages section
    if ! grep -q '^\s*stages\s*{' "$file"; then
        local line=$(grep -n 'pipeline\s*{' "$file" | head -1 | cut -d: -f1)
        # Default to line 1 if pipeline block not found
        line=${line:-1}
        log_error "$line" "Missing required section 'stages'"
        log_error "$line" "  → Add 'stages { ... }' block containing your pipeline stages"
    fi

    # If stages exists, check for at least one stage
    if grep -q '^\s*stages\s*{' "$file"; then
        if ! grep -q '^\s*stage(' "$file"; then
            local line=$(grep -n 'stages\s*{' "$file" | head -1 | cut -d: -f1)
            line=${line:-1}
            log_error "$line" "stages block must contain at least one stage"
            log_error "$line" "  → Add 'stage('name') { ... }' inside stages block"
        fi
    fi
}

# Validate stage structure
validate_stages() {
    local file=$1
    local line_num=0

    while IFS= read -r line; do
        ((line_num++))

        # Check for stage definitions (but skip nested stages inside parallel blocks)
        if echo "$line" | grep -q '^\s*stage('; then
            # Check if stage has a name
            if ! echo "$line" | grep -q "stage(['\"]"; then
                log_error "$line_num" "Stage must have a name in quotes"
                log_error "$line_num" "  → Use: stage('Stage Name') { ... }"
            fi

            # Check for steps block (need to look ahead)
            local stage_start=$line_num
            local has_valid_body=false
            local has_agent=false
            local check_line=$line_num

            # Initialize brace depth - account for opening brace on stage line itself
            # e.g., stage('Build') { has one opening brace
            local stage_open_braces=$(echo "$line" | grep -o '{' | wc -l)
            local stage_close_braces=$(echo "$line" | grep -o '}' | wc -l)
            local brace_depth=$((stage_open_braces - stage_close_braces))

            # Look for steps, parallel, or script blocks within this stage
            # Use brace depth tracking to find the stage boundary correctly
            for ((i=0; i<100; i++)); do
                ((check_line++))
                local next_line=$(sed -n "${check_line}p" "$file")

                # Skip empty lines and comments
                if echo "$next_line" | grep -qE '^\s*(//.*)?$'; then
                    continue
                fi

                # Track brace depth
                local open_braces=$(echo "$next_line" | grep -o '{' | wc -l)
                local close_braces=$(echo "$next_line" | grep -o '}' | wc -l)
                brace_depth=$((brace_depth + open_braces - close_braces))

                # Check for valid stage body types at the stage level (brace_depth == 1)
                # steps, parallel, and matrix are all valid stage body types
                if echo "$next_line" | grep -qE '^\s*steps\s*\{'; then
                    has_valid_body=true
                fi

                if echo "$next_line" | grep -qE '^\s*parallel\s*\{'; then
                    has_valid_body=true
                fi

                if echo "$next_line" | grep -qE '^\s*matrix\s*\{'; then
                    has_valid_body=true
                fi

                # Check for stage-level agent
                if echo "$next_line" | grep -qE '^\s*agent\s'; then
                    has_agent=true
                fi

                # Stop if we've exited the stage block (brace depth back to 0 or negative)
                if [ $brace_depth -le 0 ]; then
                    break
                fi

                # Safety: Stop if we hit another top-level stage (at depth 0)
                if [ $brace_depth -eq 0 ] && echo "$next_line" | grep -q '^\s*stage('; then
                    break
                fi
            done

            # Only report error if no valid body found
            # Note: Stages inside parallel blocks don't need steps at their level
            # because the parallel block itself satisfies the requirement
            if [[ "$has_valid_body" == false ]] && [[ "$has_agent" == false ]]; then
                # Check if this might be a nested stage inside a parallel block
                # by looking backward for parallel {
                local is_nested_in_parallel=false
                local look_back=$line_num
                local back_depth=0

                for ((j=0; j<50 && look_back>1; j++)); do
                    ((look_back--))
                    local prev_line=$(sed -n "${look_back}p" "$file")

                    if echo "$prev_line" | grep -qE '^\s*parallel\s*\{'; then
                        is_nested_in_parallel=true
                        break
                    fi

                    # Stop if we hit the stages block start
                    if echo "$prev_line" | grep -qE '^\s*stages\s*\{'; then
                        break
                    fi
                done

                # Don't error on nested parallel stages - they're checked separately
                if [[ "$is_nested_in_parallel" == false ]]; then
                    log_error "$stage_start" "Stage must contain 'steps', 'parallel', or 'matrix' block"
                    log_error "$stage_start" "  → Add 'steps { ... }' inside stage"
                fi
            fi
        fi
    done < "$file"
}

# Check for invalid directive placement
validate_directive_placement() {
    local file=$1
    local line_num=0
    local in_steps=false
    local in_post=false

    while IFS= read -r line; do
        ((line_num++))

        # Track if we're inside steps block
        if echo "$line" | grep -q '^\s*steps\s*{'; then
            in_steps=true
        fi

        if echo "$line" | grep -q '^\s*post\s*{'; then
            in_post=true
        fi

        # Reset on closing brace (simple heuristic)
        if [[ "$in_steps" == true ]] && echo "$line" | grep -q '^\s*}\s*$'; then
            in_steps=false
        fi

        if [[ "$in_post" == true ]] && echo "$line" | grep -q '^\s*}\s*$'; then
            in_post=false
        fi

        # Check for directives that shouldn't be in steps
        if [[ "$in_steps" == true ]]; then
            if echo "$line" | grep -qE '^\s*(environment|options|parameters|triggers|tools)\s*{'; then
                log_error "$line_num" "Directive '$(echo "$line" | grep -oE '(environment|options|parameters|triggers|tools)')' cannot be inside 'steps' block"
                log_error "$line_num" "  → Move this directive to pipeline or stage level"
            fi
        fi
    done < "$file"
}

# Check for common syntax errors
validate_syntax() {
    local file=$1
    local line_num=0

    while IFS= read -r line; do
        ((line_num++))

        # Skip comment lines
        if echo "$line" | grep -q '^\s*//'; then
            continue
        fi

        # Check for semicolons at end of lines (not needed in Jenkins pipelines)
        if echo "$line" | grep -qE '[^"\047];\s*$' && ! echo "$line" | grep -q '^\s*//'; then
            log_warning "$line_num" "Semicolon at end of line is unnecessary in Jenkins pipelines"
            log_warning "$line_num" "  → Remove trailing semicolon"
        fi

        # Check for unmatched quotes (basic check)
        # Skip lines that are part of multi-line strings (triple quotes or heredocs)
        if echo "$line" | grep -qE "'''|\"\"\""; then
            continue
        fi

        # Skip lines inside multi-line sh blocks (contain just string content)
        if echo "$line" | grep -qE '^\s*(echo|mkdir|make|cd|cmake|mvn|gradle|npm|yarn|kubectl|docker|git)\s'; then
            continue
        fi

        # Remove escaped quotes before counting
        local clean_line=$(echo "$line" | sed "s/\\\\'//g" | sed 's/\\"//g')
        local single_quotes=$(echo "$clean_line" | grep -o "'" | wc -l)
        local double_quotes=$(echo "$clean_line" | grep -o '"' | wc -l)

        # Only flag truly unbalanced quotes (not in multi-line contexts)
        # Skip if line has shell command patterns that commonly span lines
        if (( single_quotes % 2 != 0 )); then
            # Only error if not a shell command continuation
            if ! echo "$line" | grep -qE "sh\s+'''|sh\s*\(|script:|'''"; then
                log_error "$line_num" "Unmatched single quote detected"
            fi
        fi

        if (( double_quotes % 2 != 0 )); then
            # Only error if not a shell command continuation
            if ! echo "$line" | grep -qE 'sh\s+"""|sh\s*\(|script:|"""'; then
                log_error "$line_num" "Unmatched double quote detected"
            fi
        fi

        # Check for common typos in section names (singular instead of plural)
        # Only flag when followed by { (not function calls like step([...]))
        # Note: 'stage' is valid as stage('name'), so we check specifically for 'stage {'
        if echo "$line" | grep -qE '^\s*(option|parameter|trigger|tool)\s*\{'; then
            local typo=$(echo "$line" | grep -oE '(option|parameter|trigger|tool)')
            log_error "$line_num" "Possible typo: '$typo' (did you mean '${typo}s'?)"
        fi
        # Special check for 'step {' (not step( which is valid)
        if echo "$line" | grep -qE '^\s*step\s*\{'; then
            log_error "$line_num" "Possible typo: 'step' (did you mean 'steps'?)"
        fi
    done < "$file"
}

# Validate environment variable syntax
validate_environment() {
    local file=$1
    local line_num=0
    local in_env=false

    while IFS= read -r line; do
        ((line_num++))

        if echo "$line" | grep -q '^\s*environment\s*{'; then
            in_env=true
            continue
        fi

        if [[ "$in_env" == true ]]; then
            if echo "$line" | grep -q '^\s*}\s*$'; then
                in_env=false
                continue
            fi

            # Check for proper environment variable syntax
            if echo "$line" | grep -q '=' && ! echo "$line" | grep -q '^\s*//' && ! echo "$line" | grep -qE '^\s*[A-Z_][A-Z0-9_]*\s*='; then
                log_warning "$line_num" "Environment variable should be UPPER_CASE"
                log_warning "$line_num" "  → Convention: MY_VAR = 'value'"
            fi
        fi
    done < "$file"
}

# Check for parallel stages usage
validate_parallel() {
    local file=$1

    if grep -q '^\s*parallel\s*{' "$file"; then
        local line=$(grep -n 'parallel\s*{' "$file" | head -1 | cut -d: -f1)

        # Check for parallelsAlwaysFailFast() in pipeline options (global setting)
        local has_global_failfast=false
        if grep -q 'parallelsAlwaysFailFast' "$file"; then
            has_global_failfast=true
        fi

        # If global setting is present, no need to check individual parallel blocks
        if [[ "$has_global_failfast" == true ]]; then
            return 0
        fi

        # Check if failFast is considered on individual parallel blocks
        # In Declarative pipelines, failFast appears BEFORE the parallel block (as a stage directive)
        # So we need to check both before (-B) and after (-A) the parallel block
        local has_failfast=false
        if grep -B 5 'parallel\s*{' "$file" | grep -q 'failFast'; then
            has_failfast=true
        fi
        if grep -A 20 'parallel\s*{' "$file" | grep -q 'failFast'; then
            has_failfast=true
        fi

        if [[ "$has_failfast" == false ]]; then
            log_info "$line" "Consider adding 'failFast true' to parallel block to stop on first failure"
            log_info "$line" "  → Or add 'parallelsAlwaysFailFast()' in pipeline options for global coverage"
        fi
    fi
}

# Validate when conditions
validate_when() {
    local file=$1
    local line_num=0

    while IFS= read -r line; do
        ((line_num++))

        if echo "$line" | grep -q '^\s*when\s*{'; then
            # Check for valid when conditions in next few lines
            local has_condition=false
            local check_line=$line_num

            for ((i=0; i<10; i++)); do
                ((check_line++))
                local next_line=$(sed -n "${check_line}p" "$file")

                if echo "$next_line" | grep -qE '^\s*(branch|environment|expression|tag|not|allOf|anyOf)'; then
                    has_condition=true
                    break
                fi

                if echo "$next_line" | grep -q '^\s*}\s*$'; then
                    break
                fi
            done

            if [[ "$has_condition" == false ]]; then
                log_warning "$line_num" "when block appears empty or has no valid condition"
                log_warning "$line_num" "  → Use: branch, environment, expression, tag, or boolean operators"
            fi
        fi
    done < "$file"
}

# Validate matrix builds (requires Jenkins 2.0+)
validate_matrix() {
    local file=$1
    local line_num=0

    while IFS= read -r line; do
        ((line_num++))

        if echo "$line" | grep -q '^\s*matrix\s*{'; then
            local matrix_start=$line_num
            local has_axes=false
            local has_stages=false
            local check_line=$line_num
            local brace_count=1

            # Check matrix block structure
            for ((i=0; i<50; i++)); do
                ((check_line++))
                local next_line=$(sed -n "${check_line}p" "$file")

                # Track braces
                local open=$(echo "$next_line" | grep -o '{' | wc -l)
                local close=$(echo "$next_line" | grep -o '}' | wc -l)
                brace_count=$((brace_count + open - close))

                if echo "$next_line" | grep -q '^\s*axes\s*{'; then
                    has_axes=true
                fi

                if echo "$next_line" | grep -q '^\s*stages\s*{'; then
                    has_stages=true
                fi

                # Exit when matrix block closes
                if [ $brace_count -le 0 ]; then
                    break
                fi
            done

            # Validate matrix structure
            if [[ "$has_axes" == false ]]; then
                log_error "$matrix_start" "Matrix block missing required 'axes' section"
                log_error "$matrix_start" "  → Add 'axes { axis { name 'AXIS_NAME'; values 'val1', 'val2' } }'"
            fi

            if [[ "$has_stages" == false ]]; then
                log_error "$matrix_start" "Matrix block missing required 'stages' section"
                log_error "$matrix_start" "  → Add 'stages { stage('Build') { steps { ... } } }'"
            fi

            # Check for best practices
            if ! grep -A 30 "^\s*matrix\s*{" "$file" | grep -q 'failFast'; then
                log_info "$matrix_start" "Consider adding 'failFast true' to matrix options for faster feedback"
                log_info "$matrix_start" "  → options { failFast true }"
            fi
        fi

        # Validate axis definitions
        if echo "$line" | grep -q '^\s*axis\s*{'; then
            local axis_start=$line_num
            local has_name=false
            local has_values=false
            local check_line=$line_num

            for ((i=0; i<10; i++)); do
                ((check_line++))
                local next_line=$(sed -n "${check_line}p" "$file")

                if echo "$next_line" | grep -q '^\s*name\s'; then
                    has_name=true
                fi

                if echo "$next_line" | grep -q '^\s*values\s'; then
                    has_values=true
                fi

                if echo "$next_line" | grep -q '^\s*}\s*$'; then
                    break
                fi
            done

            if [[ "$has_name" == false ]]; then
                log_error "$axis_start" "Axis missing required 'name' property"
                log_error "$axis_start" "  → Add 'name \"AXIS_NAME\"'"
            fi

            if [[ "$has_values" == false ]]; then
                log_error "$axis_start" "Axis missing required 'values' property"
                log_error "$axis_start" "  → Add 'values \"val1\", \"val2\"'"
            fi
        fi

        # Validate excludes section (optional but must be valid if present)
        if echo "$line" | grep -q '^\s*excludes\s*{'; then
            local excludes_start=$line_num
            local check_line=$line_num
            local has_exclude=false

            for ((i=0; i<20; i++)); do
                ((check_line++))
                local next_line=$(sed -n "${check_line}p" "$file")

                if echo "$next_line" | grep -q '^\s*exclude\s*{'; then
                    has_exclude=true
                fi

                if echo "$next_line" | grep -q '^\s*}\s*$'; then
                    break
                fi
            done

            if [[ "$has_exclude" == false ]]; then
                log_warning "$excludes_start" "excludes block is empty"
                log_warning "$excludes_start" "  → Add 'exclude { axis { ... } }' or remove excludes block"
            fi
        fi
    done < "$file"
}

# Validate @Library shared library imports
validate_shared_libraries() {
    local file=$1
    local line_num=0

    while IFS= read -r line; do
        ((line_num++))

        # Check for @Library annotations
        if echo "$line" | grep -q '@Library'; then
            # Check for proper syntax: @Library('name') _  or @Library(['lib1', 'lib2'])
            if ! echo "$line" | grep -qE "@Library\s*\(\s*['\"\[]"; then
                log_error "$line_num" "Invalid @Library syntax"
                log_error "$line_num" "  → Use: @Library('library-name') _ or @Library(['lib1', 'lib2']) _"
            fi

            # Check if underscore is used for implicit import
            if echo "$line" | grep -qE "@Library\s*\([^)]+\)\s*$"; then
                log_warning "$line_num" "Missing underscore after @Library - add '_' for implicit import"
                log_warning "$line_num" "  → @Library('library-name') _"
            fi

            # Check for version specification
            if ! echo "$line" | grep -qE "@Library\s*\(['\"][^'\"]+@"; then
                log_info "$line_num" "Consider pinning library version for reproducible builds"
                log_info "$line_num" "  → @Library('library-name@branch-or-tag') _"
            fi
        fi

        # Check for library step usage
        if echo "$line" | grep -q '^\s*library\s*'; then
            if ! echo "$line" | grep -qE "library\s+(identifier:|['\"])"; then
                log_warning "$line_num" "Invalid library step syntax"
                log_warning "$line_num" "  → Use: library identifier: 'name@version' or library 'name'"
            fi
        fi
    done < "$file"
}

# Main validation function
validate_declarative() {
    local file=$1

    echo -e "${BLUE}=== Validating Declarative Pipeline ===${NC}"
    echo "File: $file"
    echo ""

    # Run all validation checks (|| true prevents early exit on validation failures)
    validate_pipeline_block "$file" || true
    validate_required_sections "$file" || true
    validate_stages "$file" || true
    validate_directive_placement "$file" || true
    validate_syntax "$file" || true
    validate_environment "$file" || true
    validate_parallel "$file" || true
    validate_when "$file" || true
    validate_matrix "$file" || true
    validate_shared_libraries "$file" || true

    # Print results
    echo -e "${BLUE}=== Validation Results ===${NC}"
    echo ""

    if [ ${#ERROR_MESSAGES[@]} -gt 0 ]; then
        echo -e "${RED}ERRORS (${ERRORS}):${NC}"
        for msg in "${ERROR_MESSAGES[@]}"; do
            echo -e "${RED}$msg${NC}"
        done
        echo ""
    fi

    if [ ${#WARNING_MESSAGES[@]} -gt 0 ]; then
        echo -e "${YELLOW}WARNINGS (${WARNINGS}):${NC}"
        for msg in "${WARNING_MESSAGES[@]}"; do
            echo -e "${YELLOW}$msg${NC}"
        done
        echo ""
    fi

    if [ ${#INFO_MESSAGES[@]} -gt 0 ]; then
        echo -e "${BLUE}INFO (${INFO}):${NC}"
        for msg in "${INFO_MESSAGES[@]}"; do
            echo -e "${BLUE}$msg${NC}"
        done
        echo ""
    fi

    # Summary
    echo -e "${BLUE}=== Summary ===${NC}"
    if [ $ERRORS -eq 0 ] && [ $WARNINGS -eq 0 ]; then
        echo -e "${GREEN}✓ Validation passed with no errors or warnings${NC}"
        return 0
    elif [ $ERRORS -eq 0 ]; then
        echo -e "${YELLOW}✓ Validation passed with $WARNINGS warning(s)${NC}"
        return 0
    else
        echo -e "${RED}✗ Validation failed with $ERRORS error(s) and $WARNINGS warning(s)${NC}"
        return 1
    fi
}

# Main execution
if [ $# -ne 1 ]; then
    usage
fi

JENKINSFILE="$1"

if [ ! -f "$JENKINSFILE" ]; then
    echo -e "${RED}Error: File '$JENKINSFILE' not found${NC}"
    exit 1
fi

validate_declarative "$JENKINSFILE"

Install with Tessl CLI

npx tessl i pantheon-ai/jenkinsfile-validator@0.1.0

SKILL.md

tile.json