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
This commit is contained in:
Jesse_Chen
2026-09-01 23:16:15 +00:00
parent 75e288b0c6
commit 534f5e617c
6 changed files with 584 additions and 16 deletions
+50 -11
View File
@@ -1,23 +1,49 @@
name: Independent Staging Quality Gate
on:
# Both path lists are generated from deploy/gated-paths.txt (single source of
# truth, enforced by frontend/tests/staging-backend-workflows.test.ts). A push
# touching none of them is docs-only: it neither reruns this gate nor cancels
# a gate already running for a code push.
pull_request:
paths:
- '.gitea/workflows/backend-quality-gate.yml'
- '.gitea/workflows/deploy-staging.yml'
- '.gitea/workflows/migrate-staging-database.yml'
- '.gitea/workflows/migrate-production-database.yml'
- '.gitea/workflows/create-production-recovery.yml'
- 'deploy/**'
- 'frontend/**'
- 'jyotish_vedic/**'
- 'scripts/**'
- 'tests/**'
- '.dockerignore'
- '.gitea/**'
- '.github/workflows/**'
- 'MANIFEST.in'
- 'mcp_server.py'
- 'pyproject.toml'
- 'requirements*.txt'
- 'jyotish_vedic/**'
- 'scripts/**'
- 'tests/**'
- 'SKILL.md'
- 'assets/**'
- 'references/**'
- 'skills/**'
- 'deploy/**'
- 'frontend/**'
- 'contracts/**'
push:
branches: [staging]
paths:
- '.dockerignore'
- '.gitea/**'
- '.github/workflows/**'
- 'MANIFEST.in'
- 'mcp_server.py'
- 'pyproject.toml'
- 'requirements*.txt'
- 'jyotish_vedic/**'
- 'scripts/**'
- 'tests/**'
- 'SKILL.md'
- 'assets/**'
- 'references/**'
- 'skills/**'
- 'deploy/**'
- 'frontend/**'
- 'contracts/**'
workflow_dispatch:
concurrency:
@@ -389,7 +415,20 @@ jobs:
jq -er 'select(type == "array" and length == 1) | .[0] |
select(.ref == "refs/heads/staging") | .object.sha |
select(test("^[0-9a-f]{40}$"))')"
[[ "$current_staging_sha" == "$DEPLOY_SHA" ]] || { echo "staging advanced before deployment dispatch; refusing stale release" >&2; exit 1; }
if [[ "$current_staging_sha" != "$DEPLOY_SHA" ]]; then
# Docs-only pushes (every change outside deploy/gated-paths.txt) no
# longer run this gate, so staging may legitimately sit ahead of the
# tested SHA. Release only when the whole range is docs-only; a
# diverged, older, or code-bearing head is still refused. The Gitea
# compare API is used because this checkout is shallow and the
# newer head is not in local history.
if bash deploy/is-docs-only-range.sh --api "$DEPLOY_SHA" "$current_staging_sha"; then
echo "staging advanced to $current_staging_sha by docs-only commits; releasing tested $DEPLOY_SHA"
else
echo "staging advanced before deployment dispatch; refusing stale release" >&2
exit 1
fi
fi
payload="$(jq -cn --arg ref "refs/heads/staging" --arg deploy_sha "$DEPLOY_SHA" --arg gate_run_id "$gate_run_id" \
'{ref:$ref,inputs:{deploy_sha:$deploy_sha,gate_run_id:$gate_run_id,allow_rollback:"false"}}')"
response_file="$(mktemp "${RUNNER_TEMP:-/tmp}/jyotisha-deploy-dispatch.XXXXXX")"
+47 -3
View File
@@ -111,9 +111,16 @@ jobs:
'
}
staging_head="$(read_ref_sha staging)"
head_check=current
if [[ "$allow_rollback" == false && "$REQUESTED_SHA" != "$staging_head" ]]; then
echo "stale staging revision refused; use explicit manual rollback only when intended" >&2
exit 1
# Docs-only pushes (every change outside deploy/gated-paths.txt) no
# longer run the gate, so staging may legitimately be ahead of the
# tested SHA. That is decided only after the gate-attested controller
# bundle is downloaded, by its own deploy/is-docs-only-range.sh, so
# this job never executes an untested checker; anything that is not
# a pure docs-only advance is still refused there before mutation.
echo "staging head $staging_head differs from requested $REQUESTED_SHA; deferring the docs-only range check to the attested controller"
head_check=deferred
fi
if [[ "$allow_rollback" == true && "$REQUESTED_SHA" != "$staging_head" ]]; then
comparison="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
@@ -135,6 +142,7 @@ jobs:
echo "sha=$REQUESTED_SHA"
echo "gate_run_id=$gate_run_id"
echo "allow_rollback=$allow_rollback"
echo "head_check=$head_check"
} >>"$GITHUB_OUTPUT"
- name: Prepare pinned Node tooling
@@ -270,6 +278,39 @@ jobs:
node artifacts/staging-image/extracted/frontend/scripts/staging-image-manifest.mjs \
"$controller_manifest" "$DEPLOY_SHA" "$IMAGE_REPOSITORY" >>"$GITHUB_OUTPUT"
- name: Refuse stale staging revision unless only docs advanced
env:
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
ALLOW_ROLLBACK: ${{ steps.revision.outputs.allow_rollback }}
HEAD_CHECK: ${{ steps.revision.outputs.head_check }}
run: |
set -euo pipefail
if [[ "$ALLOW_ROLLBACK" == true ]]; then
echo "manual rollback authorised; the staging head check does not apply"
exit 0
fi
staging_head="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
--header "Authorization: token $GITEA_TOKEN" \
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/git/refs/heads/staging" |
jq -er 'select(type == "array" and length == 1) | .[0] |
select(.ref == "refs/heads/staging") | .object.sha |
select(test("^[0-9a-f]{40}$"))')"
if [[ "$staging_head" == "$DEPLOY_SHA" ]]; then
echo "staging head is the tested revision $DEPLOY_SHA (initial check: $HEAD_CHECK)"
exit 0
fi
# Only the gate-attested controller's checker and path list are trusted;
# it proves DEPLOY_SHA is an ancestor of the head and that every path in
# between is outside deploy/gated-paths.txt via the Gitea compare API.
checker=artifacts/staging-image/extracted/deploy/is-docs-only-range.sh
[[ -f "$checker" ]] || { echo "gate-attested controller bundle lacks deploy/is-docs-only-range.sh; cannot accept an advanced staging head" >&2; exit 1; }
if bash "$checker" --api "$DEPLOY_SHA" "$staging_head"; then
echo "staging advanced to $staging_head by docs-only commits; releasing tested $DEPLOY_SHA"
else
echo "stale staging revision refused; use explicit manual rollback only when intended" >&2
exit 1
fi
- name: Deploy exact image digests under pinned SSH identity
env:
SSH_PRIVATE_KEY_BASE64: ${{ secrets.STAGING_SSH_PRIVATE_KEY }}
@@ -301,7 +342,10 @@ jobs:
jq -er 'select(type == "array" and length == 1) | .[0] |
select(.ref == "refs/heads/staging") | .object.sha |
select(test("^[0-9a-f]{40}$"))')"
[[ "$current_head" == "$DEPLOY_SHA" ]] || { echo "staging advanced during deployment; refusing stale mutation" >&2; exit 1; }
[[ "$current_head" == "$DEPLOY_SHA" ]] && return
# Docs-only pushes may land while a release is in flight; the attested checker decides.
bash artifacts/staging-image/extracted/deploy/is-docs-only-range.sh --api "$DEPLOY_SHA" "$current_head" ||
{ echo "staging advanced during deployment; refusing stale mutation" >&2; exit 1; }
}
cleanup() {
if [[ -n "$incoming" ]]; then
+2 -2
View File
@@ -133,8 +133,8 @@ Deployment safety rules:
1. 动手前必须 `git fetch origin --prune`,并以远端 **`origin/staging`** 为基线。不得基于本地 `staging` 或本地 `main`:这两个本地引用经常落后远端上百个提交,基于它们做出的分析和补丁会对不上真实代码。
2. 在独立 worktree 中开发,路径 `.worktrees/<主题>-<日期>`,分支 `codex/<主题>-<日期>`。不得在存在未提交修改的工作树上切换分支、stash、reset、覆盖或顺带提交用户变更。
3. 交付到 staging 用快进推送(`git push origin HEAD:staging`)。这会触发 Gitea `backend-quality-gate`;该工作流的 `push: branches: [staging]` 没有路径过滤,任何改动(包括纯文档)都会跑完整构建与部署,应合并同批改动一次推送
4. 由 quality gate 构建 digest 固定镜像并 dispatch `deploy-staging`,随后在 `https://staging.jyotisha.chat` 完成与风险相称的验收。`GET /api/health``.deployment.gitCommit` 必须等于本次 SHA否则视为未部署。
3. 交付到 staging 用快进推送(`git push origin HEAD:staging`)。这会触发 Gitea `backend-quality-gate`;该工作流的 `push:` 触发带有与 `deploy/gated-paths.txt` 逐行一致的 `paths:` 过滤:改动**全部**落在该清单之外的纯文档推送(`docs/**`、根目录 `TASK-*.md` / `PROGRESS-*.md` / `CHANGELOG*.md` / `progress.md` / `task_plan.md` / `findings.md` / `BLOCKED.md` / `CONTEXT.md``AGENTS.md` 等记录文件)不触发门禁、不发布镜像、不部署;任何触及清单内路径的推送都会跑完整构建与部署。仍鼓励把文档与同批代码合并一次推送——文档单独推送虽不再取消正在运行的代码门禁,但会让 staging head 与已部署 SHA 分离,增加核对成本
4. 由 quality gate 构建 digest 固定镜像并 dispatch `deploy-staging`,随后在 `https://staging.jyotisha.chat` 完成与风险相称的验收。`GET /api/health``.deployment.gitCommit` 必须等于**最近一次含门禁路径改动的 staging 提交**,而不再是 staging head:若其后只有纯文档提交,`deploy/is-docs-only-range.sh <该 SHA> <staging head>` 必须退出 0publish 与 deploy-staging 对分叉、落后或含门禁路径的 head 仍会拒绝发布);否则视为未部署。
5. 提升到 `main` **必须快进,不得 merge**`.gitea/workflows/deploy-production.yml` 强制 `main``staging` 指向同一个 commit SHA;任何 merge commit 都会让生产部署以 `main and staging must identify the same reviewed release` 失败。
6. 生产部署手动执行:先跑 `release-quality-gate`,再 dispatch `deploy-production`。它复用 staging 已验收的镜像 digest,不重新构建。
7. 推送后必须核对远端 SHA,确认 `origin/staging`(以及提升后的 `origin/main`)确实包含目标提交;远端验证失败时不得声称已交付。
+32
View File
@@ -0,0 +1,32 @@
# Paths whose changes must rerun the staging quality gate and republish images.
# Single source of truth for the `paths:` filters of both triggers in
# .gitea/workflows/backend-quality-gate.yml and for deploy/is-docs-only-range.sh.
# One glob per line (GitHub/Gitea filter syntax: `*` stops at `/`, `**` does not);
# `#` comments and blank lines are ignored. A push whose every changed file falls
# outside this list is docs-only: no gate, no image, no deployment. When in doubt,
# list the path here rather than leave it out.
#
# Workflow and build-context inputs
.dockerignore
.gitea/**
.github/workflows/**
#
# Python package inputs (pyproject.toml / MANIFEST.in / `python -m build`)
MANIFEST.in
mcp_server.py
pyproject.toml
requirements*.txt
jyotish_vedic/**
scripts/**
tests/**
#
# Image inputs (deploy/railway-api.Dockerfile, deploy/railway-web.Dockerfile)
SKILL.md
assets/**
references/**
skills/**
deploy/**
frontend/**
#
# Repository files read by frontend/tests at gate time
contracts/**
+239
View File
@@ -0,0 +1,239 @@
#!/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
@@ -1250,3 +1250,217 @@ test("runner disk reclaim keeps BuildKit cache unless the runner is actually sho
// The final threshold check still fails the job instead of silently proceeding.
assert.match(script, /if \[ "\$AFTER_GIB" -lt "\$MINIMUM_FREE_GIB" \]; then\n\s+echo[^\n]+\n\s+exit 1/);
});
const gatedPathsFile = new URL("../../deploy/gated-paths.txt", import.meta.url);
const docsOnlyRangeScript = new URL("../../deploy/is-docs-only-range.sh", import.meta.url);
function gatedGlobs(): string[] {
return read(gatedPathsFile)
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith("#"));
}
function triggerPaths(workflow: string, trigger: "pull_request" | "push"): string[] {
const block = workflow.match(new RegExp(`\\n ${trigger}:\\n(?: branches: \\[staging\\]\\n)? paths:\\n((?: - '[^'\\n]+'\\n)+)`));
assert.ok(block, `${trigger} trigger has no paths list`);
return [...block[1].matchAll(/ - '([^'\n]+)'\n/g)].map((match) => match[1]);
}
// Same semantics as GitHub/Gitea path filters and deploy/is-docs-only-range.sh:
// `*` and `?` stop at `/`, `**` crosses directories.
function globMatches(glob: string, path: string): boolean {
let source = "";
for (let i = 0; i < glob.length; i += 1) {
if (glob.startsWith("**/", i) && (i === 0 || glob[i - 1] === "/")) {
source += "(?:.*/)?";
i += 2;
} else if (glob.startsWith("**", i)) {
source += ".*";
i += 1;
} else if (glob[i] === "*") {
source += "[^/]*";
} else if (glob[i] === "?") {
source += "[^/]";
} else {
source += glob[i].replace(/[.+^${}()|[\]\\]/g, "\\$&");
}
}
return new RegExp(`^${source}$`).test(path);
}
function gated(path: string, globs: string[]): boolean {
return globs.some((glob) => globMatches(glob, path) || globMatches(glob, `${path}/_`));
}
test("gated paths are one list shared by both quality-gate triggers", () => {
const workflow = read(giteaQualityWorkflow);
const globs = gatedGlobs();
assert.ok(globs.length > 0, "deploy/gated-paths.txt must not be empty");
assert.deepEqual(triggerPaths(workflow, "pull_request"), globs);
assert.deepEqual(triggerPaths(workflow, "push"), globs);
assert.match(workflow, /push:\n\s+branches: \[staging\]\n\s+paths:\n/);
assert.deepEqual(new Set(globs).size, globs.length, "gated globs must be unique");
});
test("gated paths cover every image input, package input, and gate-read repository file", () => {
const globs = gatedGlobs();
for (const required of [
"frontend/**",
"jyotish_vedic/**",
"deploy/**",
"scripts/**",
"tests/**",
"references/**",
"skills/**",
"assets/**",
"SKILL.md",
"mcp_server.py",
"pyproject.toml",
"MANIFEST.in",
"requirements*.txt",
".dockerignore",
".gitea/**",
"contracts/**",
]) {
assert.ok(globs.includes(required), `${required} missing from deploy/gated-paths.txt`);
}
// Every COPY source in both Dockerfiles (stage-to-stage copies excluded) must be gated.
const copySources = [read(apiDockerfile), read(railwayWebDockerfile)].flatMap((dockerfile) =>
[...dockerfile.matchAll(/^COPY (?!--from=)(.+)$/gm)].flatMap((match) => match[1].trim().split(/\s+/).slice(0, -1)),
);
assert.ok(copySources.length >= 20, `expected the Dockerfiles to declare COPY sources, saw ${copySources.length}`);
for (const source of copySources) {
assert.ok(gated(source, globs), `Dockerfile COPY source ${source} is not covered by deploy/gated-paths.txt`);
}
// Repository files the gate's own tests read at run time.
for (const source of [
"skills/jyotish-birth-time-rectification/SKILL.md",
"references/rectification_sealed_holdout.v1.json",
"contracts/probe-question-v1.json",
"tests/fixtures/personal_report_document.v2.json",
".gitea/actions/upload-artifact/dist/index.js",
".github/workflows/deploy-production.yml",
"deploy/gated-paths.txt",
"deploy/is-docs-only-range.sh",
]) {
assert.ok(gated(source, globs), `${source} is read by the gate but not covered`);
}
// Pure record files stay docs-only.
for (const docsOnly of [
"docs/BUG_HISTORY.md",
"docs/research/anything.md",
"TASK-example-20260901.md",
"PROGRESS-example-20260901.md",
"CHANGELOG.md",
"progress.md",
"task_plan.md",
"findings.md",
"BLOCKED.md",
"CONTEXT.md",
"AGENTS.md",
"README.md",
]) {
assert.equal(gated(docsOnly, globs), false, `${docsOnly} should be docs-only`);
}
});
test("publish dispatch and staging deploy accept docs-only advances only through the attested checker", () => {
const quality = read(giteaQualityWorkflow);
const deploy = read(giteaDeployWorkflow);
const dispatch = quality.match(/- name: Dispatch exact-SHA staging deployment[\s\S]*?(?=\n\s+- name: Logout ACR registry)/)?.[0] ?? "";
assert.match(dispatch, /if \[\[ "\$current_staging_sha" != "\$DEPLOY_SHA" \]\]; then/);
assert.match(dispatch, /bash deploy\/is-docs-only-range\.sh --api "\$DEPLOY_SHA" "\$current_staging_sha"/);
assert.match(dispatch, /staging advanced before deployment dispatch; refusing stale release/);
assert.match(dispatch, /--arg deploy_sha "\$DEPLOY_SHA"/);
// deploy-staging never checks out a branch: the checker comes from the
// gate-attested controller bundle and decides via the Gitea compare API.
assert.match(deploy, /checker=artifacts\/staging-image\/extracted\/deploy\/is-docs-only-range\.sh\n\s+\[\[ -f "\$checker" \]\] \|\| \{ echo "gate-attested controller bundle lacks deploy\/is-docs-only-range\.sh/);
assert.match(deploy, /if bash "\$checker" --api "\$DEPLOY_SHA" "\$staging_head"; then/);
assert.match(deploy, /\[\[ "\$current_head" == "\$DEPLOY_SHA" \]\] && return\n[^\n]*\n\s+bash artifacts\/staging-image\/extracted\/deploy\/is-docs-only-range\.sh --api "\$DEPLOY_SHA" "\$current_head" \|\|\n\s+\{ echo "staging advanced during deployment; refusing stale mutation"/);
assert.equal((deploy.match(/is-docs-only-range\.sh/g) ?? []).length, 4);
assert.doesNotMatch(deploy, /bash deploy\/is-docs-only-range\.sh/);
assertOrder(deploy, [
"Validate tested revision and gate run",
"deferring the docs-only range check to the attested controller",
"head_check=$head_check",
"Download exact staging gate artifact",
"Validate gate-attested staging controller and immutable image manifest",
"Refuse stale staging revision unless only docs advanced",
'if [[ "$ALLOW_ROLLBACK" == true ]]; then',
"stale staging revision refused; use explicit manual rollback only when intended",
"Deploy exact image digests under pinned SSH identity",
"staging advanced during deployment; refusing stale mutation",
]);
// The manual rollback branch is untouched.
assert.match(deploy, /if \[\[ "\$allow_rollback" == true && "\$REQUESTED_SHA" != "\$staging_head" \]\]; then\n\s+comparison="\$\(curl/);
assert.match(deploy, /rollback revision is not in current staging history/);
assert.doesNotMatch(deploy, /if \[\[ "\$allow_rollback" == false && "\$REQUESTED_SHA" != "\$staging_head" \]\]; then\n\s+echo "stale staging revision refused/);
});
test("is-docs-only-range.sh decides from local history and refuses non-ancestor ranges", () => {
const root = mkdtempSync(join(tmpdir(), "jyotisha-docs-only-range-"));
const script = fileURLToPath(docsOnlyRangeScript);
const gatedPaths = fileURLToPath(gatedPathsFile);
const env = {
...process.env,
GATED_PATHS_FILE: gatedPaths,
GIT_CONFIG_GLOBAL: "/dev/null",
GIT_CONFIG_NOSYSTEM: "1",
GIT_AUTHOR_NAME: "t",
GIT_AUTHOR_EMAIL: "t@example.invalid",
GIT_COMMITTER_NAME: "t",
GIT_COMMITTER_EMAIL: "t@example.invalid",
};
const git = (...args: string[]): string => {
const result = spawnSync("git", args, { cwd: root, env, encoding: "utf8" });
assert.equal(result.status, 0, result.stderr);
return result.stdout.trim();
};
const commit = (relative: string, message: string): string => {
mkdirSync(join(root, relative, ".."), { recursive: true });
writeFileSync(join(root, relative), `${message}\n`);
git("add", "-A");
git("commit", "-q", "-m", message);
return git("rev-parse", "HEAD");
};
const run = (...args: string[]) => spawnSync("bash", [script, ...args], { cwd: root, env, encoding: "utf8" });
try {
git("init", "-q", "-b", "staging");
const base = commit("frontend/src/app.ts", "code base");
const docs = commit("docs/notes.md", "docs one");
const task = commit("TASK-example-20260901.md", "docs two");
const code = commit("jyotish_vedic/engine.py", "code after docs");
git("checkout", "-q", "-b", "side", base);
const diverged = commit("docs/side.md", "diverged docs");
const docsOnly = run(base, task);
assert.equal(docsOnly.status, 0, docsOnly.stderr);
assert.match(docsOnly.stdout, /docs-only: 2 changed path\(s\)/);
const gatedRange = run(base, code);
assert.equal(gatedRange.status, 1, gatedRange.stderr);
assert.match(gatedRange.stderr, /jyotish_vedic\/engine\.py \(matches jyotish_vedic\/\*\*\)/);
const behind = run(task, base);
assert.equal(behind.status, 2, behind.stderr);
assert.match(behind.stderr, /is not an ancestor of/);
const forked = run(docs, diverged);
assert.equal(forked.status, 2, forked.stderr);
const same = run(task, task);
assert.equal(same.status, 0, same.stderr);
const malformed = run("abc", task);
assert.equal(malformed.status, 2);
assert.match(malformed.stderr, /lowercase full commit SHA/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});