Skip to main content

Check YAML script code

#!/usr/bin/env bash
# check-yaml-v11.sh — recursively lint/validate YAML across git repos under a root folder.
# ----------------------------------------------------------------------------------------
# Compatible with macOS /bin/bash 3.2 (no GNU-only features). Interactive-safe.
#
# FEATURES
# • Scans all git repos under a given ROOT for *.yml / *.yaml (tracked + untracked, respects .gitignore)
# • Parse check via `yq`, lint via `yamllint`, optional Kubernetes schema check via `kubeconform`
# • Interactive fixer (y/n/a/N/d/q) or “fix all” mode using:
# - yamlfmt (preferred)
# - prettier (fallback)
# - yq (last-resort pretty-print; may drop comments)
# • SOPS-aware: files with a top-level `sops:` key get special handling by default
# - kubeconform is skipped for SOPS files
# - yamllint runs with line-length disabled for SOPS files
# - You can turn this behavior off or skip entirely (see FLAGS below)
# • “DO NOT EDIT” auto-skip: any file containing that phrase (case-insensitive) is skipped entirely
# - Toggle with --do-not-edit-off if you want to include them
# • Helpful diffs in interactive mode; optional auto-insert of '---' with --fix-doc-start
# • Robust argument parsing: flags don’t get mistaken for the ROOT directory
#
# USAGE
# ./check-yaml-v11.sh [ROOT_DIR] [flags...]
#
# EXAMPLES
# # Default root ($HOME/Projects), non-interactive report only
# ./check-yaml-v11.sh
#
# # Interactive fixes under a specific repo
# ./check-yaml-v11.sh ~/Projects/cloudflare -i
#
# # Skip SOPS entirely and auto-insert '---' when missing
# ./check-yaml-v11.sh ~/Projects -i --skip-sops-all --fix-doc-start
#
# # Use custom yamllint config (overrides SOPS relaxed lint rule)
# ./check-yaml-v11.sh ~/Projects --yamllint-config ~/.config/yamllint/config
#
# REQUIREMENTS
# - yq (mikefarah v4) : brew install yq
# - yamllint : brew install yamllint
# - kubeconform (optional): brew install kubeconform
# - yamlfmt (optional) : brew install yamlfmt
# - prettier (optional) : npm i -g prettier
#
# EXIT CODES
# 0 = all good
# 1 = at least one failure
# 2 = missing required tools
#
# FLAGS
# -h, --help Show this help and exit
# -V, --version Print version and exit
# -i, --interactive Prompt to fix files one-by-one (y/n/a/N/d/q)
# -A, --fix-all Auto-fix all lint issues without prompting
# --no-k8s Disable Kubernetes schema checks (kubeconform)
# --yamllint-config PATH Use a custom yamllint config file
# --fix-doc-start Auto-insert '---' at top of files missing it
#
# DO-NOT-EDIT HANDLING
# (default) Files containing "DO NOT EDIT" (any case) are skipped entirely
# --do-not-edit-off Treat such files like normal (do not skip)
#
# SOPS HANDLING (top-level `sops:` key detection)
# --sops-off Treat SOPS files like normal (no skipping/relaxing)
# --skip-sops-lint Skip yamllint for SOPS files
# --skip-sops-all Skip ALL checks for SOPS files (parse, lint, kubeconform)
#
# NOTES
# - If you see an empty diff for “document-start”, add --fix-doc-start to preview adding '---'.
# - On macOS, this script uses only POSIX/GNU-compatible bits that are present by default.
#
set -euo pipefail

VERSION="11.0"

print_help() {
sed -n '1,200p' "$0" | sed 's/^# \{0,1\}//' | awk 'BEGIN{skip=1} /^ check-yaml-v11/{skip=0} {if(skip==0) print}'
exit 0
}

# Defaults
ROOT="${HOME}/Projects"
INTERACTIVE=0
FIX_ALL=0
NO_K8S=0
SOPS_OFF=0
SOPS_SKIP_LINT=0
SOPS_SKIP_ALL=0
FIX_DOC_START=0
YAMLLINT_CONFIG=""
DNE_SKIP=1 # default: skip "DO NOT EDIT" files
ROOT_SET=0

# Early help/version
for arg in "$@"; do
case "$arg" in
-h|--help) print_help ;;
-V|--version) echo "check-yaml v${VERSION}"; exit 0 ;;
esac
done

