Files
Jyotisha/deploy/is-docs-only-range.sh
T
Jesse_Chen 534f5e617c ci(gate): skip the staging gate for docs-only pushes without releasing stale code
Twenty-four of the last sixty staging pushes were pure documentation, yet each
one ran (and cancelled) the full gate and image publish. Introduce
deploy/gated-paths.txt as the single source of truth for what must rerun the
gate: every Dockerfile COPY source, the Python package inputs, the workflow and
build-context files, and the repository files frontend/tests read at gate time.
Both triggers of backend-quality-gate.yml now carry that exact list; pushes
that touch none of it neither run the gate nor cancel a running code gate.

Because staging head may then legitimately sit ahead of the last tested SHA,
add deploy/is-docs-only-range.sh: it proves <base> is an ancestor of <head>
and that no changed path matches a gated glob, from local history when it is
available and otherwise from the Gitea compare API (per-commit `files`, parent
walk for ancestry, total_commits cross-checked). The publish dispatch and the
deploy-staging head checks accept an advanced head only when that script
succeeds; diverged, older, or code-bearing heads are still refused. In
deploy-staging the check runs after the gate-attested controller bundle is
extracted so only the tested checker and path list are ever executed; the
manual rollback branch is unchanged.

AGENTS.md §6.3/§6.4 describe the new contract: `.deployment.gitCommit` must
equal the latest staging commit that touched a gated path, not staging head.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VawU7Xfd5jS9wUEXz1XYmS
2026-09-02 04:02:04 +00:00

240 lines
9.1 KiB
Bash
Executable File

