Files
Jyotisha/.gitea/workflows/deploy-staging.yml
T

248 lines
13 KiB
YAML

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
allow_rollback:
description: Explicitly permit a manual rollback to an older tested SHA
required: true
default: false
type: boolean
permissions:
contents: read
actions: read
concurrency:
group: staging-mutation
cancel-in-progress: false
queue: max
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:
GITEA_SHA: ${{ gitea.sha }}
GITEA_API_URL: ${{ gitea.api_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
REGISTRY_HOST: crpi-d1feco6itet73spp.cn-hongkong.personal.cr.aliyuncs.com
IMAGE_REPOSITORY: crpi-d1feco6itet73spp.cn-hongkong.personal.cr.aliyuncs.com/copse/jyotisha
DEPLOY_HOST: ${{ vars.STAGING_HOST }}
DEPLOY_PORT: ${{ vars.STAGING_PORT }}
DEPLOY_USER: ${{ vars.STAGING_USER }}
DEPLOY_PATH: ${{ vars.STAGING_PATH }}
STAGING_URL: ${{ vars.STAGING_URL }}
STAGING_KNOWN_HOSTS: ${{ vars.STAGING_KNOWN_HOSTS }}
steps:
- 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_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 \
--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" '
[.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
' <<<"$runs")"
gate_run_id="$(jq -er '.id' <<<"$selected_run")"
fi
[[ "$gate_run_id" =~ ^[0-9]+$ ]] || { echo "no successful exact-SHA staging quality gate run found" >&2; exit 1; }
staging_head="$(git ls-remote https://git.copse.top/root/Jyotisha.git refs/heads/staging | awk '{print $1}')"
[[ "$staging_head" =~ ^[0-9a-f]{40}$ ]]
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
{
echo "sha=$REQUESTED_SHA"
echo "gate_run_id=$gate_run_id"
echo "allow_rollback=$allow_rollback"
} >>"$GITHUB_OUTPUT"
- name: Checkout trusted main controller
env:
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
run: |
set -euo pipefail
git init .
git remote remove origin 2>/dev/null || true
git remote add origin https://git.copse.top/root/Jyotisha.git
fetch_succeeded=false
for attempt in 1 2 3; do
if timeout 120 git fetch --no-tags origin main "$DEPLOY_SHA"; then
fetch_succeeded=true
break
fi
if [ "$attempt" -eq 3 ]; then
echo "trusted main fetch failed after $attempt bounded attempts" >&2
exit 1
fi
sleep $((attempt * 10))
done
[[ "$fetch_succeeded" == true ]]
git checkout --detach --force origin/main
git merge-base --is-ancestor "$DEPLOY_SHA" HEAD || { echo "staging revision is not in trusted main history" >&2; exit 1; }
- name: Prepare pinned Node tooling
env:
NODE_TOOL_SOURCE_IMAGE: swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/node:22-bookworm-slim@sha256:ef343465b6a14bbdf2ab52f6e100ec0659a792464fcf72c462370d88b3df909c
NODE_TOOL_IMAGE: node:22-bookworm-slim
run: |
set -euo pipefail
if ! docker image inspect "$NODE_TOOL_SOURCE_IMAGE" >/dev/null 2>&1; then
for attempt in 1 2 3; do
if timeout 180 docker pull "$NODE_TOOL_SOURCE_IMAGE"; then
break
fi
if [ "$attempt" -eq 3 ]; then
echo "Failed to preload $NODE_TOOL_IMAGE after $attempt attempts" >&2
exit 1
fi
sleep $((attempt * 15))
done
fi
docker tag "$NODE_TOOL_SOURCE_IMAGE" "$NODE_TOOL_IMAGE"
docker image inspect "$NODE_TOOL_IMAGE" >/dev/null
tool_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/jyotisha-node-tools.XXXXXX")"
cat > "$tool_dir/node" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
workdir="$(pwd -P)"
exec docker run --rm \
--user "$(id -u):$(id -g)" \
--volume "$workdir:$workdir" \
--workdir "$workdir" \
--env HOME=/tmp \
node:22-bookworm-slim "${0##*/}" "$@"
EOF
chmod 0755 "$tool_dir/node"
ln -s node "$tool_dir/npm"
test -n "${GITHUB_PATH:-}"
printf '%s\n' "$tool_dir" >> "$GITHUB_PATH"
export PATH="$tool_dir:$PATH"
node --version
npm --version
- name: Download gate-produced image manifest
env:
GATE_RUN_ID: ${{ steps.revision.outputs.gate_run_id }}
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
run: |
set -euo pipefail
artifact_prefix="staging-image-manifest-$DEPLOY_SHA-"
artifacts="$(curl --fail --silent --show-error \
--header "Authorization: token $GITEA_TOKEN" \
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs/$GATE_RUN_ID/artifacts?limit=100")"
selected_artifact="$(jq -cer --arg prefix "$artifact_prefix" '
[(.artifacts // [])[]
| select(.expired == false and (.name | startswith($prefix)))
| . + {attempt: ((.name | ltrimstr($prefix)) | tonumber?)}
| select(.attempt != null and .attempt >= 1)
] | sort_by(.attempt, .id) | reverse | first
' <<<"$artifacts")"
artifact_name="$(jq -er '.name' <<<"$selected_artifact")"
artifact_id="$(jq -er '.id' <<<"$selected_artifact")"
artifact_attempt="${artifact_name#"$artifact_prefix"}"
[[ "$artifact_name" == "$artifact_prefix"* ]]
[[ "$artifact_attempt" =~ ^[1-9][0-9]*$ ]]
[[ "$artifact_id" =~ ^[0-9]+$ ]]
install -d -m 700 artifacts/staging-image
curl --fail --silent --show-error --location \
--header "Authorization: token $GITEA_TOKEN" \
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/artifacts/$artifact_id/zip" \
--output "${RUNNER_TEMP}/staging-image-manifest.zip"
unzip -q "${RUNNER_TEMP}/staging-image-manifest.zip" -d artifacts/staging-image
[[ -f artifacts/staging-image/manifest.env ]]
- name: Validate immutable image manifest
id: images
env:
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
run: |
set -euo pipefail
node frontend/scripts/staging-image-manifest.mjs \
artifacts/staging-image/manifest.env "$DEPLOY_SHA" "$IMAGE_REPOSITORY" >>"$GITHUB_OUTPUT"
- name: Deploy exact image digests under pinned SSH identity
env:
SSH_PRIVATE_KEY_BASE64: ${{ secrets.STAGING_SSH_PRIVATE_KEY }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
API_IMAGE: ${{ steps.images.outputs.api_image }}
WEB_IMAGE: ${{ steps.images.outputs.web_image }}
ALLOW_ROLLBACK: ${{ steps.revision.outputs.allow_rollback }}
run: |
set -euo pipefail
ssh_root="${RUNNER_TEMP}/staging-ssh"
key_path="$ssh_root/id_ed25519"
known_hosts_path="$ssh_root/known_hosts"
incoming=""
install -m 700 -d "$ssh_root"
test -n "$SSH_PRIVATE_KEY_BASE64"
printf '%s' "$SSH_PRIVATE_KEY_BASE64" | base64 --decode > "$key_path"
printf '%s\n' "$STAGING_KNOWN_HOSTS" | tr -d '\r' > "$known_hosts_path"
chmod 600 "$key_path" "$known_hosts_path"
ssh-keygen -y -f "$key_path" >/dev/null
ssh_options=(-i "$key_path" -p "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path")
remote="$DEPLOY_USER@$DEPLOY_HOST"
require_current_staging_head() {
[[ "$ALLOW_ROLLBACK" == true ]] && return
current_head="$(git ls-remote https://git.copse.top/root/Jyotisha.git refs/heads/staging | awk '{print $1}')"
[[ "$current_head" == "$DEPLOY_SHA" ]] || { echo "staging advanced during deployment; refusing stale mutation" >&2; exit 1; }
}
cleanup() {
if [[ -n "$incoming" ]]; then
ssh "${ssh_options[@]}" "$remote" "sudo -n docker --config '$incoming/.docker' logout '$REGISTRY_HOST' >/dev/null 2>&1 || true; sudo -n rm -rf -- '$incoming'" >/dev/null 2>&1 || true
fi
rm -rf -- "$ssh_root"
}
trap cleanup EXIT
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'"
tar -cf "${RUNNER_TEMP}/deploy.tar" deploy
scp -i "$key_path" -P "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path" "${RUNNER_TEMP}/deploy.tar" "$remote:$incoming/deploy.tar"
ssh "${ssh_options[@]}" "$remote" "tar -xf '$incoming/deploy.tar' -C '$incoming' && rm -f -- '$incoming/deploy.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
forward_verified=false
if [[ "$previous_sha" != not-deployed && "$previous_sha" != "$DEPLOY_SHA" && "$ALLOW_ROLLBACK" != true ]]; then
git cat-file -e "${previous_sha}^{commit}" 2>/dev/null || git fetch origin "$previous_sha"
git merge-base --is-ancestor "$previous_sha" "$DEPLOY_SHA" || { echo "automatic staging rollback or divergent deploy refused" >&2; exit 1; }
forward_verified=true
fi
require_current_staging_head
printf '%s' "$REGISTRY_PASSWORD" | ssh "${ssh_options[@]}" "$remote" "sudo -n docker --config '$incoming/.docker' login '$REGISTRY_HOST' --username '$REGISTRY_USERNAME' --password-stdin"
ssh "${ssh_options[@]}" "$remote" "sudo -n env INCOMING_PATH='$incoming' DEPLOY_PATH='$DEPLOY_PATH' API_IMAGE='$API_IMAGE' WEB_IMAGE='$WEB_IMAGE' DEPLOY_SHA='$DEPLOY_SHA' EXPECTED_PREVIOUS_SHA='$previous_sha' ALLOW_ROLLBACK='$ALLOW_ROLLBACK' FORWARD_REVISION_VERIFIED='$forward_verified' DOCKER_CONFIG='$incoming/.docker' DOCKER_BIN='docker' STAGING_URL='$STAGING_URL' bash '$incoming/deploy/run-staging-deploy.sh'"
require_current_staging_head