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:
co-authored by
Claude Fable 5.1
parent
75e288b0c6
commit
534f5e617c
@@ -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")"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user