254 lines
12 KiB
YAML
254 lines
12 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
|
|
packages: read
|
|
|
|
concurrency:
|
|
group: staging-mutation
|
|
cancel-in-progress: false
|
|
queue: max
|
|
|
|
jobs:
|
|
deploy:
|
|
if: github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'staging')
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 30
|
|
environment:
|
|
name: staging
|
|
url: ${{ vars.STAGING_URL }}
|
|
env:
|
|
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: ${{ github.event.workflow_run.head_sha || inputs.deploy_sha }}
|
|
WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }}
|
|
WORKFLOW_RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }}
|
|
REQUESTED_ROLLBACK: ${{ inputs.allow_rollback || 'false' }}
|
|
GH_TOKEN: ${{ github.token }}
|
|
shell: bash
|
|
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
|
|
[ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ] || {
|
|
echo "rollback authorization is manual-only" >&2
|
|
exit 1
|
|
}
|
|
allow_rollback=true
|
|
fi
|
|
|
|
gate_run_id="$WORKFLOW_RUN_ID"
|
|
gate_run_attempt="$WORKFLOW_RUN_ATTEMPT"
|
|
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
|
|
runs="$(curl --fail --silent --show-error \
|
|
--header "Authorization: Bearer $GH_TOKEN" \
|
|
--header "Accept: application/vnd.github+json" \
|
|
--header "X-GitHub-Api-Version: 2022-11-28" \
|
|
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/backend-quality-gate.yml/runs?head_sha=$REQUESTED_SHA&branch=staging&event=push&status=success&per_page=100")"
|
|
selected_run="$(jq -cer --arg sha "$REQUESTED_SHA" '
|
|
[.workflow_runs[] | select(
|
|
.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")"
|
|
gate_run_attempt="$(jq -er '.run_attempt' <<<"$selected_run")"
|
|
fi
|
|
[[ "$gate_run_id" =~ ^[0-9]+$ ]] || {
|
|
echo "no successful exact-SHA staging quality gate run found" >&2
|
|
exit 1
|
|
}
|
|
[[ "$gate_run_attempt" =~ ^[1-9][0-9]*$ ]] || {
|
|
echo "invalid staging quality gate run attempt" >&2
|
|
exit 1
|
|
}
|
|
|
|
staging_head="$(curl --fail --silent --show-error \
|
|
--header "Authorization: Bearer $GH_TOKEN" \
|
|
--header "Accept: application/vnd.github+json" \
|
|
--header "X-GitHub-Api-Version: 2022-11-28" \
|
|
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/heads/staging" |
|
|
jq -er '.object.sha')"
|
|
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 "gate_run_attempt=$gate_run_attempt"
|
|
echo "allow_rollback=$allow_rollback"
|
|
} >>"$GITHUB_OUTPUT"
|
|
|
|
- name: Checkout trusted main controller
|
|
uses: actions/checkout@v4
|
|
with:
|
|
ref: main
|
|
fetch-depth: 0
|
|
persist-credentials: false
|
|
|
|
- name: Download gate-produced image manifest
|
|
uses: actions/download-artifact@v4
|
|
with:
|
|
name: staging-image-manifest-${{ steps.revision.outputs.sha }}-${{ steps.revision.outputs.gate_run_attempt }}
|
|
path: artifacts/staging-image
|
|
github-token: ${{ github.token }}
|
|
run-id: ${{ steps.revision.outputs.gate_run_id }}
|
|
|
|
- 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" >>"$GITHUB_OUTPUT"
|
|
|
|
- name: Verify reviewed revision and staging target
|
|
env:
|
|
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
|
|
run: |
|
|
set -euo pipefail
|
|
git cat-file -e "$DEPLOY_SHA^{commit}"
|
|
git merge-base --is-ancestor "$DEPLOY_SHA" HEAD || {
|
|
echo "staging revision is not in the reviewed main history" >&2
|
|
exit 1
|
|
}
|
|
test "$DEPLOY_HOST" = "118.26.111.127"
|
|
test "$DEPLOY_PORT" = "22"
|
|
test "$DEPLOY_USER" = "deploy"
|
|
test "$DEPLOY_PATH" = "/opt/jyotisha-staging"
|
|
test "$STAGING_URL" = "https://staging.jyotisha.chat"
|
|
test -n "$STAGING_KNOWN_HOSTS"
|
|
|
|
- name: Configure pinned staging SSH
|
|
env:
|
|
SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }}
|
|
run: |
|
|
set -euo pipefail
|
|
test -n "$SSH_PRIVATE_KEY"
|
|
install -m 700 -d ~/.ssh
|
|
printf '%s\n' "$SSH_PRIVATE_KEY" >~/.ssh/jyotisha-staging
|
|
chmod 600 ~/.ssh/jyotisha-staging
|
|
printf '%s\n' "$STAGING_KNOWN_HOSTS" >~/.ssh/known_hosts
|
|
chmod 600 ~/.ssh/known_hosts
|
|
|
|
- name: Verify forward-only deployed revision
|
|
id: previous
|
|
env:
|
|
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
|
|
ALLOW_ROLLBACK: ${{ steps.revision.outputs.allow_rollback }}
|
|
GH_TOKEN: ${{ github.token }}
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes"
|
|
previous_sha="$(ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \
|
|
"state='$DEPLOY_PATH/.state/deployed-revision'; if [ -f \"\$state\" ]; then cat \"\$state\"; else id=\$(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 value=\$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \"\$id\" | sed -n 's/^GITHUB_SHA=//p' | head -n 1); printf '%s' \"\${value:-not-deployed}\"; else printf not-deployed; fi; fi")"
|
|
if [ "$previous_sha" != "not-deployed" ] && [[ ! "$previous_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
|
echo "invalid deployed staging revision state" >&2
|
|
exit 1
|
|
fi
|
|
forward_verified=true
|
|
if [ "$ALLOW_ROLLBACK" = "false" ] &&
|
|
[ "$previous_sha" != "not-deployed" ] &&
|
|
[ "$previous_sha" != "$DEPLOY_SHA" ]; then
|
|
comparison="$(curl --fail --silent --show-error \
|
|
--header "Authorization: Bearer $GH_TOKEN" \
|
|
--header "Accept: application/vnd.github+json" \
|
|
--header "X-GitHub-Api-Version: 2022-11-28" \
|
|
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/compare/$previous_sha...$DEPLOY_SHA")"
|
|
jq -e --arg base "$previous_sha" '
|
|
.status == "ahead" and .merge_base_commit.sha == $base
|
|
' <<<"$comparison" >/dev/null || {
|
|
echo "automatic staging rollback or divergent deploy refused" >&2
|
|
exit 1
|
|
}
|
|
fi
|
|
{
|
|
echo "sha=$previous_sha"
|
|
echo "forward_verified=$forward_verified"
|
|
} >>"$GITHUB_OUTPUT"
|
|
|
|
- name: Stage trusted controller files in an isolated incoming directory
|
|
id: incoming
|
|
run: |
|
|
set -euo pipefail
|
|
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20"
|
|
RSYNC_SSH="ssh $SSH_OPTIONS"
|
|
incoming="$DEPLOY_PATH/.incoming/$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
|
|
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "install -d -m 700 '$incoming'"
|
|
echo "path=$incoming" >>"$GITHUB_OUTPUT"
|
|
rsync -az --delete --prune-empty-dirs \
|
|
--include='/deploy/' --include='/deploy/***' --exclude='*' \
|
|
-e "$RSYNC_SSH" ./ "$DEPLOY_USER@$DEPLOY_HOST:$incoming/"
|
|
|
|
- name: Log in to GHCR with run-local Docker state
|
|
env:
|
|
GHCR_TOKEN: ${{ github.token }}
|
|
INCOMING_PATH: ${{ steps.incoming.outputs.path }}
|
|
run: |
|
|
set -euo pipefail
|
|
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes"
|
|
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "install -d -m 700 '$INCOMING_PATH/.docker'"
|
|
printf '%s' "$GHCR_TOKEN" | ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \
|
|
"DOCKER_CONFIG='$INCOMING_PATH/.docker' docker login ghcr.io --username '$GITHUB_ACTOR' --password-stdin"
|
|
|
|
- name: Deploy and verify exact image digests under host lock
|
|
env:
|
|
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 }}
|
|
EXPECTED_PREVIOUS_SHA: ${{ steps.previous.outputs.sha }}
|
|
FORWARD_REVISION_VERIFIED: ${{ steps.previous.outputs.forward_verified }}
|
|
INCOMING_PATH: ${{ steps.incoming.outputs.path }}
|
|
run: |
|
|
set -euo pipefail
|
|
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20"
|
|
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \
|
|
"INCOMING_PATH='$INCOMING_PATH' DEPLOY_PATH='$DEPLOY_PATH' API_IMAGE='$API_IMAGE' WEB_IMAGE='$WEB_IMAGE' DEPLOY_SHA='$DEPLOY_SHA' EXPECTED_PREVIOUS_SHA='$EXPECTED_PREVIOUS_SHA' ALLOW_ROLLBACK='$ALLOW_ROLLBACK' FORWARD_REVISION_VERIFIED='$FORWARD_REVISION_VERIFIED' DOCKER_CONFIG='$INCOMING_PATH/.docker' STAGING_URL='$STAGING_URL' bash '$INCOMING_PATH/deploy/run-staging-deploy.sh'" |
|
|
tee staging-deploy-result.txt
|
|
sed 's/^/- /' staging-deploy-result.txt >>"$GITHUB_STEP_SUMMARY"
|
|
|
|
- name: Remove run-local staging files
|
|
if: always() && steps.incoming.outputs.path != ''
|
|
continue-on-error: true
|
|
env:
|
|
INCOMING_PATH: ${{ steps.incoming.outputs.path }}
|
|
run: |
|
|
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes"
|
|
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \
|
|
"DOCKER_CONFIG='$INCOMING_PATH/.docker' docker logout ghcr.io >/dev/null 2>&1 || true; rm -rf -- '$INCOMING_PATH'"
|