Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions .gosqlx.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,13 @@ dialect: postgresql

# Linting rules
lint:
# Enable/disable specific rules (use rule codes)
# Available rules: L001-L010
# Enable/disable specific rule categories
rules:
- L007 # Keyword Case Consistency
- L001 # Trailing Whitespace
- L002 # Mixed Indentation
- L004 # Indentation Depth
- L009 # Aliasing Consistency
- L007 # Keyword Case — enforce uppercase SQL keywords
- L001 # Trailing Whitespace — no trailing whitespace
- L002 # Mixed Indentation — consistent indentation
- L006 # No SELECT * — discourage SELECT *
- L009 # Aliasing Consistency — require table aliases in JOINs

# Maximum line length (0 = unlimited)
max-line-length: 120
Expand Down
177 changes: 28 additions & 149 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ inputs:
required: true
default: '**/*.sql'

rules:
description: 'Comma-separated list of lint rule codes to enable (e.g., "L007,L006"). See docs/LINTING_RULES.md for the full list.'
required: false
default: ''

severity:
description: 'Severity threshold for failure: error, warning, or info'
required: false
default: 'warning'

validate:
description: 'Enable SQL validation (syntax checking)'
required: false
Expand Down Expand Up @@ -72,6 +82,11 @@ inputs:
required: false
default: 'latest'

timeout:
description: 'Timeout in seconds for each file validation (default: 600)'
required: false
default: '600'

working-directory:
description: 'Working directory for SQL file operations'
required: false
Expand Down Expand Up @@ -100,7 +115,7 @@ runs:
- name: Setup Go
uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0
with:
go-version: '1.25'
go-version: '1.24'

# Disable cache when testing in repository to always build from latest source
# - name: Cache GoSQLX binary
Expand Down Expand Up @@ -157,160 +172,24 @@ runs:
id: find-files
shell: bash
working-directory: ${{ inputs.working-directory }}
run: |
# Validate working-directory exists and is within repo
WORKDIR="${{ inputs.working-directory }}"
if [ ! -d "$WORKDIR" ]; then
echo "::error::Working directory does not exist: $WORKDIR"
exit 1
fi

# Ensure working directory is within the repository
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo "$GITHUB_WORKSPACE")
WORKDIR_ABS=$(cd "$WORKDIR" && pwd)
if [[ "$WORKDIR_ABS" != "$REPO_ROOT"* ]]; then
echo "::error::Working directory must be within repository: $WORKDIR_ABS"
exit 1
fi

# Sanitize file pattern to prevent command injection
PATTERN="${{ inputs.files }}"
# Remove dangerous characters that could lead to command injection
if echo "$PATTERN" | grep -qE '[;&|`$()]'; then
echo "::error::File pattern contains invalid characters: $PATTERN"
exit 1
fi

echo "Finding SQL files matching pattern: $PATTERN"

# Use find to locate SQL files matching the pattern
# Convert glob pattern to find-compatible pattern
# Handle common glob patterns
if [[ "$PATTERN" == "**/*.sql" ]]; then
FILES=$(find . -type f -name "*.sql" 2>/dev/null | sort || true)
elif [[ "$PATTERN" == "*.sql" ]]; then
FILES=$(find . -maxdepth 1 -type f -name "*.sql" 2>/dev/null | sort || true)
elif [[ "$PATTERN" =~ ^(.+)/\*\*/(.+)$ ]]; then
# Handle patterns like "dir/**/*.sql" - extract base dir and file pattern
BASE_DIR="${BASH_REMATCH[1]}"
FILE_PATTERN="${BASH_REMATCH[2]}"
FILES=$(find "./$BASE_DIR" -type f -name "$FILE_PATTERN" 2>/dev/null | sort || true)
else
# Custom pattern without ** - try direct find with escaped pattern
FILES=$(find . -type f -path "./$PATTERN" 2>/dev/null | sort || true)
fi

if [ -z "$FILES" ]; then
echo "WARNING: No SQL files found matching pattern: ${{ inputs.files }}"
echo "file-count=0" >> $GITHUB_OUTPUT
exit 0
fi

FILE_COUNT=$(echo "$FILES" | wc -l)
echo "Found $FILE_COUNT SQL file(s)"
echo "$FILES" | head -10
if [ $FILE_COUNT -gt 10 ]; then
echo "... and $((FILE_COUNT - 10)) more files"
fi

# Save file list for later steps (use RUNNER_TEMP for cross-platform compatibility)
echo "$FILES" > "$RUNNER_TEMP/gosqlx-files.txt"
echo "file-count=$FILE_COUNT" >> $GITHUB_OUTPUT
env:
INPUT_FILES: ${{ inputs.files }}
INPUT_WORKING_DIRECTORY: ${{ inputs.working-directory }}
run: bash "${{ github.action_path }}/action/scripts/find-files.sh"

- name: Validate SQL files
id: validate
if: inputs.validate == 'true' && steps.find-files.outputs.file-count != '0'
shell: bash
working-directory: ${{ inputs.working-directory }}
run: |
echo "::group::SQL Validation"

