#!/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)"