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-02 04:02:04 +00:00
co-authored by Claude Fable 5.1
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