From 013c15a9e9d12c244abe5129be0db8592691a276 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Thu, 13 Aug 2026 11:57:24 +0800 Subject: [PATCH] ci(staging): decouple deployment from main --- .gitea/workflows/backend-quality-gate.yml | 28 +++++ .gitea/workflows/deploy-staging.yml | 105 +++++++++--------- .gitea/workflows/migrate-staging-database.yml | 2 - deploy/README.md | 30 ++--- docs/BUG_HISTORY.md | 14 +++ .../tests/staging-backend-workflows.test.ts | 30 +++-- 6 files changed, 127 insertions(+), 82 deletions(-) diff --git a/.gitea/workflows/backend-quality-gate.yml b/.gitea/workflows/backend-quality-gate.yml index 517f4a70..ab1c0ba5 100644 --- a/.gitea/workflows/backend-quality-gate.yml +++ b/.gitea/workflows/backend-quality-gate.yml @@ -361,6 +361,34 @@ jobs: node:22-bookworm-slim \ node -e 'process.env["INPUT_IF-NO-FILES-FOUND"]="error"; process.env["INPUT_RETENTION-DAYS"]="30"; process.env["INPUT_COMPRESSION-LEVEL"]="6"; require("./.gitea/actions/upload-artifact/dist/index.js")' + - name: Dispatch exact-SHA staging deployment + env: + DEPLOY_SHA: ${{ gitea.sha }} + run: | + set -euo pipefail + [[ "$DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]] + gate_run_id="${GITHUB_RUN_ID:-}" + [[ "$gate_run_id" =~ ^[0-9]+$ ]] + current_staging_sha="$(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}$"))')" + [[ "$current_staging_sha" == "$DEPLOY_SHA" ]] || { echo "staging advanced before deployment dispatch; refusing stale release" >&2; exit 1; } + 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")" + trap 'rm -f -- "$response_file"' EXIT + curl --fail --silent --show-error --request POST \ + --header "Authorization: token $GITEA_TOKEN" \ + --header "Content-Type: application/json" \ + --data "$payload" \ + "$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/workflows/deploy-staging.yml/dispatches?return_run_details=true" \ + --output "$response_file" + deploy_run_id="$(jq -er '.workflow_run_id | select(type == "number" and . > 0)' "$response_file")" + echo "Dispatched Deploy staging run $deploy_run_id for $DEPLOY_SHA" + - name: Logout ACR registry if: always() run: docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true diff --git a/.gitea/workflows/deploy-staging.yml b/.gitea/workflows/deploy-staging.yml index c10d0e2c..54be8627 100644 --- a/.gitea/workflows/deploy-staging.yml +++ b/.gitea/workflows/deploy-staging.yml @@ -1,15 +1,16 @@ name: Deploy staging on: - workflow_run: - workflows: ["Staging Backend Quality Gate"] - types: [completed] workflow_dispatch: inputs: deploy_sha: description: Exact tested 40-character staging commit SHA required: true type: string + gate_run_id: + description: Source staging quality-gate run ID; automatic dispatch supplies it + required: false + type: string allow_rollback: description: Explicitly permit a manual rollback to an older tested SHA required: true @@ -27,7 +28,6 @@ concurrency: jobs: deploy: - if: gitea.event_name == 'workflow_dispatch' || (gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push' && gitea.event.workflow_run.head_branch == 'staging') runs-on: manman-linux timeout-minutes: 30 env: @@ -47,22 +47,45 @@ jobs: - name: Validate tested revision and gate run id: revision env: - REQUESTED_SHA: ${{ gitea.event.workflow_run.head_sha || inputs.deploy_sha }} - WORKFLOW_RUN_ID: ${{ gitea.event.workflow_run.id }} + REQUESTED_SHA: ${{ inputs.deploy_sha }} + REQUESTED_GATE_RUN_ID: ${{ inputs.gate_run_id }} REQUESTED_ROLLBACK: ${{ inputs.allow_rollback || 'false' }} - GITEA_EVENT_NAME: ${{ gitea.event_name }} run: | set -euo pipefail [[ "$REQUESTED_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "deploy_sha must be a lowercase full commit SHA" >&2; exit 1; } allow_rollback=false if [[ "$REQUESTED_ROLLBACK" == true ]]; then - [[ "$GITEA_EVENT_NAME" == workflow_dispatch ]] || { echo "rollback authorization is manual-only" >&2; exit 1; } allow_rollback=true fi - gate_run_id="${WORKFLOW_RUN_ID:-}" - if [[ "$GITEA_EVENT_NAME" == workflow_dispatch ]]; then - runs="$(curl --fail --silent --show-error \ + gate_run_id="$REQUESTED_GATE_RUN_ID" + if [[ -n "$gate_run_id" ]]; then + [[ "$gate_run_id" =~ ^[0-9]+$ ]] || { echo "gate_run_id must be numeric" >&2; exit 1; } + gate_succeeded=false + for attempt in $(seq 1 120); do + gate_run="$(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/$gate_run_id")" + jq -e --arg sha "$REQUESTED_SHA" --argjson id "$gate_run_id" ' + .id == $id and + (.path | split("@")[0] | endswith("backend-quality-gate.yml")) and + .head_sha == $sha and .head_branch == "staging" and .event == "push" + ' <<<"$gate_run" >/dev/null || { echo "gate_run_id does not attest the requested staging SHA" >&2; exit 1; } + conclusion="$(jq -r '.conclusion // ""' <<<"$gate_run")" + status="$(jq -r '.status // ""' <<<"$gate_run")" + if [[ "$conclusion" == success ]]; then + gate_succeeded=true + break + fi + if [[ "$status" == completed || -n "$conclusion" ]]; then + echo "source staging quality gate did not succeed: ${conclusion:-$status}" >&2 + exit 1 + fi + sleep 5 + done + [[ "$gate_succeeded" == true ]] || { echo "timed out waiting for source staging quality gate success" >&2; exit 1; } + else + 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=$REQUESTED_SHA&branch=staging&event=push&status=success&limit=100")" selected_run="$(jq -cer --arg sha "$REQUESTED_SHA" ' @@ -88,17 +111,15 @@ jobs: ' } staging_head="$(read_ref_sha staging)" - controller_sha="$(read_ref_sha main)" - [[ "$controller_sha" == "$staging_head" ]] || { echo "reviewed main and staging controller heads differ" >&2; exit 1; } if [[ "$allow_rollback" == false && "$REQUESTED_SHA" != "$staging_head" ]]; then echo "stale staging revision refused; use explicit manual rollback only when intended" >&2 exit 1 fi - if [[ "$allow_rollback" == true && "$REQUESTED_SHA" != "$controller_sha" ]]; then + 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 \ --header "Authorization: token $GITEA_TOKEN" \ - "$GITEA_API_URL/repos/$GITEA_REPOSITORY/compare/$REQUESTED_SHA...$controller_sha")" - jq -e --arg base "$REQUESTED_SHA" --arg head "$controller_sha" ' + "$GITEA_API_URL/repos/$GITEA_REPOSITORY/compare/$REQUESTED_SHA...$staging_head")" + jq -e --arg base "$REQUESTED_SHA" --arg head "$staging_head" ' (.commits // []) as $commits | def parents($sha): [$commits[] | select(.sha == $sha) | (.parents // [])[] | .sha]; def reaches($sha; $seen): @@ -108,29 +129,11 @@ jobs: (.total_commits | type) == "number" and .total_commits == ($commits | length) and ($commits | length) > 0 and ([$commits[].sha] | length == (unique | length)) and reaches($head; []) - ' <<<"$comparison" >/dev/null || { echo "rollback revision is not in reviewed main history" >&2; exit 1; } + ' <<<"$comparison" >/dev/null || { echo "rollback revision is not in current staging history" >&2; exit 1; } fi - - controller_gate_run_id="$gate_run_id" - if [[ "$controller_sha" != "$REQUESTED_SHA" ]]; then - controller_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=$controller_sha&branch=staging&event=push&status=success&limit=100")" - controller_run="$(jq -cer --arg sha "$controller_sha" ' - [.workflow_runs[] | select( - (.path | split("@")[0] | endswith("backend-quality-gate.yml")) and - .head_sha == $sha and .head_branch == "staging" and - .event == "push" and .conclusion == "success" - )] | sort_by(.id) | reverse | first - ' <<<"$controller_runs")" - controller_gate_run_id="$(jq -er '.id' <<<"$controller_run")" - fi - [[ "$controller_gate_run_id" =~ ^[0-9]+$ ]] { echo "sha=$REQUESTED_SHA" echo "gate_run_id=$gate_run_id" - echo "controller_sha=$controller_sha" - echo "controller_gate_run_id=$controller_gate_run_id" echo "allow_rollback=$allow_rollback" } >>"$GITHUB_OUTPUT" @@ -174,12 +177,10 @@ jobs: node --version npm --version - - name: Download target and controller gate artifacts + - name: Download exact staging gate artifact env: - TARGET_GATE_RUN_ID: ${{ steps.revision.outputs.gate_run_id }} + GATE_RUN_ID: ${{ steps.revision.outputs.gate_run_id }} DEPLOY_SHA: ${{ steps.revision.outputs.sha }} - CONTROLLER_GATE_RUN_ID: ${{ steps.revision.outputs.controller_gate_run_id }} - CONTROLLER_SHA: ${{ steps.revision.outputs.controller_sha }} run: | set -euo pipefail download_bundle() { @@ -230,24 +231,22 @@ jobs: PY [[ -f "$destination/manifest.env" ]] } - rm -rf artifacts/staging-image artifacts/controller - download_bundle "$TARGET_GATE_RUN_ID" "$DEPLOY_SHA" artifacts/staging-image "${RUNNER_TEMP}/staging-target.zip" - download_bundle "$CONTROLLER_GATE_RUN_ID" "$CONTROLLER_SHA" artifacts/controller "${RUNNER_TEMP}/staging-controller.zip" + rm -rf artifacts/staging-image + download_bundle "$GATE_RUN_ID" "$DEPLOY_SHA" artifacts/staging-image "${RUNNER_TEMP}/staging-image.zip" - - name: Validate gate-attested controller and immutable image manifest + - name: Validate gate-attested staging controller and immutable image manifest id: images env: DEPLOY_SHA: ${{ steps.revision.outputs.sha }} - CONTROLLER_SHA: ${{ steps.revision.outputs.controller_sha }} run: | set -euo pipefail - controller_manifest=artifacts/controller/manifest.env - controller_tar=artifacts/controller/controller.tar + controller_manifest=artifacts/staging-image/manifest.env + controller_tar=artifacts/staging-image/controller.tar [[ -f "$controller_tar" ]] [[ "$(wc -l < "$controller_manifest" | tr -d ' ')" == 4 ]] manifest_controller_sha="$(awk -F= '$1 == "git_sha" {print $2}' "$controller_manifest")" expected_controller_digest="$(awk -F= '$1 == "controller_sha256" {print $2}' "$controller_manifest")" - [[ "$manifest_controller_sha" == "$CONTROLLER_SHA" ]] + [[ "$manifest_controller_sha" == "$DEPLOY_SHA" ]] [[ "$expected_controller_digest" =~ ^[0-9a-f]{64}$ ]] printf '%s %s\n' "$expected_controller_digest" "$controller_tar" | sha256sum --check --status python3 - "$controller_tar" <<'PY' @@ -266,12 +265,10 @@ jobs: if path.is_absolute() or ".." in path.parts or not (member.isdir() or member.isfile()): raise SystemExit("unsafe staging controller bundle") PY - install -d -m 700 artifacts/controller/extracted - tar -xf "$controller_tar" -C artifacts/controller/extracted - node artifacts/controller/extracted/frontend/scripts/staging-image-manifest.mjs \ - "$controller_manifest" "$CONTROLLER_SHA" "$IMAGE_REPOSITORY" >/dev/null - node artifacts/controller/extracted/frontend/scripts/staging-image-manifest.mjs \ - artifacts/staging-image/manifest.env "$DEPLOY_SHA" "$IMAGE_REPOSITORY" >>"$GITHUB_OUTPUT" + install -d -m 700 artifacts/staging-image/extracted + tar -xf "$controller_tar" -C artifacts/staging-image/extracted + node artifacts/staging-image/extracted/frontend/scripts/staging-image-manifest.mjs \ + "$controller_manifest" "$DEPLOY_SHA" "$IMAGE_REPOSITORY" >>"$GITHUB_OUTPUT" - name: Deploy exact image digests under pinned SSH identity env: @@ -316,7 +313,7 @@ jobs: incoming="$(ssh "${ssh_options[@]}" "$remote" "mktemp -d /tmp/jyotisha-staging.XXXXXXXXXX")" [[ "$incoming" == /tmp/jyotisha-staging.* ]] ssh "${ssh_options[@]}" "$remote" "install -d -m 700 '$incoming/.docker'" - scp -i "$key_path" -P "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path" artifacts/controller/controller.tar "$remote:$incoming/controller.tar" + scp -i "$key_path" -P "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path" artifacts/staging-image/controller.tar "$remote:$incoming/controller.tar" ssh "${ssh_options[@]}" "$remote" "tar -xf '$incoming/controller.tar' -C '$incoming' && rm -f -- '$incoming/controller.tar'" previous_sha="$(ssh "${ssh_options[@]}" "$remote" "state='$DEPLOY_PATH/.state/deployed-revision'; if [ -f \"\$state\" ]; then cat \"\$state\"; else id=\$(sudo -n docker ps -aq --filter 'label=com.docker.compose.project=jyotisha-staging' --filter 'label=com.docker.compose.service=web' | head -n 1); if [ -n \"\$id\" ]; then sudo -n docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \"\$id\" | sed -n 's/^GITHUB_SHA=//p' | head -n 1; else printf not-deployed; fi; fi")" [[ "$previous_sha" == not-deployed || "$previous_sha" =~ ^[0-9a-f]{40}$ ]] || exit 1 diff --git a/.gitea/workflows/migrate-staging-database.yml b/.gitea/workflows/migrate-staging-database.yml index bd2df30e..799f11be 100644 --- a/.gitea/workflows/migrate-staging-database.yml +++ b/.gitea/workflows/migrate-staging-database.yml @@ -53,9 +53,7 @@ jobs: ' } staging_head="$(read_ref_sha staging)" - main_head="$(read_ref_sha main)" [[ "$staging_head" == "$DEPLOY_SHA" ]] || { echo "migration requires current staging head" >&2; exit 1; } - [[ "$main_head" == "$DEPLOY_SHA" ]] || { echo "staging migration revision must equal reviewed main head" >&2; exit 1; } runs="$(curl --fail --silent --show-error \ --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")" diff --git a/deploy/README.md b/deploy/README.md index 025365d8..3093841e 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -152,9 +152,9 @@ Staging is isolated from production: | Identity | Better Auth + Resend OTP on the same private PostgreSQL cluster | | Actions control plane | Gitea 1.26.2 (`git.copse.top`) | -Gitea is the primary source repository and Actions control plane. Gitea automatically injects the per-job `${{ secrets.GITEA_TOKEN }}` token; its access is limited by each workflow's `permissions` block and it must not be configured as a repository secret. Configure repository Actions secrets `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`, and `STAGING_SSH_PRIVATE_KEY`, plus variables `STAGING_HOST`, `STAGING_PORT`, `STAGING_USER`, `STAGING_PATH`, `STAGING_URL`, and `STAGING_KNOWN_HOSTS`. `STAGING_SSH_PRIVATE_KEY` must be the private-key file encoded as one unwrapped base64 line (for example, `base64 < key | tr -d '\n'`), not a multiline PEM/OpenSSH value; staging workflows decode it only into a mode-`0600` temporary file and validate it with `ssh-keygen`. The `workflow_run` controller is loaded from the default `main` branch while separately requiring the successfully tested upstream branch to be `staging`. The controller checks out only `main` with full history, requires the requested staging SHA to be an ancestor of that reviewed history, and uploads only the allowlisted `deploy/` control files. It never executes deployment validators or remote orchestration scripts from the target/rollback revision. The staging key, database, Resend key, and model-provider keys must not be shared with production. Staging image publishing has no Supabase build variables. GitHub workflows are upstream/mirror fallback only, not the normal staging release path. +Gitea is the primary source repository and Actions control plane. Gitea automatically injects the per-job `${{ secrets.GITEA_TOKEN }}` token; its access is limited by each workflow's `permissions` block and it must not be configured as a repository secret. Configure repository Actions secrets `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`, and `STAGING_SSH_PRIVATE_KEY`, plus variables `STAGING_HOST`, `STAGING_PORT`, `STAGING_USER`, `STAGING_PATH`, `STAGING_URL`, and `STAGING_KNOWN_HOSTS`. `STAGING_SSH_PRIVATE_KEY` must be the private-key file encoded as one unwrapped base64 line (for example, `base64 < key | tr -d '\n'`), not a multiline PEM/OpenSSH value; staging workflows decode it only into a mode-`0600` temporary file and validate it with `ssh-keygen`. Staging is an independent test line and is not required to equal or remain inside `main` history. A push to `staging` runs the exact-SHA quality gate; its publish job creates immutable API/web image digests plus an allowlisted controller bundle from that same staging SHA, then explicitly dispatches `Deploy staging` from `refs/heads/staging`. The deploy workflow validates the source gate run, consumes only that gate-attested artifact, rejects stale normal releases, and never checks out or executes an untested branch controller. The staging key, database, Resend key, and model-provider keys must not be shared with production. Staging image publishing has no Supabase build variables. GitHub workflows are upstream/mirror fallback only, not the normal staging release path. -`Staging Backend Quality Gate` runs for relevant `pull_request` paths, pushes to `staging`, and `workflow_dispatch`. It validates the Python/database/frontend contract; only a successful push to `staging` publishes the API/web images and a run-bound manifest containing their `sha256` digests. `.gitea/workflows/deploy-staging.yml` consumes that exact successful run, validates its manifest against the full 40-character commit, and deploys digest references rather than trusting the discoverability tags. +`Staging Backend Quality Gate` runs for relevant `pull_request` paths, pushes to `staging`, and `workflow_dispatch`. It validates the Python/database/frontend contract; only a successful push to `staging` publishes the API/web images and a run-bound artifact containing their `sha256` digests plus the allowlisted controller bundle. The publish job rechecks the current staging head and dispatches `.gitea/workflows/deploy-staging.yml` from `refs/heads/staging` with the exact SHA and source gate run ID. The deploy workflow waits for that gate's success, validates the artifact against the full 40-character commit, and deploys digest references rather than trusting discoverability tags. The staging env file must include these non-secret selectors so Compose cannot fall back to production paths: @@ -168,19 +168,19 @@ SITE_ADDRESS=https://staging.jyotisha.chat Staging is fully self-hosted: set `AUTH_PROVIDER=self-hosted` and `SELF_HOSTED_IDENTITY_ENABLED=true`. Add the four role-specific server-only database URLs, the exact `AUTH_USER_ORIGIN=https://staging.jyotisha.chat` and `ADMIN_USER_ORIGIN=https://admin.staging.jyotisha.chat`, the single `BETTER_AUTH_USER_SECRET`, and staging-only Resend settings listed in `deploy/.env.staging.identity.example`. Both hosts run the same application and Better Auth service, but cookies remain host-only; the admin host `/` redirects to `/admin`, and unauthenticated admin requests continue to `/login` on that host. Better Auth trusts only those two origins, while unknown identity hosts fail closed. Persisted `identity.users.role=admin` is the only self-hosted backend role, while `viewer` and ordinary users are denied. Browser code uses same-origin APIs; it receives neither database credentials nor Supabase keys. Production uses the same architecture only after the reviewed migration and cutover. See `docs/operations/self-hosted-identity.md` for identity validation. -After source sync and before `up`, the workflow validates `.env.staging` mode/selectors, explicitly pins the three staging selectors against ambient shell overrides, and runs `docker compose --env-file .env.staging -f deploy/docker-compose.server.yml config --quiet`. For later manual inspections, run the same checks only after the tracked deployment files exist on the server. Do not use a manual gate run from `main` as the first publishing path: publishing requires a successful push to `staging`, while manual `Deploy staging` requires a successful gate run for the exact SHA. +After source sync and before `up`, the workflow validates `.env.staging` mode/selectors, explicitly pins the three staging selectors against ambient shell overrides, and runs `docker compose --env-file .env.staging -f deploy/docker-compose.server.yml config --quiet`. For later manual inspections, run the same checks only after the tracked deployment files exist on the server. Do not use a manual gate run from `main` as the first publishing path: publishing requires a successful push to `staging`, while manual `Deploy staging` requires a successful push gate for the exact SHA and must use the staging workflow ref. ### First-deploy sequence 1. Complete the server and Gitea bootstrap: create both mode-`0600` env files, preload the reviewed `postgres:17-alpine` image, and configure the listed Actions variables/secrets. No repository-level Supabase variables are required. Deployment and migration workflows use `--pull never` for PostgreSQL, so database image upgrades remain an explicit operator-controlled maintenance action rather than an application-deploy side effect. -2. Open a PR and merge the reviewed change to `main`, then fast-forward/push that same exact SHA to `staging`; do not create a staging-only target or rely on a `main` workflow dispatch to publish images. -3. The `Staging Backend Quality Gate` runs for that push and, when successful, publishes API/web images plus an artifact binding the exact SHA to both immutable image digests. -4. The automatic `Deploy staging` workflow downloads that gate-run artifact, syncs only the trusted default-`main` controller's allowlisted `deploy/` files under the shared staging host lock, and validates both `.env.staging` and `.env.staging.database` before any app change. The target application's code is carried only by the digest-pinned images. -5. If environment validation fails, fix the server-side env files without committing or copying secrets, then manually rerun `Deploy staging` from `main` with the same successful SHA in `deploy_sha`; the workflow rechecks a successful staging gate for that exact SHA. -6. If the read-only checker reports a pending migration, stop app deployment and run `Migrate Staging Database` manually with the same full SHA. Migration success does not dispatch deployment. -7. After migration succeeds, the operator must manually start `Deploy staging` from `main` with that same exact SHA, then confirm `https://staging.jyotisha.chat/api/health` reports it and private API health. +2. Push the reviewed test revision directly to `staging`. It may advance independently of `main`; do not merge or reset `main` merely to satisfy staging release mechanics. +3. The `Staging Backend Quality Gate` runs for that push and, when successful, publishes API/web images plus an artifact binding the exact SHA to both immutable image digests and the allowlisted staging controller bundle. +4. The successful publish job rechecks that `staging` still points at the exact SHA, then dispatches `Deploy staging` from `refs/heads/staging` with the source gate run ID. The deploy workflow waits for that gate's final success, validates the artifact, and performs the normal forward-only release under the shared staging host lock. +5. If environment validation fails, fix the server-side env files without committing or copying secrets, then manually rerun `Deploy staging` using **Use workflow from: staging**, the same successful SHA in `deploy_sha`, an empty `gate_run_id`, and `allow_rollback=false`; the workflow resolves a successful exact-SHA staging push gate before mutation. +6. If the read-only checker reports a pending migration, stop app deployment and run `Migrate Staging Database` manually using **Use workflow from: staging** with the same full SHA. Migration success does not dispatch deployment. +7. After migration succeeds, manually start `Deploy staging` from `staging` with that same exact SHA and `allow_rollback=false`, then confirm `https://staging.jyotisha.chat/api/health` reports it and private API health. -Application rollback uses the same workflow: manually dispatch `Deploy staging` from the `main` controller with a previous known-good full SHA that has a successful `Staging Backend Quality Gate` run, and explicitly set `allow_rollback=true`. Normal and migration-triggered deployments reject stale, divergent, or backward revisions. Rollback still consumes the selected gate run's digest manifest and is supported only during that artifact's 30-day retention window; after expiry, stop and prepare a separately reviewed republish/recovery change rather than substituting a mutable tag or assuming the old run can still be rerun. Database migrations are separate and are not rolled back by an application deployment. Restore a staging database backup before running any destructive migration rehearsal. +Application rollback uses the same workflow: manually dispatch `Deploy staging` using **Use workflow from: staging** with a previous known-good full SHA that has a successful `Staging Backend Quality Gate` push run, leave `gate_run_id` empty, and explicitly set `allow_rollback=true`. The requested SHA must be an ancestor of the current `staging` head. Normal deployments reject stale or divergent revisions. Rollback still consumes the selected gate run's digest and controller artifact and is supported only during that artifact's 30-day retention window; after expiry, stop and prepare a separately reviewed republish/recovery change rather than substituting a mutable tag or assuming the old run can still be rerun. Database migrations are separate and are not rolled back by an application deployment. Restore a staging database backup before running any destructive migration rehearsal. Inspect staging without printing secrets: @@ -240,11 +240,11 @@ PostgreSQL is private: `deploy/docker-compose.postgres.yml` has no `ports` mappi Use this order for every staging revision: -1. Open a PR and merge the reviewed revision to `main`, then fast-forward/push that same exact SHA to `staging`. -2. Wait for `Staging Backend Quality Gate` to pass and publish that exact full SHA's API/web digest manifest. -3. The automatic `Deploy staging` workflow 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: main**, and enter the reported full lowercase 40-character SHA in `deploy_sha`. The controller validates that exact SHA against a successful `staging` gate and reviewed `main` history, starts only PostgreSQL, and runs the digest-pinned migrator without executing scripts from the target revision. -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: main**, and enter the same exact SHA in `deploy_sha` with `allow_rollback=false`. If `staging` advanced, stop rather than substituting a branch name, short SHA, or newer commit. +1. Review the test change, then push its exact commit directly to `staging`; `main` may remain at a different SHA. +2. Wait for `Staging Backend 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. 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/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 739ca99c..c443e628 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -3036,3 +3036,17 @@ - 相关记录:BUG-163 - 复发自:BUG-163 - 修复版本:本次提交(staging 精确 SHA 以发布记录为准) + +## BUG-178 | staging 发布控制器被默认 main 分支耦合,阻止测试分支独立演进 + +- 状态:resolved(本地候选,待 staging gate/deploy 验收) +- 首次发现:2026-08-13 +- 最近更新:2026-08-13 +- 影响面:Gitea staging quality gate、Deploy staging、Migrate Staging Database;production 发布门禁未修改。 +- 用户现象:staging 作为测试分支需要领先或偏离 main 时,旧部署工作流仍要求 main 与 staging 同一 SHA,且 `workflow_run` 从默认 main 加载控制器,导致 staging-only 变更无法按自身已测试工作流发布。 +- 触发条件:`staging` 推送了尚未进入 `main` 的测试提交并完成 quality gate。 +- 根因:staging 发布把 production 的 reviewed-main 收敛约束复用到了测试环境,同时依赖默认分支的 `workflow_run` controller;即使删除 SHA 相等检查,旧 main controller 仍可能继续执行旧门禁。 +- 修复:staging push gate 在发布同一 exact-SHA 的不可变镜像与 allowlisted controller bundle 后,显式从 `refs/heads/staging` dispatch `Deploy staging`,并传入源 gate run ID;deploy 等待并验证该 gate 最终成功,正常发布仍要求当前 staging HEAD,回滚仍要求当前 staging history 中的旧成功 gate SHA。staging migration 只要求当前 staging HEAD 和 exact-SHA gate artifact。main 与 production workflow 均不改动。 +- 验证:工作流契约测试锁定 staging-ref dispatch、源 gate run 证明、无 main 引用、当前 HEAD 防陈旧发布、同一 gate artifact 的 controller/digest 校验和回滚祖先限制;远端 gate/deploy 与运行时 SHA 待本次 staging 发布记录。 +- 防复发:测试环境的部署控制器必须来自被同一 quality gate 证明的 staging SHA;不得重新引入 `workflow_run` 默认分支控制器或 staging/main 相等门禁。production 继续保持独立的 main/staging 收敛要求。 +- 修复版本:本次 staging workflow 提交 diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts index 2c5fd760..02899a95 100644 --- a/frontend/tests/staging-backend-workflows.test.ts +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -584,27 +584,35 @@ test("Gitea staging mutations resolve bundles from actual gate-run artifacts", ( } }); -test("Gitea deployment follows only a successful staging push gate and keeps rollback manual", () => { +test("Gitea staging deployment is dispatched from staging after the exact push gate", () => { + const quality = read(giteaQualityWorkflow); const workflow = read(giteaDeployWorkflow); - assert.match(workflow, /workflow_run:\n\s+workflows: \["Staging Backend Quality Gate"\]/); - assert.match(workflow, /workflow_dispatch:/); - assert.match(workflow, /workflow_run\.conclusion == 'success'/); - assert.match(workflow, /workflow_run\.event == 'push'/); - assert.match(workflow, /workflow_run\.head_branch == 'staging'/); + assert.match(workflow, /^on:\n\s+workflow_dispatch:/m); + assert.doesNotMatch(workflow, /workflow_run:|read_ref_sha main|refs\/heads\/main|reviewed main/); + assert.match(workflow, /gate_run_id:/); + assert.match(workflow, /REQUESTED_GATE_RUN_ID: \$\{\{ inputs\.gate_run_id \}\}/); + assert.match(workflow, /actions\/runs\/\$gate_run_id/); + assert.match(workflow, /timed out waiting for source staging quality gate success/); assert.match(workflow, /concurrency:\n\s+group: staging-mutation\n\s+cancel-in-progress: false\n\s+queue: max/); - assert.match(workflow, /rollback authorization is manual-only/); assert.match(workflow, /stale staging revision refused/); + assert.match(workflow, /rollback revision is not in current staging history/); assert.match(workflow, /ALLOW_ROLLBACK: \$\{\{ steps\.revision\.outputs\.allow_rollback \}\}/); - assert.match(workflow, /reviewed main and staging controller heads differ/); - assert.match(workflow, /rollback revision is not in reviewed main history/); - assert.match(workflow, /controller_gate_run_id/); assert.match(workflow, /def reaches\(\$sha; \$seen\)/); assert.match(workflow, /\.total_commits == \(\$commits \| length\)/); assert.match(workflow, /staging advanced during deployment; refusing stale mutation/); assert.match(workflow, /automatic staging rollback or divergent deploy refused/); assert.match(workflow, /API_IMAGE: \$\{\{ steps\.images\.outputs\.api_image \}\}/); assert.match(workflow, /WEB_IMAGE: \$\{\{ steps\.images\.outputs\.web_image \}\}/); + + assert.match(quality, /Dispatch exact-SHA staging deployment/); + assert.match(quality, /staging advanced before deployment dispatch; refusing stale release/); + assert.match(quality, /actions\/workflows\/deploy-staging\.yml\/dispatches\?return_run_details=true/); + assert.match(quality, /--arg ref "refs\/heads\/staging"/); + assert.match(quality, /gate_run_id="\$\{GITHUB_RUN_ID:-\}"/); + assert.match(quality, /--arg gate_run_id "\$gate_run_id"/); + assert.match(quality, /allow_rollback:"false"/); + assert.match(quality, /\.workflow_run_id \| select\(type == "number" and \. > 0\)/); }); test("Gitea migration remains manual and consumes only the gate-pinned web image", () => { @@ -614,7 +622,7 @@ test("Gitea migration remains manual and consumes only the gate-pinned web image 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.match(workflow, /staging migration revision must equal reviewed main head/); + 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/); assert.match(workflow, /WEB_IMAGE: \$\{\{ steps\.image\.outputs\.web_image \}\}/);