Files
Jyotisha/.gitea/workflows/backend-quality-gate.yml
T
linmeng f711e77849
Staging Backend Quality Gate (push the reviewed main SHA to staging to auto-deploy) / validate (push) Successful in 20m16s
Staging Backend Quality Gate (push the reviewed main SHA to staging to auto-deploy) / publish-and-deploy (push) Failing after 5m23s
fix: isolate Gitea Python dependencies
Run Python tooling in repository virtual environments so xiaoxin respects the PEP 668 system package boundary.
2026-07-27 21:23:29 +08:00

190 lines
10 KiB
YAML

name: Staging Backend Quality Gate (push the reviewed main SHA to staging to auto-deploy)
on:
pull_request:
paths:
- '.gitea/workflows/**'
- 'deploy/**'
- 'frontend/**'
- 'jyotish_vedic/**'
- 'scripts/**'
- 'tests/**'
- 'mcp_server.py'
- 'pyproject.toml'
- 'requirements*.txt'
push:
branches: [staging]
workflow_dispatch:
concurrency:
group: staging-quality-${{ gitea.ref }}
cancel-in-progress: true
jobs:
validate:
runs-on: xiaoxin
timeout-minutes: 30
env:
GITEA_SHA: ${{ gitea.sha }}
steps:
- name: Checkout current Gitea revision
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"
git fetch --no-tags origin "${GITEA_SHA}"
git checkout --detach --force "${GITEA_SHA}"
- name: Verify Linux runner toolchain
run: |
set -euo pipefail
python3 --version
node --version
npm --version
docker version
- name: Install dependencies
run: |
set -euo pipefail
python3 -m venv .venv
export PATH="$PWD/.venv/bin:$PATH"
python -m pip install --upgrade pip
python -m pip install -r requirements.txt -r requirements-dev.txt
npm ci --prefix frontend
- name: Validate backend, package, frontend, and database contracts
env:
NEXT_PUBLIC_SUPABASE_URL: https://ci-placeholder.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY: ci-placeholder
run: |
set -euo pipefail
export PATH="$PWD/.venv/bin:$PATH"
.venv/bin/ruff check scripts/run_quality_gate.py tests/test_varga_bphs.py tests/test_ashtakavarga_invariants.py tests/test_cli_smoke.py tests/test_yoga_rules_integrity.py
.venv/bin/python -m py_compile scripts/*.py jyotish_vedic/*.py mcp_server.py
.venv/bin/python scripts/run_quality_gate.py --profile quick --skip-yoga-logic --skip-frontend-runtime
.venv/bin/python scripts/commercial_privacy_artifact_scan.py --json
.venv/bin/python -m build
npm test --prefix frontend
npm run lint --prefix frontend
npm run build --prefix frontend
publish-and-deploy:
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/staging'
needs: validate
runs-on: xiaoxin
timeout-minutes: 45
env:
GITEA_SHA: ${{ gitea.sha }}
GITEA_REF: ${{ gitea.ref }}
GITEA_EVENT_NAME: ${{ gitea.event_name }}
GITEA_RUN_NUMBER: ${{ gitea.run_number }}
GITEA_RUN_ATTEMPT: ${{ gitea.run_attempt }}
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: Checkout current Gitea revision
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"
git fetch --no-tags origin main "${GITEA_SHA}"
git checkout --detach --force "${GITEA_SHA}"
- name: Build, publish, and deploy immutable staging images
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }}
run: |
set -euo pipefail
[[ "${GITEA_EVENT_NAME:-}" == "push" && "${GITEA_REF:-}" == "refs/heads/staging" ]] || {
echo "not an exact staging push" >&2
exit 1
}
[[ "${GITEA_SHA:-}" =~ ^[0-9a-f]{40}$ ]] || { echo "invalid staging commit SHA" >&2; exit 1; }
[[ "${DEPLOY_HOST:-}" =~ ^[A-Za-z0-9.-]+$ ]] || { echo "invalid staging host" >&2; exit 1; }
[[ "${DEPLOY_PORT:-}" =~ ^[1-9][0-9]{0,4}$ ]] || { echo "invalid staging port" >&2; exit 1; }
[[ "${DEPLOY_USER:-}" =~ ^[a-z_][a-z0-9_-]*$ ]] || { echo "invalid staging user" >&2; exit 1; }
[[ "${DEPLOY_PATH:-}" =~ ^/[A-Za-z0-9._/-]+$ ]] || { echo "invalid staging path" >&2; exit 1; }
[[ "${STAGING_URL:-}" =~ ^https://[A-Za-z0-9.-]+(:[1-9][0-9]{0,4})?$ ]] || { echo "invalid staging URL" >&2; exit 1; }
[[ -n "${STAGING_KNOWN_HOSTS:-}" && -n "${REGISTRY_USERNAME:-}" && -n "${REGISTRY_PASSWORD:-}" && -n "${SSH_PRIVATE_KEY:-}" ]] || {
echo "required staging credentials or configuration are missing" >&2
exit 1
}
remote_sha="$(git ls-remote origin refs/heads/staging | awk '{print $1}')"
[[ "$remote_sha" == "$GITEA_SHA" ]] || { echo "staging head changed before publication" >&2; exit 1; }
git fetch origin main
git merge-base --is-ancestor "$GITEA_SHA" origin/main || {
echo "staging revision is not in reviewed main history" >&2
exit 1
}
ssh_root="${RUNNER_TEMP}/jyotisha-staging-ssh"
key_path="${ssh_root}/id_ed25519"
known_hosts_path="${ssh_root}/known_hosts"
archive_path="${RUNNER_TEMP}/deploy-${GITEA_RUN_NUMBER}-${GITEA_RUN_ATTEMPT}.tar"
incoming="${DEPLOY_PATH}/.incoming/${GITEA_RUN_NUMBER}-${GITEA_RUN_ATTEMPT}"
remote_prepared=false
mkdir -p "$ssh_root"
umask 077
printf '%s\n' "$SSH_PRIVATE_KEY" | tr -d '\r' > "$key_path"
printf '%s\n' "$STAGING_KNOWN_HOSTS" | tr -d '\r' > "$known_hosts_path"
chmod 600 "$key_path" "$known_hosts_path"
ssh_options=(-i "$key_path" -p "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path")
scp_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}"
cleanup() {
if [[ "$remote_prepared" == true ]]; then
ssh "${ssh_options[@]}" "$remote" "DOCKER_CONFIG='$incoming/.docker' docker logout '$REGISTRY_HOST' >/dev/null 2>&1 || true; rm -rf -- '$incoming'" >/dev/null 2>&1 || true
fi
docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true
rm -rf -- "$ssh_root" "$archive_path"
}
trap cleanup EXIT
printf '%s' "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOST" --username "$REGISTRY_USERNAME" --password-stdin
api_tag="${IMAGE_REPOSITORY}:api-${GITEA_SHA}"
web_tag="${IMAGE_REPOSITORY}:web-${GITEA_SHA}"
docker build --file deploy/railway-api.Dockerfile --tag "$api_tag" .
docker push "$api_tag"
docker build --file deploy/railway-web.Dockerfile --tag "$web_tag" .
docker push "$web_tag"
api_ref="$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$api_tag" | grep -E "^${IMAGE_REPOSITORY}@sha256:[0-9a-f]{64}$" | head -n 1)"
web_ref="$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$web_tag" | grep -E "^${IMAGE_REPOSITORY}@sha256:[0-9a-f]{64}$" | head -n 1)"
[[ -n "$api_ref" && -n "$web_ref" ]] || { echo "immutable image digest was not published" >&2; exit 1; }
manifest_path="${RUNNER_TEMP}/staging-image-manifest.env"
printf 'git_sha=%s\napi_digest=%s\nweb_digest=%s\n' "$GITEA_SHA" "${api_ref#*@}" "${web_ref#*@}" > "$manifest_path"
manifest_output="$(node frontend/scripts/staging-image-manifest.mjs "$manifest_path" "$GITEA_SHA" "$IMAGE_REPOSITORY")"
api_image="$(printf '%s\n' "$manifest_output" | sed -n 's/^api_image=//p')"
web_image="$(printf '%s\n' "$manifest_output" | sed -n 's/^web_image=//p')"
[[ -n "$api_image" && -n "$web_image" ]] || { echo "image manifest output is incomplete" >&2; exit 1; }
tar -cf "$archive_path" deploy
ssh "${ssh_options[@]}" "$remote" "install -d -m 700 '$incoming/.docker'"
remote_prepared=true
scp "${scp_options[@]}" "$archive_path" "${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=\$(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")"
[[ "$previous_sha" == "not-deployed" || "$previous_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "invalid deployed staging revision state" >&2; exit 1; }
forward_verified=false
if [[ "$previous_sha" != "not-deployed" && "$previous_sha" != "$GITEA_SHA" ]]; then
git cat-file -e "${previous_sha}^{commit}" 2>/dev/null || git fetch origin "$previous_sha"
git merge-base --is-ancestor "$previous_sha" "$GITEA_SHA" || {
echo "automatic rollback or divergent staging deployment refused" >&2
exit 1
}
forward_verified=true
fi
printf '%s' "$REGISTRY_PASSWORD" | ssh "${ssh_options[@]}" "$remote" "DOCKER_CONFIG='$incoming/.docker' docker login '$REGISTRY_HOST' --username '$REGISTRY_USERNAME' --password-stdin"
ssh "${ssh_options[@]}" "$remote" "INCOMING_PATH='$incoming' DEPLOY_PATH='$DEPLOY_PATH' API_IMAGE='$api_image' WEB_IMAGE='$web_image' DEPLOY_SHA='$GITEA_SHA' EXPECTED_PREVIOUS_SHA='$previous_sha' ALLOW_ROLLBACK='false' FORWARD_REVISION_VERIFIED='$forward_verified' DOCKER_CONFIG='$incoming/.docker' STAGING_URL='$STAGING_URL' bash '$incoming/deploy/run-staging-deploy.sh'"