# Build validation command
CMD="$HOME/go/bin/gosqlx validate"

# Add config if provided
if [ -n "${{ inputs.config }}" ]; then
if [ -f "${{ inputs.config }}" ]; then
echo "Using config file: ${{ inputs.config }}"
export GOSQLX_CONFIG="${{ inputs.config }}"
else
echo "::warning::Config file not found: ${{ inputs.config }}"
fi
fi

# Add dialect if provided (with validation)
DIALECT="${{ inputs.dialect }}"
if [ -n "$DIALECT" ]; then
# Validate dialect is one of the allowed values
if [[ "$DIALECT" =~ ^(postgresql|mysql|sqlserver|oracle|sqlite)$ ]]; then
CMD="$CMD --dialect $DIALECT"
else
echo "::warning::Invalid dialect '$DIALECT', skipping dialect flag"
fi
fi

# Add strict mode if enabled
if [ "${{ inputs.strict }}" = "true" ]; then
CMD="$CMD --strict"
fi

# Add stats if enabled
if [ "${{ inputs.show-stats }}" = "true" ]; then
CMD="$CMD --stats"
fi

# Add verbose output for GitHub Actions
CMD="$CMD --verbose"

# Read files and validate
START_TIME=$(date +%s%3N)
VALIDATED=0
INVALID=0

while IFS= read -r file; do
# Sanitize file path for display
SAFE_FILE="${file//[^a-zA-Z0-9\/._-]/}"
echo "Validating: $file"

# Use quoted variable to prevent word splitting
if $CMD "$file" 2>&1; then
echo "✓ Valid: $file"
VALIDATED=$((VALIDATED + 1))
else
echo "✗ Invalid: $file"
echo "::error file=$SAFE_FILE::SQL validation failed"
INVALID=$((INVALID + 1))
fi
done < "$RUNNER_TEMP/gosqlx-files.txt"

END_TIME=$(date +%s%3N)
DURATION=$((END_TIME - START_TIME))

echo "::endgroup::"

# Output summary
echo "::notice::Validation complete: $VALIDATED valid, $INVALID invalid files (${DURATION}ms)"

# Set outputs
echo "validated-files=$VALIDATED" >> $GITHUB_OUTPUT
echo "invalid-files=$INVALID" >> $GITHUB_OUTPUT
echo "validation-time=$DURATION" >> $GITHUB_OUTPUT

# Create job summary
echo "## SQL Validation Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Metric | Value |" >> $GITHUB_STEP_SUMMARY
echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Files Validated | $VALIDATED |" >> $GITHUB_STEP_SUMMARY
echo "| Validation Errors | $INVALID |" >> $GITHUB_STEP_SUMMARY
echo "| Duration | ${DURATION}ms |" >> $GITHUB_STEP_SUMMARY
echo "| Throughput | $(awk "BEGIN {printf \"%.2f\", $VALIDATED * 1000 / $DURATION}") files/sec |" >> $GITHUB_STEP_SUMMARY

# Fail if there are invalid files and fail-on-error is true
if [ $INVALID -gt 0 ] && [ "${{ inputs.fail-on-error }}" = "true" ]; then
echo "::error::Validation failed with $INVALID invalid file(s)"
exit 1
fi
env:
INPUT_CONFIG: ${{ inputs.config }}
INPUT_DIALECT: ${{ inputs.dialect }}
INPUT_STRICT: ${{ inputs.strict }}
INPUT_SHOW_STATS: ${{ inputs.show-stats }}
INPUT_FAIL_ON_ERROR: ${{ inputs.fail-on-error }}
INPUT_TIMEOUT: ${{ inputs.timeout }}
run: bash "${{ github.action_path }}/action/scripts/validate.sh"

- name: Generate SARIF output
id: sarif
Expand Down
38 changes: 19 additions & 19 deletions action/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ RULES="${RULES:-}"
SEVERITY="${SEVERITY:-warning}"
CONFIG="${CONFIG:-}"
GOSQLX_BIN="${GOSQLX_BIN:-gosqlx}"
TIMEOUT="${TIMEOUT:-600}"

# Resolve gosqlx binary
if ! command -v "$GOSQLX_BIN" &>/dev/null; then
Expand Down Expand Up @@ -44,11 +45,13 @@ elif [[ "$SQL_FILES" == "*.sql" ]]; then
FILES+=("$f")
done < <(find . -maxdepth 1 -type f -name "*.sql" -print0 2>/dev/null | sort -z)
else
# Use bash globbing
# Use find with sanitized pattern to avoid command injection
shopt -s globstar nullglob 2>/dev/null || true
for f in $SQL_FILES; do
[ -f "$f" ] && FILES+=("$f")
done
# Sanitize: only allow safe glob characters
SAFE_PATTERN=$(echo "$SQL_FILES" | sed 's/[^a-zA-Z0-9_.*/?\/\-]//g')
while IFS= read -r -d '' f; do
FILES+=("$f")
done < <(find . -type f -path "./$SAFE_PATTERN" -print0 2>/dev/null | sort -z)
fi

