#!/usr/bin/env bash
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
}
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
ROOT_SET=0
for arg in "$@"; do
case "$arg" in
-h|--help) print_help ;;
-V|--version) echo "check-yaml v${VERSION}"; exit 0 ;;
esac
done
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
;;
-*)
;;
*)
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=""
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
}
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
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_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
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