#!/usr/bin/env bash # scripts/ci-cache-hash.sh — deterministic content hash of all test- # affecting files for the CI auto-cache. # # Outputs a 16-character hex prefix of sha256(sorted list of # ` ` lines for every tracked file EXCEPT the # deny-list below). Same tree → same hash. Different code → different # hash. Doc-only changes → same hash (cache hit). # # Used by .github/workflows/test.yml's cache-check job. Cache key is # `ci-pass-`. When the hash matches a prior green run, the test # matrix skips and reports green immediately. # # DESIGN: deny-list NOT allowlist. New files default to "include in # hash" — worst case, cache miss (waste 8min). Allowlist would default # to "exclude", risking false-pass (broken code shipped under green # check) when someone adds a new file type that tests read. # # WHAT'S DENY-LISTED (genuinely test-irrelevant): # - CHANGELOG.md, TODOS.md pure documentation # - README.md, LICENSE marketing / metadata, no test reads them # - docs/**/*.md, *.txt all docs/ subtree is doc-only # # WHAT'S DELIBERATELY NOT DENY-LISTED (affects test outcomes): # - CLAUDE.md 8+ test files reference it (resolver-merge, schema-cli, # public-exports, eval-cross-modal-batch, etc.) # - AGENTS.md same — referenced by resolver tests; counterpart to # CLAUDE.md for OpenClaw hosts # - skills/** SKILL.md files are read by skill conformance tests # - everything else under src/, test/, scripts/, .github/, package.json, # bun.lock, tsconfig*.json, the schema files — obviously test-affecting # # POLICY-DOC RE-ADMIT (the docs/ exception): some docs/*.md files carry # CI / release / test CONTRACTS that the test suite reads (e.g. the # build-llms content-contract test, the doc-history guard). The broad # `^docs/.*\.md$` deny above would let a policy edit to those skip CI — a # false-pass. The ALLOW_PATTERNS list below re-admits them into the hash # AFTER the deny. ADD a path there whenever you move a policy/contract doc # under docs/ (current entries: docs/TESTING.md, docs/RELEASING.md). # # Locale-stable: LC_ALL=C on the sort step so byte-order is identical # across runners (different default locales would re-order the line list # and change the final hash). # # Usage: # bash scripts/ci-cache-hash.sh # print hash # bash scripts/ci-cache-hash.sh --verbose # print hash + diagnostics to stderr # # Exit codes: # 0 printed a 16-char hex hash # 1 internal error (git failure, etc.) # 2 usage error set -euo pipefail VERBOSE=0 if [ "${1:-}" = "--verbose" ]; then VERBOSE=1 shift fi if [ "$#" -gt 0 ]; then echo "usage: bash scripts/ci-cache-hash.sh [--verbose]" >&2 exit 2 fi cd "$(dirname "$0")/.." # Deny-list as an extended regex matched against full paths emitted by # `git ls-files`. -x makes the match anchored (full-line). Each pattern # is a path predicate, not a glob — `\.` to literal-match dots. # # To add a new deny entry: add another `-e ''` line below. To # REMOVE a deny entry (= include the path back in hash): delete its line. # Either change invalidates the cache for everyone on next run (different # hash output), which is the correct behavior. DENY_PATTERNS=( -e '^CHANGELOG\.md$' -e '^TODOS\.md$' -e '^README\.md$' -e '^LICENSE$' -e '^docs/.*\.md$' -e '^docs/.*\.txt$' ) # Use `git ls-files -s` for one-shot enumeration of tracked files + their # index blob shas. Output shape: ` \t` per line. # Far faster than per-file `git hash-object` (~30ms vs ~9s on 2000 files). # # Trade-off: this reflects the INDEX (committed/staged state), not the # working tree. CI always works against a committed tree so this matches # what tests actually run. Local dev with uncommitted edits sees the # committed-side hash (close enough — the hash is for CI's cache lookup, # not a tree-state diagnostic). LS_FILES=$(git ls-files -s) if [ -z "$LS_FILES" ]; then echo "error: git ls-files -s returned empty (are we in a git repo?)" >&2 exit 1 fi # Apply deny-list. Each line ends in `\t` so the deny patterns # anchor on a tab boundary. Compose the alternation regex from # DENY_PATTERNS — each entry is `^$`; strip the `^` (since `\t` # acts as our anchor in `git ls-files -s` output) and the trailing `$` # stays as-is. DENY_ALT="" i=1 while [ $i -lt ${#DENY_PATTERNS[@]} ]; do p="${DENY_PATTERNS[$i]}" p="${p#^}" if [ -z "$DENY_ALT" ]; then DENY_ALT="$p" else DENY_ALT="$DENY_ALT|$p" fi i=$((i + 2)) done DENY_RE=$(printf '\t(%s)' "$DENY_ALT") # Note: each $p already ends in `$` to anchor end-of-line, so the full # regex is `\t(^CHANGELOG\.md$|^TODOS\.md$|...)`. Wait — we stripped `^` # from each but kept `$`, so the composed regex is `\t(CHANGELOG\.md$| # TODOS\.md$|docs/.*\.md$|...)`. Each alternative anchors its own end. INCLUDED=$(printf '%s\n' "$LS_FILES" | grep -vE "$DENY_RE" || true) # Re-admit test-affecting policy docs that live under docs/ but carry CI / # release / test contracts. The broad `^docs/.*\.md$` deny above removed # them; without this re-admit a policy edit to docs/TESTING.md or # docs/RELEASING.md would produce the SAME hash and skip the test shard # that runs the build-llms + doc-history guards — a false-pass. Patterns # anchor on the `\t` boundary in `git ls-files -s` output, matching # the deny-list convention above. Re-admitted lines that don't exist yet # (pre-relocation) simply match nothing. # Path predicates only (no leading tab here) — the `\t` boundary is added # via printf below so it is a REAL tab byte, not the two-char string `\t`. # GNU grep (CI/Ubuntu) does not interpret `\t` in an ERE as a tab the way # BSD grep (macOS) does, so an inline `\t` matches nothing on CI and the # re-admit silently no-ops. Mirror the DENY_RE construction exactly. ALLOW_PATTERNS=( 'docs/TESTING\.md$' 'docs/RELEASING\.md$' ) ALLOW_ALT="" for p in "${ALLOW_PATTERNS[@]}"; do if [ -z "$ALLOW_ALT" ]; then ALLOW_ALT="$p"; else ALLOW_ALT="$ALLOW_ALT|$p"; fi done ALLOW_RE=$(printf '\t(%s)' "$ALLOW_ALT") READMIT=$(printf '%s\n' "$LS_FILES" | grep -E "$ALLOW_RE" || true) if [ -n "$READMIT" ]; then INCLUDED=$(printf '%s\n%s\n' "$INCLUDED" "$READMIT" | grep -v '^$' | LC_ALL=C sort -u) fi if [ -z "$INCLUDED" ]; then echo "error: every tracked file is deny-listed — refusing to hash empty set" >&2 exit 1 fi # Sort by full line (LC_ALL=C for byte-order stability across locales), # hash the concatenation. Each line carries (mode, sha, path) so any # change to content (sha), mode (executable bit), or path (rename) flips # the final hash. HASH=$(printf '%s\n' "$INCLUDED" \ | LC_ALL=C sort \ | sha256sum \ | cut -c1-16) if [ "$VERBOSE" = "1" ]; then included_count=$(printf '%s\n' "$INCLUDED" | wc -l | tr -d ' ') all_count=$(printf '%s\n' "$LS_FILES" | wc -l | tr -d ' ') denied_count=$((all_count - included_count)) { echo "ci-cache-hash: $included_count/$all_count files in hash ($denied_count deny-listed)" } >&2 fi echo "$HASH"