# Parse args
i=1
while [ $i -le $# ]; do
eval "arg=\${$i}"
case "$arg" in
--interactive|-i) INTERACTIVE=1 ;;
--fix-all|-A) FIX_ALL=1 ;;
--no-k8s) NO_K8S=1 ;;
--sops-off) SOPS_OFF=1 ;;
--skip-sops-lint) SOPS_SKIP_LINT=1 ;;
--skip-sops-all) SOPS_SKIP_ALL=1 ;;
--fix-doc-start) FIX_DOC_START=1 ;;
--do-not-edit-off) DNE_SKIP=0 ;;
--yamllint-config)
j=$((i+1))
if [ $j -le $# ]; then
eval "YAMLLINT_CONFIG=\${$j}"
i=$((i+1))
else
echo "ERROR: --yamllint-config requires a path" >&2
exit 2
fi
;;
-*)
# unknown flag; ignore quietly (or emit a warning if you prefer)
;;
*)
if [ $ROOT_SET -eq 0 ] && [ -d "$arg" ]; then
ROOT="$arg"
ROOT_SET=1
fi
;;
esac
i=$((i+1))
done

need() { command -v "$1" >/dev/null 2>&1; }

fail=0
if ! need yq; then echo "ERROR: yq is required (brew install yq)"; exit 2; fi
if ! need yamllint; then echo "ERROR: yamllint is required (brew install yamllint)"; exit 2; fi

HAVE_KUBECONFORM=0; if [ "$NO_K8S" -eq 0 ] && need kubeconform; then HAVE_KUBECONFORM=1; fi
HAVE_YAMLFMT=0; if need yamlfmt; then HAVE_YAMLFMT=1; fi
HAVE_PRETTIER=0; if need prettier; then HAVE_PRETTIER=1; fi

echo "Scanning git repos under: $ROOT"
echo " interactive fix : $INTERACTIVE"
echo " fix all : $FIX_ALL"
if [ "$HAVE_KUBECONFORM" -eq 1 ]; then echo " kubeconform : enabled"; else echo " kubeconform : disabled"; fi
echo " DO NOT EDIT skip: $( [ $DNE_SKIP -eq 1 ] && echo enabled || echo disabled )"
echo " SOPS handling : $( [ $SOPS_OFF -eq 1 ] && echo 'off' || echo 'skip K8s + relax lint' )"
if [ -n "$YAMLLINT_CONFIG" ]; then echo " yamllint config : $YAMLLINT_CONFIG"; fi
if [ $FIX_DOC_START -eq 1 ]; then echo " fix doc-start : enabled"; fi
if [ $SOPS_SKIP_LINT -eq 1 ]; then echo " SOPS lint : skipped"; fi
if [ $SOPS_SKIP_ALL -eq 1 ]; then echo " SOPS all checks : skipped"; fi
echo

total_files=0
parse_errors=0
lint_error_repos=0
k8s_errors=0
fixed_files=0
sops_detected=0
sops_skipped_k8s=0
sops_skipped_lint=0
sops_skipped_all=0
dne_skipped=0
GLOBAL_DECISION="" # "" | "all" | "none"

is_sops_file() {
local file="$1"
if yq -e 'select(has("sops")) | true' "$file" >/dev/null 2>&1; then return 0; else return 1; fi
}

is_do_not_edit_file() {
local file="$1"
if grep -qiE 'do[[:space:]]+not[[:space:]]+edit' "$file" 2>/dev/null; then
return 0
else
return 1
fi
}

prompt_read() {
local prompt="$1"
if [ -t 0 ]; then
printf "%s" "$prompt"
read ans
else
printf "%s" "$prompt" > /dev/tty
read ans < /dev/tty
fi
echo "$ans"
}

has_doc_start() {
local file="$1"
awk 'BEGIN{found=0}
/^[[:space:]]*$/ {next}
/^[[:space:]]*#/ {next}
{ if ($0 ~ /^---[[:space:]]*$/) exit 0; else exit 1 }' "$file"
}

insert_doc_start_to_tmp() {
local in="$1"; local out="$2"
{ echo '---'; cat "$in"; } > "$out"
}

fix_with_yamlfmt() {
local file="$1"
local tmp; tmp="$(mktemp)"
if ! yamlfmt -in < "$file" > "$tmp"; then rm -f "$tmp"; return 1; fi
if [ $FIX_DOC_START -eq 1 ] && ! has_doc_start "$tmp"; then
local tmp2; tmp2="$(mktemp)"; insert_doc_start_to_tmp "$tmp" "$tmp2"; mv "$tmp2" "$tmp"
fi
mv "$tmp" "$file"; return 0
}