#!/usr/bin/env bash
# Decide whether every change between two staging commits is docs-only, i.e.
# outside deploy/gated-paths.txt, so a deployment of <base_sha> may proceed
# even though staging has advanced to <head_sha>.
#
# Usage: deploy/is-docs-only-range.sh [--git|--api] <base_sha> <head_sha>
#
# Exit status:
# 0 base is an ancestor of head and no changed path matches a gated glob
# 1 at least one changed path matches a gated glob (the gate must rerun)
# 2 undecidable: bad arguments, head is not a descendant of base (diverged,
# behind, or unknown), history unavailable, or the Gitea API failed
#
# Without --git/--api the local repository is used when it holds both commits
# and can prove ancestry; otherwise the Gitea API is used. The API path needs
# GITEA_API_URL (default https://git.copse.top/api/v1), GITEA_REPOSITORY
# (default root/Jyotisha) and, for a private repository, GITEA_TOKEN (or
# GITEA_BASIC_AUTH="user:secret" for operator runs outside Actions).
#
# Gitea API shape (verified against Gitea 1.26.2):
# GET /repos/{owner}/{repo}/compare/{base}...{head}
# -> {"total_commits": N, "commits": [ {"sha": "...", "parents": [{"sha": "..."}],
# "files": [{"filename": "path", "status": "added|modified|deleted|..."}],
# "stats": {...}, "commit": {...}, ...}, ... ]}
# `commits` lists every commit reachable from head but not from base (no
# pagination was observed for a 316-commit range; total_commits must still
# equal the returned length or the answer is undecidable). Each commit's
# `files` is its diff against its first parent, so the union over all
# commits is a superset of `git diff --name-only base head`, which errs on
# the side of "not docs-only". Ancestry is proven by walking `parents` from
# head back to base inside that set; a reversed or diverged range yields
# `{"total_commits": 0, "commits": []}` or a walk that never reaches base.
# GET /repos/{owner}/{repo}/git/commits/{sha} returns the same per-commit
# `files`, but would cost one request per commit, so it is not used.
set -euo pipefail
usage() {
echo "usage: $0 [--git|--api] <base_sha> <head_sha>" >&2
exit 2
}
mode=auto
while [ "$#" -gt 0 ]; do
case "$1" in
--git) mode=git ;;
--api) mode=api ;;
--) shift; break ;;
-*) usage ;;
*) break ;;
esac
shift
done
[ "$#" -eq 2 ] || usage
base_sha="$1"
head_sha="$2"
[[ "$base_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "base_sha must be a lowercase full commit SHA" >&2; exit 2; }
[[ "$head_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "head_sha must be a lowercase full commit SHA" >&2; exit 2; }
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
GATED_PATHS_FILE="${GATED_PATHS_FILE:-$script_dir/gated-paths.txt}"
[ -r "$GATED_PATHS_FILE" ] || { echo "gated path list $GATED_PATHS_FILE is not readable" >&2; exit 2; }
if [ "$base_sha" = "$head_sha" ]; then
echo "docs-only: $head_sha is the requested revision itself (no changes)"
exit 0
fi
# Prints the changed paths for base..head on stdout, one per line, or exits
# 3 when the local repository cannot answer (missing objects, shallow history
# that cannot prove ancestry, or no repository at all).
git_changed_paths() {
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 3
git cat-file -e "$base_sha^{commit}" 2>/dev/null || return 3
git cat-file -e "$head_sha^{commit}" 2>/dev/null || return 3
local ancestry=0
git merge-base --is-ancestor "$base_sha" "$head_sha" || ancestry=$?
if [ "$ancestry" -ne 0 ]; then
if [ "$(git rev-parse --is-shallow-repository 2>/dev/null)" = true ]; then
echo "local history is shallow and cannot prove $base_sha is an ancestor of $head_sha" >&2
return 3
fi
echo "not docs-only: $base_sha is not an ancestor of $head_sha (diverged, behind, or unrelated)" >&2
return 2
fi
git diff --name-only --no-renames "$base_sha" "$head_sha"
}
api_changed_paths() {
local api_url="${GITEA_API_URL:-https://git.copse.top/api/v1}"
local repository="${GITEA_REPOSITORY:-root/Jyotisha}"
local -a auth=()
if [ -n "${GITEA_TOKEN:-}" ]; then
auth=(--header "Authorization: token $GITEA_TOKEN")
elif [ -n "${GITEA_BASIC_AUTH:-}" ]; then
# Operator verification outside Actions: "user:password-or-token" via HTTP basic auth.
auth=(--user "$GITEA_BASIC_AUTH")
fi
local response_file
response_file="$(mktemp "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/jyotisha-compare.XXXXXX")"
# shellcheck disable=SC2064
trap "rm -f -- '$response_file'" RETURN
if ! curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
"${auth[@]}" "$api_url/repos/$repository/compare/$base_sha...$head_sha" --output "$response_file"; then
echo "Gitea compare request failed for $base_sha...$head_sha" >&2
return 3
fi
RESPONSE_FILE="$response_file" BASE_SHA="$base_sha" HEAD_SHA="$head_sha" python3 - <<'PY'
import json, os, sys
base = os.environ["BASE_SHA"]
head = os.environ["HEAD_SHA"]
try:
with open(os.environ["RESPONSE_FILE"], encoding="utf-8") as handle:
payload = json.load(handle)
except (OSError, json.JSONDecodeError):
sys.stderr.write("Gitea compare response is not JSON\n")
sys.exit(3)
commits = payload.get("commits") or []
total = payload.get("total_commits")
if not isinstance(total, int) or total != len(commits):
sys.stderr.write(f"Gitea compare returned {len(commits)} of {total!r} commits; range undecidable\n")
sys.exit(3)
by_sha = {c.get("sha"): c for c in commits if isinstance(c, dict)}
if len(by_sha) != len(commits):
sys.stderr.write("Gitea compare returned malformed or duplicate commits\n")
sys.exit(3)
# Walk first-and-other parents from head back to base within the returned set.
seen, stack, reached = set(), [head], False
while stack:
sha = stack.pop()
if sha == base:
reached = True
break
if sha in seen or sha not in by_sha:
continue
seen.add(sha)
stack.extend(p.get("sha") for p in by_sha[sha].get("parents") or [] if isinstance(p, dict))
if not reached:
sys.stderr.write(f"not docs-only: {base} is not an ancestor of {head} according to Gitea compare\n")
sys.exit(2)
paths = set()
for commit in commits:
files = commit.get("files")
if files is None:
sys.stderr.write(f"Gitea compare omitted files for {commit.get('sha')}; range undecidable\n")
sys.exit(3)
for entry in files:
for key in ("filename", "previous_filename"):
value = entry.get(key) if isinstance(entry, dict) else None
if value:
paths.add(value)
for path in sorted(paths):
print(path)
PY
}
changed=""
status=0
case "$mode" in
git)
changed="$(git_changed_paths)" || status=$?
;;
api)
changed="$(api_changed_paths)" || status=$?
;;
auto)
changed="$(git_changed_paths)" || status=$?
if [ "$status" -eq 3 ]; then
echo "local history cannot decide; consulting the Gitea compare API" >&2
status=0
changed="$(api_changed_paths)" || status=$?
fi
;;
esac
if [ "$status" -eq 2 ]; then
exit 2
fi
if [ "$status" -ne 0 ]; then
echo "unable to determine the changed paths for $base_sha..$head_sha" >&2
exit 2
fi
changed_file="$(mktemp "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/jyotisha-changed-paths.XXXXXX")"
# shellcheck disable=SC2064
trap "rm -f -- '$changed_file'" EXIT
printf '%s\n' "$changed" >"$changed_file"
# GitHub/Gitea filter globs: `*` and `?` stop at `/`, `**` crosses directories.
CHANGED_FILE="$changed_file" GATED_PATHS_FILE="$GATED_PATHS_FILE" BASE_SHA="$base_sha" HEAD_SHA="$head_sha" python3 - <<'PY'
import os, re, sys
def glob_to_regex(pattern: str) -> re.Pattern:
out, i = [], 0
while i < len(pattern):
if pattern.startswith("**/", i) and (i == 0 or pattern[i - 1] == "/"):
out.append("(?:.*/)?")
i += 3
elif pattern.startswith("**", i):
out.append(".*")
i += 2
elif pattern[i] == "*":
out.append("[^/]*")
i += 1
elif pattern[i] == "?":
out.append("[^/]")
i += 1
else:
out.append(re.escape(pattern[i]))
i += 1
return re.compile("^" + "".join(out) + "$")
globs = []
with open(os.environ["GATED_PATHS_FILE"], encoding="utf-8") as handle:
for raw in handle:
line = raw.strip()
if line and not line.startswith("#"):
globs.append((line, glob_to_regex(line)))
if not globs:
sys.stderr.write("gated path list is empty; refusing to treat anything as docs-only\n")
sys.exit(2)
with open(os.environ["CHANGED_FILE"], encoding="utf-8") as handle:
changed = [line.strip() for line in handle if line.strip()]
gated = []
for path in changed:
for source, regex in globs:
if regex.match(path):
gated.append((path, source))
break
base, head = os.environ["BASE_SHA"], os.environ["HEAD_SHA"]
if gated:
sys.stderr.write(f"not docs-only: {len(gated)} gated path(s) changed in {base[:12]}..{head[:12]}\n")
for path, source in gated:
sys.stderr.write(f" {path} (matches {source})\n")
sys.exit(1)
print(f"docs-only: {len(changed)} changed path(s) in {base[:12]}..{head[:12]}, none gated")
for path in changed:
print(f" {path}")
PY