f711e77849
Run Python tooling in repository virtual environments so xiaoxin respects the PEP 668 system package boundary.
190 lines
10 KiB
YAML
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'"
|