Summarize successful steps, print the failing check and error text, and split the validate job into seven steps. Local npm test stays TAP.
655 lines
30 KiB
YAML
655 lines
30 KiB
YAML
name: Independent Staging Quality Gate
|
||
|
||
on:
|
||
# Both path lists are generated from deploy/gated-paths.txt (single source of
|
||
# truth, enforced by frontend/tests/staging-backend-workflows.test.ts). A push
|
||
# touching none of them is docs-only: it neither reruns this gate nor cancels
|
||
# a gate already running for a code push.
|
||
pull_request:
|
||
paths:
|
||
- '.dockerignore'
|
||
- '.gitea/**'
|
||
- 'MANIFEST.in'
|
||
- 'mcp_server.py'
|
||
- 'pyproject.toml'
|
||
- 'requirements*.txt'
|
||
- 'jyotish_vedic/**'
|
||
- 'scripts/**'
|
||
- 'tests/**'
|
||
- 'SKILL.md'
|
||
- 'assets/**'
|
||
- 'references/**'
|
||
- 'skills/**'
|
||
- 'deploy/**'
|
||
- 'frontend/**'
|
||
- 'vendor/**'
|
||
- 'contracts/**'
|
||
push:
|
||
branches: [staging]
|
||
paths:
|
||
- '.dockerignore'
|
||
- '.gitea/**'
|
||
- 'MANIFEST.in'
|
||
- 'mcp_server.py'
|
||
- 'pyproject.toml'
|
||
- 'requirements*.txt'
|
||
- 'jyotish_vedic/**'
|
||
- 'scripts/**'
|
||
- 'tests/**'
|
||
- 'SKILL.md'
|
||
- 'assets/**'
|
||
- 'references/**'
|
||
- 'skills/**'
|
||
- 'deploy/**'
|
||
- 'frontend/**'
|
||
- 'vendor/**'
|
||
- 'contracts/**'
|
||
workflow_dispatch:
|
||
|
||
concurrency:
|
||
group: staging-quality-${{ gitea.ref }}
|
||
cancel-in-progress: true
|
||
|
||
permissions:
|
||
contents: read
|
||
actions: write
|
||
|
||
jobs:
|
||
validate:
|
||
runs-on: xiaoxin
|
||
timeout-minutes: 45
|
||
env:
|
||
GITEA_SHA: ${{ gitea.sha }}
|
||
GITEA_EVENT_NAME: ${{ gitea.event_name }}
|
||
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
|
||
steps:
|
||
- name: Checkout exact Gitea revision
|
||
env:
|
||
MIRROR_PATH: /root/.cache/jyotisha-mirror.git
|
||
run: |
|
||
set -euo pipefail
|
||
[[ "$GITEA_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||
git init .
|
||
git remote remove origin 2>/dev/null || true
|
||
git remote add origin https://git.copse.top/root/Jyotisha.git
|
||
bounded_git() {
|
||
timeout 300 git -c http.connectTimeout=15 -c http.lowSpeedLimit=1 -c http.lowSpeedTime=60 "$@"
|
||
}
|
||
fetch_succeeded=false
|
||
# act_runner's hostexecutor discards this workspace after every run but
|
||
# keeps the host filesystem, so a bare mirror at MIRROR_PATH amortises
|
||
# the 105 MB tree across runs; the exact SHA is then fetched from local
|
||
# disk in seconds instead of 4-8 minutes per job over the WAN. The
|
||
# mirror is an accelerator, never a dependency: every failure below
|
||
# falls through to the bounded remote fetch that has always been used.
|
||
sync_mirror() {
|
||
if [ -d "$MIRROR_PATH" ] && [ "$(git -C "$MIRROR_PATH" rev-parse --is-bare-repository 2>/dev/null)" = true ]; then
|
||
# We hold the host lock, so any git lock file left by a cancelled job is stale.
|
||
find "$MIRROR_PATH" -name '*.lock' -type f -delete 2>/dev/null || true
|
||
if ! git -C "$MIRROR_PATH" cat-file -e "$GITEA_SHA^{commit}" 2>/dev/null; then
|
||
bounded_git -C "$MIRROR_PATH" fetch --prune origin || return 1
|
||
fi
|
||
else
|
||
rm -rf "$MIRROR_PATH"
|
||
timeout 900 git -c http.connectTimeout=15 -c http.lowSpeedLimit=1 -c http.lowSpeedTime=60 \
|
||
clone --quiet --mirror https://git.copse.top/root/Jyotisha.git "$MIRROR_PATH" || { rm -rf "$MIRROR_PATH"; return 1; }
|
||
fi
|
||
git -C "$MIRROR_PATH" cat-file -e "$GITEA_SHA^{commit}"
|
||
}
|
||
if mkdir -p "$(dirname "$MIRROR_PATH")" 2>/dev/null && exec 9>"$MIRROR_PATH.lock" 2>/dev/null; then
|
||
if flock -w 900 9; then
|
||
if sync_mirror; then
|
||
git remote set-url origin "$MIRROR_PATH"
|
||
if timeout 300 git fetch --no-tags origin "$GITEA_SHA"; then
|
||
fetch_succeeded=true
|
||
else
|
||
echo "mirror fetch of $GITEA_SHA failed; falling back to remote fetch" >&2
|
||
fi
|
||
git remote set-url origin https://git.copse.top/root/Jyotisha.git
|
||
else
|
||
echo "mirror sync at $MIRROR_PATH failed; falling back to remote fetch" >&2
|
||
fi
|
||
flock -u 9
|
||
else
|
||
echo "mirror lock $MIRROR_PATH.lock is busy; falling back to remote fetch" >&2
|
||
fi
|
||
exec 9>&-
|
||
else
|
||
echo "mirror path $MIRROR_PATH is unavailable; falling back to remote fetch" >&2
|
||
fi
|
||
if [ "$fetch_succeeded" != true ]; then
|
||
for attempt in 1 2 3; do
|
||
if bounded_git fetch --depth=1 --no-tags origin "$GITEA_SHA"; then
|
||
fetch_succeeded=true
|
||
break
|
||
fi
|
||
if [ "$attempt" -eq 3 ]; then
|
||
echo "exact staging gate checkout failed after $attempt bounded attempts" >&2
|
||
exit 1
|
||
fi
|
||
sleep $((attempt * 10))
|
||
done
|
||
fi
|
||
[[ "$fetch_succeeded" == true ]]
|
||
git checkout --detach --force "$GITEA_SHA"
|
||
git clean -ffdx
|
||
test "$(git rev-parse HEAD)" = "$GITEA_SHA"
|
||
test -z "$(git status --porcelain --untracked-files=all)"
|
||
|
||
- name: Install and verify Linux runner toolchain
|
||
run: |
|
||
set -euo pipefail
|
||
packages=()
|
||
if ! docker compose version --short 2>/dev/null | grep -Eq '^v?2\.'; then
|
||
packages+=(docker-compose-v2)
|
||
fi
|
||
venv_probe="$(mktemp -d "${RUNNER_TEMP:-/tmp}/jyotisha-venv-probe.XXXXXX")"
|
||
if ! python3 -m venv "$venv_probe/venv" >/dev/null 2>&1; then
|
||
packages+=(python3-venv)
|
||
fi
|
||
rm -rf "$venv_probe"
|
||
if ! python3 -c 'import pathlib, sysconfig; assert pathlib.Path(sysconfig.get_path("include"), "Python.h").is_file()' >/dev/null 2>&1; then
|
||
packages+=(python3-dev)
|
||
fi
|
||
if ! command -v g++ >/dev/null 2>&1; then
|
||
packages+=(g++)
|
||
fi
|
||
if ! command -v rsync >/dev/null 2>&1; then
|
||
packages+=(rsync)
|
||
fi
|
||
if [ "${#packages[@]}" -gt 0 ]; then
|
||
apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 \
|
||
-o Acquire::https::Timeout=30 update
|
||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||
"${packages[@]}"
|
||
fi
|
||
python3 --version
|
||
curl --version
|
||
git --version
|
||
openssl version
|
||
docker version
|
||
docker compose version
|
||
docker compose version --short | grep -Eq '^v?2\.'
|
||
docker compose --help | grep -q -- '--project-name'
|
||
|
||
- name: Reclaim runner disk
|
||
env:
|
||
KEEP_IMAGE_SHA: ${{ gitea.sha }}
|
||
run: bash deploy/reclaim-runner-disk.sh
|
||
|
||
- name: Prepare pinned Node tooling
|
||
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")"
|
||
container_id="$(docker create "$NODE_TOOL_IMAGE")"
|
||
trap 'docker rm -f "$container_id" >/dev/null 2>&1 || true' EXIT
|
||
docker cp "$container_id:/usr/local/bin/node" "$tool_dir/node"
|
||
docker cp "$container_id:/usr/local/lib/node_modules/npm" "$tool_dir/npm-package"
|
||
docker rm "$container_id" >/dev/null
|
||
trap - EXIT
|
||
ln -s "$tool_dir/npm-package/bin/npm-cli.js" "$tool_dir/npm"
|
||
chmod 0755 "$tool_dir/node" "$tool_dir/npm-package/bin/npm-cli.js"
|
||
test -n "${GITHUB_PATH:-}"
|
||
printf '%s\n' "$tool_dir" >> "$GITHUB_PATH"
|
||
export PATH="$tool_dir:$PATH"
|
||
node --version | grep -Eq '^v22\.'
|
||
npm --version
|
||
|
||
- name: Preload PostgreSQL integration image
|
||
env:
|
||
POSTGRES_TEST_SOURCE_IMAGE: public.ecr.aws/docker/library/postgres:17-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
|
||
POSTGRES_TEST_IMAGE: postgres:17-alpine
|
||
run: |
|
||
set -euo pipefail
|
||
if ! docker image inspect "$POSTGRES_TEST_IMAGE" >/dev/null 2>&1; then
|
||
for attempt in 1 2 3; do
|
||
if timeout 180 docker pull "$POSTGRES_TEST_SOURCE_IMAGE"; then
|
||
docker tag "$POSTGRES_TEST_SOURCE_IMAGE" "$POSTGRES_TEST_IMAGE"
|
||
break
|
||
fi
|
||
if [ "$attempt" -eq 3 ]; then
|
||
echo "Failed to preload $POSTGRES_TEST_IMAGE after $attempt attempts" >&2
|
||
exit 1
|
||
fi
|
||
sleep $((attempt * 15))
|
||
done
|
||
fi
|
||
docker image inspect "$POSTGRES_TEST_IMAGE" >/dev/null
|
||
|
||
- name: Install dependencies
|
||
env:
|
||
PIP_INDEX_URL: https://mirrors.aliyun.com/pypi/simple/
|
||
NPM_CONFIG_REGISTRY: https://registry.npmmirror.com
|
||
run: |
|
||
set -euo pipefail
|
||
# Hostexecutor may keep a gitignored .venv/; Python 3.14 mkdir then
|
||
# raises FileExistsError. Clear first instead of reusing leftovers.
|
||
rm -rf -- .venv
|
||
python3 -m venv --clear .venv
|
||
export PATH="$PWD/.venv/bin:$PATH"
|
||
python -m pip install --upgrade pip
|
||
python -m pip install \
|
||
"mcp==1.28.1" \
|
||
"pydantic==2.13.4" \
|
||
"numpy==2.5.1" \
|
||
"pandas==2.3.3" \
|
||
"timezonefinder==8.2.5" \
|
||
-r requirements.txt -r requirements-dev.txt
|
||
workdir="$(pwd -P)"
|
||
npm_diagnostics="$(mktemp -d "${TMPDIR:-/tmp}/jyotisha-npm-XXXXXXXX")"
|
||
cleanup_npm() {
|
||
if [ -s "$npm_diagnostics/container.cid" ]; then
|
||
docker rm -f "$(cat "$npm_diagnostics/container.cid")" >/dev/null 2>&1 || true
|
||
fi
|
||
}
|
||
trap cleanup_npm EXIT
|
||
set +e
|
||
docker run --cidfile "$npm_diagnostics/container.cid" \
|
||
--cpus=1.5 \
|
||
--memory=2g \
|
||
--memory-swap=2g \
|
||
--pids-limit=256 \
|
||
--user "$(id -u):$(id -g)" \
|
||
--volume "$workdir:$workdir" \
|
||
--workdir "$workdir" \
|
||
--env HOME=/tmp \
|
||
--env LC_ALL=C \
|
||
--volume "$npm_diagnostics:/npm-diagnostics" \
|
||
--env "NPM_CONFIG_REGISTRY=$NPM_CONFIG_REGISTRY" \
|
||
"$NODE_TOOL_SOURCE_IMAGE" \
|
||
timeout --verbose --signal=TERM --kill-after=30s 900s \
|
||
npm ci --prefix frontend \
|
||
--no-audit \
|
||
--no-fund \
|
||
--progress=false \
|
||
--maxsockets=4 \
|
||
--fetch-timeout=60000 \
|
||
--fetch-retries=2 \
|
||
--fetch-retry-mintimeout=1000 \
|
||
--fetch-retry-maxtimeout=10000 \
|
||
--foreground-scripts \
|
||
--loglevel=http \
|
||
--logs-dir=/npm-diagnostics/npm 2>&1 | tee "$npm_diagnostics/install.log"
|
||
npm_ci_status=${PIPESTATUS[0]}
|
||
set -e
|
||
if [ "$npm_ci_status" -ne 0 ]; then
|
||
echo "npm diagnostics retained at $npm_diagnostics (runner-local; do not publish unredacted logs)" >&2
|
||
npm_oom="$(docker inspect --format '{{.State.OOMKilled}}' "$(cat "$npm_diagnostics/container.cid" 2>/dev/null)" 2>/dev/null || echo unknown)"
|
||
echo "npm container exit=$npm_ci_status OOMKilled=$npm_oom" >&2
|
||
if [ "$npm_oom" = true ]; then
|
||
exit "$npm_ci_status"
|
||
fi
|
||
fi
|
||
if [ "$npm_ci_status" -eq 124 ] || {
|
||
[ "$npm_ci_status" -eq 137 ] &&
|
||
grep -Eq '^timeout: sending signal (TERM|KILL) to command' "$npm_diagnostics/install.log"
|
||
}; then
|
||
echo "frontend npm ci exceeded bounded 900-second timeout; check npm mirror/network or dependency postinstall hang" >&2
|
||
exit 124
|
||
fi
|
||
if [ "$npm_ci_status" -ne 0 ]; then
|
||
echo "frontend npm ci failed with status $npm_ci_status inside bounded Node container" >&2
|
||
exit "$npm_ci_status"
|
||
fi
|
||
cleanup_npm
|
||
trap - EXIT
|
||
rm -rf -- "$npm_diagnostics"
|
||
|
||
- name: Lint and compile Python
|
||
run: |
|
||
set -euo pipefail
|
||
export PATH="$PWD/.venv/bin:$PATH"
|
||
mkdir -p gate-logs
|
||
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
|
||
python -m py_compile scripts/*.py jyotish_vedic/*.py mcp_server.py
|
||
|
||
- name: Python quality gate (quick)
|
||
run: |
|
||
set -euo pipefail
|
||
export PATH="$PWD/.venv/bin:$PATH"
|
||
python scripts/run_quality_gate.py \
|
||
--profile quick --skip-yoga-logic --skip-frontend-runtime
|
||
|
||
- name: Privacy artifact scan
|
||
run: |
|
||
set -euo pipefail
|
||
export PATH="$PWD/.venv/bin:$PATH"
|
||
python scripts/commercial_privacy_artifact_scan.py --json
|
||
|
||
- name: Build Python package
|
||
run: |
|
||
set -euo pipefail
|
||
export PATH="$PWD/.venv/bin:$PATH"
|
||
build_status=0
|
||
python -m build > gate-logs/python-build.log 2>&1 || build_status=$?
|
||
if [ "$build_status" -ne 0 ]; then
|
||
echo "python -m build failed exit=$build_status log=gate-logs/python-build.log" >&2
|
||
tail -n 200 gate-logs/python-build.log >&2
|
||
exit "$build_status"
|
||
fi
|
||
|
||
- name: Frontend and database tests
|
||
run: |
|
||
set -euo pipefail
|
||
export PATH="$PWD/.venv/bin:$PATH"
|
||
npm test --prefix frontend
|
||
|
||
- name: Frontend lint
|
||
run: |
|
||
set -euo pipefail
|
||
export PATH="$PWD/.venv/bin:$PATH"
|
||
npm run lint --prefix frontend
|
||
|
||
- name: Frontend production build (non-push)
|
||
run: |
|
||
set -euo pipefail
|
||
export PATH="$PWD/.venv/bin:$PATH"
|
||
if [ "$GITEA_EVENT_NAME" != "push" ]; then
|
||
if ! timeout 600 npm run build --prefix frontend -- --webpack; then
|
||
echo "frontend production build exceeded bounded 600-second timeout" >&2
|
||
exit 124
|
||
fi
|
||
else
|
||
echo "staging push production build is verified once by the publish image build"
|
||
fi
|
||
|
||
publish:
|
||
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/staging'
|
||
needs: validate
|
||
runs-on: xiaoxin
|
||
timeout-minutes: 60
|
||
env:
|
||
GITEA_SHA: ${{ gitea.sha }}
|
||
GITEA_RUN_ATTEMPT: ${{ gitea.run_attempt }}
|
||
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
|
||
steps:
|
||
- name: Checkout exact Gitea revision
|
||
env:
|
||
MIRROR_PATH: /root/.cache/jyotisha-mirror.git
|
||
run: |
|
||
set -euo pipefail
|
||
[[ "$GITEA_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||
git init .
|
||
git remote remove origin 2>/dev/null || true
|
||
git remote add origin https://git.copse.top/root/Jyotisha.git
|
||
bounded_git() {
|
||
timeout 300 git -c http.connectTimeout=15 -c http.lowSpeedLimit=1 -c http.lowSpeedTime=60 "$@"
|
||
}
|
||
fetch_succeeded=false
|
||
# act_runner's hostexecutor discards this workspace after every run but
|
||
# keeps the host filesystem, so a bare mirror at MIRROR_PATH amortises
|
||
# the 105 MB tree across runs; the exact SHA is then fetched from local
|
||
# disk in seconds instead of 4-8 minutes per job over the WAN. The
|
||
# mirror is an accelerator, never a dependency: every failure below
|
||
# falls through to the bounded remote fetch that has always been used.
|
||
sync_mirror() {
|
||
if [ -d "$MIRROR_PATH" ] && [ "$(git -C "$MIRROR_PATH" rev-parse --is-bare-repository 2>/dev/null)" = true ]; then
|
||
# We hold the host lock, so any git lock file left by a cancelled job is stale.
|
||
find "$MIRROR_PATH" -name '*.lock' -type f -delete 2>/dev/null || true
|
||
if ! git -C "$MIRROR_PATH" cat-file -e "$GITEA_SHA^{commit}" 2>/dev/null; then
|
||
bounded_git -C "$MIRROR_PATH" fetch --prune origin || return 1
|
||
fi
|
||
else
|
||
rm -rf "$MIRROR_PATH"
|
||
timeout 900 git -c http.connectTimeout=15 -c http.lowSpeedLimit=1 -c http.lowSpeedTime=60 \
|
||
clone --quiet --mirror https://git.copse.top/root/Jyotisha.git "$MIRROR_PATH" || { rm -rf "$MIRROR_PATH"; return 1; }
|
||
fi
|
||
git -C "$MIRROR_PATH" cat-file -e "$GITEA_SHA^{commit}"
|
||
}
|
||
if mkdir -p "$(dirname "$MIRROR_PATH")" 2>/dev/null && exec 9>"$MIRROR_PATH.lock" 2>/dev/null; then
|
||
if flock -w 900 9; then
|
||
if sync_mirror; then
|
||
git remote set-url origin "$MIRROR_PATH"
|
||
if timeout 300 git fetch --no-tags origin "$GITEA_SHA"; then
|
||
fetch_succeeded=true
|
||
else
|
||
echo "mirror fetch of $GITEA_SHA failed; falling back to remote fetch" >&2
|
||
fi
|
||
git remote set-url origin https://git.copse.top/root/Jyotisha.git
|
||
else
|
||
echo "mirror sync at $MIRROR_PATH failed; falling back to remote fetch" >&2
|
||
fi
|
||
flock -u 9
|
||
else
|
||
echo "mirror lock $MIRROR_PATH.lock is busy; falling back to remote fetch" >&2
|
||
fi
|
||
exec 9>&-
|
||
else
|
||
echo "mirror path $MIRROR_PATH is unavailable; falling back to remote fetch" >&2
|
||
fi
|
||
if [ "$fetch_succeeded" != true ]; then
|
||
for attempt in 1 2 3; do
|
||
if bounded_git fetch --depth=1 --no-tags origin "$GITEA_SHA"; then
|
||
fetch_succeeded=true
|
||
break
|
||
fi
|
||
if [ "$attempt" -eq 3 ]; then
|
||
echo "exact staging gate checkout failed after $attempt bounded attempts" >&2
|
||
exit 1
|
||
fi
|
||
sleep $((attempt * 10))
|
||
done
|
||
fi
|
||
[[ "$fetch_succeeded" == true ]]
|
||
git checkout --detach --force "$GITEA_SHA"
|
||
git clean -ffdx
|
||
test "$(git rev-parse HEAD)" = "$GITEA_SHA"
|
||
test -z "$(git status --porcelain --untracked-files=all)"
|
||
|
||
- name: Reclaim runner disk
|
||
env:
|
||
KEEP_IMAGE_SHA: ${{ gitea.sha }}
|
||
run: bash deploy/reclaim-runner-disk.sh
|
||
|
||
- 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: Build and publish exact-SHA ACR images
|
||
env:
|
||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||
run: |
|
||
set -euo pipefail
|
||
printf '%s' "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOST" --username "$REGISTRY_USERNAME" --password-stdin
|
||
docker build -f deploy/railway-api.Dockerfile -t "$IMAGE_REPOSITORY:api-$GITEA_SHA" .
|
||
docker build --build-arg NEXT_DEPLOYMENT_ID="$GITEA_SHA" -f deploy/railway-web.Dockerfile -t "$IMAGE_REPOSITORY:web-$GITEA_SHA" .
|
||
docker push "$IMAGE_REPOSITORY:api-$GITEA_SHA"
|
||
docker push "$IMAGE_REPOSITORY:web-$GITEA_SHA"
|
||
|
||
- name: Record immutable linux-amd64 image manifest
|
||
run: |
|
||
set -euo pipefail
|
||
[[ "$GITEA_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||
[[ "$GITEA_RUN_ATTEMPT" =~ ^[0-9]+$ ]]
|
||
select_digest='import json,sys; d=json.load(sys.stdin); xs=d if isinstance(d,list) else [d]; xs=[x for x in xs if isinstance(x,dict) and isinstance(x.get("Descriptor",x),dict)]; x=next((x for x in xs if x.get("Descriptor",x).get("platform",{}).get("os")=="linux" and x.get("Descriptor",x).get("platform",{}).get("architecture")=="amd64"),None); print(x.get("Descriptor",x).get("digest","") if x else "")'
|
||
api_digest="$(docker manifest inspect "$IMAGE_REPOSITORY:api-$GITEA_SHA" --verbose | python3 -c "$select_digest")"
|
||
web_digest="$(docker manifest inspect "$IMAGE_REPOSITORY:web-$GITEA_SHA" --verbose | python3 -c "$select_digest")"
|
||
[[ "$api_digest" =~ ^sha256:[0-9a-f]{64}$ ]]
|
||
[[ "$web_digest" =~ ^sha256:[0-9a-f]{64}$ ]]
|
||
install -d -m 700 artifacts/staging-images
|
||
umask 077
|
||
git archive --format=tar --output artifacts/staging-images/controller.tar \
|
||
"$GITEA_SHA" deploy frontend/scripts/staging-image-manifest.mjs
|
||
controller_sha256="$(sha256sum artifacts/staging-images/controller.tar | awk '{print $1}')"
|
||
[[ "$controller_sha256" =~ ^[0-9a-f]{64}$ ]]
|
||
printf 'git_sha=%s\napi_digest=%s\nweb_digest=%s\ncontroller_sha256=%s\n' \
|
||
"$GITEA_SHA" "$api_digest" "$web_digest" "$controller_sha256" \
|
||
> artifacts/staging-images/manifest.env
|
||
node frontend/scripts/staging-image-manifest.mjs \
|
||
artifacts/staging-images/manifest.env "$GITEA_SHA" "$IMAGE_REPOSITORY" >/dev/null
|
||
|
||
- name: Upload immutable staging image manifest
|
||
run: |
|
||
set -euo pipefail
|
||
test -n "${ACTIONS_RUNTIME_TOKEN:-}"
|
||
test -n "${ACTIONS_RESULTS_URL:-}"
|
||
test -n "${GITHUB_RUN_ID:-}"
|
||
test -n "${GITHUB_REPOSITORY:-}"
|
||
workdir="$(pwd -P)"
|
||
docker run --rm \
|
||
--user "$(id -u):$(id -g)" \
|
||
--volume "$workdir:$workdir" \
|
||
--workdir "$workdir" \
|
||
--env HOME=/tmp \
|
||
--env "INPUT_NAME=staging-image-manifest-$GITEA_SHA-$GITEA_RUN_ATTEMPT" \
|
||
--env INPUT_PATH=artifacts/staging-images/ \
|
||
--env INPUT_OVERWRITE=false \
|
||
--env ACTIONS_RUNTIME_TOKEN \
|
||
--env ACTIONS_RESULTS_URL \
|
||
--env GITHUB_RUN_ID \
|
||
--env GITHUB_REPOSITORY \
|
||
--env "GITHUB_SHA=$GITEA_SHA" \
|
||
--env "GITHUB_WORKSPACE=$workdir" \
|
||
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}$"))')"
|
||
if [[ "$current_staging_sha" != "$DEPLOY_SHA" ]]; then
|
||
# Docs-only pushes (every change outside deploy/gated-paths.txt) no
|
||
# longer run this gate, so staging may legitimately sit ahead of the
|
||
# tested SHA. Release only when the whole range is docs-only; a
|
||
# diverged, older, or code-bearing head is still refused. The Gitea
|
||
# compare API is used because this checkout is shallow and the
|
||
# newer head is not in local history.
|
||
if bash deploy/is-docs-only-range.sh --api "$DEPLOY_SHA" "$current_staging_sha"; then
|
||
echo "staging advanced to $current_staging_sha by docs-only commits; releasing tested $DEPLOY_SHA"
|
||
else
|
||
echo "staging advanced before deployment dispatch; refusing stale release" >&2
|
||
exit 1
|
||
fi
|
||
fi
|
||
response_file="$(mktemp "${RUNNER_TEMP:-/tmp}/jyotisha-deploy-dispatch.XXXXXX")"
|
||
trap 'rm -f -- "$response_file"' EXIT
|
||
migrate_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}}')"
|
||
curl --fail --silent --show-error --request POST \
|
||
--header "Authorization: token $GITEA_TOKEN" \
|
||
--header "Content-Type: application/json" \
|
||
--data "$migrate_payload" \
|
||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/workflows/migrate-staging-database.yml/dispatches?return_run_details=true" \
|
||
--output "$response_file"
|
||
if ! migrate_run_id="$(jq -er '.workflow_run_id | select(type == "number" and . > 0)' "$response_file")"; then
|
||
echo "dispatch 没有返回 run id,改按 SHA 查找迁移 run"
|
||
migrate_run_id=""
|
||
for find_attempt in $(seq 1 12); do
|
||
migrate_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=$DEPLOY_SHA&event=workflow_dispatch&limit=20")"
|
||
migrate_run_id="$(jq -r --arg sha "$DEPLOY_SHA" '
|
||
[(.workflow_runs // [])[] | select(
|
||
(.path | split("@")[0] | endswith("migrate-staging-database.yml")) and
|
||
.head_sha == $sha and
|
||
.event == "workflow_dispatch"
|
||
)] | sort_by(.id) | reverse | first | .id // empty
|
||
' <<<"$migrate_runs")"
|
||
[[ "$migrate_run_id" =~ ^[0-9]+$ ]] && break
|
||
sleep 5
|
||
done
|
||
[[ "$migrate_run_id" =~ ^[0-9]+$ ]] || { echo "找不到刚触发的 staging 迁移 run" >&2; exit 1; }
|
||
fi
|
||
echo "triggered staging migration run $migrate_run_id for $DEPLOY_SHA"
|
||
migrate_ok=false
|
||
for wait_attempt in $(seq 1 120); do
|
||
migrate_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/$migrate_run_id")"
|
||
migrate_status="$(jq -r '.status // ""' <<<"$migrate_run")"
|
||
migrate_conclusion="$(jq -r '.conclusion // ""' <<<"$migrate_run")"
|
||
if [[ "$migrate_conclusion" == success ]]; then
|
||
migrate_ok=true
|
||
break
|
||
fi
|
||
if [[ "$migrate_status" == completed || -n "$migrate_conclusion" ]]; then
|
||
echo "staging 迁移失败(run $migrate_run_id 结论 ${migrate_conclusion:-$migrate_status}),不部署" >&2
|
||
exit 1
|
||
fi
|
||
sleep 10
|
||
done
|
||
[[ "$migrate_ok" == true ]] || { echo "staging 迁移超时(run $migrate_run_id 在 20 分钟内没有完成)" >&2; exit 1; }
|
||
echo "staging 迁移成功(run $migrate_run_id)"
|
||
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"}}')"
|
||
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
|