#!/usr/bin/env bash # ci/lint.sh — Stage 1: yaml/lint. Runs in CI without a cluster (live now). # Lints all YAML in the repo against ci/yamllint.yaml. set -euo pipefail CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck disable=SC1091 source "${CI_DIR}/lib.sh" log "Stage: yaml/lint" require_tool yamllint "Install: pip install 'yamllint==${YAMLLINT_VERSION}'" # Lint tracked + untracked (not yet committed) YAML; skip .git and artifact dirs. cd "$REPO_ROOT" mapfile -t files < <( { git ls-files '*.yaml' '*.yml' 2>/dev/null git ls-files --others --exclude-standard '*.yaml' '*.yml' 2>/dev/null } | sort -u ) # Fallback for a non-git checkout. if [ "${#files[@]}" -eq 0 ]; then mapfile -t files < <(find . -path ./.git -prune -o \( -name '*.yaml' -o -name '*.yml' \) -print) fi if [ "${#files[@]}" -eq 0 ]; then ok "no YAML files to lint" exit 0 fi # yamllint is single-threaded, so the file list is split into LINT_JOBS CONTIGUOUS slices of the # sorted list, one yamllint per slice, run concurrently. Each slice's output goes to its own file # and the files are printed in slice order afterwards: the log is the same text, in the same # file order, as one serial `yamllint "${files[@]}"`, and a finding is never interleaved with # another process's output (yamllint's standard format puts the FILE NAME on its own line above # that file's `line:col` findings, so interleaving would misattribute them). The stage's exit # code is the highest any slice returned (yamllint: 1 = error, 2 = warning under strict), so a # single failing slice fails the stage exactly as the serial call did. LINT_JOBS=1 is serial. lint_jobs="${LINT_JOBS:-$(n="$(nproc 2>/dev/null || echo 1)"; [ "$n" -gt 8 ] && n=8; echo "$n")}" case "$lint_jobs" in ''|*[!0-9]*|0) err "LINT_JOBS must be a positive integer, got '${lint_jobs}'"; exit 2 ;; esac [ "$lint_jobs" -gt "${#files[@]}" ] && lint_jobs="${#files[@]}" lint_tmp="$(mktemp -d)" trap 'rm -rf "$lint_tmp"' EXIT per=$(( (${#files[@]} + lint_jobs - 1) / lint_jobs )) pids=() for (( i = 0; i < lint_jobs; i++ )); do slice=("${files[@]:$(( i * per )):${per}}") [ "${#slice[@]}" -gt 0 ] || break ( rc=0 yamllint -c "${CI_DIR}/yamllint.yaml" -f "${YAMLLINT_FORMAT:-standard}" "${slice[@]}" \ >"${lint_tmp}/${i}.out" 2>&1 || rc=$? echo "$rc" >"${lint_tmp}/${i}.rc" ) & pids+=("$!") done wait "${pids[@]}" || true lint_rc=0 for (( i = 0; i < ${#pids[@]}; i++ )); do cat "${lint_tmp}/${i}.out" # A slice that left no rc file died before yamllint returned: that is a failure, not a pass. slice_rc="$(cat "${lint_tmp}/${i}.rc" 2>/dev/null || true)" case "$slice_rc" in ''|*[!0-9]*) slice_rc=1 ;; esac [ "$slice_rc" -gt "$lint_rc" ] && lint_rc="$slice_rc" done if [ "$lint_rc" -ne 0 ]; then err "yaml/lint failed (yamllint exit ${lint_rc}; ${#files[@]} files in ${#pids[@]} parallel slice(s))" exit "$lint_rc" fi ok "yaml/lint passed (${#files[@]} files, ${#pids[@]} parallel slice(s))" # A workflow's `configurationFile:` must name a file that exists. Renovate's did not after #79 # renamed renovate.json -> renovate.json5, and every scheduled run failed with ENOENT for 11 days # while the gate stayed green. Valid YAML, so yamllint cannot see it. missing=0 while IFS=: read -r wf _ value; do value="${value#*configurationFile:}" value="$(echo "$value" | sed -E 's/#.*//; s/^[[:space:]"'\'']+//; s/[[:space:]"'\'']+$//')" if [ -n "$value" ] && [ ! -e "$value" ]; then err "${wf}: configurationFile '${value}' does not exist in the repo" missing=1 fi done < <(grep -nE '^[[:space:]]*configurationFile:' .gitea/workflows/*.y*ml 2>/dev/null || true) [ "$missing" -eq 0 ] || exit 1 ok "workflow configurationFile references resolve" # A workflow must not use an action that needs a Docker daemon: jobs on this runner get NONE, by # decision (decisions/2026-10-29-image-pipeline.md; act_runner maps docker_host to `-`). Such an # action is valid YAML and passes every other stage, then dies at runtime with # "failed to connect to the docker API at unix:///var/run/docker.sock". renovatebot/github-action # did exactly that on every Renovate run until 2026-10-30 (run 343). Run the tool's image as the # job `container:` instead (see .gitea/workflows/renovate.yaml). Container (`docker://`) actions # and Docker's own daemon-bound actions are rejected for the same reason. Comments are ignored. daemon_uses='uses:[[:space:]]*["'\'']?(docker://|renovatebot/github-action|docker/(build-push|setup-buildx|setup-qemu|login)-action)' bad=0 while IFS= read -r hit; do err "${hit%%:*}: needs a Docker daemon, which jobs here do not get: $(echo "${hit#*:*:}" | sed -E 's/^[[:space:]]+//')" bad=1 done < <(grep -HnE "^[^#]*${daemon_uses}" .gitea/workflows/*.y*ml 2>/dev/null || true) [ "$bad" -eq 0 ] || exit 1 ok "no workflow uses a Docker-daemon-bound action"