format_to_tmp_for_diff() {
local file="$1"; local out_tmp="$2"
if [ "$HAVE_YAMLFMT" -eq 1 ]; then
yamlfmt -in < "$file" > "$out_tmp" || return $?
elif [ "$HAVE_PRETTIER" -eq 1 ]; then
cp "$file" "$out_tmp"; prettier --log-level warn --write "$out_tmp" >/dev/null || return $?
else
yq -P '.' "$file" > "$out_tmp" || return $?
fi
if [ $FIX_DOC_START -eq 1 ] && ! has_doc_start "$out_tmp"; then
local tmp2; tmp2="$(mktemp)"; insert_doc_start_to_tmp "$out_tmp" "$tmp2"; mv "$tmp2" "$out_tmp"
fi
return 0
}

fix_with_tool() {
local file="$1"
if [ "$HAVE_YAMLFMT" -eq 1 ]; then fix_with_yamlfmt "$file" && return 0; fi
if [ "$HAVE_PRETTIER" -eq 1 ]; then
prettier --log-level warn --write "$file" >/dev/null || true
if [ $FIX_DOC_START -eq 1 ] && ! has_doc_start "$file"; then
local tmp; tmp="$(mktemp)"; insert_doc_start_to_tmp "$file" "$tmp"; mv "$tmp" "$file"
fi
return 0
fi
local tmp; tmp="$(mktemp)"
yq -P '.' "$file" > "$tmp"
if [ $FIX_DOC_START -eq 1 ] && ! has_doc_start "$tmp"; then
local tmp2; tmp2="$(mktemp)"; insert_doc_start_to_tmp "$tmp" "$tmp2"; mv "$tmp2" "$tmp"
fi
mv "$tmp" "$file"
}

maybe_fix_file() {
local file="$1"
if [ "$FIX_ALL" -eq 1 ]; then fix_with_tool "$file"; fixed_files=$((fixed_files+1)); return 0; fi
if [ "$INTERACTIVE" -eq 0 ]; then return 1; fi
if [ "$GLOBAL_DECISION" = "all" ]; then fix_with_tool "$file"; fixed_files=$((fixed_files+1)); return 0; fi
if [ "$GLOBAL_DECISION" = "none" ]; then return 1; fi

echo
local ans; ans="$(prompt_read $'Lint issues detected in: '"$file"$'\nChoose: [y] fix this, [n] skip, fix [a]ll, fix [N]one, [d]iff, [q]uit\n> ')"
case "$ans" in
y|Y) fix_with_tool "$file"; fixed_files=$((fixed_files+1));;
a|A) GLOBAL_DECISION="all"; fix_with_tool "$file"; fixed_files=$((fixed_files+1));;
n) : ;;
N) GLOBAL_DECISION="none" ;;
d|D)
local tmp; tmp="$(mktemp)"
if format_to_tmp_for_diff "$file" "$tmp"; then
echo "--- diff ---"; diff -u "$file" "$tmp" || true
else
echo "Formatter failed to produce output for diff."
fi
rm -f "$tmp"; maybe_fix_file "$file"
;;
q|Q) echo "Aborting on user request."; exit 1 ;;
*) echo "Skipping." ;;
esac
}

# Build repo list without pipeline subshell
repos_tmp="$(mktemp)"
find "$ROOT" -type d -name .git -prune -print0 > "$repos_tmp"

exec 3<"$repos_tmp"
while IFS= read -r -d '' gitdir <&3; do
repo="${gitdir%/.git}"
echo "==> Repo: $repo"
pushd "$repo" >/dev/null

files_nul_tmp="$(mktemp)"
git ls-files -z --cached --others --exclude-standard '*.yml' '*.yaml' 2>/dev/null > "$files_nul_tmp" || true

