Files

153 lines
4.7 KiB
Bash
Executable File

#!/usr/bin/env bash
# check-process.sh
# Process compliance checks for PR branches.
# Validates: no main commits, no .DS_Store, scripts executable,
# spec artifacts exist, iteration counter incremented, commit tags,
# and file-scope allowlist enforcement.
set -euo pipefail
BASE_REF="${1:-origin/main}"
if ! git rev-parse --verify "$BASE_REF" >/dev/null 2>&1; then
BASE_REF="HEAD~1"
fi
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
# In GitHub Actions merge refs, HEAD is detached. Derive branch from GITHUB_HEAD_REF
# or from the spec directory that matches changed files.
if [[ "$BRANCH" == "HEAD" ]]; then
if [[ -n "${GITHUB_HEAD_REF:-}" ]]; then
BRANCH="$GITHUB_HEAD_REF"
else
# Fallback: find the spec directory from changed files
for f in "${CHANGED_FILES[@]:-}"; do
if [[ "$f" == specs/*/spec.md ]]; then
BRANCH="${f#specs/}"
BRANCH="${BRANCH%/spec.md}"
break
fi
done
fi
fi
if [[ "$BRANCH" == "main" ]]; then
echo "[check-process] Failing: direct changes on 'main' are not allowed."
exit 1
fi
CHANGED_FILES=()
while IFS= read -r line; do
[[ -n "$line" ]] && CHANGED_FILES+=("$line")
done < <(git diff --name-only "$BASE_REF"...HEAD)
if [[ ${#CHANGED_FILES[@]} -eq 0 ]]; then
echo "[check-process] No changed files relative to $BASE_REF."
exit 0
fi
FAILURES=0
# --- Check 1: No .DS_Store ---
if command -v rg >/dev/null 2>&1; then
HAS_DS_STORE="$(printf '%s\n' "${CHANGED_FILES[@]}" | rg -q '(^|/)\.DS_Store$' && echo 1 || echo 0)"
else
HAS_DS_STORE="$(printf '%s\n' "${CHANGED_FILES[@]}" | grep -Eq '(^|/)\.DS_Store$' && echo 1 || echo 0)"
fi
if [[ "$HAS_DS_STORE" == "1" ]]; then
echo "[check-process] FAIL: .DS_Store must not be committed."
FAILURES=$((FAILURES + 1))
fi
# --- Check 2: Scripts executable ---
for file in "${CHANGED_FILES[@]}"; do
if [[ "$file" == scripts/*.sh ]] && [[ -f "$file" ]] && [[ ! -x "$file" ]]; then
echo "[check-process] FAIL: script is not executable: $file"
FAILURES=$((FAILURES + 1))
fi
done
# --- Check 3: Spec artifacts exist ---
SPEC_DIR="specs/${BRANCH}"
if [[ -d "$SPEC_DIR" ]]; then
for artifact in spec.md plan.md tasks.md allowed-files.txt; do
if [[ ! -f "$SPEC_DIR/$artifact" ]]; then
echo "[check-process] FAIL: missing spec artifact: $SPEC_DIR/$artifact"
FAILURES=$((FAILURES + 1))
fi
done
else
echo "[check-process] FAIL: spec directory not found: $SPEC_DIR"
FAILURES=$((FAILURES + 1))
fi
# --- Check 4: ITERATION incremented ---
if [[ -f ITERATION ]]; then
BRANCH_ITER="$(tr -d '[:space:]' < ITERATION)"
BASE_ITER="$(git show "$BASE_REF":ITERATION 2>/dev/null | tr -d '[:space:]' || echo "0")"
if [[ "$BRANCH_ITER" -le "$BASE_ITER" ]] 2>/dev/null; then
echo "[check-process] FAIL: ITERATION ($BRANCH_ITER) must be > base ($BASE_ITER)"
FAILURES=$((FAILURES + 1))
fi
fi
# --- Check 5: Commit messages contain [iter N] ---
# Skip merge commits (merge resolution, GitHub merge refs) — they don't carry iter tags.
COMMITS_WITHOUT_TAG=0
while IFS= read -r msg; do
# Skip merge commits (start with "Merge " or "merge:")
if echo "$msg" | grep -qEi '^(Merge |merge:)'; then
continue
fi
if ! echo "$msg" | grep -qE '\[iter [0-9]+\]'; then
echo "[check-process] FAIL: commit missing [iter N] tag: $msg"
COMMITS_WITHOUT_TAG=$((COMMITS_WITHOUT_TAG + 1))
fi
done < <(git log --format='%s' "$BASE_REF"...HEAD)
if [[ $COMMITS_WITHOUT_TAG -gt 0 ]]; then
FAILURES=$((FAILURES + COMMITS_WITHOUT_TAG))
fi
# --- Check 6: File-scope allowlist ---
ALLOWLIST="$SPEC_DIR/allowed-files.txt"
if [[ -f "$ALLOWLIST" ]]; then
ALLOWED_PATTERNS=()
while IFS= read -r line; do
# Skip comments and blank lines
line="$(echo "$line" | sed 's/#.*//' | xargs)"
[[ -z "$line" ]] && continue
ALLOWED_PATTERNS+=("$line")
done < "$ALLOWLIST"
for file in "${CHANGED_FILES[@]}"; do
MATCHED=false
for pattern in "${ALLOWED_PATTERNS[@]}"; do
# Use bash pattern matching (supports * and **)
# Convert ** to match any path and * to match within directory
local_pattern="${pattern}"
# shellcheck disable=SC2254
if [[ "$file" == $local_pattern ]]; then
MATCHED=true
break
fi
# Also try fnmatch-style: specs/foo/* should match specs/foo/bar.md
if command -v python3 >/dev/null 2>&1; then
if python3 -c "import fnmatch; exit(0 if fnmatch.fnmatch('$file', '$local_pattern') else 1)" 2>/dev/null; then
MATCHED=true
break
fi
fi
done
if [[ "$MATCHED" == "false" ]]; then
echo "[check-process] FAIL: file not in allowlist: $file"
FAILURES=$((FAILURES + 1))
fi
done
fi
# --- Result ---
if [[ $FAILURES -gt 0 ]]; then
echo "[check-process] FAILED ($FAILURES issues)"
exit 1
fi
echo "[check-process] PASS ($BASE_REF...HEAD)"