#!/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 may proceed # even though staging has advanced to . # # Usage: deploy/is-docs-only-range.sh [--git|--api] # # 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] " >&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