fix(staging): let migrate accept docs-only-ahead like deploy
Independent Staging Quality Gate / validate (push) Successful in 8m57s
Independent Staging Quality Gate / publish (push) Successful in 1m49s

Docs-only staging HEAD blocked Migrate Staging Database even when an ancestor already had a successful exact-SHA gate, so pending SQL could not be applied.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-03 10:28:59 +08:00
parent 1d77b5b129
commit 13bde020fb
3 changed files with 73 additions and 10 deletions
+49 -7
View File
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
deploy_sha:
description: Full current staging SHA to migrate
description: Exact tested 40-character staging commit SHA
required: true
type: string
@@ -52,9 +52,7 @@ jobs:
select(test("^[0-9a-f]{40}$"))
'
}
staging_head="$(read_ref_sha staging)"
[[ "$staging_head" == "$DEPLOY_SHA" ]] || { echo "migration requires current staging head" >&2; exit 1; }
runs="$(curl --fail --silent --show-error \
runs="$(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/actions/runs?head_sha=$DEPLOY_SHA&branch=staging&event=push&status=success&limit=100")"
selected_run="$(jq -cer --arg sha "$DEPLOY_SHA" '
@@ -65,10 +63,23 @@ jobs:
)] | sort_by(.id) | reverse | first
' <<<"$runs")"
gate_run_id="$(jq -er '.id' <<<"$selected_run")"
[[ "$gate_run_id" =~ ^[0-9]+$ ]]
[[ "$gate_run_id" =~ ^[0-9]+$ ]] || { echo "no successful exact-SHA staging quality gate run found" >&2; exit 1; }
staging_head="$(read_ref_sha staging)"
head_check=current
if [[ "$DEPLOY_SHA" != "$staging_head" ]]; then
# 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 $DEPLOY_SHA; deferring the docs-only range check to the attested controller"
head_check=deferred
fi
{
echo "sha=$DEPLOY_SHA"
echo "gate_run_id=$gate_run_id"
echo "head_check=$head_check"
} >>"$GITHUB_OUTPUT"
- name: Prepare pinned Node tooling
@@ -181,7 +192,7 @@ jobs:
python3 - "$controller_tar" <<'PY'
import pathlib, sys, tarfile
archive = pathlib.Path(sys.argv[1])
required = {"deploy/run-staging-migration.sh", "frontend/scripts/staging-image-manifest.mjs"}
required = {"deploy/run-staging-migration.sh", "deploy/is-docs-only-range.sh", "frontend/scripts/staging-image-manifest.mjs"}
with tarfile.open(archive, "r:") as bundle:
members = bundle.getmembers()
names = [member.name for member in members]
@@ -199,6 +210,34 @@ jobs:
node artifacts/staging-image/extracted/frontend/scripts/staging-image-manifest.mjs \
"$manifest" "$DEPLOY_SHA" "$IMAGE_REPOSITORY" >>"$GITHUB_OUTPUT"
- name: Refuse stale staging revision unless only docs advanced
env:
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
HEAD_CHECK: ${{ steps.revision.outputs.head_check }}
run: |
set -euo pipefail
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; migrate the current gated SHA" >&2
exit 1
fi
- name: Apply digest-pinned migration under host lock
env:
SSH_PRIVATE_KEY_BASE64: ${{ secrets.STAGING_SSH_PRIVATE_KEY }}
@@ -227,7 +266,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 migration; refusing stale mutation" >&2; exit 1; }
[[ "$current_head" == "$DEPLOY_SHA" ]] && return
# Docs-only pushes may land while a migration 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 migration; refusing stale mutation" >&2; exit 1; }
}
cleanup() {
if [[ -n "$incoming" ]]; then
+2 -2
View File
@@ -263,8 +263,8 @@ Use this order for every staging revision:
1. Review the test change, then push its exact commit directly to `staging`; `main` may remain at a different SHA.
2. Wait for `Independent Staging Quality Gate` to pass and publish that exact full SHA's API/web digest and controller artifact. Its publish job dispatches the staging-ref deployment and refuses dispatch if `staging` already advanced.
3. The dispatched `Deploy staging` workflow validates the source gate run and checks the exact SHA in read-only migration-check mode before changing API, web, or Caddy. If it reports pending or drifted migrations, stop; do not retry the application deployment as if it were a migration.
4. Open **Migrate Staging Database -> Run workflow**, select **Use workflow from: staging**, and enter the reported full lowercase 40-character SHA in `deploy_sha`. The workflow requires the current `staging` head and a successful exact-SHA staging gate, starts only PostgreSQL, and runs the digest-pinned migrator from the gate-attested controller bundle.
5. A successful migration rechecks that `staging` still points at the same exact SHA and prints the ordered migration ledger, but does not dispatch deployment. The operator must then open **Deploy staging -> Run workflow**, select **Use workflow from: staging**, enter the same exact SHA in `deploy_sha`, leave `gate_run_id` empty, and set `allow_rollback=false`. If `staging` advanced, stop rather than substituting a branch name, short SHA, or newer commit.
4. Open **Migrate Staging Database -> Run workflow**, select **Use workflow from: staging**, and enter the reported full lowercase 40-character SHA in `deploy_sha`. The workflow requires a successful exact-SHA staging push gate. If `staging` is already ahead, the gate-attested `deploy/is-docs-only-range.sh` must prove the extra commits are docs-only; a gated-path advance still refuses. It starts only PostgreSQL and runs the digest-pinned migrator from the gate-attested controller bundle.
5. A successful migration rechecks that `staging` is still the migrated SHA or only docs-only ahead, then prints the ordered migration ledger, but does not dispatch deployment. The operator must then open **Deploy staging -> Run workflow**, select **Use workflow from: staging**, enter the same exact SHA in `deploy_sha`, leave `gate_run_id` empty, and set `allow_rollback=false`. If `staging` advanced by a gated path, stop rather than substituting a branch name, short SHA, or newer commit.
6. Confirm `https://staging.jyotisha.chat/api/health` and verify that its deployment SHA is the SHA from step 2.
7. After health verification, create the local encrypted backup described below.
@@ -689,7 +689,10 @@ test("Gitea migration remains manual and consumes only the gate-pinned web image
assert.match(workflow, /^on:\n\s+workflow_dispatch:/m);
assert.doesNotMatch(workflow, /workflow_run:|\n\s+push:/);
assert.match(workflow, /concurrency:\n\s+group: staging-mutation\n\s+cancel-in-progress: false\n\s+queue: max/);
assert.match(workflow, /migration requires current staging head/);
assert.doesNotMatch(workflow, /migration requires current staging head/);
assert.match(workflow, /deferring the docs-only range check to the attested controller/);
assert.match(workflow, /head_check=\$head_check/);
assert.match(workflow, /no successful exact-SHA staging quality gate run found/);
assert.doesNotMatch(workflow, /read_ref_sha main|refs\/heads\/main|reviewed main/);
assert.doesNotMatch(workflow, /--deepen=/);
assert.match(workflow, /staging advanced during migration; refusing stale mutation/);
@@ -697,6 +700,17 @@ test("Gitea migration remains manual and consumes only the gate-pinned web image
assert.doesNotMatch(workflow, /API_IMAGE:/);
assert.match(workflow, /run-staging-migration\.sh/);
assert.doesNotMatch(workflow, /run-staging-deploy\.sh/);
assertOrder(workflow, [
"Validate current staging revision and successful gate",
"deferring the docs-only range check to the attested controller",
"head_check=$head_check",
"Download gate-produced migration manifest",
"Validate gate-attested controller and digest-pinned migration image",
"Refuse stale staging revision unless only docs advanced",
"stale staging revision refused; migrate the current gated SHA",
"Apply digest-pinned migration under host lock",
"staging advanced during migration; refusing stale mutation",
]);
});
test("Gitea staging mutations use deploy-owned temporary paths", () => {
@@ -1369,6 +1383,7 @@ test("gated paths cover every image input, package input, and gate-read reposito
test("publish dispatch and staging deploy accept docs-only advances only through the attested checker", () => {
const quality = read(giteaQualityWorkflow);
const deploy = read(giteaDeployWorkflow);
const migrate = read(giteaMigrationWorkflow);
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/);
@@ -1399,6 +1414,12 @@ test("publish dispatch and staging deploy accept docs-only advances only through
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/);
assert.match(migrate, /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(migrate, /if bash "\$checker" --api "\$DEPLOY_SHA" "\$staging_head"; then/);
assert.match(migrate, /\[\[ "\$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 migration; refusing stale mutation"/);
assert.equal((migrate.match(/is-docs-only-range\.sh/g) ?? []).length, 5);
assert.doesNotMatch(migrate, /bash deploy\/is-docs-only-range\.sh/);
});
test("is-docs-only-range.sh decides from local history and refuses non-ancestor ranges", () => {