diff --git a/.gitea/workflows/backend-quality-gate.yml b/.gitea/workflows/backend-quality-gate.yml index 963d3409..71ae55c1 100644 --- a/.gitea/workflows/backend-quality-gate.yml +++ b/.gitea/workflows/backend-quality-gate.yml @@ -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")" diff --git a/.gitea/workflows/deploy-staging.yml b/.gitea/workflows/deploy-staging.yml index 75fb1fb1..34e3f913 100644 --- a/.gitea/workflows/deploy-staging.yml +++ b/.gitea/workflows/deploy-staging.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 37bcc14d..08489714 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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> ` 必须退出 0(publish 与 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`)确实包含目标提交;远端验证失败时不得声称已交付。 diff --git a/deploy/gated-paths.txt b/deploy/gated-paths.txt new file mode 100644 index 00000000..e28babcc --- /dev/null +++ b/deploy/gated-paths.txt @@ -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/** diff --git a/deploy/is-docs-only-range.sh b/deploy/is-docs-only-range.sh new file mode 100755 index 00000000..b29b5ac8 --- /dev/null +++ b/deploy/is-docs-only-range.sh @@ -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 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 diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts index 2cca69a0..132e7e83 100644 --- a/frontend/tests/staging-backend-workflows.test.ts +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -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 }); + } +});