ci(staging): decouple deployment from main
Staging Backend Quality Gate / validate (push) Successful in 14m50s
Staging Backend Quality Gate / publish (push) Failing after 7m58s

This commit is contained in:
Jesse_Chen
2026-08-13 11:57:24 +08:00
parent cf7598b337
commit 013c15a9e9
6 changed files with 127 additions and 82 deletions
+28
View File
@@ -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
+51 -54
View File
@@ -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
@@ -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")"