if [ ${#FILES[@]} -eq 0 ]; then
Expand All @@ -64,19 +67,6 @@ VALIDATE_FLAGS=()

if [ -n "$CONFIG" ] && [ -f "$CONFIG" ]; then
echo "Using config: $CONFIG"
# Validate rule names in config match implemented rules
VALID_RULES="L001 L002 L003 L004 L005 L006 L007 L008 L009 L010"
if command -v grep &>/dev/null && grep -q 'rules:' "$CONFIG"; then
while IFS= read -r rule_line; do
rule_id=$(echo "$rule_line" | sed 's/^[[:space:]]*-[[:space:]]*//' | sed 's/[[:space:]]*#.*//')
if [ -n "$rule_id" ]; then
if ! echo "$VALID_RULES" | grep -qw "$rule_id"; then
echo "::error::Unknown rule '$rule_id' in $CONFIG. Valid rules: $VALID_RULES"
exit 1
fi
fi
done < <(sed -n '/^[[:space:]]*rules:/,/^[[:space:]]*[^-[:space:]]/{ /^[[:space:]]*-/p; }' "$CONFIG")
fi
export GOSQLX_CONFIG="$CONFIG"
fi

Expand All @@ -99,11 +89,21 @@ for file in "${FILES[@]}"; do
# Strip leading ./
display_file="${file#./}"

# --- Validate ---
if output=$("$GOSQLX_BIN" validate "$file" 2>&1); then
# --- Validate (with timeout) ---
TIMEOUT_CMD=""
if command -v timeout &>/dev/null; then
TIMEOUT_CMD="timeout $TIMEOUT"
elif command -v gtimeout &>/dev/null; then
TIMEOUT_CMD="gtimeout $TIMEOUT"
fi
if output=$($TIMEOUT_CMD "$GOSQLX_BIN" validate "$file" 2>&1); then
TOTAL_VALID=$((TOTAL_VALID + 1))
else
VALIDATE_ERRORS=$((VALIDATE_ERRORS + 1))
# Check if it was a timeout
if [ $? -eq 124 ]; then
echo "::error file=${display_file}::Validation timed out after ${TIMEOUT}s"
fi
# Parse output for line-level annotations if possible
while IFS= read -r line; do
if [[ "$line" =~ [Ll]ine[[:space:]]*([0-9]+) ]]; then
Expand Down
57 changes: 57 additions & 0 deletions action/scripts/find-files.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# find-files.sh — Locate SQL files matching the given glob pattern.
# Inputs (env): INPUT_FILES, INPUT_WORKING_DIRECTORY
# Outputs: file-count (GITHUB_OUTPUT), file list (RUNNER_TEMP/gosqlx-files.txt)
set -euo pipefail

WORKDIR="${INPUT_WORKING_DIRECTORY:-.}"

# Validate working-directory exists and is within repo
if [ ! -d "$WORKDIR" ]; then
echo "::error::Working directory does not exist: $WORKDIR"
exit 1
fi

REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo "$GITHUB_WORKSPACE")
WORKDIR_ABS=$(cd "$WORKDIR" && pwd)
if [[ "$WORKDIR_ABS" != "$REPO_ROOT"* ]]; then
echo "::error::Working directory must be within repository: $WORKDIR_ABS"
exit 1
fi

# Sanitize file pattern to prevent command injection
PATTERN="${INPUT_FILES:-**/*.sql}"
if echo "$PATTERN" | grep -qE '[;&|`$()]'; then
echo "::error::File pattern contains invalid characters: $PATTERN"
exit 1
fi

echo "Finding SQL files matching pattern: $PATTERN"

if [[ "$PATTERN" == "**/*.sql" ]]; then
FILES=$(find . -type f -name "*.sql" 2>/dev/null | sort || true)
elif [[ "$PATTERN" == "*.sql" ]]; then
FILES=$(find . -maxdepth 1 -type f -name "*.sql" 2>/dev/null | sort || true)
elif [[ "$PATTERN" =~ ^(.+)/\*\*/(.+)$ ]]; then
BASE_DIR="${BASH_REMATCH[1]}"
FILE_PATTERN="${BASH_REMATCH[2]}"
FILES=$(find "./$BASE_DIR" -type f -name "$FILE_PATTERN" 2>/dev/null | sort || true)
else
FILES=$(find . -type f -path "./$PATTERN" 2>/dev/null | sort || true)
fi

if [ -z "$FILES" ]; then
echo "WARNING: No SQL files found matching pattern: $PATTERN"
echo "file-count=0" >> "$GITHUB_OUTPUT"
exit 0
fi

FILE_COUNT=$(echo "$FILES" | wc -l)
echo "Found $FILE_COUNT SQL file(s)"
echo "$FILES" | head -10
if [ "$FILE_COUNT" -gt 10 ]; then
echo "... and $((FILE_COUNT - 10)) more files"
fi

echo "$FILES" > "$RUNNER_TEMP/gosqlx-files.txt"
echo "file-count=$FILE_COUNT" >> "$GITHUB_OUTPUT"
Loading
Loading