# Parse check with yq (+ count files)
file_count=0
while IFS= read -r -d '' f <&4; do
case "$f" in */.git/*|*/node_modules/*) continue;; esac
if [ $DNE_SKIP -eq 1 ] && is_do_not_edit_file "$f"; then dne_skipped=$((dne_skipped+1)); continue; fi
if [ $SOPS_OFF -eq 0 ] && [ $SOPS_SKIP_ALL -eq 1 ] && is_sops_file "$f"; then
sops_skipped_all=$((sops_skipped_all+1)); sops_detected=$((sops_detected+1)); continue
fi
file_count=$((file_count+1))
if ! yq -e 'true' "$f" >/dev/null 2>&1; then
echo "PARSE ERROR: $f"
parse_errors=$((parse_errors+1)); fail=1
fi
done 4<"$files_nul_tmp"

# Lint each file and collect output
lint_output_tmp="$(mktemp)"
lint_had_issues=0
while IFS= read -r -d '' f <&4; do
case "$f" in */.git/*|*/node_modules/*) continue;; esac
if [ $DNE_SKIP -eq 1 ] && is_do_not_edit_file "$f"; then continue; fi
if [ -n "$YAMLLINT_CONFIG" ]; then
if ! yamllint -f parsable -c "$YAMLLINT_CONFIG" "$f" >> "$lint_output_tmp" 2>/dev/null; then lint_had_issues=1; fi
continue
fi
if [ $SOPS_OFF -eq 0 ] && is_sops_file "$f"; then
sops_detected=$((sops_detected+1))
if [ $SOPS_SKIP_ALL -eq 1 ] || [ $SOPS_SKIP_LINT -eq 1 ]; then sops_skipped_lint=$((sops_skipped_lint+1)); continue; fi
if ! yamllint -f parsable -d "{extends: default, rules: {line-length: disable}}" "$f" >> "$lint_output_tmp" 2>/dev/null; then lint_had_issues=1; fi
continue
fi
if ! yamllint -f parsable "$f" >> "$lint_output_tmp" 2>/dev/null; then lint_had_issues=1; fi
done 4<"$files_nul_tmp"

if [ "$lint_had_issues" -eq 1 ]; then
lint_error_repos=$((lint_error_repos+1)); fail=1
cat "$lint_output_tmp"
prob_tmp="$(mktemp)"
awk -F: '{print $1}' "$lint_output_tmp" | sort -u > "$prob_tmp"
while IFS= read -r pf; do
[ -f "$pf" ] && maybe_fix_file "$pf" || true
done < "$prob_tmp"
rm -f "$prob_tmp"
fi

# Optional kubeconform
if [ "$HAVE_KUBECONFORM" -eq 1 ]; then
k8s_tmp="$(mktemp)"
git ls-files -z --cached --others --exclude-standard 'k8s/**/*.yml' 'k8s/**/*.yaml' \
'clusters/**/*.yml' 'clusters/**/*.yaml' \
'manifests/**/*.yml' 'manifests/**/*.yaml' 2>/dev/null > "$k8s_tmp" || true
kc_fail=0
while IFS= read -r -d '' kf <&4; do
if [ $DNE_SKIP -eq 1 ] && is_do_not_edit_file "$kf"; then continue; fi
if [ $SOPS_OFF -eq 0 ] && is_sops_file "$kf"; then
sops_detected=$((sops_detected+1)); sops_skipped_k8s=$((sops_skipped_k8s+1)); continue
fi
if ! kubeconform -summary -strict -ignore-missing-schemas "$kf"; then kc_fail=1; fi
done 4<"$k8s_tmp"
if [ "$kc_fail" -eq 1 ]; then k8s_errors=$((k8s_errors+1)); fail=1; fi
rm -f "$k8s_tmp"
fi

total_files=$((total_files+file_count))

popd >/dev/null
echo
rm -f "$files_nul_tmp" "$lint_output_tmp"
done
exec 3<&-
rm -f "$repos_tmp"

echo "Summary:"
echo " YAML files checked : $total_files"
echo " Parse errors : $parse_errors"
echo " Repos w/ lint issues: $lint_error_repos"
if [ "$HAVE_KUBECONFORM" -eq 1 ]; then
echo " K8s schema issues : $k8s_errors"
else
echo " K8s schema : (disabled or kubeconform not installed)"
fi
echo " Files auto-fixed : $fixed_files"
echo " SOPS files detected : $sops_detected"
echo " DO NOT EDIT skipped : $dne_skipped"
if [ $SOPS_SKIP_ALL -eq 1 ]; then
echo " SOPS skipped (all) : $sops_skipped_all"
else
echo " SOPS skipped (k8s) : $sops_skipped_k8s"
if [ -n "$YAMLLINT_CONFIG" ]; then
echo " SOPS lint mode : from custom yamllint config"
elif [ $SOPS_SKIP_LINT -eq 1 ]; then
echo " SOPS skipped (lint) : $sops_skipped_lint"
else
echo " SOPS lint mode : $( [ $SOPS_OFF -eq 0 ] && echo 'relaxed (line-length disabled)' || echo 'normal')"
fi
fi

exit $fail