diff --git a/.gitea/workflows/migrate-staging-database.yml b/.gitea/workflows/migrate-staging-database.yml index 69d62c34..ba844864 100644 --- a/.gitea/workflows/migrate-staging-database.yml +++ b/.gitea/workflows/migrate-staging-database.yml @@ -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 diff --git a/deploy/README.md b/deploy/README.md index e430b0ad..a02dfde4 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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. diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts index d440d0a0..efe036da 100644 --- a/frontend/tests/staging-backend-workflows.test.ts +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -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", () => {