fix(staging): let migrate accept docs-only-ahead like deploy
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:
@@ -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
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user