Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd79689f66 | |||
| f42a886f87 | |||
| 482796fc52 | |||
| 4dc0c8c7b3 | |||
| 2e76af4057 | |||
| 7557e0d2ed | |||
| f56f62a1f4 | |||
| 2733c50295 | |||
| 152acbef8d | |||
| f542c02490 | |||
| eeb82dfdc4 | |||
| 6356d7b549 | |||
| aff9d19343 | |||
| ad9dba5c79 | |||
| 02f06255c1 | |||
| 8e04dcb061 | |||
| 701d4f92a2 | |||
| f7428909d2 | |||
| 68414a8223 | |||
| 534f5e617c | |||
| 75e288b0c6 | |||
| fb69e43c90 |
@@ -1,60 +0,0 @@
|
||||
name: Apply Supabase profile migrations
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: supabase-profile-migrations
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GITEA_SHA: ${{ gitea.sha }}
|
||||
DEPLOY_HOST: 103.117.123.53
|
||||
DEPLOY_PORT: '22000'
|
||||
DEPLOY_USER: root
|
||||
DEPLOY_PATH: /opt/jyotisha-app
|
||||
|
||||
jobs:
|
||||
apply:
|
||||
runs-on: xiaoxin
|
||||
timeout-minutes: 15
|
||||
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" main
|
||||
git checkout --detach --force "$GITEA_SHA"
|
||||
- name: Verify runner toolchain and require current main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 --version
|
||||
node --version
|
||||
npm --version
|
||||
docker version
|
||||
test "$(git rev-parse HEAD)" = "$(git ls-remote origin refs/heads/main | awk '{print $1}')"
|
||||
- name: Configure SSH and apply reviewed files
|
||||
env: { SSH_PRIVATE_KEY: '${{ secrets.PRODUCTION_SSH_PRIVATE_KEY }}' }
|
||||
run: |
|
||||
set -euo pipefail
|
||||
install -m 700 -d ~/.ssh
|
||||
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/jyotisha-production; chmod 600 ~/.ssh/jyotisha-production
|
||||
printf '%s\n' '[103.117.123.53]:22000 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHQJvN2Mo3Yq8e6ZIK4P2blJ5Vjj0HbknEuk7TyjhMbO' > ~/.ssh/known_hosts
|
||||
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-production -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes"
|
||||
remote="$DEPLOY_PATH/tmp/profile-migrations/$GITEA_RUN_NUMBER"
|
||||
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "install -m 700 -d '$remote'"
|
||||
rsync -az -e "ssh $SSH_OPTIONS" frontend/supabase/migrations/20260718*.sql frontend/supabase/migrations/20260721100000_chat_sessions_delete_grant.sql "$DEPLOY_USER@$DEPLOY_HOST:$remote/"
|
||||
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "cd '$DEPLOY_PATH' && REMOTE_DIR='$remote' bash -s" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
set +x
|
||||
trap 'rm -rf "$REMOTE_DIR"' EXIT
|
||||
set -a; . .env.production; set +a
|
||||
DB_URL="${SUPABASE_DB_URL:-${DATABASE_URL:-}}"
|
||||
test -n "$DB_URL"
|
||||
for sql_file in "$REMOTE_DIR"/*.sql; do
|
||||
echo "applying $(basename "$sql_file")"
|
||||
docker run --rm -i postgres:16-alpine psql "$DB_URL" --set ON_ERROR_STOP=1 --quiet < "$sql_file"
|
||||
done
|
||||
REMOTE
|
||||
@@ -1,23 +1,47 @@
|
||||
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:
|
||||
- '.gitea/workflows/backend-quality-gate.yml'
|
||||
- '.gitea/workflows/deploy-staging.yml'
|
||||
- '.gitea/workflows/migrate-staging-database.yml'
|
||||
- '.gitea/workflows/migrate-production-database.yml'
|
||||
- '.gitea/workflows/create-production-recovery.yml'
|
||||
- 'deploy/**'
|
||||
- 'frontend/**'
|
||||
- 'jyotish_vedic/**'
|
||||
- 'scripts/**'
|
||||
- 'tests/**'
|
||||
- '.dockerignore'
|
||||
- '.gitea/**'
|
||||
- 'MANIFEST.in'
|
||||
- 'mcp_server.py'
|
||||
- 'pyproject.toml'
|
||||
- 'requirements*.txt'
|
||||
- 'jyotish_vedic/**'
|
||||
- 'scripts/**'
|
||||
- 'tests/**'
|
||||
- 'SKILL.md'
|
||||
- 'assets/**'
|
||||
- 'references/**'
|
||||
- 'skills/**'
|
||||
- 'deploy/**'
|
||||
- 'frontend/**'
|
||||
- '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/**'
|
||||
- 'contracts/**'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -39,25 +63,72 @@ jobs:
|
||||
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
|
||||
for attempt in 1 2 3; do
|
||||
if timeout 300 git -c http.connectTimeout=15 -c http.lowSpeedLimit=1 -c http.lowSpeedTime=60 \
|
||||
fetch --depth=1 --no-tags origin "$GITEA_SHA"; then
|
||||
fetch_succeeded=true
|
||||
break
|
||||
# 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
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "exact staging gate checkout failed after $attempt bounded attempts" >&2
|
||||
exit 1
|
||||
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
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
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
|
||||
@@ -246,25 +317,72 @@ jobs:
|
||||
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
|
||||
for attempt in 1 2 3; do
|
||||
if timeout 300 git -c http.connectTimeout=15 -c http.lowSpeedLimit=1 -c http.lowSpeedTime=60 \
|
||||
fetch --depth=1 --no-tags origin "$GITEA_SHA"; then
|
||||
fetch_succeeded=true
|
||||
break
|
||||
# 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
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "exact staging gate checkout failed after $attempt bounded attempts" >&2
|
||||
exit 1
|
||||
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
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
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
|
||||
@@ -389,7 +507,20 @@ jobs:
|
||||
jq -er 'select(type == "array" and length == 1) | .[0] |
|
||||
select(.ref == "refs/heads/staging") | .object.sha |
|
||||
select(test("^[0-9a-f]{40}$"))')"
|
||||
[[ "$current_staging_sha" == "$DEPLOY_SHA" ]] || { echo "staging advanced before deployment dispatch; refusing stale release" >&2; exit 1; }
|
||||
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
|
||||
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"}}')"
|
||||
response_file="$(mktemp "${RUNNER_TEMP:-/tmp}/jyotisha-deploy-dispatch.XXXXXX")"
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
name: Jyotish Skill CI (manual)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
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 runner toolchain
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 --version
|
||||
node --version
|
||||
npm --version
|
||||
docker version
|
||||
- name: Install and validate
|
||||
env:
|
||||
NEXT_PUBLIC_SUPABASE_URL: https://ci-placeholder.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ci-placeholder
|
||||
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
|
||||
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
|
||||
python scripts/run_quality_gate.py --profile quick --skip-yoga-logic --skip-frontend-runtime
|
||||
python scripts/commercial_privacy_artifact_scan.py --json
|
||||
npm test --prefix frontend
|
||||
npm run lint --prefix frontend
|
||||
npm run build --prefix frontend
|
||||
python -m build
|
||||
@@ -111,9 +111,16 @@ jobs:
|
||||
'
|
||||
}
|
||||
staging_head="$(read_ref_sha staging)"
|
||||
head_check=current
|
||||
if [[ "$allow_rollback" == false && "$REQUESTED_SHA" != "$staging_head" ]]; then
|
||||
echo "stale staging revision refused; use explicit manual rollback only when intended" >&2
|
||||
exit 1
|
||||
# Docs-only pushes (every change outside deploy/gated-paths.txt) no
|
||||
# longer run the gate, so staging may legitimately be ahead of the
|
||||
# tested SHA. That is decided only after the gate-attested controller
|
||||
# bundle is downloaded, by its own deploy/is-docs-only-range.sh, so
|
||||
# this job never executes an untested checker; anything that is not
|
||||
# a pure docs-only advance is still refused there before mutation.
|
||||
echo "staging head $staging_head differs from requested $REQUESTED_SHA; deferring the docs-only range check to the attested controller"
|
||||
head_check=deferred
|
||||
fi
|
||||
if [[ "$allow_rollback" == true && "$REQUESTED_SHA" != "$staging_head" ]]; then
|
||||
comparison="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
@@ -135,6 +142,7 @@ jobs:
|
||||
echo "sha=$REQUESTED_SHA"
|
||||
echo "gate_run_id=$gate_run_id"
|
||||
echo "allow_rollback=$allow_rollback"
|
||||
echo "head_check=$head_check"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Prepare pinned Node tooling
|
||||
@@ -270,6 +278,39 @@ jobs:
|
||||
node artifacts/staging-image/extracted/frontend/scripts/staging-image-manifest.mjs \
|
||||
"$controller_manifest" "$DEPLOY_SHA" "$IMAGE_REPOSITORY" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Refuse stale staging revision unless only docs advanced
|
||||
env:
|
||||
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
|
||||
ALLOW_ROLLBACK: ${{ steps.revision.outputs.allow_rollback }}
|
||||
HEAD_CHECK: ${{ steps.revision.outputs.head_check }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$ALLOW_ROLLBACK" == true ]]; then
|
||||
echo "manual rollback authorised; the staging head check does not apply"
|
||||
exit 0
|
||||
fi
|
||||
staging_head="$(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 [[ "$staging_head" == "$DEPLOY_SHA" ]]; then
|
||||
echo "staging head is the tested revision $DEPLOY_SHA (initial check: $HEAD_CHECK)"
|
||||
exit 0
|
||||
fi
|
||||
# Only the gate-attested controller's checker and path list are trusted;
|
||||
# it proves DEPLOY_SHA is an ancestor of the head and that every path in
|
||||
# between is outside deploy/gated-paths.txt via the Gitea compare API.
|
||||
checker=artifacts/staging-image/extracted/deploy/is-docs-only-range.sh
|
||||
[[ -f "$checker" ]] || { echo "gate-attested controller bundle lacks deploy/is-docs-only-range.sh; cannot accept an advanced staging head" >&2; exit 1; }
|
||||
if bash "$checker" --api "$DEPLOY_SHA" "$staging_head"; then
|
||||
echo "staging advanced to $staging_head by docs-only commits; releasing tested $DEPLOY_SHA"
|
||||
else
|
||||
echo "stale staging revision refused; use explicit manual rollback only when intended" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Deploy exact image digests under pinned SSH identity
|
||||
env:
|
||||
SSH_PRIVATE_KEY_BASE64: ${{ secrets.STAGING_SSH_PRIVATE_KEY }}
|
||||
@@ -301,7 +342,10 @@ jobs:
|
||||
jq -er 'select(type == "array" and length == 1) | .[0] |
|
||||
select(.ref == "refs/heads/staging") | .object.sha |
|
||||
select(test("^[0-9a-f]{40}$"))')"
|
||||
[[ "$current_head" == "$DEPLOY_SHA" ]] || { echo "staging advanced during deployment; refusing stale mutation" >&2; exit 1; }
|
||||
[[ "$current_head" == "$DEPLOY_SHA" ]] && return
|
||||
# Docs-only pushes may land while a release is in flight; the attested checker decides.
|
||||
bash artifacts/staging-image/extracted/deploy/is-docs-only-range.sh --api "$DEPLOY_SHA" "$current_head" ||
|
||||
{ echo "staging advanced during deployment; refusing stale mutation" >&2; exit 1; }
|
||||
}
|
||||
cleanup() {
|
||||
if [[ -n "$incoming" ]]; then
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
name: Publish to PyPI (manual only)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
runs-on: xiaoxin
|
||||
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 runner toolchain
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 --version
|
||||
node --version
|
||||
npm --version
|
||||
docker version
|
||||
- name: Build and check package
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 -m venv .venv
|
||||
export PATH="$PWD/.venv/bin:$PATH"
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install build twine
|
||||
python -m build
|
||||
python -m twine check dist/*
|
||||
- name: Publish to PyPI
|
||||
env:
|
||||
TWINE_USERNAME: __token__
|
||||
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export PATH="$PWD/.venv/bin:$PATH"
|
||||
python -m twine upload --skip-existing dist/*
|
||||
@@ -80,3 +80,5 @@ jobs:
|
||||
python -m playwright install --with-deps chromium
|
||||
npm ci --prefix frontend
|
||||
python scripts/run_quality_gate.py --profile release
|
||||
python -m pytest -q --maxfail=1
|
||||
python tests/run_all.py
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
name: Reset Staging Account (manual only)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
expected_deploy_sha:
|
||||
description: Exact 40-character SHA currently deployed to staging
|
||||
required: true
|
||||
type: string
|
||||
email:
|
||||
description: Exact staging account email
|
||||
required: true
|
||||
type: string
|
||||
confirmation:
|
||||
description: Type RESET followed by a space and the exact email
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: staging-mutation
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
|
||||
jobs:
|
||||
reset:
|
||||
if: gitea.ref == 'refs/heads/staging'
|
||||
runs-on: xiaoxin
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
GITEA_SHA: ${{ gitea.sha }}
|
||||
GITEA_REF: ${{ gitea.ref }}
|
||||
DEPLOY_HOST: ${{ vars.STAGING_HOST }}
|
||||
DEPLOY_PORT: ${{ vars.STAGING_PORT }}
|
||||
DEPLOY_USER: ${{ vars.STAGING_USER }}
|
||||
DEPLOY_PATH: ${{ vars.STAGING_PATH }}
|
||||
STAGING_KNOWN_HOSTS: ${{ vars.STAGING_KNOWN_HOSTS }}
|
||||
EXPECTED_DEPLOY_SHA: ${{ inputs.expected_deploy_sha }}
|
||||
RESET_EMAIL: ${{ inputs.email }}
|
||||
RESET_CONFIRMATION: ${{ inputs.confirmation }}
|
||||
steps:
|
||||
- name: Checkout exact Gitea revision
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$GITEA_REF" == refs/heads/staging ]] || { echo "account reset must be dispatched from refs/heads/staging" >&2; exit 1; }
|
||||
[[ "$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
|
||||
fetch_succeeded=false
|
||||
for attempt in 1 2 3; do
|
||||
if timeout 300 git -c http.connectTimeout=15 -c http.lowSpeedLimit=1 -c http.lowSpeedTime=60 \
|
||||
fetch --depth=1 --no-tags origin "$GITEA_SHA"; then
|
||||
fetch_succeeded=true
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "exact staging controller checkout failed after $attempt bounded attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
[[ "$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: Validate account reset request and staging target
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$EXPECTED_DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "expected_deploy_sha must be a lowercase full commit SHA" >&2; exit 1; }
|
||||
[[ "$RESET_EMAIL" =~ ^[[:alnum:]._%+-]+@[[:alnum:].-]+\.[[:alpha:]]{2,63}$ ]] || { echo "email is not a plain account address" >&2; exit 1; }
|
||||
test "$RESET_CONFIRMATION" = "RESET $RESET_EMAIL" || { echo "confirmation must be exactly: RESET <email>" >&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 -n "$STAGING_KNOWN_HOSTS"
|
||||
bash -n deploy/reset-staging-account.sh
|
||||
|
||||
- name: Reset one staging account under host lock
|
||||
env:
|
||||
SSH_PRIVATE_KEY_BASE64: ${{ secrets.STAGING_SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ssh_root="${RUNNER_TEMP}/staging-account-reset-ssh"
|
||||
key_path="$ssh_root/id_ed25519"
|
||||
known_hosts_path="$ssh_root/known_hosts"
|
||||
install -m 700 -d "$ssh_root"
|
||||
trap 'rm -rf -- "$ssh_root"' EXIT
|
||||
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" -o ServerAliveInterval=30 -o ServerAliveCountMax=10)
|
||||
remote="$DEPLOY_USER@$DEPLOY_HOST"
|
||||
ssh "${ssh_options[@]}" "$remote" \
|
||||
"sudo -n env DEPLOY_PATH='$DEPLOY_PATH' EXPECTED_DEPLOY_SHA='$EXPECTED_DEPLOY_SHA' RESET_EMAIL='$RESET_EMAIL' RESET_CONFIRMATION='$RESET_CONFIRMATION' bash -s" \
|
||||
< deploy/reset-staging-account.sh
|
||||
@@ -1,42 +0,0 @@
|
||||
name: Jyotish Skill Tests (manual only)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: xiaoxin
|
||||
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 runner toolchain
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 --version
|
||||
node --version
|
||||
npm --version
|
||||
docker version
|
||||
- name: Install dependencies and run tests
|
||||
env:
|
||||
NEXT_PUBLIC_SUPABASE_URL: https://ci-placeholder.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ci-placeholder
|
||||
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
|
||||
python -m pytest -vv --maxfail=1
|
||||
python tests/run_all.py
|
||||
npm test --prefix frontend
|
||||
npm run lint --prefix frontend
|
||||
npm run build --prefix frontend
|
||||
@@ -1,176 +0,0 @@
|
||||
name: Apply production rectification migrations
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
operation:
|
||||
description: Check pending migrations or apply them
|
||||
required: true
|
||||
default: check
|
||||
type: choice
|
||||
options:
|
||||
- check
|
||||
- apply
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: production-database-migrations
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
DEPLOY_HOST: 103.117.123.53
|
||||
DEPLOY_PORT: "22000"
|
||||
DEPLOY_USER: root
|
||||
DEPLOY_PATH: /opt/jyotisha-app
|
||||
|
||||
jobs:
|
||||
migrate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout current main revision
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Reject stale revision
|
||||
run: |
|
||||
tested_sha="$(git rev-parse HEAD)"
|
||||
main_sha="$(git ls-remote origin refs/heads/main | awk '{print $1}')"
|
||||
test "$tested_sha" = "$main_sha" || {
|
||||
echo "Refusing stale migration revision $tested_sha; current main is $main_sha" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "Using current main revision $tested_sha"
|
||||
|
||||
- name: Configure SSH
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.PRODUCTION_SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
install -m 700 -d ~/.ssh
|
||||
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/jyotisha-production
|
||||
chmod 600 ~/.ssh/jyotisha-production
|
||||
printf '%s\n' '[103.117.123.53]:22000 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHQJvN2Mo3Yq8e6ZIK4P2blJ5Vjj0HbknEuk7TyjhMbO' > ~/.ssh/known_hosts
|
||||
|
||||
- name: Upload reviewed migration files
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-production -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes"
|
||||
SCP_OPTIONS="-i $HOME/.ssh/jyotisha-production -P $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes"
|
||||
REMOTE_DIR="$DEPLOY_PATH/tmp/production-migrations/$GITHUB_RUN_ID"
|
||||
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "install -m 700 -d '$REMOTE_DIR'"
|
||||
scp $SCP_OPTIONS \
|
||||
frontend/supabase/migrations/20260723030000_align_conversational_follow_up_request.sql \
|
||||
frontend/supabase/migrations/20260724010000_global_birth_locations.sql \
|
||||
frontend/supabase/migrations/20260724020000_align_global_birthplace_rectification_contract.sql \
|
||||
frontend/supabase/migrations/20260724030000_allow_assistant_only_rectification_regenerate.sql \
|
||||
frontend/supabase/migrations/20260725010000_structured_conversational_date_confirmation.sql \
|
||||
frontend/supabase/migrations/20260725020000_repair_structured_conversational_date_validator.sql \
|
||||
frontend/supabase/migrations/20260726010000_backfill_reported_birth_time_status.sql \
|
||||
"$DEPLOY_USER@$DEPLOY_HOST:$REMOTE_DIR/"
|
||||
|
||||
- name: Check or apply reviewed migrations
|
||||
env:
|
||||
OPERATION: ${{ inputs.operation }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
set +x
|
||||
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-production -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20"
|
||||
REMOTE_DIR="$DEPLOY_PATH/tmp/production-migrations/$GITHUB_RUN_ID"
|
||||
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"cd '$DEPLOY_PATH' && OPERATION='$OPERATION' REMOTE_DIR='$REMOTE_DIR' bash -s" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
set +x
|
||||
trap 'rm -rf "$REMOTE_DIR"' EXIT
|
||||
case "$OPERATION" in
|
||||
check|apply) ;;
|
||||
*) echo "invalid migration operation" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
ENV_FILE="$PWD/.env.production"
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo ".env.production missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
set -a
|
||||
. "$ENV_FILE"
|
||||
set +a
|
||||
DB_URL="${SUPABASE_DB_URL:-${DATABASE_URL:-}}"
|
||||
if [ -z "$DB_URL" ]; then
|
||||
echo "production database URL is missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
psql_query() {
|
||||
docker run --rm postgres:16-alpine \
|
||||
psql "$DB_URL" --set ON_ERROR_STOP=1 --tuples-only --no-align --quiet --command "$1"
|
||||
}
|
||||
|
||||
ledger="$(psql_query "select to_regclass('migration.schema_migrations')")"
|
||||
if [ "$ledger" != "migration.schema_migrations" ]; then
|
||||
echo "production migration ledger is missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pending=0
|
||||
for sql_file in \
|
||||
"$REMOTE_DIR/20260723030000_align_conversational_follow_up_request.sql" \
|
||||
"$REMOTE_DIR/20260724010000_global_birth_locations.sql" \
|
||||
"$REMOTE_DIR/20260724020000_align_global_birthplace_rectification_contract.sql" \
|
||||
"$REMOTE_DIR/20260724030000_allow_assistant_only_rectification_regenerate.sql" \
|
||||
"$REMOTE_DIR/20260725010000_structured_conversational_date_confirmation.sql" \
|
||||
"$REMOTE_DIR/20260725020000_repair_structured_conversational_date_validator.sql" \
|
||||
"$REMOTE_DIR/20260726010000_backfill_reported_birth_time_status.sql"
|
||||
do
|
||||
filename="$(basename "$sql_file")"
|
||||
checksum="$(sha256sum "$sql_file" | awk '{print $1}')"
|
||||
recorded="$(psql_query "select checksum from migration.schema_migrations where filename = '$filename'")"
|
||||
if [ -n "$recorded" ]; then
|
||||
test "$recorded" = "$checksum" || {
|
||||
echo "migration checksum mismatch: $filename" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "already applied $filename"
|
||||
continue
|
||||
fi
|
||||
|
||||
pending=$((pending + 1))
|
||||
if [ "$OPERATION" = "check" ]; then
|
||||
echo "pending $filename"
|
||||
continue
|
||||
fi
|
||||
|
||||
wrapped="$REMOTE_DIR/.wrapped-$filename"
|
||||
python3 - "$sql_file" "$wrapped" "$filename" "$checksum" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
source_path, target_path, filename, checksum = sys.argv[1:]
|
||||
source = Path(source_path).read_text(encoding="utf-8")
|
||||
source = re.sub(r"\A\s*begin\s*;\s*", "", source, count=1, flags=re.I)
|
||||
source = re.sub(r"\s*commit\s*;\s*\Z", "\n", source, count=1, flags=re.I)
|
||||
ledger = (
|
||||
"\ninsert into migration.schema_migrations (filename, checksum) "
|
||||
f"values ('{filename}', '{checksum}');\n"
|
||||
)
|
||||
Path(target_path).write_text(source + ledger, encoding="utf-8")
|
||||
PY
|
||||
docker run --rm -i postgres:16-alpine \
|
||||
psql "$DB_URL" --set ON_ERROR_STOP=1 --single-transaction --quiet < "$wrapped"
|
||||
verified="$(psql_query "select checksum from migration.schema_migrations where filename = '$filename'")"
|
||||
test "$verified" = "$checksum" || {
|
||||
echo "migration ledger verification failed: $filename" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "applied $filename"
|
||||
done
|
||||
|
||||
if [ "$OPERATION" = "check" ] && [ "$pending" -gt 0 ]; then
|
||||
echo "$pending reviewed production migrations are pending"
|
||||
else
|
||||
echo "production migration state is current"
|
||||
fi
|
||||
REMOTE
|
||||
@@ -1,87 +0,0 @@
|
||||
name: Apply Supabase profile migrations
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: supabase-profile-migrations
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
DEPLOY_HOST: 103.117.123.53
|
||||
DEPLOY_PORT: "22000"
|
||||
DEPLOY_USER: root
|
||||
DEPLOY_PATH: /opt/jyotisha-app
|
||||
|
||||
jobs:
|
||||
apply:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout migration files
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Configure SSH
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.PRODUCTION_SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
install -m 700 -d ~/.ssh
|
||||
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/jyotisha-production
|
||||
chmod 600 ~/.ssh/jyotisha-production
|
||||
printf '%s\n' '[103.117.123.53]:22000 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHQJvN2Mo3Yq8e6ZIK4P2blJ5Vjj0HbknEuk7TyjhMbO' > ~/.ssh/known_hosts
|
||||
|
||||
- name: Copy profile migrations to VPS
|
||||
run: |
|
||||
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-production -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes"
|
||||
RSYNC_SSH="ssh $SSH_OPTIONS"
|
||||
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "install -m 700 -d '$DEPLOY_PATH/tmp/profile-migrations'"
|
||||
rsync -az -e "$RSYNC_SSH" \
|
||||
frontend/supabase/migrations/20260718050000_profiles_service_role_upsert_grants.sql \
|
||||
frontend/supabase/migrations/20260718060000_profiles_service_role_least_privilege.sql \
|
||||
frontend/supabase/migrations/20260718070000_profiles_service_role_upsert_id.sql \
|
||||
frontend/supabase/migrations/20260718080000_profiles_service_role_account_upsert_selects.sql \
|
||||
frontend/supabase/migrations/20260718100000_repair_missing_chart_profiles.sql \
|
||||
frontend/supabase/migrations/20260718102000_recover_missing_profile_rows.sql \
|
||||
frontend/supabase/migrations/20260718103000_profile_birth_time_declaration_grants.sql \
|
||||
frontend/supabase/migrations/20260718104000_chart_profiles_upsert_id_grant.sql \
|
||||
frontend/supabase/migrations/20260721100000_chat_sessions_delete_grant.sql \
|
||||
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/tmp/profile-migrations/"
|
||||
|
||||
- name: Apply profile migrations using VPS database URL
|
||||
run: |
|
||||
SSH_OPTIONS="-i $HOME/.ssh/jyotisha-production -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes"
|
||||
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "cd '$DEPLOY_PATH' && bash -s" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
set +x
|
||||
ENV_FILE="$PWD/.env.production"
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo ".env.production missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
set -a
|
||||
. "$ENV_FILE"
|
||||
set +a
|
||||
DB_URL="${SUPABASE_DB_URL:-${DATABASE_URL:-}}"
|
||||
if [ -z "$DB_URL" ]; then
|
||||
echo "SUPABASE_DB_URL or DATABASE_URL is required in .env.production" >&2
|
||||
exit 1
|
||||
fi
|
||||
for SQL_FILE in \
|
||||
tmp/profile-migrations/20260718050000_profiles_service_role_upsert_grants.sql \
|
||||
tmp/profile-migrations/20260718060000_profiles_service_role_least_privilege.sql \
|
||||
tmp/profile-migrations/20260718070000_profiles_service_role_upsert_id.sql \
|
||||
tmp/profile-migrations/20260718080000_profiles_service_role_account_upsert_selects.sql \
|
||||
tmp/profile-migrations/20260718100000_repair_missing_chart_profiles.sql \
|
||||
tmp/profile-migrations/20260718102000_recover_missing_profile_rows.sql \
|
||||
tmp/profile-migrations/20260718103000_profile_birth_time_declaration_grants.sql \
|
||||
tmp/profile-migrations/20260718104000_chart_profiles_upsert_id_grant.sql \
|
||||
tmp/profile-migrations/20260721100000_chat_sessions_delete_grant.sql
|
||||
do
|
||||
echo "applying $(basename "$SQL_FILE")"
|
||||
cat "$SQL_FILE" | docker run --rm -i postgres:16-alpine \
|
||||
psql "$DB_URL" --set ON_ERROR_STOP=1 --quiet
|
||||
done
|
||||
REMOTE
|
||||
@@ -1,145 +0,0 @@
|
||||
name: Staging Backend Quality Gate
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/backend-quality-gate.yml'
|
||||
- '.github/workflows/deploy-staging.yml'
|
||||
- '.github/workflows/migrate-staging-database.yml'
|
||||
- 'deploy/**'
|
||||
- 'frontend/**'
|
||||
- 'jyotish_vedic/**'
|
||||
- 'scripts/**'
|
||||
- 'tests/**'
|
||||
- 'mcp_server.py'
|
||||
- 'pyproject.toml'
|
||||
- 'requirements*.txt'
|
||||
push:
|
||||
branches: [staging]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: backend-quality-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -r requirements.txt -r requirements-dev.txt
|
||||
python -m pip install playwright
|
||||
python -m playwright install --with-deps chrome
|
||||
npm ci --prefix frontend
|
||||
|
||||
- name: Run Python quick quality gate
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
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
|
||||
mkdir -p artifacts
|
||||
python scripts/run_quality_gate.py \
|
||||
--profile quick --skip-yoga-logic --skip-frontend-runtime \
|
||||
2>&1 | tee artifacts/quick-quality-gate.log
|
||||
python scripts/commercial_privacy_artifact_scan.py --json
|
||||
python -m build
|
||||
|
||||
- name: Upload quick quality gate diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: quick-quality-gate-diagnostics
|
||||
path: artifacts/quick-quality-gate.log
|
||||
|
||||
- name: Validate frontend and database contracts
|
||||
run: |
|
||||
npm test --prefix frontend
|
||||
npm run lint --prefix frontend
|
||||
npm run build --prefix frontend
|
||||
|
||||
publish:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/staging'
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and publish API image
|
||||
id: api_build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: deploy/railway-api.Dockerfile
|
||||
push: true
|
||||
tags: ghcr.io/jesse-ux/jyotisha-api:${{ github.sha }}
|
||||
|
||||
- name: Build and publish web image
|
||||
id: web_build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: deploy/railway-web.Dockerfile
|
||||
build-args: |
|
||||
NEXT_DEPLOYMENT_ID=${{ github.sha }}
|
||||
push: true
|
||||
tags: ghcr.io/jesse-ux/jyotisha-web:${{ github.sha }}
|
||||
|
||||
- name: Record immutable staging image manifest
|
||||
env:
|
||||
API_DIGEST: ${{ steps.api_build.outputs.digest }}
|
||||
WEB_DIGEST: ${{ steps.web_build.outputs.digest }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$GITHUB_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||||
[[ "$API_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]
|
||||
[[ "$WEB_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]
|
||||
install -d -m 700 artifacts/staging-images
|
||||
umask 077
|
||||
printf 'git_sha=%s\napi_digest=%s\nweb_digest=%s\n' \
|
||||
"$GITHUB_SHA" "$API_DIGEST" "$WEB_DIGEST" \
|
||||
> artifacts/staging-images/manifest.env
|
||||
node frontend/scripts/staging-image-manifest.mjs \
|
||||
artifacts/staging-images/manifest.env "$GITHUB_SHA" >/dev/null
|
||||
|
||||
- name: Upload immutable staging image manifest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: staging-image-manifest-${{ github.sha }}-${{ github.run_attempt }}
|
||||
path: artifacts/staging-images/manifest.env
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
@@ -1,68 +0,0 @@
|
||||
name: Jyotish Skill CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [staging]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -r requirements.txt -r requirements-dev.txt
|
||||
npm ci --prefix frontend
|
||||
|
||||
- name: Print environment diagnostics
|
||||
run: |
|
||||
python --version
|
||||
node --version
|
||||
npm --version
|
||||
|
||||
- name: Run Ruff lint for quality gate files
|
||||
run: 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
|
||||
|
||||
- name: Run Python syntax check
|
||||
run: python -m py_compile scripts/*.py jyotish_vedic/*.py mcp_server.py
|
||||
|
||||
- name: Run quick quality gate
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
python scripts/run_quality_gate.py --profile quick --skip-yoga-logic --skip-frontend-runtime 2>&1 | tee artifacts/quick-quality-gate.log
|
||||
- name: Run commercial privacy artifact gate
|
||||
run: python scripts/commercial_privacy_artifact_scan.py --json
|
||||
- name: Upload quick quality gate diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: quick-quality-gate-diagnostics
|
||||
path: artifacts/
|
||||
|
||||
- name: Validate production web
|
||||
env:
|
||||
NEXT_PUBLIC_SUPABASE_URL: https://ci-placeholder.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ci-placeholder
|
||||
run: |
|
||||
npm test --prefix frontend
|
||||
npm run lint --prefix frontend
|
||||
npm run build --prefix frontend
|
||||
|
||||
- name: Build Python package
|
||||
run: python -m build --no-isolation
|
||||
@@ -1,16 +0,0 @@
|
||||
name: Production deployment moved to Gitea
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
retired:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Refuse deployment from the mirror
|
||||
run: |
|
||||
echo "Production deployment is controlled by .gitea/workflows/deploy-production.yml in git.copse.top." >&2
|
||||
exit 1
|
||||
@@ -1,254 +0,0 @@
|
||||
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_BASE64: ${{ secrets.STAGING_SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$SSH_PRIVATE_KEY_BASE64"
|
||||
install -m 700 -d ~/.ssh
|
||||
printf '%s' "$SSH_PRIVATE_KEY_BASE64" | base64 --decode >~/.ssh/jyotisha-staging
|
||||
chmod 600 ~/.ssh/jyotisha-staging
|
||||
ssh-keygen -y -f ~/.ssh/jyotisha-staging >/dev/null
|
||||
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 [ -r \"\$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'"
|
||||
@@ -1,234 +0,0 @@
|
||||
name: Migrate Staging Database
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
deploy_sha:
|
||||
description: Full tested staging commit SHA to migrate
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: staging-mutation
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
packages: read
|
||||
|
||||
jobs:
|
||||
migrate:
|
||||
environment: staging
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
DEPLOY_HOST: ${{ vars.STAGING_HOST }}
|
||||
DEPLOY_PORT: ${{ vars.STAGING_PORT }}
|
||||
DEPLOY_USER: ${{ vars.STAGING_USER }}
|
||||
DEPLOY_PATH: ${{ vars.STAGING_PATH }}
|
||||
STAGING_KNOWN_HOSTS: ${{ vars.STAGING_KNOWN_HOSTS }}
|
||||
|
||||
steps:
|
||||
- name: Validate current tested staging revision
|
||||
id: revision
|
||||
env:
|
||||
REQUESTED_SHA: ${{ inputs.deploy_sha }}
|
||||
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
|
||||
}
|
||||
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")"
|
||||
[[ "$gate_run_id" =~ ^[0-9]+$ ]]
|
||||
[[ "$gate_run_attempt" =~ ^[1-9][0-9]*$ ]]
|
||||
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')"
|
||||
[ "$REQUESTED_SHA" = "$staging_head" ] || {
|
||||
echo "stale staging migration refused; migrate the current staging head" >&2
|
||||
exit 1
|
||||
}
|
||||
{
|
||||
echo "sha=$REQUESTED_SHA"
|
||||
echo "gate_run_id=$gate_run_id"
|
||||
echo "gate_run_attempt=$gate_run_attempt"
|
||||
} >>"$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 migration image
|
||||
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 -n "$STAGING_KNOWN_HOSTS"
|
||||
|
||||
- name: Configure pinned staging SSH
|
||||
env:
|
||||
SSH_PRIVATE_KEY_BASE64: ${{ secrets.STAGING_SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$SSH_PRIVATE_KEY_BASE64"
|
||||
install -m 700 -d ~/.ssh
|
||||
printf '%s' "$SSH_PRIVATE_KEY_BASE64" | base64 --decode >~/.ssh/jyotisha-staging
|
||||
chmod 600 ~/.ssh/jyotisha-staging
|
||||
ssh-keygen -y -f ~/.ssh/jyotisha-staging >/dev/null
|
||||
printf '%s\n' "$STAGING_KNOWN_HOSTS" >~/.ssh/known_hosts
|
||||
chmod 600 ~/.ssh/known_hosts
|
||||
|
||||
- name: Verify forward-only migration revision
|
||||
id: previous
|
||||
env:
|
||||
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
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
|
||||
if [ "$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 "stale or divergent staging migration refused" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
{
|
||||
echo "sha=$previous_sha"
|
||||
echo "forward_verified=true"
|
||||
} >>"$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: Apply exact-image migrations under host lock
|
||||
env:
|
||||
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
|
||||
WEB_IMAGE: ${{ steps.images.outputs.web_image }}
|
||||
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' WEB_IMAGE='$WEB_IMAGE' DEPLOY_SHA='$DEPLOY_SHA' EXPECTED_PREVIOUS_SHA='$EXPECTED_PREVIOUS_SHA' FORWARD_REVISION_VERIFIED='$FORWARD_REVISION_VERIFIED' DOCKER_CONFIG='$INCOMING_PATH/.docker' bash '$INCOMING_PATH/deploy/run-staging-migration.sh'"
|
||||
|
||||
- name: Dispatch current exact-SHA staging deployment
|
||||
env:
|
||||
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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')"
|
||||
[ "$DEPLOY_SHA" = "$staging_head" ] || {
|
||||
echo "staging advanced during migration; refusing stale deployment dispatch" >&2
|
||||
exit 1
|
||||
}
|
||||
payload="$(jq -cn --arg deploy_sha "$DEPLOY_SHA" \
|
||||
'{ref:"main",inputs:{deploy_sha:$deploy_sha,allow_rollback:"false"}}')"
|
||||
curl --fail --silent --show-error --request POST \
|
||||
--header "Authorization: Bearer $GH_TOKEN" \
|
||||
--header "Accept: application/vnd.github+json" \
|
||||
--header "X-GitHub-Api-Version: 2022-11-28" \
|
||||
--header "Content-Type: application/json" \
|
||||
--data "$payload" \
|
||||
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/deploy-staging.yml/dispatches"
|
||||
|
||||
- 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'"
|
||||
@@ -1,33 +0,0 @@
|
||||
name: Publish to PyPI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install build tools
|
||||
run: pip install build twine
|
||||
|
||||
- name: Build package
|
||||
run: python -m build
|
||||
|
||||
- name: Check package metadata
|
||||
run: twine check dist/*
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
skip-existing: true
|
||||
@@ -1,54 +0,0 @@
|
||||
name: Jyotish Release Quality Gate
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release-quality-gate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -r requirements.txt -r requirements-dev.txt
|
||||
python -m pip install playwright
|
||||
python -m playwright install --with-deps chromium
|
||||
npm ci --prefix frontend
|
||||
|
||||
- name: Print environment diagnostics
|
||||
run: |
|
||||
python --version
|
||||
node --version
|
||||
npm --version
|
||||
npm --prefix frontend exec -- next --version
|
||||
|
||||
- name: Run release quality gate
|
||||
env:
|
||||
NEXT_PUBLIC_SUPABASE_URL: https://ci-placeholder.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ci-placeholder
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
python scripts/run_quality_gate.py --profile release 2>&1 | tee artifacts/release-quality-gate.log
|
||||
|
||||
- name: Upload release quality gate diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-quality-gate-diagnostics
|
||||
path: artifacts/
|
||||
@@ -1,82 +0,0 @@
|
||||
name: Reset Staging Account
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
expected_deploy_sha:
|
||||
description: Exact 40-character SHA currently deployed to staging
|
||||
required: true
|
||||
type: string
|
||||
email:
|
||||
description: Exact staging account email
|
||||
required: true
|
||||
type: string
|
||||
confirmation:
|
||||
description: Type RESET followed by a space and the exact email
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: staging-mutation
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
reset:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
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_KNOWN_HOSTS: ${{ vars.STAGING_KNOWN_HOSTS }}
|
||||
EXPECTED_DEPLOY_SHA: ${{ inputs.expected_deploy_sha }}
|
||||
RESET_EMAIL: ${{ inputs.email }}
|
||||
RESET_CONFIRMATION: ${{ inputs.confirmation }}
|
||||
|
||||
steps:
|
||||
- name: Checkout trusted controller
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate account reset request and staging target
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$EXPECTED_DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||||
[[ "$RESET_EMAIL" =~ ^[[:alnum:]._%+-]+@[[:alnum:].-]+\.[[:alpha:]]{2,63}$ ]]
|
||||
test "$RESET_CONFIRMATION" = "RESET $RESET_EMAIL"
|
||||
test "$DEPLOY_HOST" = "118.26.111.127"
|
||||
test "$DEPLOY_PORT" = "22"
|
||||
test "$DEPLOY_USER" = "deploy"
|
||||
test "$DEPLOY_PATH" = "/opt/jyotisha-staging"
|
||||
test -n "$STAGING_KNOWN_HOSTS"
|
||||
bash -n deploy/reset-staging-account.sh
|
||||
|
||||
- name: Configure pinned staging SSH
|
||||
env:
|
||||
SSH_PRIVATE_KEY_BASE64: ${{ secrets.STAGING_SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$SSH_PRIVATE_KEY_BASE64"
|
||||
install -d -m 700 ~/.ssh
|
||||
printf '%s' "$SSH_PRIVATE_KEY_BASE64" | base64 --decode >~/.ssh/jyotisha-staging
|
||||
chmod 600 ~/.ssh/jyotisha-staging
|
||||
ssh-keygen -y -f ~/.ssh/jyotisha-staging >/dev/null
|
||||
printf '%s\n' "$STAGING_KNOWN_HOSTS" >~/.ssh/known_hosts
|
||||
chmod 600 ~/.ssh/known_hosts
|
||||
|
||||
- name: Reset one staging account under host lock
|
||||
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=10"
|
||||
ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"DEPLOY_PATH='$DEPLOY_PATH' EXPECTED_DEPLOY_SHA='$EXPECTED_DEPLOY_SHA' RESET_EMAIL='$RESET_EMAIL' RESET_CONFIRMATION='$RESET_CONFIRMATION' bash -s" \
|
||||
< deploy/reset-staging-account.sh
|
||||
@@ -1,47 +0,0 @@
|
||||
name: Jyotish Skill Tests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with: { python-version: '3.11' }
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -r requirements.txt -r requirements-dev.txt
|
||||
npm ci --prefix frontend
|
||||
- name: Print environment diagnostics
|
||||
run: |
|
||||
python --version
|
||||
node --version
|
||||
npm --version
|
||||
- name: Run pytest suite
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
python -m pytest -vv --maxfail=1 --junitxml=artifacts/pytest.xml 2>&1 | tee artifacts/pytest.log
|
||||
- name: Upload pytest diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pytest-diagnostics
|
||||
path: artifacts/
|
||||
- name: Run legacy runner
|
||||
run: python tests/run_all.py
|
||||
- name: Validate production web
|
||||
env:
|
||||
NEXT_PUBLIC_SUPABASE_URL: https://ci-placeholder.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ci-placeholder
|
||||
run: |
|
||||
npm test --prefix frontend
|
||||
npm run lint --prefix frontend
|
||||
npm run build --prefix frontend
|
||||
@@ -133,12 +133,12 @@ Deployment safety rules:
|
||||
|
||||
1. 动手前必须 `git fetch origin --prune`,并以远端 **`origin/staging`** 为基线。不得基于本地 `staging` 或本地 `main`:这两个本地引用经常落后远端上百个提交,基于它们做出的分析和补丁会对不上真实代码。
|
||||
2. 在独立 worktree 中开发,路径 `.worktrees/<主题>-<日期>`,分支 `codex/<主题>-<日期>`。不得在存在未提交修改的工作树上切换分支、stash、reset、覆盖或顺带提交用户变更。
|
||||
3. 交付到 staging 用快进推送(`git push origin HEAD:staging`)。这会触发 Gitea `backend-quality-gate`;该工作流的 `push: branches: [staging]` 没有路径过滤,任何改动(包括纯文档)都会跑完整构建与部署,应合并同批改动一次推送。
|
||||
4. 由 quality gate 构建 digest 固定镜像并 dispatch `deploy-staging`,随后在 `https://staging.jyotisha.chat` 完成与风险相称的验收。`GET /api/health` 的 `.deployment.gitCommit` 必须等于本次 SHA,否则视为未部署。
|
||||
3. 交付到 staging 用快进推送(`git push origin HEAD:staging`)。这会触发 Gitea `backend-quality-gate`;该工作流的 `push:` 触发带有与 `deploy/gated-paths.txt` 逐行一致的 `paths:` 过滤:改动**全部**落在该清单之外的纯文档推送(`docs/**`、根目录 `TASK-*.md` / `PROGRESS-*.md` / `CHANGELOG*.md` / `progress.md` / `task_plan.md` / `findings.md` / `BLOCKED.md` / `CONTEXT.md`、`AGENTS.md` 等记录文件)不触发门禁、不发布镜像、不部署;任何触及清单内路径的推送都会跑完整构建与部署。仍鼓励把文档与同批代码合并一次推送——文档单独推送虽不再取消正在运行的代码门禁,但会让 staging head 与已部署 SHA 分离,增加核对成本。
|
||||
4. 由 quality gate 构建 digest 固定镜像并 dispatch `deploy-staging`,随后在 `https://staging.jyotisha.chat` 完成与风险相称的验收。`GET /api/health` 的 `.deployment.gitCommit` 必须等于**最近一次含门禁路径改动的 staging 提交**,而不再是 staging head:若其后只有纯文档提交,`deploy/is-docs-only-range.sh <该 SHA> <staging head>` 必须退出 0(publish 与 deploy-staging 对分叉、落后或含门禁路径的 head 仍会拒绝发布);否则视为未部署。
|
||||
5. 提升到 `main` **必须快进,不得 merge**。`.gitea/workflows/deploy-production.yml` 强制 `main` 与 `staging` 指向同一个 commit SHA;任何 merge commit 都会让生产部署以 `main and staging must identify the same reviewed release` 失败。
|
||||
6. 生产部署手动执行:先跑 `release-quality-gate`,再 dispatch `deploy-production`。它复用 staging 已验收的镜像 digest,不重新构建。
|
||||
7. 推送后必须核对远端 SHA,确认 `origin/staging`(以及提升后的 `origin/main`)确实包含目标提交;远端验证失败时不得声称已交付。
|
||||
8. GitHub `upstream` 仅为镜像,其工作流已停用,不得用它验证交付状态。
|
||||
8. GitHub `upstream` 仅为镜像:其工作流文件已从仓库删除,GitHub Actions 已在仓库设置中关闭,不得用它验证交付状态。
|
||||
|
||||
## 7. Bug History Workflow Hard Constraint
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# PROGRESS · 首页加载统一:一次等待、一次揭幕(2026-09-02)
|
||||
|
||||
分支:`codex/unified-loading-20260902`
|
||||
基线:`origin/staging` @ `02f06255`,并先行合入 `codex/streaming-ux-20260901` @ `c846c44a`(任务书前置条件;合并无冲突,merge commit `25e3094f`)。本轮改动写在合并后的代码形态上。
|
||||
|
||||
执行方:Claude(用户 2026-09-02 明示"直接开始执行")。
|
||||
|
||||
## 结论
|
||||
|
||||
登录后只有一次揭幕。加载屏两阶段(载入账户 → 准备对话),阶段二并行取推荐问题、今日星语、校正入口摘要并预热校正分包;全部就绪或 4 秒到期才揭幕;揭幕后首页无任何组件级 spinner / busy / "正在…"文案(流式生成中除外)。
|
||||
|
||||
## 与任务书的差异
|
||||
|
||||
- **当前会话消息**:合入的流式分支已把 `fetchSessionDetail` 前移到 `setHydrated` 之前(`page.tsx` 启动 effect),本轮不再重复做,只把它计入"阶段二"的语义说明。
|
||||
- **阶段二实现方式**:没有把三个请求搬进启动 effect 的 `Promise.allSettled`,而是把三个既有 effect 的门槛从 `hydrated` 改为 `bootstrapPhase`(`"account" → "prepare"`),再用一个揭幕 effect 汇总就绪状态。理由:三个 effect 各自带重试/身份守卫/缓存逻辑(推荐问题的 `onboardingRequestIdentity`、今日星语的当日缓存与一次重试),原样复用不会产生重复请求,也不必复制这些逻辑。合同测试锁 `bootstrapPhase === "account" || !accountId` 门槛恰为 3 处。
|
||||
- **今日星语的 pending 文案**:任务书写"非个性化文案";实际采用与 unavailable 相同的静态句「今天的星语还没写出来。」,到达后静默替换。原「正在写下今天的星语。」删除。
|
||||
|
||||
## 改动清单
|
||||
|
||||
| 文件 | 内容 |
|
||||
| --- | --- |
|
||||
| `src/lib/home-bootstrap.ts`(新) | `BootstrapPhase`、`BOOTSTRAP_PREPARE_TIMEOUT_MS = 4000`、`SESSION_PREFETCH_COUNT = 5`、`bootstrapPrepareSettled`、`bootstrapRevealDelayMs`、`bootstrapLoadingCopy`、`sessionIdsToPrefetch` |
|
||||
| `src/app/page.tsx` | `bootstrapPhase` / `rectificationEntrySummarySettled` state、`prepareStartedAt` ref;`finally` 里成功路径改 `setBootstrapPhase("prepare")`(失败仍 `setHydrated(true)`);三个 effect 门槛与依赖改为 `bootstrapPhase`;入口摘要 effect 加 `finally` 置 settled;新增揭幕 effect(就绪或超时)、分包预热 effect、揭幕后预取 effect;加载屏文案按阶段;删 `starter-loading` 分支、`dailyStarlanguageBusy`、`InlineSpinner` import 与会话切换 spinner |
|
||||
| `src/components/starter-home.tsx` | 删 `dailyStarlanguageBusy` prop 与 `aria-busy` |
|
||||
| `src/app/globals.css` | 删两处 `.starter-loading` 定义(已无使用) |
|
||||
| `DESIGN.md` §9 | 整页阻塞行改为"两阶段载入";补首页揭幕规则段 |
|
||||
| `tests/home-bootstrap-reveal.test.ts`(新) | 7 条:纯函数 + 源码合同 |
|
||||
| `tests/daily-starlanguage.test.ts` | 例外条款:effect 切片标记改为新门槛与新依赖(原值 `!hydrated ...` / `[..., hydrated, ...]`);`aria-busy={dailyStarlanguageBusy}` 正断言改为不存在(原值见注释) |
|
||||
| `tests/starter-questions.test.ts` | 例外条款:守卫切片终点由 `"(onboardingPending ?"` 改为 `"<StarterHome"`(原值见注释) |
|
||||
| `docs/BUG_HISTORY.md` | BUG-479 |
|
||||
|
||||
`onboardingPending` 变量保留(滚动 effect 与 `productEntrypointsDisabled` 仍用),只是不再驱动渲染分支。
|
||||
|
||||
## 验证(本机,无 Docker)
|
||||
|
||||
- `./node_modules/.bin/tsc --noEmit`:exit 0。
|
||||
- `npm run lint`:0 error(72 warning,全部既有;本轮改动文件无新增 warning)。
|
||||
- `npm test`:2493 条,pass 2459 / fail 24 / skipped 10;24 条失败清单与 `551d6317` 基线**逐字相同**(数据库/部署类既有环境缺口),无新增。
|
||||
- Python quick-gate 前端契约四文件:24 passed。
|
||||
- `next build`(默认 Turbopack):exit 0,`┌ ○ /` Static。
|
||||
- 首屏 JS gzip-9(预渲染 `index.html` 引用的 `/_next/static/**.js` 去重后求和):合并点 `25e3094f` 基线 513,570 B → 本轮 513,889 B,**+319 B / +0.06%**(±2% 内)。
|
||||
|
||||
## 未做 / 待用户
|
||||
|
||||
- 无登录态:登录后单次揭幕、Slow 3G 下 4 秒兜底揭幕、切换会话零 spinner 均未在浏览器实测,由合同测试覆盖。建议照 `docs/testing/staging-manual-walkthrough-20260901.md` 加一条:DevTools 节流 Slow 3G 刷新首页,加载屏应出现"正在准备对话"文案,最多约 4 秒后一次进入完整界面,进入后无任何转圈。
|
||||
- 本分支包含 `codex/streaming-ux-20260901` 的合并;合入 staging 时会一并带入该分支(其独立验收见该轮记录)。
|
||||
@@ -16,25 +16,25 @@
|
||||
```text
|
||||
jyotisha.chat
|
||||
-> Spaceship DNS
|
||||
-> Caddy on Hong Kong VPS (80/443)
|
||||
-> Caddy on the production VPS 118.194.235.34 (80/443)
|
||||
-> Next.js + Mastra web container (3000, private)
|
||||
-> Python Jyotish API container (5200, private)
|
||||
-> Swiss Ephemeris / local calculation engine
|
||||
-> VedAstro gateway with local fallback
|
||||
-> Supabase Cloud (Auth, Postgres, profiles, sessions, credits)
|
||||
-> external OpenAI-compatible model API
|
||||
-> private PostgreSQL 17 + Better Auth (profiles, sessions, credits)
|
||||
-> external OpenAI-compatible model and mail providers
|
||||
```
|
||||
|
||||
Current production infrastructure:
|
||||
|
||||
- Domain: `https://jyotisha.chat`
|
||||
- Server: Hong Kong, Ubuntu 22.04, `103.117.123.53`, SSH port `22000`
|
||||
- Capacity: 1 vCPU, 2 GB RAM, 40 GB disk, 5 Mbps; intended for demos and low concurrency
|
||||
- Runtime directory: `/opt/jyotisha-app`
|
||||
- Server: Ubuntu x86_64, `118.194.235.34`, dedicated `deploy` user on a confirmed variable SSH port
|
||||
- Capacity: 2 vCPU, 4 GB RAM; digest-pinned images only, no application builds on the host
|
||||
- Runtime directory: `/opt/jyotisha-production`, Compose project `jyotisha-production`
|
||||
- Compose file: `deploy/docker-compose.server.yml`
|
||||
- Production environment: `/opt/jyotisha-app/.env.production` (`0600`, never commit)
|
||||
- Supabase project: `vtvnfqmonbfuxmqkqdlc`
|
||||
- Primary source repository: `https://git.copse.top/root/Jyotisha.git`; GitHub upstream/mirror: `https://github.com/jesse-ux/Jyotisha.git`
|
||||
- Production environment: `/opt/jyotisha-production/.env.production` and `.env.production.database` (`0600`, never commit)
|
||||
- Persistence: private PostgreSQL 17 + Better Auth on the same host
|
||||
- Primary source repository and Actions control plane: `https://git.copse.top/root/Jyotisha.git`; GitHub (`https://github.com/jesse-ux/Jyotisha.git`) is a read-only mirror with no workflows
|
||||
|
||||
Deployment, recovery, DNS, HTTPS, update and verification commands are documented in [`deploy/README.md`](deploy/README.md). Railway/Vercel remain optional alternatives, not the current production topology.
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# 任务书 · 生时校正会话面:消除空白假死与交互摩擦(2026-09-02)
|
||||
|
||||
基线:**`codex/streaming-ux-20260901`(HEAD `c846c44a`)合入后的 `origin/staging`**。本轮改的文件与那条分支高度重叠(`rectification-agentic-chat.tsx`、`page.tsx`、`globals.css`、`rectification-agentic-entry.test.ts`),**必须在它合入之后开工**。
|
||||
|
||||
## 与同日其它任务书的关系(先读)
|
||||
|
||||
| 任务书 | 关系 | 结论 |
|
||||
| --- | --- | --- |
|
||||
| `TASK-unified-loading-20260902.md` | 产品裁决:揭幕后不得再出现 spinner / 骨架 / "正在加载"文案,流式生成中除外;且"与其它改 `page.tsx` 的轮次不得并行" | 本轮**遵守同一裁决**:进入校正面的等待全部提前到切换之前(并行拉完再一次揭幕,见 0.2),面板内不设加载态;剩余等待都是生成中(timeline live 行)。两轮都改 `page.tsx`,**串行执行**:streaming-ux 合入 → 本轮 → unified-loading(本轮对 `page.tsx` 只有两处小改,先做冲突面小)。 |
|
||||
| `TASK-rectification-walkthrough-polish-20260902.md` | 服务端抛光。其 **B.2**(流结束后前端立即刷新快照)与本轮 0.4 重复;其 **D.2**(问题槽必须在对话流内)与本轮问题槽改动同文件 | B.2 由本轮 0.4 承担,polish 执行方只做服务端 emit(若选 `question.ready` 事件,本轮 0.4 直接消费它);D.2 的 UI 部分并入本轮 0.3。两轮同改 `rectification-agentic-chat.tsx`,polish 以服务端为主,**polish 先合入**,本轮 rebase。 |
|
||||
|
||||
用户反馈原话:"动画加载的过程中还有一段时间是空白状态,也没有加载也没有状态,导致用户以为页面卡了;交互也不是很友好。"下面每一条空白都对着代码找到了成因。**先读完「硬红线」再动手。**
|
||||
|
||||
---
|
||||
|
||||
## 事故实证:六段空白 + 一个死角
|
||||
|
||||
行号基于 `c846c44a`,按符号定位。
|
||||
|
||||
### 空白 1 · 首页卡片点下去没有任何反馈
|
||||
|
||||
`starter-home.tsx` 生时校正卡:`rectificationLoading` 期间只把按钮 `disabled`,文案、图标、光标都不变。`use-rectification-surface.ts` `openRectificationCase` 要等 `/api/rectification/cases/open`(鉴权 + RPC `open_agentic_rectification_case_v2` + profile 加载 + 时区解析)返回才切会话。2 vCPU 的生产机上这一步以秒计,用户看到的是"点了没反应"。
|
||||
|
||||
### 空白 2 · 从侧栏点开已有校正会话:先闪普通对话、再整块空白、再重挂
|
||||
|
||||
`use-session-management.ts` `selectSession`:先 `setActiveSessionId`,再 `void openRectificationSession(id)`。`page.tsx` 的 `rectificationSurfaceOpen = activeRectificationSession && activeSession.id === rectificationSessionId`——在 open 返回前是 **false**,于是这一帧渲染的是普通 `ChatTranscript`(用镜像的 `session.messages`)。open 返回后 `setRectificationTurns([])`、面板以 `key=…-loading` 挂载,`initialTurns=[]` → **整块空白**(没有任何 loading 文案),直到 `refreshRectificationCase` 拉回 turns,key 翻成 `-ready` → **整个面板卸载重挂**。三段画面:普通对话 → 空白 → 校正面板。
|
||||
|
||||
### 空白 3 · 新建校正:第一轮回答结束时整个面板重挂一次
|
||||
|
||||
同一个 key:新 case 挂载时 turns 为空(`-loading`),开场轮 `run.completed` → `onCompleted` → `refreshRectificationCase` → turns > 0 → key 变 `-ready` → **重挂**。用户刚读完第一条引导,画面闪一下、滚动归零、时间线开合状态丢失、消息从内存态换成持久化态(trace 只剩回执)。如果用户在这一拍已经开始输入第二轮,重挂会丢掉那次流(`runAbort` 不在卸载时中止,`setMessages` 落到已卸载的实例)。`tests/rectification-agentic-entry.test.ts` :79、:190 两处正则**锁的正是这个 key 写法**。
|
||||
|
||||
### 空白 4 · 恢复会话后,问题槽在快照回来前什么都不显示
|
||||
|
||||
`rectification-agentic-chat.tsx`:`caseSnapshotLoaded` 为 false 时 `showLiveChoiceCard` / `showMissingQuestion` / `showUnavailableQuestion` 全为 false,`.rectification-question-slot` 是空的。用户看到历史消息但没有可做的事,不知道要等。
|
||||
|
||||
### 空白 5 · 选择题点下去之后有两段缝、一次闪卡
|
||||
|
||||
`submitStructuredChoice`(:760–:860):
|
||||
1. 追加一条 thinking 行「正在记录本次选择…」(好)。
|
||||
2. `fetch` 返回 → `await loadCaseSnapshot()` → **`willContinue` 分支把这条 thinking 行删掉**,置 `choiceContinuationPending`,`finally setPending(false)`。
|
||||
3. 下一次 effect 才 `send("read_only")` → 再追加一条新的 thinking 行「正在处理…」并 `setPending(true)`。
|
||||
|
||||
2→3 之间至少有一帧:没有任何 live 行,且 `busy=false` + 快照刚装进的新 `choiceCard` → `showLiveChoiceCard` 为 true → **下一题的卡片闪现一帧又消失**。然后 read_only 的回答流完 → `run.completed` → `await loadCaseSnapshot()`(这次在 `finally` 之前,busy 仍为 true,没缝)→ 卡片出现。
|
||||
|
||||
### 空白 6 · 采用候选时间:只有按钮文案变了
|
||||
|
||||
`acceptCandidate`:`setAcceptingCandidateId` + `setPending(true)`,transcript 里不出现任何 live 行;POST 完成 → `await loadCaseSnapshot()` → `choiceContinuationPending` → 再走空白 5 的 effect 路径。用户盯着一个变灰的按钮「正在采用…」等好几秒,页面其它部分静止。
|
||||
|
||||
### 死角 · 空 turns 的已有会话永远空白
|
||||
|
||||
服务端 `open_agentic_rectification_case_v2` 对 `resumed` 一律返回 `should_start_opening=false`(迁移 `20260812010000_agentic_rectification_v9_runtime.sql` :427/:459/:480)。若一个 case 的开场轮当时失败或未持久化(turns 为空),从侧栏再进来:`initialTurns=[]`、`!shouldStartOpening` → 面板挂载后**什么都不发生、什么都不显示**,composer 可用但用户不知道要先说什么。没有任何 CTA。
|
||||
|
||||
### 交互摩擦(不是空白,但用户说"不友好"的来源)
|
||||
|
||||
- **两条开发者文案**:「当前没有可回答的问题,正在等待服务端更新。」「当前问题暂时无法显示,请等待服务端更新。」——没有动作、没有时限、用户不知道等多久。
|
||||
- **选择题选中无确认感**:`.rectification-choice-card.is-pending` 只改 `cursor: wait`;选中项没有对勾,卡片里没有进度。
|
||||
- **用户的选择不回显**:答过的卡贴在 assistant 消息下(`choiceAttachment`),transcript 里没有一条"我选了 B"的用户气泡;服务端已经返回 `userMessage`(`applied.userDisplay`)但客户端没用;持久化 turn 里的结构化选择又被 `isStructuredChoiceUserText` 过滤掉。刷新前后都看不到自己答了什么。
|
||||
- **右侧盘面首态是一整块空面板**:桌面端 `minmax(18rem, 22.5rem)` 的面板,第一阶段只有一句「补充经历后,这里会显示当前本命宫位和换升时刻。」;移动端 peek 是「当前盘面 · 补充经历后会在这里更新」。用户填过出生时间,面板却像没数据。
|
||||
- **开场 live 行文案是通用的「正在处理…」**,第一次进入的用户不知道系统在做什么。
|
||||
- **停止**:`send` 的 `catch` 已区分 `AbortError`,但请核对 aborted 分支落地的文案不是「生时校正暂时不可用,请稍后再试。」(当前 :700 附近的通用兜底)。
|
||||
|
||||
---
|
||||
|
||||
## 硬红线
|
||||
|
||||
1. **不改服务端语义、不改 SQL。** `should_start_opening`、`choice.applied` 的返回、`awaitTurnExitBeforeResponse` 都不动。死角修复用已有的 `send("opening")`(服务端已抑制重复 opening,`rectification-agentic-entry.test.ts` :190 锁着这条性质)。
|
||||
2. **只用上一轮统一好的那一套活动 UI**:live 行 = `ConsultationRunTimeline` 的 queued/live 行(`InlineSpinner` + shimmer 文案),不得新造第二种 spinner、骨架屏或呼吸动画。§9 等待词汇表不扩表。
|
||||
3. **不得手写 `useCallback` / `useMemo`**;`rectification-agentic-chat.tsx` 既有的不删不加。
|
||||
4. **不得修改既有测试断言**,除非它锁的正是缺陷本身(本轮明确允许:`rectification-agentic-entry.test.ts` :79/:190 的 `-ready/-loading` key 锁、任何锁「等待服务端更新」文案的断言);改时在断言上方注释原值与错因,PROGRESS 单列。
|
||||
5. `./node_modules/.bin/tsc --noEmit` 通过(不要 `npx tsc`);`next build` 通过;测试数不低于基线、失败清单逐条比对无新增。
|
||||
6. 浅色/深色/`prefers-reduced-motion` 三套都验。
|
||||
7. 不改 `.gitea/workflows/**`;不在脏工作树切分支;不自行把 staging 提升到 main。
|
||||
8. BUG 编号开工时先看远端最大号(写本任务书时 staging 最大 472,streaming 分支占 473–478,**本轮从 479 起,仍需现场确认**)。
|
||||
|
||||
让步顺序:功能与测试不回归 > 可验证的修复 > 视觉一致 > 代码整洁。
|
||||
|
||||
---
|
||||
|
||||
## 任务 0(P0)· 六段空白与死角
|
||||
|
||||
### 0.1 入口有反馈,但不转圈
|
||||
|
||||
`starter-home.tsx`:`rectificationLoading` 时卡片 `aria-busy="true"`、`data-opening="true"`,footer 的 action 文案换成「正在打开…」(静态文案,**不加 spinner**,遵守 unified-loading 裁决),卡片 `cursor: progress`。文案在 `rectificationCardLabel` 的派生处加 loading 分支。侧栏校正会话行在 `rectificationLoading && 目标是该行` 时同样只加 `aria-busy` 与静态「打开中」尾注,不转圈。
|
||||
|
||||
### 0.2 一次揭幕:open + 记录 + 快照并行拉完再切面板,面板挂一次不重挂
|
||||
|
||||
- `use-rectification-surface.ts` `openRectificationCase`:`/cases/open` 返回后**不立刻**切会话;改为 `Promise.allSettled([refreshRectificationCase(caseId, sessionId), fetch 案例快照])` 并行拉 turns 与快照(上限 4 秒,与 unified-loading 同一常量),全部落地后再一次性 `setRectificationTurns / setRectificationSnapshot / setRectificationSessionId / setActiveSessionId`。超时或失败:turns 用空数组、快照用 null,仍然切换(面板会走 0.6 空态或 0.4 的重试路径),并 composer notice「校正记录没有完全加载,可以继续」。
|
||||
- `use-session-management.ts` `selectSession`:对校正会话**不再先 `setActiveSessionId`**,改为只调 `openRectificationSession(id)`,由上一条在数据齐了以后切换;期间旧画面保持不动(这就是"先闪普通对话"的消除)。URL 写入时机随之后移到切换那一刻。
|
||||
- `page.tsx`:面板 key 去掉 `-ready/-loading` 后缀,只保留 `${sessionId}-${caseId}`;props 增加 `initialSnapshot`。面板内 `useState(() => messagesFromTurns(initialTurns))` 与 `useState(() => initialSnapshot)` 初始化,`caseSnapshotLoaded` 初值 = `initialSnapshot !== null`;挂载后**不再**自己拉一次快照(0.4 的重试路径除外)。
|
||||
- turns 的后续到达(`onCompleted` → `refreshRectificationCase`)改为 **prop 更新**:面板内 `useEffect([initialTurns])`——本地 `messages` 为空且 `initialTurns.length > 0` 时用 `messagesFromTurns` 填充;本地已有消息时忽略,不覆盖、不重挂。
|
||||
- 卸载时 `runAbort.current?.abort()`(cleanup effect)。
|
||||
|
||||
### 0.3 问题槽只有生成中态,且始终在对话流内
|
||||
|
||||
- 快照随揭幕一起到位后,问题槽没有"等待快照"这一态;仅当 0.4 的重试在跑时显示 live 行。
|
||||
- **承接 polish D.2**:问题槽(live 选择卡 / spoken prompt / 状态行)渲染为 transcript 的**最后一条内容**——放在候选卡之后、`rectification-saved` 之前,用 `.message-entry` 的同一缩进与间距(`--assistant-content-inset`),不得悬在卡片外。
|
||||
|
||||
### 0.4 两条"等待服务端更新"文案改为有动作的状态
|
||||
|
||||
`showMissingQuestion` / `showUnavailableQuestion` 命中时:
|
||||
1. 先自动重拉快照:若 polish 轮落地了 `question.ready` 公开事件,则收到即拉;否则在 `run.completed` 后立即拉一次,再最多 2 次、间隔 2s(`useVisibilityAwarePoll` 已有,复用)。期间问题槽显示 live 行「正在准备下一个问题…」——这是生成中等待,符合裁决。
|
||||
2. 3 次后仍命中:显示「没有拿到下一个问题。」+ 一个 44px 次级按钮「重新加载」(调 `loadCaseSnapshot`)。
|
||||
3. 两条旧文案从源码删除。
|
||||
|
||||
### 0.5 选择题点击后不留缝、不闪卡
|
||||
|
||||
`submitStructuredChoice` 的 `willContinue` 分支:**不删 thinking 行、不经 effect 中转**。把 continuation 收进同一个 async 流程:`fetch` 成功 → `await loadCaseSnapshot()` → 直接 `await send("read_only", "")`,并让 `send` 接受一个可选参数 `reuseAssistantRenderKey`,用已存在的那条 thinking 行(同一个 renderKey)承接后续事件,label 从「正在记录本次选择…」自然过渡到「正在处理…」/tool 文案。`busy` 全程为 true(`setPending(false)` 只在整条链的最后)。`choiceContinuationPending` 这条 ref + 对应 effect 删除。
|
||||
`acceptCandidate` 同样:点击即在 transcript 末尾追加 thinking 行「正在采用 HH:MM…」,POST → 快照 → `send("read_only")` 复用该行。
|
||||
|
||||
### 0.6 空 turns 的已有会话给出起点
|
||||
|
||||
面板挂载且 turns 已加载为空、`!shouldStartOpening`、`!readonly` → `.message-list` 显示空态:「这段校正还没有开始。」+ 主按钮「开始提问」(调 `send("opening", "")`)。服务端幂等由 :190 锁定,客户端只需 `openingStarted` 守卫。
|
||||
|
||||
### 0.7 停止后的文案
|
||||
|
||||
核对 `catch` 的 aborted 分支:已有内容时行内保留,composer notice 为「已停止,已生成的内容保留;本次不会扣点。」;无内容时移除该行、不报错。若现状已如此,只补一条源码锁。
|
||||
|
||||
### 验收(任务 0)
|
||||
|
||||
- 契约测试(源码锁 + 纯函数):`page.tsx` 无 `"ready" : "loading"`;`use-rectification-surface.ts` 含 `allSettled` 与 4 秒常量;`selectSession` 对校正会话不直接 `setActiveSessionId`;面板源码含 `initialSnapshot`;揭幕后 `rectification-agentic-chat.tsx` / `starter-home.tsx` 无非生成中的 `InlineSpinner`;`rectification-agentic-chat.tsx` 无 `choiceContinuationPending`、无「等待服务端更新」;存在「正在打开…」「正在准备下一个问题」「这段校正还没有开始」;`send` 签名含 `reuseAssistantRenderKey`;卸载 cleanup 调 `abort`。
|
||||
- 纯函数:新增 `rectification-surface-state.ts`(把"恢复中 / 空态 / 问题槽四态"的判定抽成纯函数)并测全部分支。
|
||||
- 手工清单追加到 `docs/testing/staging-manual-walkthrough-20260901.md`:① 首页点卡片看到「正在打开…」,随后一次性出现完整面板(消息 + 问题 + 盘面);② 侧栏切校正会话:旧画面保持到数据齐、不闪普通对话、不出现空白;③ 新建校正第一轮结束不闪、滚动不归零;④ 连点两道选择题中间无空帧无闪卡;⑤ 采用候选看到 live 行;⑥ 一个开场失败的旧会话进来有「开始提问」。
|
||||
|
||||
### 建档
|
||||
|
||||
BUG-479(校正会话面挂载/重挂造成三段空白)、BUG-480(选择题与采用候选之间的缝与闪卡)、BUG-481(空 turns 会话无起点)。
|
||||
|
||||
---
|
||||
|
||||
## 任务 1(P1)· 交互摩擦
|
||||
|
||||
### 1.1 选择题卡有确认感
|
||||
|
||||
`rectification-choice-card.tsx` + CSS:选中项显示 `Check` 图标与「已选择」;`pending` 时卡片顶部一行 `InlineSpinner size={12}` + 「正在记录…」(同 timeline live 行的排版,不另造);未选项在 pending 时降到 `.48` 透明度(已有)。所有选项按钮 `min-height: 44px`(核对 `touch-target-contract`)。
|
||||
|
||||
### 1.2 用户选择回显为用户气泡
|
||||
|
||||
选择成功后,用服务端返回的 `userMessage`(`applied.userDisplay`)在 transcript 追加一条 **用户气泡**(`role: "user"`),紧跟在答过的卡片之后、thinking 行之前。持久化侧:`isStructuredChoiceUserText` 过滤要改成**保留**这类 turn 并原样显示(否则刷新后回显消失)。`choiceAttachment` 贴卡逻辑保留(卡本身仍显示所选项)。`tests/rectification-answer-choice.test.ts` 若锁了过滤行为,按红线 4。
|
||||
|
||||
### 1.3 盘面首态不空
|
||||
|
||||
- `rectification-board.tsx`:`result` 为空时,header 时钟位显示 profile 的填报时间(面板 props 增加 `declaredTime: string | null`,由 `page.tsx` 从 profile 传入),正文改为两行:「填报出生时间 HH:MM」「回答几个问题后,这里会显示宫位随时间的变化。」;`rectificationBoardPeekCopy` 同步为「当前盘面 · 填报 HH:MM」。
|
||||
- 若快照 API 已提供填报时间对应的宫位表(先 grep `natal`/`declared` 字段确认),则直接渲染那张表作为首态;**没有就不要造数据**,只做文案。
|
||||
- CSS:`result` 为空时 `.rectification-workspace` 的板列收为 `minmax(16rem, 18rem)`(加 `is-board-empty` 修饰类),有结果后恢复。
|
||||
|
||||
### 1.4 开场 live 行文案
|
||||
|
||||
`send("opening")` 的初始 live 行 label 用「正在读取你的出生资料,准备第一个问题…」;`send("message")` 用「正在处理…」;`read_only` 沿用上一步传入的 label。通过 `send` 的 action 分支决定,不要在渲染层判断。
|
||||
|
||||
### 1.5 402 跳转前先给提示
|
||||
|
||||
`window.location.assign(membershipHref("rectification"))` 前先 `setError("校正点数不足,正在前往兑换…")`,并延迟 600ms 再跳,避免页面无预警消失。
|
||||
|
||||
### 验收(任务 1)
|
||||
|
||||
- 契约:choice card 源码含 `Check`;`isStructuredChoiceUserText` 不再用于过滤渲染;board 源码含 `declaredTime`;`send` 源码含开场文案。
|
||||
- 手工:一轮完整校正(开场 → 3 道选择题 → 候选 → 采用)录屏,浅色一份。
|
||||
|
||||
### 建档
|
||||
|
||||
BUG-482(选择不回显、无确认感)、BUG-483(盘面首态空)。
|
||||
|
||||
---
|
||||
|
||||
## 任务 2(P2)· DESIGN.md
|
||||
|
||||
§5 新增「Rectification surface」条目:
|
||||
|
||||
- **States**:`opening`(首轮引导流中)、`resuming`(恢复记录/进度)、`empty`(无 turns 有起点)、`waiting-question`(准备下一题,含自动重试与手动重载)、`choice-live`、`choice-pending`、`candidates`、`accepted`、`confirmed`、`readonly`、`failed`。每态写明问题槽、transcript 末尾 live 行、composer 三者各显示什么。
|
||||
- **Rule**:面板一个会话只挂载一次;turns 与快照都是 prop/state 更新,不是 remount。任何"等待"都必须是 timeline live 行或问题槽 live 行,禁止裸文案等待、禁止「等待服务端更新」类措辞。
|
||||
- **Board**:首态显示填报时间;空态收窄;有结果后展开。
|
||||
- §9 表不新增行;写一句"校正面所有等待复用行内等待"。
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序
|
||||
|
||||
0.2 先做(它改变挂载模型,其余都建立在"不重挂"上)→ 0.1/0.3/0.4/0.6/0.7 → 0.5 → 任务 1 → 任务 2。每个任务单独 commit。
|
||||
|
||||
## PROGRESS 要求
|
||||
|
||||
`PROGRESS-rectification-ux-20260902.md`:每任务改动文件、被触碰断言(原值/新值/理由)、六段空白各自消除的证据(契约名或纯函数用例名)、tsc/build/测试数字与基线比对、BUG 编号、未做与原因。
|
||||
@@ -0,0 +1,77 @@
|
||||
# 任务书 · 首次真实走查抛光:题干必达、时序、去重、采用后续流、门一致性(2026-09-02)
|
||||
|
||||
基线:`origin/staging` 当前 tip(含 PR #47/#48/#49 全部合并)。
|
||||
|
||||
## 0. 背景
|
||||
|
||||
2026-09-02 产品负责人在真实 staging 环境完整走查生时校正(case `a17efc37-1494-4d13-91a9-d719838ef66c`,快照向发起人索取)。**三轮修复全部生效**:收窄有进度播报、offer 出牌、采用 04:53 成功、ledger 含 `dasha-transition-proximity`、品质题生成、`matrix-scoring-7 / policy-v3` 在线。本轮修走查暴露的 5 个抛光问题,全部已静态定位。**不改任何决策门语义与真实性边界**(问题 E 除外,它是把两个已存在的权威对齐,需产品选边)。
|
||||
|
||||
## 问题 A(P0)· 选择卡出现过"无题干"——静默降级必须 fail-closed
|
||||
|
||||
**现象**:第一道区分题(D9 相处方式)卡片只有 A-D 选项,无题干;同一 focus 的题干也没写进 turn 历史(后续各题的题干均由 answer_choice 路径正常写入并显示)。
|
||||
|
||||
**定位**:卡片组件渲染 `card.prompt`(`rectification-choice-card.tsx:51` 的 `<legend>`),无题干 = focus 创建时 schema.prompt 为空。`serverOwnedChoiceCopy`(`choice-card.ts:433`)在 `frame.prompt`/`period` 缺失或超出 4-80 字符窗时**静默 return null**;`server-focus.ts:80-84` 拿到 null 后 focus 仍带空 prompt 建立 → 卡出、题干丢;turn-exit 的题干持久化因 `question?.prompt` 空同步被跳过——一个根因两个症状。message/agent 路径创建的第一个 choice focus 命中此路径(answer_choice 路径正常,说明缺口在 agent 后 `persistNextInterviewIfIdle` → `persistServerOwnedFocus`/`persistFocusAfterChoice` 这条创建链上;按符号追)。
|
||||
|
||||
**修法**:
|
||||
1. choice focus 创建 fail-closed:`serverOwnedChoiceCopy` 返回 null 时**不得**建立可渲染的 choice focus——回退为 spoken collect(`spokenCollectFallbackFollowup` 已存在)或跳过该 probe 记入 dropped(reason 如 `unrenderable_choice_copy`),二选一并写明理由。
|
||||
2. 排查 null 的实际成因(该 D9 probe 的 `question` 为"亲密关系里,你更接近哪一种相处方式?",14 字在窗内——大概率是 choice_frame 组装时 prompt 字段没接上,而不是超长),修实际断点。
|
||||
3. 不变量测试:任何 `choice_card` 非空 ⇒ `choice_card.prompt` 非空;任何 active choice focus ⇒ `projectCurrentQuestion(...)?.prompt` 非空。用走查 case 的 probe 形状做回归。
|
||||
|
||||
## 问题 B(P1)· "界面上有下一问"先于问题出现——时序与措辞双修
|
||||
|
||||
**现象**:模型正文说"界面上继续有下一问",但题干 turn 在流结束后的 turn-exit 才持久化,用户当下看不到。
|
||||
|
||||
**修法**:
|
||||
1. 措辞:`agentic-rectification.ts` 指令补一条——正文不得断言界面当前状态("界面上有/出现了…"),过渡用中性表述("接下来我们继续"类);VOICE.md 同步。
|
||||
2. 时序:turn-exit 持久化题干/focus 后,在关闭流之前向前端 emit 一个已允许的公开事件(如 question.ready,走 `safePublicEvent` 白名单)或由前端在 `done` 事件后立即刷新 case 快照。选实现小的,说明理由。前端已有刷新机制的话只需确认时机覆盖。
|
||||
|
||||
## 问题 C(P2)· 同域采集问句逐字复读
|
||||
|
||||
**现象**:"感情这边,还记得哪年认真在一起、分开,或结婚吗?"在历史里逐字出现两次(用户第一次用工作事件回答,感情域仍未覆盖,重问正确,但复读机器感强)。
|
||||
|
||||
**修法**:同一域的 collect 问句在**已问过且未被拒答**时重问,需换第二措辞(copy 模块每域加一条 retry 变体,如"回到感情这边——刚才说的工作我记下了,哪年认真在一起或分开还记得吗?")。判定用结构化状态(该域 collect focus 曾建立过),不做正文匹配。
|
||||
|
||||
## 问题 D(P1)· 采用后没有切入前事核对,旧采集问题挂在卡下
|
||||
|
||||
**现象**:采用 04:53 成功后,`current_question` 仍是采用前的"钱的方面…"采集 focus,显示在候选卡下方、脱离对话流;预期是 `verify_adopted_time`:按采用分钟反推最多两件前事核对(reverse_verify 链路已实现但没被接上)。
|
||||
|
||||
**修法**:
|
||||
1. 采用(accept RPC 成功)后的下一次 turn-exit / GET 投影:关闭或替换采用前的 collect focus,按 `next_user_action.id=verify_adopted_time` 建立 reverse_verify 焦点(`remainingReverseVerifyProbes` 已存在,`MAX_REVERSE_VERIFY=2`);无可用前事探针时给出 `start_consultation` 引导,不留旧问题。
|
||||
2. UI:当前问题槽在候选卡区域之后渲染时必须仍在对话流内(作为最新一条内容),不得视觉上悬挂在卡片外;具体实现交给前端组件(`rectification-agentic-chat.tsx` 的 currentQuestion 渲染位置)。
|
||||
3. 不变量:`accepted_time` 非空 ⇒ `current_question` 为 reverse_verify 类或空且 `next_user_action ∈ {verify_adopted_time, start_consultation}`;不得为采用前的 collect focus。
|
||||
|
||||
## 问题 E(P1,需产品选边)· coverage 门与 offer 工具不一致
|
||||
|
||||
**现象**:走查中 occupation(职业)从未被问,TS overlay 判 `can_adopt: false`(coverage 未完成),但模型 offer 工具 + 引擎 `accept_allowed=true` 照常出牌且采用成功——coverage 门被 offer 链路绕过。结果可用但两个权威打架,且 skill 文本写"职业挡出牌"。
|
||||
|
||||
**二选一(在 PR 里写明选择与理由,默认选 1)**:
|
||||
1. **对齐引擎(推荐)**:coverage(含 occupation)从 `deliveryCapability` 的采用门移除,只保留为**问询路由**优先级(没问过职业时优先问,但不挡出牌);skill 10.0.14 文本中"职业挡出牌"一句需在下次 skill 版本 bump 时同步修订(本轮先在 PR 记录偏差,不 bump skill)。
|
||||
2. 对齐 TS:offer 工具服务端校验 coverage,未完成时拒绝 offer。风险:重新引入"差一问才能出牌"的摩擦,与 Round A 的用户体验目标相悖。
|
||||
|
||||
无论选哪个:overlay 与实际可执行动作必须一致——不允许再出现 `can_adopt:false` 与"卡已出、采用成功"并存。
|
||||
|
||||
## 硬红线
|
||||
|
||||
1. 不改采用/确认门的真实性语义(问题 E 按上面二选一后对齐,不新增放宽);确认门恒 fail-closed。
|
||||
2. 问题槽服务端唯一所有不变;所有判定结构化,不做正文字符串匹配。
|
||||
3. 引擎(Python)不动。
|
||||
4. `./node_modules/.bin/tsc --noEmit` 通过(不要用 `npx tsc`);`rectification-*` / `consultation-*` / `consult-*` / `chat-*` 测试 fail=0;改动的既有断言逐条三栏说明(旧→新→保留语义)。
|
||||
5. 无凭据不得声称已真实环境验证;修完把走查 case 形状的前后对照贴 PR。
|
||||
|
||||
## 开工前置
|
||||
|
||||
```bash
|
||||
git fetch origin --prune
|
||||
git worktree add -b codex/rectification-walkthrough-polish-20260902 \
|
||||
../.worktrees/rectification-walkthrough-polish-20260902 origin/staging
|
||||
```
|
||||
|
||||
读 `frontend/AGENTS.md`、`docs/BUG_HISTORY.md`(BUG-456/460/463/468/469 链)、`frontend/docs/VOICE.md`。**行号是线索,按符号名定位。**
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 问题 A 的两条不变量全绿;D9 probe 形状回归:卡必有题干,历史必有题干 turn。
|
||||
2. 问题 D 的采用后不变量全绿;走查 case 形状:采用后 `current_question` 切换为前事核对。
|
||||
3. 问题 E 所选方案落地后:overlay 的 `can_adopt` 与 offer/accept 实际可执行性一致(合成两个方向的测试)。
|
||||
4. tsc + 四组测试 fail=0;BUG_HISTORY 追加条目(编号接现有最大号之后,先 grep 确认,避免再撞号)。
|
||||
5. 建议真实环境复测清单(人工):完整走一遍并确认——首道选择卡有题干;无"界面上有下一问"式断言;同域重问换措辞;采用后立即出现前事核对问题且在对话流内。
|
||||
@@ -0,0 +1,82 @@
|
||||
# 任务书 · 首页加载统一:一次等待、一次揭幕(2026-09-02)
|
||||
|
||||
基线:`origin/staging` 最新。**前置条件:`codex/streaming-ux-20260901`(c846c44a)必须先合入**——它改了消息区渲染链路,本轮改揭幕时机,两者同时改 `page.tsx` 渲染路径必然冲突。未合入前不得开工,登记 `BLOCKED.md`。与其它改 `page.tsx` 的轮次不得并行。
|
||||
|
||||
产品要求原话:"外面一层加载进去之后里面组件又有加载动画"→ 改成"外层加载显示不同状态,进去之后直接显示完整的,不加组件加载动画"。
|
||||
|
||||
---
|
||||
|
||||
## 为什么要做(事故实证)
|
||||
|
||||
行号基于 `701d4f92`,按符号定位。
|
||||
|
||||
1. **外层加载只等三样。** `page.tsx:862` 的 `Promise.all` 只拉账户、模型目录、会话列表,拿到就 `setHydrated(true)` 揭幕(`main.app-loading` 在 1503)。
|
||||
2. **揭幕后还有四处各自转圈**:
|
||||
- **当前会话消息**:BUG-464 后列表不带消息,揭幕时当前会话(默认最近一条或 `?c=` 指定)的消息尚未加载,`ensureSessionMessages`(485–487 的 effect)才开始拉,消息区先显示 `session-messages-loading` + `InlineSpinner`(1867–1868)。**这是用户最先看见的第二层等待**。
|
||||
- **starter 推荐问题**:profile 完整时 1153–1187 的 effect 拉 `/api/onboarding`,期间显示 `starter-loading`(1836)。
|
||||
- **每日星语**:1205 起拉 `fetchDailyStarlanguage`,卡片 `aria-busy`(`starter-home.tsx:88`,`dailyStarlanguageBusy` 630–634)。
|
||||
- **校正入口摘要**:523 拉 `entry-summary`,影响校正卡文案;打开校正时 `BirthTimeRectification` 动态分包还有一行"正在加载出生时间评估..."(230–235)。
|
||||
3. 这些请求彼此独立,全部可以在揭幕前并行完成——现在是串行体验(先等外层、再逐个等内层),并非数据依赖所迫。
|
||||
|
||||
## 决策记录(产品授权,2026-09-02)
|
||||
|
||||
1. **单一加载动画**:全站首页只保留 `AppLoadingIndicator`(轨道动画)这一种加载表现。揭幕后不得再出现任何 spinner/骨架/"正在加载"文案,**流式回答的生成中状态除外**(那是内容本身在生成,不是加载)。
|
||||
2. **两阶段揭幕**:阶段一(账户/模型/会话列表)→ 阶段二并行(当前会话消息、starter 问题、每日星语、校正入口摘要、校正分包预热 `import()`)→ 全部就绪才揭幕。外层加载屏的 `detail` 文案随阶段切换(例如"正在读取账户…"→"正在准备对话…"),文案由执行方按现有 `AppLoadingIndicator` 的 title/detail 口径拟,浅深两套主题下检查。
|
||||
3. **超时降级,不无限等**:阶段二整体上限 **4 秒**(常量,可配)。超时未到的项一律进入其**静态最终态**,不是加载态:starter 用现有安全默认问题(1836 附近已有"个性化问题暂时不可用"路径)、每日星语用非个性化"查看今日运势"文案、校正卡用无摘要文案、当前会话消息未到则揭幕后**静默补上**(消息区空白但不转圈,到达即渲染)。阶段一失败仍走现有 `app-loading-error`。
|
||||
4. **后续切换会话**:揭幕后在后台按侧栏顺序预取最近 5 条会话的消息(低优先级、串行、复用 `ensureSessionMessages` 与 hydrated 缓存),让常见切换零等待;缓存未命中时消息区**不显示 spinner**,仅保留静态占位(无动画)直到消息到达。这一条是折中,产品若要"未命中也完全空白"可在验收时改口。
|
||||
5. 打开校正的动态分包 loading 文案保留为兜底(分包已预热,正常不会看到)。
|
||||
|
||||
## 硬红线
|
||||
|
||||
1. **外层加载屏的 DOM 合同不变**:`main.app-loading[aria-busy="true"]` 与 `main.app-loading-error` 选择器被 `stale-client-recovery.tsx:34-35` 依赖,必须原样保留。
|
||||
2. 阶段二**不得阻塞阶段一的错误处理**;401 仍走 `redirectToLogin`,`?c=` 恢复、sessionStorage 回跳(BUG-465)语义不变。
|
||||
3. 揭幕后不得新增任何 `InlineSpinner` / `role="status"` 加载态;被移除的加载态所对应的合同测试(先 `grep -rn 'starter-loading\|session-messages-loading\|dailyStarlanguageBusy\|正在加载出生时间' frontend/tests tests`)按例外条款修改,注明原值与原因;其余断言不得改。
|
||||
4. 不得手写 `useCallback` / `useMemo`;`npm run lint` 0 error(BUG-470 后 react-hooks 规则已能分析 Home,effect 内同步 setState、render 写 ref 都会被拦)。
|
||||
5. `./node_modules/.bin/tsc --noEmit` 通过;测试总数不低于开工时 `origin/staging` 实测(Docker 环境 fail=0/skipped=0,无 Docker 逐条比对既有缺口);`next build` 后 `/` 仍 `○ Static`;首屏 gzip ±2%。
|
||||
6. 不改 `.gitea/workflows/**`;不动数据库;不在脏工作树切分支;不自行提升 main。
|
||||
|
||||
让步顺序:功能与测试不回归 > 一次揭幕的体验 > 揭幕耗时 > 代码整洁。
|
||||
|
||||
## 开工前置
|
||||
|
||||
```bash
|
||||
git fetch origin --prune
|
||||
git worktree add -b codex/unified-loading-20260902 \
|
||||
../.worktrees/unified-loading-20260902 origin/staging
|
||||
```
|
||||
|
||||
确认 streaming-ux 已合入。读 `pre_work_error_ledger.md`、`frontend/AGENTS.md`、`docs/BUG_HISTORY.md` 的 BUG-464/465/470。先读:`page.tsx` 启动 effect(840–960)、`ensureSessionMessages`(`use-session-management.ts`)、starter/每日星语/入口摘要三个 effect、`app-loading-indicator.tsx`、`starter-home.tsx`。
|
||||
|
||||
## 任务分解
|
||||
|
||||
### 任务 1(P0)· 两阶段揭幕
|
||||
|
||||
- 启动 effect 改为:阶段一现有 `Promise.all` → 计算选中会话(复用 `resolveBootstrapSessionSelection`)→ 阶段二 `Promise.allSettled` 并行拉当前会话消息、starter 问题(仅 profile 完整时)、每日星语、校正入口摘要,并 `void import("@/components/birth-time-rectification")` 预热 → 4 秒上限 → `setHydrated(true)`。
|
||||
- 加载屏 `detail` 按阶段切换文案。
|
||||
- 原来揭幕后才触发的三个 effect 改为"若已在阶段二拿到则跳过",避免重复请求(观测:首屏网络面板同一端点不得出现两次)。
|
||||
|
||||
### 任务 2(P0)· 移除揭幕后的加载态
|
||||
|
||||
- 删除首屏路径上的 `session-messages-loading`、`starter-loading`、每日星语首屏 `aria-busy`;按决策记录 3 落各自的静态最终态。
|
||||
- 保留:流式生成中的状态;校正分包兜底文案。
|
||||
|
||||
### 任务 3(P1)· 切换预取
|
||||
|
||||
- 揭幕后按侧栏顺序串行预取最近 5 条会话消息;缓存未命中时的静态占位(决策记录 4)。
|
||||
|
||||
### 任务 4(P1)· 合同测试
|
||||
|
||||
- 锁:阶段二包含四项请求与预热;4 秒上限常量存在;揭幕后 `page.tsx` + `starter-home.tsx` 无 `InlineSpinner`/`role="status"` 加载态(流式区除外);`stale-client-recovery` 依赖的选择器仍在。
|
||||
|
||||
## 总验收
|
||||
|
||||
1. tsc / lint / 测试基线 / `/` Static / gzip(红线 4–5)。
|
||||
2. 首屏时序说明写进 PROGRESS:阶段一、阶段二各自耗时(本地测量即可),并附网络面板截图或请求清单证明无重复请求。
|
||||
3. 行为:登录后从加载屏到完整界面**只有一次揭幕**,消息、推荐问题、每日星语、校正卡同时就位;断网或慢速(DevTools 节流 Slow 3G)下 4 秒后揭幕且各项为静态兜底态、无任何转圈。无登录态环境则合同测试覆盖并如实标注。
|
||||
|
||||
## 明确不做
|
||||
|
||||
- 不改流式回答的生成中表现(streaming-ux 轮已定)。
|
||||
- 不改校正会话面内部的加载/busy 语义(`rectification-agentic-chat` 的 `aria-busy` 是生成中,不是加载)。
|
||||
- 不做骨架屏——产品要的是"不显示,直到完整"。
|
||||
- 不动报告页、会员页的加载表现(另议)。
|
||||
+7
-3
@@ -8,14 +8,14 @@ This file is the operational source of truth for Jyotisha deployment. The produc
|
||||
| --- | --- |
|
||||
| Public domain | `https://jyotisha.chat` |
|
||||
| DNS | Spaceship nameservers (`launch1.spaceship.net`, `launch2.spaceship.net`) |
|
||||
| Current public host | Old VPS; keep as a rollback asset until reconciliation completes |
|
||||
| Current public host | `118.194.235.34`; production health reports the local business/identity PostgreSQL databases. The old VPS `103.117.123.53` is no longer a deployment target |
|
||||
| Target host | `118.194.235.34`, Ubuntu x86_64 |
|
||||
| Target SSH | dedicated `deploy` user, confirmed variable port, public-key authentication only |
|
||||
| Target capacity | 2 vCPU / 4 GB RAM; no application builds on host |
|
||||
| Target app directory | `/opt/jyotisha-production` |
|
||||
| Target environment files | `.env.production` and `.env.production.database` (`0600`) |
|
||||
| Primary source repository | `https://git.copse.top/root/Jyotisha.git` |
|
||||
| GitHub upstream/mirror | `https://github.com/jesse-ux/Jyotisha.git` |
|
||||
| GitHub mirror | `https://github.com/jesse-ux/Jyotisha.git` (read-only mirror; no workflows, Actions disabled) |
|
||||
| Migration source | Supabase project `vtvnfqmonbfuxmqkqdlc` + Supabase Auth |
|
||||
| Migration target | private PostgreSQL 17 + Better Auth |
|
||||
|
||||
@@ -159,7 +159,7 @@ Staging is isolated from production:
|
||||
| Identity | Better Auth + Resend OTP on the same private PostgreSQL cluster |
|
||||
| Actions control plane | Gitea 1.26.2 (`git.copse.top`) |
|
||||
|
||||
Gitea is the primary source repository and Actions control plane. Gitea automatically injects the per-job `${{ secrets.GITEA_TOKEN }}` token; its access is limited by each workflow's `permissions` block and it must not be configured as a repository secret. Configure repository Actions secrets `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`, and `STAGING_SSH_PRIVATE_KEY`, plus variables `STAGING_HOST`, `STAGING_PORT`, `STAGING_USER`, `STAGING_PATH`, `STAGING_URL`, and `STAGING_KNOWN_HOSTS`. `STAGING_SSH_PRIVATE_KEY` must be the private-key file encoded as one unwrapped base64 line (for example, `base64 < key | tr -d '\n'`), not a multiline PEM/OpenSSH value; staging workflows decode it only into a mode-`0600` temporary file and validate it with `ssh-keygen`. Staging is an independent test line and is not required to equal or remain inside `main` history. A push to `staging` runs the exact-SHA quality gate; its publish job creates immutable API/web image digests plus an allowlisted controller bundle from that same staging SHA, then explicitly dispatches `Deploy staging` from `refs/heads/staging`. The deploy workflow validates the source gate run, consumes only that gate-attested artifact, rejects stale normal releases, and never checks out or executes an untested branch controller. The staging key, database, Resend key, and model-provider keys must not be shared with production. Staging image publishing has no Supabase build variables. GitHub workflows are upstream/mirror fallback only, not the normal staging release path.
|
||||
Gitea is the primary source repository and Actions control plane. Gitea automatically injects the per-job `${{ secrets.GITEA_TOKEN }}` token; its access is limited by each workflow's `permissions` block and it must not be configured as a repository secret. Configure repository Actions secrets `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`, and `STAGING_SSH_PRIVATE_KEY`, plus variables `STAGING_HOST`, `STAGING_PORT`, `STAGING_USER`, `STAGING_PATH`, `STAGING_URL`, and `STAGING_KNOWN_HOSTS`. `STAGING_SSH_PRIVATE_KEY` must be the private-key file encoded as one unwrapped base64 line (for example, `base64 < key | tr -d '\n'`), not a multiline PEM/OpenSSH value; staging workflows decode it only into a mode-`0600` temporary file and validate it with `ssh-keygen`. Staging is an independent test line and is not required to equal or remain inside `main` history. A push to `staging` runs the exact-SHA quality gate; its publish job creates immutable API/web image digests plus an allowlisted controller bundle from that same staging SHA, then explicitly dispatches `Deploy staging` from `refs/heads/staging`. The deploy workflow validates the source gate run, consumes only that gate-attested artifact, rejects stale normal releases, and never checks out or executes an untested branch controller. The staging key, database, Resend key, and model-provider keys must not be shared with production. Staging image publishing has no Supabase build variables. GitHub is a read-only mirror with no workflow files and Actions disabled; every staging and production workflow runs only in Gitea.
|
||||
|
||||
`Independent Staging Quality Gate` runs for relevant `pull_request` paths, pushes to `staging`, and `workflow_dispatch`. It validates the Python/database/frontend contract; only a successful push to `staging` publishes the API/web images and a run-bound artifact containing their `sha256` digests plus the allowlisted controller bundle. The publish job rechecks the current staging head and dispatches `.gitea/workflows/deploy-staging.yml` from `refs/heads/staging` with the exact SHA and source gate run ID. The deploy workflow waits for that gate's success, validates the artifact against the full 40-character commit, and deploys digest references rather than trusting discoverability tags.
|
||||
|
||||
@@ -196,6 +196,10 @@ After source sync and before `up`, the workflow validates `.env.staging` mode/se
|
||||
6. If the read-only checker reports a pending migration, stop app deployment and run `Migrate Staging Database` manually using **Use workflow from: staging** with the same full SHA. Migration success does not dispatch deployment.
|
||||
7. After migration succeeds, manually start `Deploy staging` from `staging` with that same exact SHA and `allow_rollback=false`, then confirm `https://staging.jyotisha.chat/api/health` reports it and private API health.
|
||||
|
||||
### Resetting one staging account
|
||||
|
||||
`.gitea/workflows/reset-staging-account.yml` (`Reset Staging Account (manual only)`) wipes the onboarding profile, chat sessions, chart profiles, and synastry reports of exactly one staging account while preserving its identity rows, credits, and ledgers. Dispatch it from **Use workflow from: staging** with `expected_deploy_sha` equal to the SHA currently reported by `https://staging.jyotisha.chat/api/health`, `email` set to the exact account address, and `confirmation` typed as `RESET <email>`. It shares the `staging-mutation` concurrency group and the on-host `mutation.lock` with deploy and migration, refuses to run unless the deployed revision matches `expected_deploy_sha`, and pipes `deploy/reset-staging-account.sh` to the staging host over the same pinned `STAGING_SSH_PRIVATE_KEY` / `STAGING_KNOWN_HOSTS` channel. It never touches production variables or hosts.
|
||||
|
||||
Application rollback uses the same workflow: manually dispatch `Deploy staging` using **Use workflow from: staging** with a previous known-good full SHA that has a successful `Independent Staging Quality Gate` push run, leave `gate_run_id` empty, and explicitly set `allow_rollback=true`. The requested SHA must be an ancestor of the current `staging` head. Normal deployments reject stale or divergent revisions. Rollback still consumes the selected gate run's digest and controller artifact and is supported only during that artifact's 30-day retention window; after expiry, stop and prepare a separately reviewed republish/recovery change rather than substituting a mutable tag or assuming the old run can still be rerun. Database migrations are separate and are not rolled back by an application deployment. Restore a staging database backup before running any destructive migration rehearsal.
|
||||
|
||||
Inspect staging without printing secrets:
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Paths whose changes must rerun the staging quality gate and republish images.
|
||||
# Single source of truth for the `paths:` filters of both triggers in
|
||||
# .gitea/workflows/backend-quality-gate.yml and for deploy/is-docs-only-range.sh.
|
||||
# One glob per line (GitHub/Gitea filter syntax: `*` stops at `/`, `**` does not);
|
||||
# `#` comments and blank lines are ignored. A push whose every changed file falls
|
||||
# outside this list is docs-only: no gate, no image, no deployment. When in doubt,
|
||||
# list the path here rather than leave it out.
|
||||
#
|
||||
# Workflow and build-context inputs
|
||||
.dockerignore
|
||||
.gitea/**
|
||||
#
|
||||
# Python package inputs (pyproject.toml / MANIFEST.in / `python -m build`)
|
||||
MANIFEST.in
|
||||
mcp_server.py
|
||||
pyproject.toml
|
||||
requirements*.txt
|
||||
jyotish_vedic/**
|
||||
scripts/**
|
||||
tests/**
|
||||
#
|
||||
# Image inputs (deploy/railway-api.Dockerfile, deploy/railway-web.Dockerfile)
|
||||
SKILL.md
|
||||
assets/**
|
||||
references/**
|
||||
skills/**
|
||||
deploy/**
|
||||
frontend/**
|
||||
#
|
||||
# Repository files read by frontend/tests at gate time
|
||||
contracts/**
|
||||
Executable
+239
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env bash
|
||||
# Decide whether every change between two staging commits is docs-only, i.e.
|
||||
# outside deploy/gated-paths.txt, so a deployment of <base_sha> may proceed
|
||||
# even though staging has advanced to <head_sha>.
|
||||
#
|
||||
# Usage: deploy/is-docs-only-range.sh [--git|--api] <base_sha> <head_sha>
|
||||
#
|
||||
# Exit status:
|
||||
# 0 base is an ancestor of head and no changed path matches a gated glob
|
||||
# 1 at least one changed path matches a gated glob (the gate must rerun)
|
||||
# 2 undecidable: bad arguments, head is not a descendant of base (diverged,
|
||||
# behind, or unknown), history unavailable, or the Gitea API failed
|
||||
#
|
||||
# Without --git/--api the local repository is used when it holds both commits
|
||||
# and can prove ancestry; otherwise the Gitea API is used. The API path needs
|
||||
# GITEA_API_URL (default https://git.copse.top/api/v1), GITEA_REPOSITORY
|
||||
# (default root/Jyotisha) and, for a private repository, GITEA_TOKEN (or
|
||||
# GITEA_BASIC_AUTH="user:secret" for operator runs outside Actions).
|
||||
#
|
||||
# Gitea API shape (verified against Gitea 1.26.2):
|
||||
# GET /repos/{owner}/{repo}/compare/{base}...{head}
|
||||
# -> {"total_commits": N, "commits": [ {"sha": "...", "parents": [{"sha": "..."}],
|
||||
# "files": [{"filename": "path", "status": "added|modified|deleted|..."}],
|
||||
# "stats": {...}, "commit": {...}, ...}, ... ]}
|
||||
# `commits` lists every commit reachable from head but not from base (no
|
||||
# pagination was observed for a 316-commit range; total_commits must still
|
||||
# equal the returned length or the answer is undecidable). Each commit's
|
||||
# `files` is its diff against its first parent, so the union over all
|
||||
# commits is a superset of `git diff --name-only base head`, which errs on
|
||||
# the side of "not docs-only". Ancestry is proven by walking `parents` from
|
||||
# head back to base inside that set; a reversed or diverged range yields
|
||||
# `{"total_commits": 0, "commits": []}` or a walk that never reaches base.
|
||||
# GET /repos/{owner}/{repo}/git/commits/{sha} returns the same per-commit
|
||||
# `files`, but would cost one request per commit, so it is not used.
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
echo "usage: $0 [--git|--api] <base_sha> <head_sha>" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
mode=auto
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--git) mode=git ;;
|
||||
--api) mode=api ;;
|
||||
--) shift; break ;;
|
||||
-*) usage ;;
|
||||
*) break ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
[ "$#" -eq 2 ] || usage
|
||||
base_sha="$1"
|
||||
head_sha="$2"
|
||||
[[ "$base_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "base_sha must be a lowercase full commit SHA" >&2; exit 2; }
|
||||
[[ "$head_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "head_sha must be a lowercase full commit SHA" >&2; exit 2; }
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
GATED_PATHS_FILE="${GATED_PATHS_FILE:-$script_dir/gated-paths.txt}"
|
||||
[ -r "$GATED_PATHS_FILE" ] || { echo "gated path list $GATED_PATHS_FILE is not readable" >&2; exit 2; }
|
||||
|
||||
if [ "$base_sha" = "$head_sha" ]; then
|
||||
echo "docs-only: $head_sha is the requested revision itself (no changes)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Prints the changed paths for base..head on stdout, one per line, or exits
|
||||
# 3 when the local repository cannot answer (missing objects, shallow history
|
||||
# that cannot prove ancestry, or no repository at all).
|
||||
git_changed_paths() {
|
||||
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 3
|
||||
git cat-file -e "$base_sha^{commit}" 2>/dev/null || return 3
|
||||
git cat-file -e "$head_sha^{commit}" 2>/dev/null || return 3
|
||||
local ancestry=0
|
||||
git merge-base --is-ancestor "$base_sha" "$head_sha" || ancestry=$?
|
||||
if [ "$ancestry" -ne 0 ]; then
|
||||
if [ "$(git rev-parse --is-shallow-repository 2>/dev/null)" = true ]; then
|
||||
echo "local history is shallow and cannot prove $base_sha is an ancestor of $head_sha" >&2
|
||||
return 3
|
||||
fi
|
||||
echo "not docs-only: $base_sha is not an ancestor of $head_sha (diverged, behind, or unrelated)" >&2
|
||||
return 2
|
||||
fi
|
||||
git diff --name-only --no-renames "$base_sha" "$head_sha"
|
||||
}
|
||||
|
||||
api_changed_paths() {
|
||||
local api_url="${GITEA_API_URL:-https://git.copse.top/api/v1}"
|
||||
local repository="${GITEA_REPOSITORY:-root/Jyotisha}"
|
||||
local -a auth=()
|
||||
if [ -n "${GITEA_TOKEN:-}" ]; then
|
||||
auth=(--header "Authorization: token $GITEA_TOKEN")
|
||||
elif [ -n "${GITEA_BASIC_AUTH:-}" ]; then
|
||||
# Operator verification outside Actions: "user:password-or-token" via HTTP basic auth.
|
||||
auth=(--user "$GITEA_BASIC_AUTH")
|
||||
fi
|
||||
local response_file
|
||||
response_file="$(mktemp "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/jyotisha-compare.XXXXXX")"
|
||||
# shellcheck disable=SC2064
|
||||
trap "rm -f -- '$response_file'" RETURN
|
||||
if ! curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
"${auth[@]}" "$api_url/repos/$repository/compare/$base_sha...$head_sha" --output "$response_file"; then
|
||||
echo "Gitea compare request failed for $base_sha...$head_sha" >&2
|
||||
return 3
|
||||
fi
|
||||
RESPONSE_FILE="$response_file" BASE_SHA="$base_sha" HEAD_SHA="$head_sha" python3 - <<'PY'
|
||||
import json, os, sys
|
||||
|
||||
base = os.environ["BASE_SHA"]
|
||||
head = os.environ["HEAD_SHA"]
|
||||
try:
|
||||
with open(os.environ["RESPONSE_FILE"], encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
sys.stderr.write("Gitea compare response is not JSON\n")
|
||||
sys.exit(3)
|
||||
commits = payload.get("commits") or []
|
||||
total = payload.get("total_commits")
|
||||
if not isinstance(total, int) or total != len(commits):
|
||||
sys.stderr.write(f"Gitea compare returned {len(commits)} of {total!r} commits; range undecidable\n")
|
||||
sys.exit(3)
|
||||
by_sha = {c.get("sha"): c for c in commits if isinstance(c, dict)}
|
||||
if len(by_sha) != len(commits):
|
||||
sys.stderr.write("Gitea compare returned malformed or duplicate commits\n")
|
||||
sys.exit(3)
|
||||
# Walk first-and-other parents from head back to base within the returned set.
|
||||
seen, stack, reached = set(), [head], False
|
||||
while stack:
|
||||
sha = stack.pop()
|
||||
if sha == base:
|
||||
reached = True
|
||||
break
|
||||
if sha in seen or sha not in by_sha:
|
||||
continue
|
||||
seen.add(sha)
|
||||
stack.extend(p.get("sha") for p in by_sha[sha].get("parents") or [] if isinstance(p, dict))
|
||||
if not reached:
|
||||
sys.stderr.write(f"not docs-only: {base} is not an ancestor of {head} according to Gitea compare\n")
|
||||
sys.exit(2)
|
||||
paths = set()
|
||||
for commit in commits:
|
||||
files = commit.get("files")
|
||||
if files is None:
|
||||
sys.stderr.write(f"Gitea compare omitted files for {commit.get('sha')}; range undecidable\n")
|
||||
sys.exit(3)
|
||||
for entry in files:
|
||||
for key in ("filename", "previous_filename"):
|
||||
value = entry.get(key) if isinstance(entry, dict) else None
|
||||
if value:
|
||||
paths.add(value)
|
||||
for path in sorted(paths):
|
||||
print(path)
|
||||
PY
|
||||
}
|
||||
|
||||
changed=""
|
||||
status=0
|
||||
case "$mode" in
|
||||
git)
|
||||
changed="$(git_changed_paths)" || status=$?
|
||||
;;
|
||||
api)
|
||||
changed="$(api_changed_paths)" || status=$?
|
||||
;;
|
||||
auto)
|
||||
changed="$(git_changed_paths)" || status=$?
|
||||
if [ "$status" -eq 3 ]; then
|
||||
echo "local history cannot decide; consulting the Gitea compare API" >&2
|
||||
status=0
|
||||
changed="$(api_changed_paths)" || status=$?
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
if [ "$status" -eq 2 ]; then
|
||||
exit 2
|
||||
fi
|
||||
if [ "$status" -ne 0 ]; then
|
||||
echo "unable to determine the changed paths for $base_sha..$head_sha" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
changed_file="$(mktemp "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/jyotisha-changed-paths.XXXXXX")"
|
||||
# shellcheck disable=SC2064
|
||||
trap "rm -f -- '$changed_file'" EXIT
|
||||
printf '%s\n' "$changed" >"$changed_file"
|
||||
|
||||
# GitHub/Gitea filter globs: `*` and `?` stop at `/`, `**` crosses directories.
|
||||
CHANGED_FILE="$changed_file" GATED_PATHS_FILE="$GATED_PATHS_FILE" BASE_SHA="$base_sha" HEAD_SHA="$head_sha" python3 - <<'PY'
|
||||
import os, re, sys
|
||||
|
||||
def glob_to_regex(pattern: str) -> re.Pattern:
|
||||
out, i = [], 0
|
||||
while i < len(pattern):
|
||||
if pattern.startswith("**/", i) and (i == 0 or pattern[i - 1] == "/"):
|
||||
out.append("(?:.*/)?")
|
||||
i += 3
|
||||
elif pattern.startswith("**", i):
|
||||
out.append(".*")
|
||||
i += 2
|
||||
elif pattern[i] == "*":
|
||||
out.append("[^/]*")
|
||||
i += 1
|
||||
elif pattern[i] == "?":
|
||||
out.append("[^/]")
|
||||
i += 1
|
||||
else:
|
||||
out.append(re.escape(pattern[i]))
|
||||
i += 1
|
||||
return re.compile("^" + "".join(out) + "$")
|
||||
|
||||
globs = []
|
||||
with open(os.environ["GATED_PATHS_FILE"], encoding="utf-8") as handle:
|
||||
for raw in handle:
|
||||
line = raw.strip()
|
||||
if line and not line.startswith("#"):
|
||||
globs.append((line, glob_to_regex(line)))
|
||||
if not globs:
|
||||
sys.stderr.write("gated path list is empty; refusing to treat anything as docs-only\n")
|
||||
sys.exit(2)
|
||||
|
||||
with open(os.environ["CHANGED_FILE"], encoding="utf-8") as handle:
|
||||
changed = [line.strip() for line in handle if line.strip()]
|
||||
gated = []
|
||||
for path in changed:
|
||||
for source, regex in globs:
|
||||
if regex.match(path):
|
||||
gated.append((path, source))
|
||||
break
|
||||
base, head = os.environ["BASE_SHA"], os.environ["HEAD_SHA"]
|
||||
if gated:
|
||||
sys.stderr.write(f"not docs-only: {len(gated)} gated path(s) changed in {base[:12]}..{head[:12]}\n")
|
||||
for path, source in gated:
|
||||
sys.stderr.write(f" {path} (matches {source})\n")
|
||||
sys.exit(1)
|
||||
print(f"docs-only: {len(changed)} changed path(s) in {base[:12]}..{head[:12]}, none gated")
|
||||
for path in changed:
|
||||
print(f" {path}")
|
||||
PY
|
||||
@@ -66,7 +66,21 @@ if [ -n "$STALE_IMAGES" ]; then
|
||||
printf '%s\n' "$STALE_IMAGES" | xargs -r docker image rm || true
|
||||
fi
|
||||
|
||||
docker builder prune --force --all
|
||||
# BuildKit layer cache is what lets the publish job reuse the Dockerfile
|
||||
# `npm ci` layer instead of rebuilding it from scratch behind the mirror (a
|
||||
# 7-minute image build versus 47). `docker image prune --force` above removes
|
||||
# only dangling images; BuildKit cache records live in the builder store, not
|
||||
# in dangling images, so nothing before this point touches them. Reclaim them
|
||||
# in tiers: drop entries nobody has used for 72 hours, re-measure, and escalate
|
||||
# to `--all` only when the runner is still below the threshold.
|
||||
docker builder prune --force --filter until=72h
|
||||
|
||||
TIERED_GIB="$(free_gib)"
|
||||
echo "docker root $DOCKER_ROOT has ${TIERED_GIB} GiB free after aged build-cache reclaim"
|
||||
if [ "$TIERED_GIB" -lt "$MINIMUM_FREE_GIB" ]; then
|
||||
echo "still below ${MINIMUM_FREE_GIB} GiB; escalating to a full BuildKit cache prune"
|
||||
docker builder prune --force --all
|
||||
fi
|
||||
|
||||
AFTER_GIB="$(free_gib)"
|
||||
echo "docker root $DOCKER_ROOT has ${AFTER_GIB} GiB free after reclaim"
|
||||
|
||||
@@ -7362,3 +7362,19 @@
|
||||
- 相关记录:BUG-477
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-479 | 首页揭幕后仍有组件级加载:外层加载屏只等三样,推荐问题、今日星语、校正摘要各自转圈
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-02
|
||||
- 最近更新:2026-09-02
|
||||
- 影响面:`page.tsx` 启动 effect 与揭幕条件、`starter-home.tsx`、`globals.css`(`.starter-loading`)、`lib/home-bootstrap.ts`(新增)、`DESIGN.md` §9
|
||||
- 用户现象:登录后先看一次整页加载屏,进入界面后推荐问题位置再转一次(`starter-loading`)、今日星语卡 `aria-busy` 并显示「正在写下今天的星语。」、切会话消息区出现 spinner;用户感受是"进来了又在等"。
|
||||
- 触发条件:任何一次进入首页;切换到消息尚未缓存的会话。
|
||||
- 根因:`setHydrated(true)` 只等账户、模型目录、会话列表;推荐问题、今日星语、校正入口摘要三个 effect 都以 `hydrated` 为门槛,只能在揭幕之后才发请求,于是各自带一套等待表现。这些请求彼此无数据依赖,串行体验是历史堆出来的。
|
||||
- 修复:启动分两阶段(`bootstrapPhase: "account" → "prepare"`)。账户阶段完成后不揭幕,改进入 prepare:三个 effect 改以 `bootstrapPhase` 为门槛在揭幕前并行发起,并预热校正分包 `import()`;揭幕 effect 在全部适用项就绪(`bootstrapPrepareSettled`)或 4 秒预算(`BOOTSTRAP_PREPARE_TIMEOUT_MS`,从进入 prepare 起算)到期时 `setHydrated(true)`。加载屏文案按阶段切换(`bootstrapLoadingCopy`)。揭幕后删除 `starter-loading` 分支、今日星语 `aria-busy` 与「正在写下」文案(pending 显示静态「今天的星语还没写出来。」,到达后静默替换)、会话切换的 `InlineSpinner`(保留 `sr-only` 文案)。揭幕后按侧栏顺序后台预取最近 5 条会话消息(`sessionIdsToPrefetch`)。账户阶段失败仍直接揭幕到错误屏;8 秒 bootstrap 超时不变。
|
||||
- 验证:`home-bootstrap-reveal.test.ts`(纯函数 + 源码合同:prepare 门槛 ×3、揭幕延时、预热、预取、揭幕后无 spinner、加载屏 DOM 合同);`daily-starlanguage.test.ts` / `starter-questions.test.ts` 按例外条款改读新门槛;`tsc`、`eslint` 0 error、`next build` `/` Static、首屏 gzip-9 513570 → 513889 B(+0.06%)。
|
||||
- 防复发:任何首页数据拉取不得以 `hydrated` 为门槛再在揭幕后展示等待态;新增的揭幕前依赖加入 `bootstrapPrepareSettled` 的输入,而不是各自转圈。`main.app-loading[aria-busy="true"]` / `main.app-loading-error` 选择器被 `stale-client-recovery` 依赖,不得改。
|
||||
- 相关记录:BUG-464(消息按需加载引入的首屏消息等待,流式轮已把当前会话消息前移到揭幕前)、BUG-470(effect 内 setState 规则)
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
+2
-2
@@ -401,13 +401,13 @@ or user IDs.
|
||||
|
||||
| 类别 | 语义 | 组件 / 样式 | 时长 | `prefers-reduced-motion: reduce` |
|
||||
| --- | --- | --- | ---: | --- |
|
||||
| 整页 / 整块阻塞 | 载入账户、正在准备问题、生时评估浮层 | `AppLoadingIndicator`(轨道环 `app-loading-orbit`) | 1.4s linear | 全局 `*` 规则把循环收成一帧;不要改它的 DOM |
|
||||
| 整页 / 整块阻塞 | 首页揭幕前的两阶段载入(载入账户 → 准备对话)、生时评估浮层 | `AppLoadingIndicator`(轨道环 `app-loading-orbit`) | 1.4s linear | 全局 `*` 规则把循环收成一帧;不要改它的 DOM |
|
||||
| 行内 / 局部等待 | 出生地解析、两个会话面时间线的 live 步、兜底活动面板的 live 行、个人报告列表与详情 | `InlineSpinner`(`inline-spin`) | 0.8s linear | `animation: none`,收成静止圆点,不要半圈圆弧 |
|
||||
| 流式生成中 | 引导语打字、时间线 summary 与 live 行的文案 | `onboarding-caret` / `agent-activity-shimmer` | 700ms steps / 1.6s linear | 保持现有全局降级 |
|
||||
|
||||
Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小球(`thinking-orbs`)已移除,不得再引入第二种 live 标记。
|
||||
|
||||
今日星语首次拉取是行内等待,但不用 spinner、也不用透明度呼吸:卡片用静态占位文案(`aria-busy` 仍保留)。轨道环消失后不得再换一套动效继续等。
|
||||
首页只揭幕一次。揭幕前的加载屏分两阶段:先取账户、模型目录与会话列表,再并行取当前会话消息、推荐问题、今日星语与校正入口摘要,并预热校正分包;全部就绪或 4 秒预算到期(`BOOTSTRAP_PREPARE_TIMEOUT_MS`)才揭幕。揭幕后不得再出现任何阻塞等待或组件级 spinner:推荐问题未到显示安全默认问题,今日星语未到显示静态文案「今天的星语还没写出来。」(不带 `aria-busy`),校正卡用无摘要文案,内容到达后静默替换。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。
|
||||
|
||||
Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
|
||||
|
||||
|
||||
@@ -1009,7 +1009,6 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.product-entrypoint-action { display: inline-flex; flex: 0 0 auto; align-items: center; justify-content: flex-end; gap: var(--space-1); color: var(--color-action); font-size: var(--type-caption); font-weight: 600; white-space: nowrap; }
|
||||
.product-entrypoint-action .starter-arrow { width: 15px; height: 15px; color: currentColor; }
|
||||
.rectification-entry-error { grid-column: 1 / -1; margin: 0; }
|
||||
.starter-loading { color: var(--color-ink-secondary); margin-left: 0; padding: var(--space-5); border-radius: var(--radius-lg); background: var(--color-canvas-muted); font-size: 14px; }
|
||||
.starter-note { margin: 10px 0 0; color: var(--color-ink-secondary); line-height: 1.5; grid-column: 1 / -1; font-size: 13px; }
|
||||
.message-list {
|
||||
--assistant-content-inset: calc(32px + var(--space-3));
|
||||
@@ -2181,14 +2180,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
transition: color 180ms ease-out, transform 180ms var(--ease-out);
|
||||
}
|
||||
|
||||
.starter-loading {
|
||||
width: min(1040px, 100%);
|
||||
margin-left: 0;
|
||||
padding: var(--space-6);
|
||||
min-height: min(52vh, 460px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.starter-note {
|
||||
margin: calc(var(--space-2) * -1) 0 0;
|
||||
|
||||
+63
-21
@@ -5,7 +5,6 @@ import Link from "next/link";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { InlineSpinner } from "@/components/inline-spinner";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { FormEvent, KeyboardEvent } from "react";
|
||||
import { AccountDialogOverlay, type AccountOverlayModel } from "@/components/account-dialog-overlay";
|
||||
@@ -227,6 +226,13 @@ import {
|
||||
waitForUndoWindow,
|
||||
writeStoredDailyStarlanguage,
|
||||
} from "@/lib/home-cloud-sync";
|
||||
import {
|
||||
bootstrapLoadingCopy,
|
||||
bootstrapPrepareSettled,
|
||||
bootstrapRevealDelayMs,
|
||||
sessionIdsToPrefetch,
|
||||
type BootstrapPhase,
|
||||
} from "@/lib/home-bootstrap";
|
||||
|
||||
const BirthTimeRectification = dynamic(
|
||||
() => import("@/components/birth-time-rectification").then((module) => module.BirthTimeRectification),
|
||||
@@ -291,6 +297,9 @@ export default function Home() {
|
||||
const [rectificationTurns, setRectificationTurns] = useState<PersistedRectificationTurn[]>([]);
|
||||
const [rectificationEntrySummary, setRectificationEntrySummary] = useState<RectificationEntrySummary | null>(null);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const [bootstrapPhase, setBootstrapPhase] = useState<BootstrapPhase>("account");
|
||||
const [rectificationEntrySummarySettled, setRectificationEntrySummarySettled] = useState(false);
|
||||
const prepareStartedAt = useRef<number | null>(null);
|
||||
const [guidedJourneyPreview, setGuidedJourneyPreview] = useState(false);
|
||||
const [profileSaving, setProfileSaving] = useState(false);
|
||||
const [creatingSession, setCreatingSession] = useState(false);
|
||||
@@ -518,7 +527,7 @@ export default function Home() {
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !accountId) return;
|
||||
if (bootstrapPhase === "account" || !accountId) return;
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch("/api/rectification/cases/entry-summary", { cache: "no-store" });
|
||||
@@ -527,9 +536,11 @@ export default function Home() {
|
||||
setRectificationEntrySummary(entrySummaryFromResponse(payload));
|
||||
} catch {
|
||||
// The CTA falls back to the server-agnostic default labels.
|
||||
} finally {
|
||||
setRectificationEntrySummarySettled(true);
|
||||
}
|
||||
})();
|
||||
}, [accountId, hydrated]);
|
||||
}, [accountId, bootstrapPhase]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !accountId) return;
|
||||
@@ -628,12 +639,43 @@ export default function Home() {
|
||||
: "深入看今日";
|
||||
const dailyStarlanguageTrend = dailyStarlanguage.kind === "ready"
|
||||
? dailyStarlanguage.card.trend
|
||||
: dailyStarlanguage.kind === "pending"
|
||||
? "正在写下今天的星语。"
|
||||
: "今天的星语还没写出来。";
|
||||
: "今天的星语还没写出来。";
|
||||
const dailyStarlanguageAction = dailyStarlanguage.kind === "ready" ? dailyStarlanguage.card.action : "";
|
||||
const dailyStarlanguageBusy = natalMinuteAvailable && dailyStarlanguage.kind === "pending";
|
||||
const onboardingPending = profileComplete && !onboarding && !onboardingError;
|
||||
const bootstrapPrepareReady = bootstrapPrepareSettled({
|
||||
profileComplete,
|
||||
onboardingSettled: onboarding !== null || onboardingError !== "",
|
||||
dailyStarlanguageApplicable: Boolean(accountId) && profileComplete && natalMinuteAvailable,
|
||||
dailyStarlanguageSettled: dailyStarlanguage.kind !== "pending",
|
||||
entrySummarySettled: rectificationEntrySummarySettled,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (hydrated || bootstrapPhase !== "prepare") return;
|
||||
prepareStartedAt.current ??= Date.now();
|
||||
const delay = bootstrapRevealDelayMs(Date.now(), prepareStartedAt.current, bootstrapPrepareReady);
|
||||
const timer = window.setTimeout(() => setHydrated(true), delay);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [bootstrapPhase, bootstrapPrepareReady, hydrated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (bootstrapPhase !== "prepare" || uiPreview.current) return;
|
||||
void import("@/components/birth-time-rectification");
|
||||
}, [bootstrapPhase]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || uiPreview.current) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
for (const sessionId of sessionIdsToPrefetch(sessionsRef.current, activeSessionIdRef.current)) {
|
||||
if (cancelled) return;
|
||||
await ensureSessionMessages(sessionId);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [hydrated]);
|
||||
const currentOnboardingMessage = onboardingJustCompleted
|
||||
? startGreeting || completedOnboardingMessage(profileDraft.name.trim())
|
||||
: onboardingStep === "birth"
|
||||
@@ -756,6 +798,7 @@ export default function Home() {
|
||||
|
||||
async function loadCloudData() {
|
||||
let redirectedToLogin = false;
|
||||
let bootstrapFailed = false;
|
||||
try {
|
||||
const previewMode = process.env.NODE_ENV === "development"
|
||||
? new URLSearchParams(window.location.search).get("preview")
|
||||
@@ -1001,12 +1044,16 @@ export default function Home() {
|
||||
return;
|
||||
}
|
||||
if ((caught as Error).name !== "AbortError" && !controller.signal.aborted) {
|
||||
bootstrapFailed = true;
|
||||
setAccountError(friendlyError(caught instanceof Error ? caught.message : "暂时无法读取云端数据"));
|
||||
}
|
||||
} finally {
|
||||
if (redirectedToLogin) return;
|
||||
window.clearTimeout(bootstrapTimeout);
|
||||
if (!controller.signal.aborted) setHydrated(true);
|
||||
if (!controller.signal.aborted) {
|
||||
if (bootstrapFailed) setHydrated(true);
|
||||
else setBootstrapPhase("prepare");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1151,7 +1198,7 @@ export default function Home() {
|
||||
}, [currentOnboardingMessage, hydrated, shouldStreamOnboarding]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !accountId || !profileComplete || uiPreview.current) return;
|
||||
if (bootstrapPhase === "account" || !accountId || !profileComplete || uiPreview.current) return;
|
||||
const requestIdentity = onboardingRequestIdentity(accountId, onboardingFingerprint);
|
||||
if (isCurrentOnboardingRequest(activeOnboardingRequestIdentity.current, requestIdentity)) return;
|
||||
activeOnboardingRequestIdentity.current = requestIdentity;
|
||||
@@ -1185,10 +1232,10 @@ export default function Home() {
|
||||
}
|
||||
controller.abort();
|
||||
};
|
||||
}, [accountId, hydrated, onboardingFingerprint, profile.name, profileComplete]);
|
||||
}, [accountId, bootstrapPhase, onboardingFingerprint, profile.name, profileComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !accountId || !profileComplete || !natalMinuteAvailable) return;
|
||||
if (bootstrapPhase === "account" || !accountId || !profileComplete || !natalMinuteAvailable) return;
|
||||
const today = calendarDateInTimeZone(new Date(), profile.timezoneId);
|
||||
const fingerprint = dailyStarlanguageProfileKey(profile);
|
||||
const stored = readStoredDailyStarlanguage(accountId);
|
||||
@@ -1229,7 +1276,7 @@ export default function Home() {
|
||||
controller.abort();
|
||||
if (retryTimer !== undefined) clearTimeout(retryTimer);
|
||||
};
|
||||
}, [accountId, dailyStarlanguageFingerprint, hydrated, natalMinuteAvailable, profileComplete]);
|
||||
}, [accountId, bootstrapPhase, dailyStarlanguageFingerprint, natalMinuteAvailable, profileComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (starterHomeVisible) {
|
||||
@@ -1491,9 +1538,10 @@ export default function Home() {
|
||||
}
|
||||
|
||||
if (!hydrated || (!account && !accountError)) {
|
||||
const loadingCopy = bootstrapLoadingCopy(bootstrapPhase);
|
||||
return (
|
||||
<main className="app-loading" aria-busy="true" aria-live="polite">
|
||||
<AppLoadingIndicator title="正在载入账户" detail="同步个人资料与对话记录" />
|
||||
<AppLoadingIndicator title={loadingCopy.title} detail={loadingCopy.detail} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1824,11 +1872,7 @@ export default function Home() {
|
||||
|
||||
{!profileComplete && onboardingStep === "name" && accountError && <p className="form-error onboarding-inline-error" role="alert">{accountError}</p>}
|
||||
|
||||
{profileComplete && presetMessageFinished && !rectificationSurfaceOpen && (onboardingPending ? (
|
||||
<div className="starter-loading" role="status">
|
||||
<AppLoadingIndicator title="正在准备问题" detail="根据你的资料整理今天的起点。" />
|
||||
</div>
|
||||
) : (
|
||||
{profileComplete && presetMessageFinished && !rectificationSurfaceOpen && (
|
||||
<StarterHome
|
||||
starterGreeting={starterGreeting}
|
||||
natalMinuteAvailable={natalMinuteAvailable}
|
||||
@@ -1836,7 +1880,6 @@ export default function Home() {
|
||||
dailyStarlanguageQuestion={dailyStarlanguageQuestion}
|
||||
dailyStarlanguageTrend={dailyStarlanguageTrend}
|
||||
dailyStarlanguageAction={dailyStarlanguageAction}
|
||||
dailyStarlanguageBusy={dailyStarlanguageBusy}
|
||||
productEntrypointsDisabled={productEntrypointsDisabled}
|
||||
startDailyStarlanguageConsultation={startDailyStarlanguageConsultation}
|
||||
rectificationCardLabel={rectificationCardLabel}
|
||||
@@ -1853,11 +1896,10 @@ export default function Home() {
|
||||
startSuggestedConsultation={startSuggestedConsultation}
|
||||
onboardingError={onboardingError}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
) : sessionMessagesLoading ? (
|
||||
<div className="message-list session-messages-loading" role="status" aria-busy="true" aria-live="polite">
|
||||
<InlineSpinner size={20} />
|
||||
<span className="sr-only">正在加载聊天记录</span>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -22,7 +22,6 @@ export type StarterHomeProps = {
|
||||
readonly dailyStarlanguageQuestion: string;
|
||||
readonly dailyStarlanguageTrend: string;
|
||||
readonly dailyStarlanguageAction: string;
|
||||
readonly dailyStarlanguageBusy: boolean;
|
||||
readonly productEntrypointsDisabled: boolean;
|
||||
readonly startDailyStarlanguageConsultation: () => void;
|
||||
readonly rectificationCardLabel: string;
|
||||
@@ -47,7 +46,6 @@ export function StarterHome({
|
||||
dailyStarlanguageQuestion,
|
||||
dailyStarlanguageTrend,
|
||||
dailyStarlanguageAction,
|
||||
dailyStarlanguageBusy,
|
||||
productEntrypointsDisabled,
|
||||
startDailyStarlanguageConsultation,
|
||||
rectificationCardLabel,
|
||||
@@ -85,7 +83,6 @@ export function StarterHome({
|
||||
<div className="product-entrypoint-copy">
|
||||
<h2 id="daily-starlanguage-title">{natalMinuteAvailable ? "今日星语" : "每日运势"}</h2>
|
||||
<p
|
||||
aria-busy={dailyStarlanguageBusy}
|
||||
role={natalMinuteAvailable && dailyStarlanguage.kind !== "ready" ? "status" : undefined}
|
||||
>{natalMinuteAvailable ? dailyStarlanguageTrend : "看看今天的整体节奏、适合推进的事和需要留意的地方。"}</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Home bootstrap reveal policy.
|
||||
*
|
||||
* The loading screen owns every wait before the page appears. Phase "account"
|
||||
* fetches account, model catalog and the session list; phase "prepare" fetches
|
||||
* the starter questions, today's starlanguage and the rectification entry
|
||||
* summary in parallel. The page is revealed once every applicable item has
|
||||
* settled, or when the prepare budget runs out — never with a spinner inside.
|
||||
*/
|
||||
|
||||
export type BootstrapPhase = "account" | "prepare";
|
||||
|
||||
export const BOOTSTRAP_PREPARE_TIMEOUT_MS = 4000;
|
||||
export const SESSION_PREFETCH_COUNT = 5;
|
||||
|
||||
export type BootstrapPrepareState = Readonly<{
|
||||
profileComplete: boolean;
|
||||
onboardingSettled: boolean;
|
||||
dailyStarlanguageApplicable: boolean;
|
||||
dailyStarlanguageSettled: boolean;
|
||||
entrySummarySettled: boolean;
|
||||
}>;
|
||||
|
||||
export function bootstrapPrepareSettled(state: BootstrapPrepareState): boolean {
|
||||
if (!state.entrySummarySettled) return false;
|
||||
if (state.profileComplete && !state.onboardingSettled) return false;
|
||||
if (state.dailyStarlanguageApplicable && !state.dailyStarlanguageSettled) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function bootstrapRevealDelayMs(now: number, prepareStartedAt: number | null, settled: boolean): number {
|
||||
if (settled) return 0;
|
||||
if (prepareStartedAt === null) return BOOTSTRAP_PREPARE_TIMEOUT_MS;
|
||||
return Math.max(0, BOOTSTRAP_PREPARE_TIMEOUT_MS - (now - prepareStartedAt));
|
||||
}
|
||||
|
||||
export function bootstrapLoadingCopy(phase: BootstrapPhase): Readonly<{ title: string; detail: string }> {
|
||||
return phase === "prepare"
|
||||
? { title: "正在准备对话", detail: "整理推荐问题、今日星语与对话记录" }
|
||||
: { title: "正在载入账户", detail: "同步个人资料与对话记录" };
|
||||
}
|
||||
|
||||
export type PrefetchableSession = Readonly<{
|
||||
id: string;
|
||||
sessionType: string;
|
||||
messagesHydrated?: boolean;
|
||||
}>;
|
||||
|
||||
export function sessionIdsToPrefetch(
|
||||
sessions: readonly PrefetchableSession[],
|
||||
activeSessionId: string,
|
||||
limit = SESSION_PREFETCH_COUNT,
|
||||
): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const session of sessions) {
|
||||
if (ids.length >= limit) break;
|
||||
if (session.id === activeSessionId) continue;
|
||||
if (session.messagesHydrated) continue;
|
||||
if (session.sessionType !== "consultation") continue;
|
||||
ids.push(session.id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
@@ -17,8 +17,8 @@ const acceptedExactFamilyMigration = readFileSync(
|
||||
new URL("../supabase/migrations/20260816010000_accept_exact_family_birth_times.sql", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const productionMigrationWorkflow = readFileSync(
|
||||
new URL("../../.github/workflows/apply-production-rectification-migrations.yml", import.meta.url),
|
||||
const selfHostedMigrator = readFileSync(
|
||||
new URL("../scripts/db-migrate.mjs", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
@@ -442,15 +442,19 @@ test("only a strict family exact zero-uncertainty declaration is auto-accepted",
|
||||
}
|
||||
});
|
||||
|
||||
test("reported birth-time status repair is forward-only and wired into production migration flow", () => {
|
||||
test("reported birth-time status repair is forward-only and applied by the self-hosted migrator", () => {
|
||||
assert.match(reportedStatusMigration, /birth_time_status is null/);
|
||||
assert.match(reportedStatusMigration, /birth_time_status = 'reported'/);
|
||||
assert.match(reportedStatusMigration, /active_birth_time is null/);
|
||||
assert.match(reportedStatusMigration, /rectification_case_id is null/);
|
||||
assert.equal(
|
||||
productionMigrationWorkflow.match(/20260726010000_backfill_reported_birth_time_status\.sql/g)?.length,
|
||||
2,
|
||||
// Staging and production migrations run through db-migrate.mjs, which applies
|
||||
// frontend/supabase/migrations as the compatibility directory; the repair file
|
||||
// must stay there rather than in a retired workflow's explicit file list.
|
||||
assert.ok(
|
||||
existsSync(new URL("../supabase/migrations/20260726010000_backfill_reported_birth_time_status.sql", import.meta.url)),
|
||||
);
|
||||
assert.match(selfHostedMigrator, /"\.\.\/supabase\/migrations"/);
|
||||
assert.match(selfHostedMigrator, /supabaseCompatibilityDirectory,/);
|
||||
});
|
||||
|
||||
test("existing exact family declarations are forward-repaired without claiming confirmation", () => {
|
||||
|
||||
@@ -309,11 +309,15 @@ test("the homepage card is engine-backed first, with Agent polish off the reques
|
||||
test("the home requests the card exactly when it renders one, and retries a failed day once", () => {
|
||||
const page = homeSurface;
|
||||
const effect = page.slice(
|
||||
page.indexOf("if (!hydrated || !accountId || !profileComplete || !natalMinuteAvailable) return;"),
|
||||
page.indexOf("}, [accountId, dailyStarlanguageFingerprint, hydrated, natalMinuteAvailable, profileComplete]);"),
|
||||
// Former value: guarded on `!hydrated`; the card is now requested during the bootstrap
|
||||
// "prepare" phase so it is settled before the page is revealed.
|
||||
page.indexOf("if (bootstrapPhase === \"account\" || !accountId || !profileComplete || !natalMinuteAvailable) return;"),
|
||||
page.indexOf("}, [accountId, bootstrapPhase, dailyStarlanguageFingerprint, natalMinuteAvailable, profileComplete]);"),
|
||||
);
|
||||
|
||||
assert.match(page, /aria-busy=\{dailyStarlanguageBusy\}/);
|
||||
// Former value: assert.match(page, /aria-busy=\{dailyStarlanguageBusy\}/) — the first fetch
|
||||
// no longer shows a busy state after reveal; a pending card reads as the static copy.
|
||||
assert.doesNotMatch(page, /dailyStarlanguageBusy/);
|
||||
assert.ok(effect.length > 0);
|
||||
assert.doesNotMatch(page, /birthTimeDisplayState\(profile\)/);
|
||||
assert.match(effect, /next\.kind === "unavailable" && remainingRetries > 0/);
|
||||
|
||||
@@ -77,17 +77,6 @@ test("health database contract requires the rectification identity migrations",
|
||||
assert.equal(present.latestMigration, REQUIRED_RECTIFICATION_MIGRATIONS.at(-1));
|
||||
});
|
||||
|
||||
test("GitHub mirror cannot deploy production", () => {
|
||||
const workflow = readFileSync(
|
||||
new URL("../../.github/workflows/deploy-production.yml", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(workflow, /^on:\n\s+workflow_dispatch:/m);
|
||||
assert.match(workflow, /Refuse deployment from the mirror/);
|
||||
assert.match(workflow, /exit 1/);
|
||||
assert.doesNotMatch(workflow, /ssh|rsync|docker compose/);
|
||||
});
|
||||
|
||||
test("server compose accepts staging paths while preserving production defaults", () => {
|
||||
const compose = readFileSync(
|
||||
new URL("../../deploy/docker-compose.server.yml", import.meta.url),
|
||||
@@ -188,11 +177,11 @@ test("self-hosted production Caddy isolates user and admin hosts", () => {
|
||||
|
||||
test("staging deploy consumes only the isolated staging environment and tested revision", () => {
|
||||
const qualityGate = readFileSync(
|
||||
new URL("../../.github/workflows/backend-quality-gate.yml", import.meta.url),
|
||||
new URL("../../.gitea/workflows/backend-quality-gate.yml", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const workflow = readFileSync(
|
||||
new URL("../../.github/workflows/deploy-staging.yml", import.meta.url),
|
||||
new URL("../../.gitea/workflows/deploy-staging.yml", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const syncController = readFileSync(
|
||||
@@ -201,30 +190,14 @@ test("staging deploy consumes only the isolated staging environment and tested r
|
||||
);
|
||||
|
||||
assert.match(qualityGate, /push:\s*\n\s*branches: \[staging\]/);
|
||||
assert.match(workflow, /workflows: \["Staging Backend Quality Gate"\]/);
|
||||
assert.match(
|
||||
workflow,
|
||||
/github\.event\.workflow_run\.head_branch == 'staging'/,
|
||||
);
|
||||
assert.match(workflow, /actions: read/);
|
||||
assert.match(workflow, /packages: read/);
|
||||
assert.match(workflow, /environment:\s*\n\s*name: staging/);
|
||||
assert.match(workflow, /deploy_sha:/);
|
||||
assert.match(workflow, /\^\[0-9a-f\]\{40\}\$/);
|
||||
assert.match(
|
||||
workflow,
|
||||
/actions\/workflows\/backend-quality-gate\.yml\/runs\?head_sha=/,
|
||||
);
|
||||
assert.match(workflow, /endswith\("backend-quality-gate\.yml"\)/);
|
||||
assert.match(workflow, /\.head_branch == "staging"/);
|
||||
assert.match(workflow, /STAGING_SSH_PRIVATE_KEY/);
|
||||
assert.match(workflow, /vars\.STAGING_HOST/);
|
||||
assert.match(workflow, /vars\.STAGING_KNOWN_HOSTS/);
|
||||
assert.match(workflow, /test "\$DEPLOY_HOST" = "118\.26\.111\.127"/);
|
||||
assert.match(workflow, /test "\$DEPLOY_USER" = "deploy"/);
|
||||
assert.match(workflow, /test "\$DEPLOY_PATH" = "\/opt\/jyotisha-staging"/);
|
||||
assert.match(
|
||||
workflow,
|
||||
/--include='\/deploy\/' --include='\/deploy\/\*\*\*' --exclude='\*'/,
|
||||
);
|
||||
assert.match(workflow, /run-staging-deploy\.sh/);
|
||||
assert.match(workflow, /steps\.images\.outputs\.api_image/);
|
||||
assert.match(workflow, /steps\.images\.outputs\.web_image/);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
BOOTSTRAP_PREPARE_TIMEOUT_MS,
|
||||
SESSION_PREFETCH_COUNT,
|
||||
bootstrapLoadingCopy,
|
||||
bootstrapPrepareSettled,
|
||||
bootstrapRevealDelayMs,
|
||||
sessionIdsToPrefetch,
|
||||
} from "../src/lib/home-bootstrap.ts";
|
||||
import { homeSurface } from "./home-surface.ts";
|
||||
|
||||
// Product rule (2026-09-02): the loading screen owns every wait. After the home
|
||||
// page is revealed there is no component-level spinner left — only the streaming
|
||||
// answer keeps a live marker, because that is content being generated, not data
|
||||
// being loaded.
|
||||
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const starterHome = readFileSync(new URL("../src/components/starter-home.tsx", import.meta.url), "utf8");
|
||||
const globalsCss = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
const staleClientRecovery = readFileSync(new URL("../src/components/stale-client-recovery.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("prepare phase settles only when every applicable item has an answer", () => {
|
||||
const base = {
|
||||
profileComplete: true,
|
||||
onboardingSettled: true,
|
||||
dailyStarlanguageApplicable: true,
|
||||
dailyStarlanguageSettled: true,
|
||||
entrySummarySettled: true,
|
||||
};
|
||||
assert.equal(bootstrapPrepareSettled(base), true);
|
||||
assert.equal(bootstrapPrepareSettled({ ...base, entrySummarySettled: false }), false);
|
||||
assert.equal(bootstrapPrepareSettled({ ...base, onboardingSettled: false }), false);
|
||||
assert.equal(bootstrapPrepareSettled({ ...base, dailyStarlanguageSettled: false }), false);
|
||||
// Items that do not apply to this account never hold the reveal.
|
||||
assert.equal(bootstrapPrepareSettled({ ...base, profileComplete: false, onboardingSettled: false }), true);
|
||||
assert.equal(bootstrapPrepareSettled({ ...base, dailyStarlanguageApplicable: false, dailyStarlanguageSettled: false }), true);
|
||||
});
|
||||
|
||||
test("reveal delay is zero once settled and counts down from the phase start otherwise", () => {
|
||||
assert.equal(BOOTSTRAP_PREPARE_TIMEOUT_MS, 4000);
|
||||
assert.equal(bootstrapRevealDelayMs(10_000, 9_000, true), 0);
|
||||
assert.equal(bootstrapRevealDelayMs(10_000, 9_000, false), 3000);
|
||||
assert.equal(bootstrapRevealDelayMs(20_000, 9_000, false), 0);
|
||||
assert.equal(bootstrapRevealDelayMs(10_000, null, false), BOOTSTRAP_PREPARE_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
test("loading copy changes per phase so the single loading screen narrates progress", () => {
|
||||
const account = bootstrapLoadingCopy("account");
|
||||
const prepare = bootstrapLoadingCopy("prepare");
|
||||
assert.notEqual(account.title, prepare.title);
|
||||
assert.notEqual(account.detail, prepare.detail);
|
||||
assert.match(page, /const loadingCopy = bootstrapLoadingCopy\(bootstrapPhase\);/);
|
||||
assert.match(page, /<AppLoadingIndicator title=\{loadingCopy\.title\} detail=\{loadingCopy\.detail\} \/>/);
|
||||
});
|
||||
|
||||
test("bootstrap enters the prepare phase instead of revealing after the account fetch", () => {
|
||||
assert.match(page, /if \(bootstrapFailed\) setHydrated\(true\);\n\s*else setBootstrapPhase\("prepare"\);/);
|
||||
// The three secondary fetches start in the prepare phase, not after reveal.
|
||||
const guards = page.match(/if \(bootstrapPhase === "account" \|\| !accountId/g) ?? [];
|
||||
assert.equal(guards.length, 3, "onboarding, daily starlanguage and entry summary must all key off bootstrapPhase");
|
||||
assert.match(page, /setRectificationEntrySummarySettled\(true\);/);
|
||||
assert.match(page, /bootstrapRevealDelayMs\(Date\.now\(\), prepareStartedAt\.current, bootstrapPrepareReady\)/);
|
||||
assert.match(page, /window\.setTimeout\(\(\) => setHydrated\(true\), delay\)/);
|
||||
assert.match(page, /void import\("@\/components\/birth-time-rectification"\);/);
|
||||
});
|
||||
|
||||
test("revealed home carries no component loading state", () => {
|
||||
assert.doesNotMatch(page, /starter-loading/);
|
||||
assert.doesNotMatch(starterHome, /starter-loading|dailyStarlanguageBusy|aria-busy=\{/);
|
||||
assert.doesNotMatch(globalsCss, /\.starter-loading/);
|
||||
assert.doesNotMatch(page, /InlineSpinner/);
|
||||
assert.doesNotMatch(page, /dailyStarlanguageBusy|正在写下今天的星语/);
|
||||
// Switching to a session whose messages are not cached keeps a silent placeholder.
|
||||
assert.match(page, /className="message-list session-messages-loading" role="status" aria-busy="true" aria-live="polite">\n\s*<span className="sr-only">正在加载聊天记录<\/span>/);
|
||||
});
|
||||
|
||||
test("recent sessions are prefetched after reveal so switches do not wait", () => {
|
||||
assert.equal(SESSION_PREFETCH_COUNT, 5);
|
||||
const sessions = [
|
||||
{ id: "active", sessionType: "consultation" },
|
||||
{ id: "a", sessionType: "consultation" },
|
||||
{ id: "b", sessionType: "consultation", messagesHydrated: true },
|
||||
{ id: "r", sessionType: "birth_time_rectification" },
|
||||
{ id: "c", sessionType: "consultation" },
|
||||
{ id: "d", sessionType: "consultation" },
|
||||
{ id: "e", sessionType: "consultation" },
|
||||
{ id: "f", sessionType: "consultation" },
|
||||
{ id: "g", sessionType: "consultation" },
|
||||
];
|
||||
assert.deepEqual(sessionIdsToPrefetch(sessions, "active"), ["a", "c", "d", "e", "f"]);
|
||||
assert.match(page, /sessionIdsToPrefetch\(sessionsRef\.current, activeSessionIdRef\.current\)/);
|
||||
assert.match(page, /await ensureSessionMessages\(sessionId\);/);
|
||||
});
|
||||
|
||||
test("the loading screen DOM contract stale-client recovery depends on is untouched", () => {
|
||||
assert.match(page, /<main className="app-loading" aria-busy="true" aria-live="polite">/);
|
||||
assert.match(page, /<main className="app-loading app-loading-error">/);
|
||||
assert.match(staleClientRecovery, /main\.app-loading\[aria-busy="true"\]/);
|
||||
assert.match(staleClientRecovery, /main\.app-loading-error/);
|
||||
assert.ok(homeSurface.includes("bootstrapPrepareSettled("));
|
||||
});
|
||||
@@ -6,18 +6,6 @@ import { spawnSync } from "node:child_process";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const qualityWorkflow = new URL(
|
||||
"../../.github/workflows/backend-quality-gate.yml",
|
||||
import.meta.url,
|
||||
);
|
||||
const deployWorkflow = new URL(
|
||||
"../../.github/workflows/deploy-staging.yml",
|
||||
import.meta.url,
|
||||
);
|
||||
const migrationWorkflow = new URL(
|
||||
"../../.github/workflows/migrate-staging-database.yml",
|
||||
import.meta.url,
|
||||
);
|
||||
const giteaQualityWorkflow = new URL(
|
||||
"../../.gitea/workflows/backend-quality-gate.yml",
|
||||
import.meta.url,
|
||||
@@ -54,8 +42,8 @@ const giteaReleaseQualityWorkflow = new URL(
|
||||
"../../.gitea/workflows/release-quality-gate.yml",
|
||||
import.meta.url,
|
||||
);
|
||||
const resetStagingAccountWorkflow = new URL(
|
||||
"../../.github/workflows/reset-staging-account.yml",
|
||||
const giteaResetStagingAccountWorkflow = new URL(
|
||||
"../../.gitea/workflows/reset-staging-account.yml",
|
||||
import.meta.url,
|
||||
);
|
||||
const deployScript = new URL(
|
||||
@@ -66,6 +54,10 @@ const migrationScript = new URL(
|
||||
"../../deploy/run-staging-migration.sh",
|
||||
import.meta.url,
|
||||
);
|
||||
const resetStagingAccountScript = new URL(
|
||||
"../../deploy/reset-staging-account.sh",
|
||||
import.meta.url,
|
||||
);
|
||||
const syncScript = new URL(
|
||||
"../../deploy/sync-staging-tree.sh",
|
||||
import.meta.url,
|
||||
@@ -148,12 +140,10 @@ function parseYaml(workflow: URL) {
|
||||
|
||||
test("changed staging workflows are syntactically valid YAML", () => {
|
||||
for (const workflow of [
|
||||
qualityWorkflow,
|
||||
deployWorkflow,
|
||||
migrationWorkflow,
|
||||
giteaQualityWorkflow,
|
||||
giteaDeployWorkflow,
|
||||
giteaMigrationWorkflow,
|
||||
giteaResetStagingAccountWorkflow,
|
||||
giteaProductionWorkflow,
|
||||
giteaProductionMigrationWorkflow,
|
||||
]) {
|
||||
@@ -203,33 +193,6 @@ test("railway web image uses Next standalone runtime output", () => {
|
||||
assert.doesNotMatch(dockerfile, /npm start/);
|
||||
});
|
||||
|
||||
test("quality gate validates relevant changes once and publishes a digest manifest", () => {
|
||||
const workflow = read(qualityWorkflow);
|
||||
|
||||
assert.match(workflow, /pull_request:\n\s+paths:/);
|
||||
for (const path of ["frontend/**", "deploy/**", "scripts/**", "tests/**"]) {
|
||||
assert.match(workflow, new RegExp(`'${path.replaceAll("*", "\\*")}'`));
|
||||
}
|
||||
assert.match(workflow, /push:\n\s+branches: \[staging\]/);
|
||||
assert.match(workflow, /workflow_dispatch:/);
|
||||
assert.equal((workflow.match(/npm test --prefix frontend/g) ?? []).length, 1);
|
||||
assert.match(workflow, /python -m pip install playwright/);
|
||||
assert.match(workflow, /python -m playwright install --with-deps chrome/);
|
||||
assert.doesNotMatch(workflow, /npm run test:db --prefix frontend/);
|
||||
assert.match(workflow, /id: api_build[\s\S]*steps\.api_build\.outputs\.digest/);
|
||||
assert.match(workflow, /id: web_build[\s\S]*steps\.web_build\.outputs\.digest/);
|
||||
assert.match(
|
||||
workflow,
|
||||
/id: web_build[\s\S]*build-args: \|[\s\S]*NEXT_DEPLOYMENT_ID=\$\{\{ github\.sha \}\}/,
|
||||
);
|
||||
assert.match(workflow, /\^sha256:\[0-9a-f\]\{64\}\$/);
|
||||
assert.match(workflow, /node frontend\/scripts\/staging-image-manifest\.mjs/);
|
||||
assert.match(workflow, /name: staging-image-manifest-\$\{\{ github\.sha \}\}/);
|
||||
assert.match(workflow, /uses: actions\/upload-artifact@v4/);
|
||||
assert.doesNotMatch(workflow, /STAGING_SUPABASE|NEXT_PUBLIC_SUPABASE/);
|
||||
assert.doesNotMatch(workflow, /(?:^|:)latest$/m);
|
||||
});
|
||||
|
||||
test("staging images pull official bases from Huawei SWR instead of DaoCloud", () => {
|
||||
const api = read(apiDockerfile);
|
||||
const web = read(railwayWebDockerfile);
|
||||
@@ -404,7 +367,11 @@ test("every Gitea job runs on xiaoxin, never on the retired jump-host runner", (
|
||||
|
||||
// The jump host registered itself as manman-linux:host, so its jobs shared a
|
||||
// filesystem with unrelated production services and filled it.
|
||||
assert.ok(declarations.length >= 12, `expected every Gitea job to declare a runner, saw ${declarations.length}`);
|
||||
// Nine jobs remain after the unused manual workflows (ci, test, publish-pypi,
|
||||
// apply-supabase-profile-migrations) were removed: two gate jobs plus one job
|
||||
// each for deploy/migrate staging, reset staging account, release gate,
|
||||
// deploy/migrate production, and production recovery.
|
||||
assert.ok(declarations.length >= 9, `expected every Gitea job to declare a runner, saw ${declarations.length}`);
|
||||
assert.deepEqual(declarations.filter((declaration) => !declaration.endsWith(":xiaoxin")), []);
|
||||
});
|
||||
|
||||
@@ -431,6 +398,7 @@ test("both gate jobs reclaim runner disk before they need it, and only unheld re
|
||||
assert.match(script, /docker network rm "\$network"/);
|
||||
assert.match(script, /all predefined address pools have been fully subnetted/);
|
||||
assert.match(script, /docker image prune --force\n/);
|
||||
assert.match(script, /docker builder prune --force --filter until=72h/);
|
||||
assert.match(script, /docker builder prune --force --all/);
|
||||
assert.match(script, /grep -v -F -e "api-\$KEEP_TAG_SHA" -e "web-\$KEEP_TAG_SHA"/);
|
||||
assert.match(script, /PostgreSQL fixtures and image builds need at least \$\{MINIMUM_FREE_GIB\} GiB/);
|
||||
@@ -472,9 +440,7 @@ test("staging SSH secrets are single-line base64 and never injected as multiline
|
||||
for (const workflow of [
|
||||
read(giteaDeployWorkflow),
|
||||
read(giteaMigrationWorkflow),
|
||||
read(deployWorkflow),
|
||||
read(migrationWorkflow),
|
||||
read(resetStagingAccountWorkflow),
|
||||
read(giteaResetStagingAccountWorkflow),
|
||||
]) {
|
||||
assert.match(workflow, /SSH_PRIVATE_KEY_BASE64: \$\{\{ secrets\.STAGING_SSH_PRIVATE_KEY \}\}/);
|
||||
assert.match(workflow, /printf '%s' "\$SSH_PRIVATE_KEY_BASE64" \| base64 --decode/);
|
||||
@@ -494,14 +460,14 @@ test("staging deployment scripts validate deploy-owned env files", () => {
|
||||
});
|
||||
|
||||
test("staging revision discovery falls back when the state file is unreadable", () => {
|
||||
assert.ok(read(deployWorkflow).includes('if [ -r \\"\\$state\\" ]'));
|
||||
assert.ok(read(giteaDeployWorkflow).includes('if [ -r \\"\\$state\\" ]'));
|
||||
for (const runner of [read(deployScript), read(migrationScript)]) {
|
||||
assert.ok(runner.includes('if [ -r "$state_directory/deployed-revision" ]'));
|
||||
}
|
||||
});
|
||||
|
||||
test("quality gate builds the Python package with its declared backend dependencies", () => {
|
||||
const workflow = read(qualityWorkflow);
|
||||
const workflow = read(giteaQualityWorkflow);
|
||||
|
||||
assert.match(workflow, /^\s+python -m build$/m);
|
||||
assert.doesNotMatch(workflow, /python -m build --no-isolation/);
|
||||
@@ -626,7 +592,7 @@ test("live staging sync repairs nested deploy-tree drift without preserving fore
|
||||
});
|
||||
|
||||
test("staging deploy and migration share Actions serialization and one host lock", () => {
|
||||
const workflows = [read(deployWorkflow), read(migrationWorkflow)];
|
||||
const workflows = [read(giteaDeployWorkflow), read(giteaMigrationWorkflow), read(giteaResetStagingAccountWorkflow)];
|
||||
const runners = [read(deployScript), read(migrationScript)];
|
||||
|
||||
for (const workflow of workflows) {
|
||||
@@ -640,34 +606,6 @@ test("staging deploy and migration share Actions serialization and one host lock
|
||||
}
|
||||
});
|
||||
|
||||
test("deploy and migration consume the exact successful gate artifact", () => {
|
||||
const deployment = read(deployWorkflow);
|
||||
const migration = read(migrationWorkflow);
|
||||
|
||||
for (const workflow of [deployment, migration]) {
|
||||
assert.match(workflow, /backend-quality-gate\.yml\/runs\?head_sha=/);
|
||||
assert.match(workflow, /\.head_branch == "staging"/);
|
||||
assert.match(workflow, /\.event == "push"/);
|
||||
assert.match(workflow, /\.conclusion == "success"/);
|
||||
assert.match(workflow, /sort_by\(\.id\) \| reverse \| first/);
|
||||
assert.match(workflow, /uses: actions\/download-artifact@v4/);
|
||||
assert.match(workflow, /run-id: \$\{\{ steps\.revision\.outputs\.gate_run_id \}\}/);
|
||||
assert.match(workflow, /node frontend\/scripts\/staging-image-manifest\.mjs/);
|
||||
assert.doesNotMatch(workflow, /jyotisha-(?:api|web):\$[A-Z_]*SHA/);
|
||||
}
|
||||
});
|
||||
|
||||
test("main remains the trusted GitHub deployment controller", () => {
|
||||
for (const workflow of [read(deployWorkflow), read(migrationWorkflow)]) {
|
||||
assert.match(workflow, /name: Checkout trusted main controller[\s\S]*ref: main/);
|
||||
assert.match(workflow, /fetch-depth: 0/);
|
||||
assert.match(workflow, /git merge-base --is-ancestor "\$DEPLOY_SHA" HEAD/);
|
||||
assert.match(workflow, /reviewed main history/);
|
||||
assert.match(workflow, /--include='\/deploy\/' --include='\/deploy\/\*\*\*' --exclude='\*'/);
|
||||
assert.doesNotMatch(workflow, /ref: \$\{\{ steps\.revision\.outputs\.sha \}\}/);
|
||||
}
|
||||
});
|
||||
|
||||
test("Gitea deploy and migration consume exact gate-attested controller bundles", () => {
|
||||
for (const workflow of [read(giteaDeployWorkflow), read(giteaMigrationWorkflow)]) {
|
||||
assert.match(workflow, /actions\/runs\?head_sha=\$[A-Z_]+&branch=staging&event=push&status=success/);
|
||||
@@ -792,6 +730,46 @@ test("Gitea remote staging mutations run as root with controlled Docker configur
|
||||
}
|
||||
});
|
||||
|
||||
test("Gitea staging account reset is manual, staging-ref bound, and mutates one confirmed account", () => {
|
||||
const workflow = read(giteaResetStagingAccountWorkflow);
|
||||
const script = read(resetStagingAccountScript);
|
||||
|
||||
assert.match(workflow, /^on:\n\s+workflow_dispatch:/m);
|
||||
assert.doesNotMatch(workflow, /workflow_run:|\n\s+push:|pull_request:/);
|
||||
assert.match(workflow, /if: gitea\.ref == 'refs\/heads\/staging'/);
|
||||
assert.match(workflow, /\[\[ "\$GITEA_REF" == refs\/heads\/staging \]\]/);
|
||||
assert.equal((workflow.match(/runs-on: xiaoxin/g) ?? []).length, 1);
|
||||
assert.match(workflow, /name: Checkout exact Gitea revision/);
|
||||
assert.match(workflow, /fetch --depth=1 --no-tags origin "\$GITEA_SHA"/);
|
||||
assert.match(workflow, /git checkout --detach --force "\$GITEA_SHA"/);
|
||||
assert.doesNotMatch(workflow, /uses: actions\/checkout|ref: main|refs\/heads\/main/);
|
||||
for (const input of ["expected_deploy_sha:", "email:", "confirmation:"]) {
|
||||
assert.match(workflow, new RegExp(`^\\s+${input}$`, "m"));
|
||||
}
|
||||
assert.match(workflow, /\[\[ "\$EXPECTED_DEPLOY_SHA" =~ \^\[0-9a-f\]\{40\}\$ \]\]/);
|
||||
assert.match(workflow, /\[\[ "\$RESET_EMAIL" =~ \^\[\[:alnum:\]\._%\+-\]\+@\[\[:alnum:\]\.-\]\+\\\.\[\[:alpha:\]\]\{2,63\}\$ \]\]/);
|
||||
assert.match(workflow, /test "\$RESET_CONFIRMATION" = "RESET \$RESET_EMAIL"/);
|
||||
assert.match(workflow, /test "\$DEPLOY_HOST" = "118\.26\.111\.127"/);
|
||||
assert.match(workflow, /test "\$DEPLOY_PORT" = "22"/);
|
||||
assert.match(workflow, /test "\$DEPLOY_USER" = "deploy"/);
|
||||
assert.match(workflow, /test "\$DEPLOY_PATH" = "\/opt\/jyotisha-staging"/);
|
||||
assert.match(workflow, /test -n "\$STAGING_KNOWN_HOSTS"/);
|
||||
assert.match(workflow, /bash -n deploy\/reset-staging-account\.sh/);
|
||||
assert.match(workflow, /DEPLOY_HOST: \$\{\{ vars\.STAGING_HOST \}\}/);
|
||||
assert.match(workflow, /STAGING_KNOWN_HOSTS: \$\{\{ vars\.STAGING_KNOWN_HOSTS \}\}/);
|
||||
assert.match(workflow, /-o StrictHostKeyChecking=yes -o "UserKnownHostsFile=\$known_hosts_path"/);
|
||||
assert.match(
|
||||
workflow,
|
||||
/"sudo -n env DEPLOY_PATH='\$DEPLOY_PATH' EXPECTED_DEPLOY_SHA='\$EXPECTED_DEPLOY_SHA' RESET_EMAIL='\$RESET_EMAIL' RESET_CONFIRMATION='\$RESET_CONFIRMATION' bash -s"[\s\S]*< deploy\/reset-staging-account\.sh/,
|
||||
);
|
||||
assert.doesNotMatch(workflow, /PRODUCTION_|jyotisha-production|118\.194\.235\.34|docker compose|psql/);
|
||||
assert.match(script, /\[ "\$RESET_CONFIRMATION" = "RESET \$RESET_EMAIL" \]/);
|
||||
assert.match(script, /\[ "\$DEPLOY_PATH" = "\/opt\/jyotisha-staging" \]/);
|
||||
assert.match(script, /deployed staging revision does not match the approved reset SHA/);
|
||||
assert.match(script, /flock -n 9/);
|
||||
assert.match(script, /-f deploy\/docker-compose\.postgres\.yml/);
|
||||
});
|
||||
|
||||
test("staging runners accept only controlled Docker command forms without eval", () => {
|
||||
for (const script of [read(deployScript), read(migrationScript)]) {
|
||||
assert.match(script, /case "\$\{DOCKER_BIN:-docker\}" in/);
|
||||
@@ -805,10 +783,9 @@ test("staging runners accept only controlled Docker command forms without eval",
|
||||
|
||||
test("staging mutations retain every pending deployment and migration", () => {
|
||||
for (const workflow of [
|
||||
read(deployWorkflow),
|
||||
read(migrationWorkflow),
|
||||
read(giteaDeployWorkflow),
|
||||
read(giteaMigrationWorkflow),
|
||||
read(giteaResetStagingAccountWorkflow),
|
||||
]) {
|
||||
assert.match(
|
||||
workflow,
|
||||
@@ -818,17 +795,16 @@ test("staging mutations retain every pending deployment and migration", () => {
|
||||
});
|
||||
|
||||
test("automatic staging paths reject stale and divergent revisions", () => {
|
||||
const deployment = read(deployWorkflow);
|
||||
const migration = read(migrationWorkflow);
|
||||
const deployment = read(giteaDeployWorkflow);
|
||||
const migration = read(giteaMigrationWorkflow);
|
||||
|
||||
assert.match(deployment, /allow_rollback:/);
|
||||
assert.match(deployment, /rollback authorization is manual-only/);
|
||||
assert.match(deployment, /stale staging revision refused/);
|
||||
assert.match(deployment, /compare\/\$previous_sha\.\.\.\$DEPLOY_SHA/);
|
||||
assert.match(deployment, /\.status == "ahead" and \.merge_base_commit\.sha == \$base/);
|
||||
assert.match(migration, /stale staging migration refused/);
|
||||
assert.match(migration, /staging advanced during migration; refusing stale deployment dispatch/);
|
||||
assert.match(migration, /\{ref:"main",inputs:\{deploy_sha:\$deploy_sha,allow_rollback:"false"\}\}/);
|
||||
assert.match(migration, /compare\/\$previous_sha\.\.\.\$DEPLOY_SHA/);
|
||||
assert.match(migration, /migration rollback or divergence refused/);
|
||||
assert.match(migration, /staging advanced during migration; refusing stale mutation/);
|
||||
assert.doesNotMatch(migration, /ref:"main"|refs\/heads\/main/);
|
||||
});
|
||||
|
||||
test("remote deployment verifies running image IDs, RepoDigests, and application SHA", () => {
|
||||
@@ -949,7 +925,7 @@ test("first immutable deployment rolls back to validated local image IDs", () =>
|
||||
|
||||
test("normal deployment checks migrations but never applies them", () => {
|
||||
const runner = read(deployScript);
|
||||
const stagingWorkflows = [read(deployWorkflow), read(giteaDeployWorkflow)];
|
||||
const stagingWorkflows = [read(giteaDeployWorkflow)];
|
||||
assert.match(runner, /^#!\/usr\/bin\/env bash\nset -euo pipefail\nset \+x\n/);
|
||||
assert.match(runner, /-f deploy\/docker-compose\.staging\.yml/);
|
||||
assertOrder(runner, [
|
||||
@@ -984,7 +960,7 @@ test("normal deployment checks migrations but never applies them", () => {
|
||||
});
|
||||
|
||||
test("manual migration uses only PostgreSQL and the digest-pinned migrator", () => {
|
||||
const workflow = read(migrationWorkflow);
|
||||
const workflow = read(giteaMigrationWorkflow);
|
||||
const runner = read(migrationScript);
|
||||
|
||||
assert.match(workflow, /^on:\n\s+workflow_dispatch:/m);
|
||||
@@ -1003,20 +979,6 @@ test("manual migration uses only PostgreSQL and the digest-pinned migrator", ()
|
||||
assert.doesNotMatch(runner, /\bup\b[^\n]*(?:api|web|caddy)/);
|
||||
});
|
||||
|
||||
test("run-local registry state and incoming trees are always cleaned up", () => {
|
||||
for (const workflow of [read(deployWorkflow), read(migrationWorkflow)]) {
|
||||
assert.match(workflow, /DOCKER_CONFIG='\$INCOMING_PATH\/\.docker'/);
|
||||
assert.match(workflow, /if: always\(\) && steps\.incoming\.outputs\.path != ''/);
|
||||
assert.match(workflow, /docker logout ghcr\.io/);
|
||||
assert.match(workflow, /rm -rf -- '\$INCOMING_PATH'/);
|
||||
assert.match(
|
||||
workflow,
|
||||
/install -d -m 700 [^\n]*\$incoming[^\n]*\n\s+echo "path=\$incoming" >>"\$GITHUB_OUTPUT"\n\s+rsync/,
|
||||
);
|
||||
assert.doesNotMatch(workflow, /--password(?:\s|=)/);
|
||||
}
|
||||
});
|
||||
|
||||
test("manual release gate runs where Docker Compose v2 is available", () => {
|
||||
const workflow = read(giteaReleaseQualityWorkflow);
|
||||
|
||||
@@ -1040,14 +1002,10 @@ test("manual release gate runs where Docker Compose v2 is available", () => {
|
||||
});
|
||||
|
||||
test("production deploy is manual-only and consumes the accepted staging artifact", () => {
|
||||
const mirror = readFileSync(new URL("../../.github/workflows/deploy-production.yml", import.meta.url), "utf8");
|
||||
const production = read(giteaProductionWorkflow);
|
||||
|
||||
for (const workflow of [mirror, production]) {
|
||||
assert.match(workflow, /^on:\n\s+workflow_dispatch:/m);
|
||||
assert.doesNotMatch(workflow, /workflow_run:|\n\s+push:/);
|
||||
}
|
||||
assert.match(mirror, /Production deployment is controlled by \.gitea\/workflows\/deploy-production\.yml/);
|
||||
assert.match(production, /^on:\n\s+workflow_dispatch:/m);
|
||||
assert.doesNotMatch(production, /workflow_run:|\n\s+push:/);
|
||||
assert.match(production, /branch=staging&event=push&status=success/);
|
||||
assert.match(production, /endswith\("backend-quality-gate\.yml"\)/);
|
||||
assert.match(production, /endswith\("release-quality-gate\.yml"\)/);
|
||||
@@ -1067,6 +1025,7 @@ test("deployment scripts pass shell syntax validation", () => {
|
||||
for (const script of [
|
||||
deployScript,
|
||||
migrationScript,
|
||||
resetStagingAccountScript,
|
||||
syncScript,
|
||||
reclaimRunnerDiskScript,
|
||||
productionDeployScript,
|
||||
@@ -1266,3 +1225,287 @@ test("Gitea production schema migration is exact-SHA gated and isolated from ETL
|
||||
assert.doesNotMatch(runner, /migrate-supabase-production|docker-compose\.server\.yml|docker-compose\.production\.yml/);
|
||||
assert.doesNotMatch(runner, /\bup\b[^\n]*(?:api|web|caddy)|Caddyfile|PRODUCTION_URL|PRODUCTION_ADMIN_URL|mv -f[^\n]*deployed-revision/);
|
||||
});
|
||||
|
||||
test("runner disk reclaim keeps BuildKit cache unless the runner is actually short on disk", () => {
|
||||
const script = read(reclaimRunnerDiskScript);
|
||||
|
||||
// Unconditionally pruning the whole BuildKit store made every publish job
|
||||
// rebuild the Dockerfile `npm ci` layer from scratch (47-minute image builds).
|
||||
// Aged cache goes first; `--all` is reachable only after re-measuring and
|
||||
// finding the runner still below MINIMUM_FREE_GIB.
|
||||
assert.doesNotMatch(script, /^docker builder prune --force --all$/m);
|
||||
assert.equal((script.match(/docker builder prune --force --all/g) ?? []).length, 1);
|
||||
assertOrder(script, [
|
||||
"docker image prune --force",
|
||||
"docker builder prune --force --filter until=72h",
|
||||
'TIERED_GIB="$(free_gib)"',
|
||||
'if [ "$TIERED_GIB" -lt "$MINIMUM_FREE_GIB" ]; then',
|
||||
"docker builder prune --force --all",
|
||||
'AFTER_GIB="$(free_gib)"',
|
||||
]);
|
||||
assert.match(
|
||||
script,
|
||||
/if \[ "\$TIERED_GIB" -lt "\$MINIMUM_FREE_GIB" \]; then\n\s+echo[^\n]+\n\s+docker builder prune --force --all\nfi\n/,
|
||||
);
|
||||
// The final threshold check still fails the job instead of silently proceeding.
|
||||
assert.match(script, /if \[ "\$AFTER_GIB" -lt "\$MINIMUM_FREE_GIB" \]; then\n\s+echo[^\n]+\n\s+exit 1/);
|
||||
});
|
||||
|
||||
const gatedPathsFile = new URL("../../deploy/gated-paths.txt", import.meta.url);
|
||||
const docsOnlyRangeScript = new URL("../../deploy/is-docs-only-range.sh", import.meta.url);
|
||||
|
||||
function gatedGlobs(): string[] {
|
||||
return read(gatedPathsFile)
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0 && !line.startsWith("#"));
|
||||
}
|
||||
|
||||
function triggerPaths(workflow: string, trigger: "pull_request" | "push"): string[] {
|
||||
const block = workflow.match(new RegExp(`\\n ${trigger}:\\n(?: branches: \\[staging\\]\\n)? paths:\\n((?: - '[^'\\n]+'\\n)+)`));
|
||||
assert.ok(block, `${trigger} trigger has no paths list`);
|
||||
return [...block[1].matchAll(/ - '([^'\n]+)'\n/g)].map((match) => match[1]);
|
||||
}
|
||||
|
||||
// Same semantics as GitHub/Gitea path filters and deploy/is-docs-only-range.sh:
|
||||
// `*` and `?` stop at `/`, `**` crosses directories.
|
||||
function globMatches(glob: string, path: string): boolean {
|
||||
let source = "";
|
||||
for (let i = 0; i < glob.length; i += 1) {
|
||||
if (glob.startsWith("**/", i) && (i === 0 || glob[i - 1] === "/")) {
|
||||
source += "(?:.*/)?";
|
||||
i += 2;
|
||||
} else if (glob.startsWith("**", i)) {
|
||||
source += ".*";
|
||||
i += 1;
|
||||
} else if (glob[i] === "*") {
|
||||
source += "[^/]*";
|
||||
} else if (glob[i] === "?") {
|
||||
source += "[^/]";
|
||||
} else {
|
||||
source += glob[i].replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
}
|
||||
return new RegExp(`^${source}$`).test(path);
|
||||
}
|
||||
|
||||
function gated(path: string, globs: string[]): boolean {
|
||||
return globs.some((glob) => globMatches(glob, path) || globMatches(glob, `${path}/_`));
|
||||
}
|
||||
|
||||
test("gated paths are one list shared by both quality-gate triggers", () => {
|
||||
const workflow = read(giteaQualityWorkflow);
|
||||
const globs = gatedGlobs();
|
||||
|
||||
assert.ok(globs.length > 0, "deploy/gated-paths.txt must not be empty");
|
||||
assert.deepEqual(triggerPaths(workflow, "pull_request"), globs);
|
||||
assert.deepEqual(triggerPaths(workflow, "push"), globs);
|
||||
assert.match(workflow, /push:\n\s+branches: \[staging\]\n\s+paths:\n/);
|
||||
assert.deepEqual(new Set(globs).size, globs.length, "gated globs must be unique");
|
||||
});
|
||||
|
||||
test("gated paths cover every image input, package input, and gate-read repository file", () => {
|
||||
const globs = gatedGlobs();
|
||||
for (const required of [
|
||||
"frontend/**",
|
||||
"jyotish_vedic/**",
|
||||
"deploy/**",
|
||||
"scripts/**",
|
||||
"tests/**",
|
||||
"references/**",
|
||||
"skills/**",
|
||||
"assets/**",
|
||||
"SKILL.md",
|
||||
"mcp_server.py",
|
||||
"pyproject.toml",
|
||||
"MANIFEST.in",
|
||||
"requirements*.txt",
|
||||
".dockerignore",
|
||||
".gitea/**",
|
||||
"contracts/**",
|
||||
]) {
|
||||
assert.ok(globs.includes(required), `${required} missing from deploy/gated-paths.txt`);
|
||||
}
|
||||
|
||||
// Every COPY source in both Dockerfiles (stage-to-stage copies excluded) must be gated.
|
||||
const copySources = [read(apiDockerfile), read(railwayWebDockerfile)].flatMap((dockerfile) =>
|
||||
[...dockerfile.matchAll(/^COPY (?!--from=)(.+)$/gm)].flatMap((match) => match[1].trim().split(/\s+/).slice(0, -1)),
|
||||
);
|
||||
assert.ok(copySources.length >= 20, `expected the Dockerfiles to declare COPY sources, saw ${copySources.length}`);
|
||||
for (const source of copySources) {
|
||||
assert.ok(gated(source, globs), `Dockerfile COPY source ${source} is not covered by deploy/gated-paths.txt`);
|
||||
}
|
||||
// Repository files the gate's own tests read at run time.
|
||||
for (const source of [
|
||||
"skills/jyotish-birth-time-rectification/SKILL.md",
|
||||
"references/rectification_sealed_holdout.v1.json",
|
||||
"contracts/probe-question-v1.json",
|
||||
"tests/fixtures/personal_report_document.v2.json",
|
||||
".gitea/actions/upload-artifact/dist/index.js",
|
||||
"deploy/gated-paths.txt",
|
||||
"deploy/is-docs-only-range.sh",
|
||||
]) {
|
||||
assert.ok(gated(source, globs), `${source} is read by the gate but not covered`);
|
||||
}
|
||||
// Pure record files stay docs-only.
|
||||
for (const docsOnly of [
|
||||
"docs/BUG_HISTORY.md",
|
||||
"docs/research/anything.md",
|
||||
"TASK-example-20260901.md",
|
||||
"PROGRESS-example-20260901.md",
|
||||
"CHANGELOG.md",
|
||||
"progress.md",
|
||||
"task_plan.md",
|
||||
"findings.md",
|
||||
"BLOCKED.md",
|
||||
"CONTEXT.md",
|
||||
"AGENTS.md",
|
||||
"README.md",
|
||||
]) {
|
||||
assert.equal(gated(docsOnly, globs), false, `${docsOnly} should be docs-only`);
|
||||
}
|
||||
});
|
||||
|
||||
test("publish dispatch and staging deploy accept docs-only advances only through the attested checker", () => {
|
||||
const quality = read(giteaQualityWorkflow);
|
||||
const deploy = read(giteaDeployWorkflow);
|
||||
|
||||
const dispatch = quality.match(/- name: Dispatch exact-SHA staging deployment[\s\S]*?(?=\n\s+- name: Logout ACR registry)/)?.[0] ?? "";
|
||||
assert.match(dispatch, /if \[\[ "\$current_staging_sha" != "\$DEPLOY_SHA" \]\]; then/);
|
||||
assert.match(dispatch, /bash deploy\/is-docs-only-range\.sh --api "\$DEPLOY_SHA" "\$current_staging_sha"/);
|
||||
assert.match(dispatch, /staging advanced before deployment dispatch; refusing stale release/);
|
||||
assert.match(dispatch, /--arg deploy_sha "\$DEPLOY_SHA"/);
|
||||
|
||||
// deploy-staging never checks out a branch: the checker comes from the
|
||||
// gate-attested controller bundle and decides via the Gitea compare API.
|
||||
assert.match(deploy, /checker=artifacts\/staging-image\/extracted\/deploy\/is-docs-only-range\.sh\n\s+\[\[ -f "\$checker" \]\] \|\| \{ echo "gate-attested controller bundle lacks deploy\/is-docs-only-range\.sh/);
|
||||
assert.match(deploy, /if bash "\$checker" --api "\$DEPLOY_SHA" "\$staging_head"; then/);
|
||||
assert.match(deploy, /\[\[ "\$current_head" == "\$DEPLOY_SHA" \]\] && return\n[^\n]*\n\s+bash artifacts\/staging-image\/extracted\/deploy\/is-docs-only-range\.sh --api "\$DEPLOY_SHA" "\$current_head" \|\|\n\s+\{ echo "staging advanced during deployment; refusing stale mutation"/);
|
||||
assert.equal((deploy.match(/is-docs-only-range\.sh/g) ?? []).length, 4);
|
||||
assert.doesNotMatch(deploy, /bash deploy\/is-docs-only-range\.sh/);
|
||||
assertOrder(deploy, [
|
||||
"Validate tested revision and gate run",
|
||||
"deferring the docs-only range check to the attested controller",
|
||||
"head_check=$head_check",
|
||||
"Download exact staging gate artifact",
|
||||
"Validate gate-attested staging controller and immutable image manifest",
|
||||
"Refuse stale staging revision unless only docs advanced",
|
||||
'if [[ "$ALLOW_ROLLBACK" == true ]]; then',
|
||||
"stale staging revision refused; use explicit manual rollback only when intended",
|
||||
"Deploy exact image digests under pinned SSH identity",
|
||||
"staging advanced during deployment; refusing stale mutation",
|
||||
]);
|
||||
// The manual rollback branch is untouched.
|
||||
assert.match(deploy, /if \[\[ "\$allow_rollback" == true && "\$REQUESTED_SHA" != "\$staging_head" \]\]; then\n\s+comparison="\$\(curl/);
|
||||
assert.match(deploy, /rollback revision is not in current staging history/);
|
||||
assert.doesNotMatch(deploy, /if \[\[ "\$allow_rollback" == false && "\$REQUESTED_SHA" != "\$staging_head" \]\]; then\n\s+echo "stale staging revision refused/);
|
||||
});
|
||||
|
||||
test("is-docs-only-range.sh decides from local history and refuses non-ancestor ranges", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "jyotisha-docs-only-range-"));
|
||||
const script = fileURLToPath(docsOnlyRangeScript);
|
||||
const gatedPaths = fileURLToPath(gatedPathsFile);
|
||||
const env = {
|
||||
...process.env,
|
||||
GATED_PATHS_FILE: gatedPaths,
|
||||
GIT_CONFIG_GLOBAL: "/dev/null",
|
||||
GIT_CONFIG_NOSYSTEM: "1",
|
||||
GIT_AUTHOR_NAME: "t",
|
||||
GIT_AUTHOR_EMAIL: "t@example.invalid",
|
||||
GIT_COMMITTER_NAME: "t",
|
||||
GIT_COMMITTER_EMAIL: "t@example.invalid",
|
||||
};
|
||||
const git = (...args: string[]): string => {
|
||||
const result = spawnSync("git", args, { cwd: root, env, encoding: "utf8" });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
return result.stdout.trim();
|
||||
};
|
||||
const commit = (relative: string, message: string): string => {
|
||||
mkdirSync(join(root, relative, ".."), { recursive: true });
|
||||
writeFileSync(join(root, relative), `${message}\n`);
|
||||
git("add", "-A");
|
||||
git("commit", "-q", "-m", message);
|
||||
return git("rev-parse", "HEAD");
|
||||
};
|
||||
const run = (...args: string[]) => spawnSync("bash", [script, ...args], { cwd: root, env, encoding: "utf8" });
|
||||
|
||||
try {
|
||||
git("init", "-q", "-b", "staging");
|
||||
const base = commit("frontend/src/app.ts", "code base");
|
||||
const docs = commit("docs/notes.md", "docs one");
|
||||
const task = commit("TASK-example-20260901.md", "docs two");
|
||||
const code = commit("jyotish_vedic/engine.py", "code after docs");
|
||||
git("checkout", "-q", "-b", "side", base);
|
||||
const diverged = commit("docs/side.md", "diverged docs");
|
||||
|
||||
const docsOnly = run(base, task);
|
||||
assert.equal(docsOnly.status, 0, docsOnly.stderr);
|
||||
assert.match(docsOnly.stdout, /docs-only: 2 changed path\(s\)/);
|
||||
|
||||
const gatedRange = run(base, code);
|
||||
assert.equal(gatedRange.status, 1, gatedRange.stderr);
|
||||
assert.match(gatedRange.stderr, /jyotish_vedic\/engine\.py \(matches jyotish_vedic\/\*\*\)/);
|
||||
|
||||
const behind = run(task, base);
|
||||
assert.equal(behind.status, 2, behind.stderr);
|
||||
assert.match(behind.stderr, /is not an ancestor of/);
|
||||
|
||||
const forked = run(docs, diverged);
|
||||
assert.equal(forked.status, 2, forked.stderr);
|
||||
|
||||
const same = run(task, task);
|
||||
assert.equal(same.status, 0, same.stderr);
|
||||
|
||||
const malformed = run("abc", task);
|
||||
assert.equal(malformed.status, 2);
|
||||
assert.match(malformed.stderr, /lowercase full commit SHA/);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("gate checkouts fetch the exact SHA from a host-persistent mirror and fall back to the bounded remote fetch", () => {
|
||||
const workflow = read(giteaQualityWorkflow);
|
||||
const checkouts = [...workflow.matchAll(/- name: Checkout exact Gitea revision[\s\S]*?(?=\n\s+- name: )/g)].map((match) => match[0]);
|
||||
assert.equal(checkouts.length, 2);
|
||||
|
||||
for (const step of checkouts) {
|
||||
assert.match(step, /MIRROR_PATH: \/root\/\.cache\/jyotisha-mirror\.git/);
|
||||
assert.match(step, /bounded_git\(\) \{\n\s+timeout 300 git -c http\.connectTimeout=15 -c http\.lowSpeedLimit=1 -c http\.lowSpeedTime=60 "\$@"/);
|
||||
// validate and publish (or two overlapping runs) may touch the mirror at once.
|
||||
assert.match(step, /exec 9>"\$MIRROR_PATH\.lock"/);
|
||||
assert.match(step, /flock -w 900 9/);
|
||||
assert.match(step, /flock -u 9/);
|
||||
assert.match(step, /clone --quiet --mirror https:\/\/git\.copse\.top\/root\/Jyotisha\.git "\$MIRROR_PATH"/);
|
||||
assert.match(step, /bounded_git -C "\$MIRROR_PATH" fetch --prune origin \|\| return 1/);
|
||||
assert.match(step, /git -C "\$MIRROR_PATH" cat-file -e "\$GITEA_SHA\^\{commit\}"/);
|
||||
assert.match(step, /find "\$MIRROR_PATH" -name '\*\.lock' -type f -delete/);
|
||||
// The workspace fetches from local disk, then origin points back at Gitea
|
||||
// for every later step.
|
||||
assert.match(
|
||||
step,
|
||||
/git remote set-url origin "\$MIRROR_PATH"\n\s+if timeout 300 git fetch --no-tags origin "\$GITEA_SHA"; then\n\s+fetch_succeeded=true\n[\s\S]*?git remote set-url origin https:\/\/git\.copse\.top\/root\/Jyotisha\.git/,
|
||||
);
|
||||
// Every mirror failure mode falls through to the pre-existing bounded remote fetch.
|
||||
assert.equal((step.match(/falling back to remote fetch/g) ?? []).length, 4);
|
||||
assert.match(
|
||||
step,
|
||||
/if \[ "\$fetch_succeeded" != true \]; then\n\s+for attempt in 1 2 3; do\n\s+if bounded_git fetch --depth=1 --no-tags origin "\$GITEA_SHA"; then/,
|
||||
);
|
||||
assert.equal((step.match(/exit 1/g) ?? []).length, 1, "only the exhausted remote fetch may fail the checkout");
|
||||
assertOrder(step, [
|
||||
"git remote add origin https://git.copse.top/root/Jyotisha.git",
|
||||
"sync_mirror() {",
|
||||
'flock -w 900 9',
|
||||
'git remote set-url origin "$MIRROR_PATH"',
|
||||
'if [ "$fetch_succeeded" != true ]; then',
|
||||
'fetch --depth=1 --no-tags origin "$GITEA_SHA"',
|
||||
"exact staging gate checkout failed after $attempt bounded attempts",
|
||||
'[[ "$fetch_succeeded" == true ]]',
|
||||
'git checkout --detach --force "$GITEA_SHA"',
|
||||
"git clean -ffdx",
|
||||
'test "$(git rev-parse HEAD)" = "$GITEA_SHA"',
|
||||
"git status --porcelain --untracked-files=all",
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -26,7 +26,10 @@ function sourceBetween(source: string, startMarker: string, endMarker: string) {
|
||||
test("keeps starter questions visible while the user edits a draft", () => {
|
||||
// Given: the empty-session starter block and its render guard.
|
||||
const guardStart = pageSource.indexOf("{profileComplete && presetMessageFinished");
|
||||
const onboardingBranch = pageSource.indexOf("(onboardingPending ?", guardStart);
|
||||
// Former value: pageSource.indexOf("(onboardingPending ?", guardStart) — the guard used to
|
||||
// branch into a `starter-loading` block. Home now reveals only after the starter questions
|
||||
// settle (or time out), so the guard leads straight into <StarterHome>.
|
||||
const onboardingBranch = pageSource.indexOf("<StarterHome", guardStart);
|
||||
|
||||
// When: the guard is inspected independently of the card copy and layout.
|
||||
assert.notEqual(guardStart, -1);
|
||||
|
||||
@@ -59,9 +59,10 @@ CORE_PYTEST_TARGETS = [
|
||||
# Staging quick profile never runs `tests/` wholesale. A distinguish probe
|
||||
# with empty mapping or non-positive gain must fail this gate (BUG-393).
|
||||
"tests/test_candidate_discriminator_contract.py",
|
||||
# Auto staging gate is `--profile quick`. `test.yml` / `ci.yml` run the
|
||||
# full pytest tree but are workflow_dispatch only, so a stale window_scan
|
||||
# assertion in this glob stayed red on origin/staging until listed here.
|
||||
# Auto staging gate is `--profile quick`. The full pytest tree only runs in
|
||||
# the manual `release-quality-gate.yml` (after `--profile release`), so a
|
||||
# stale window_scan assertion in this glob stayed red on origin/staging
|
||||
# until listed here.
|
||||
"tests/test_rectification_*.py",
|
||||
# This file regexes frontend source. Home-split and other page.tsx moves must keep it green.
|
||||
"tests/test_supabase_user_data_contract.py",
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
WORKFLOW = ROOT / ".github" / "workflows" / "apply-supabase-profile-migrations.yml"
|
||||
|
||||
|
||||
def test_profile_migration_workflow_is_manual_and_uses_vps_env_without_printing_secrets() -> None:
|
||||
text = WORKFLOW.read_text(encoding="utf-8")
|
||||
|
||||
assert "workflow_dispatch:" in text
|
||||
assert "PRODUCTION_SSH_PRIVATE_KEY" in text
|
||||
assert "SUPABASE_DB_URL" in text
|
||||
assert "DATABASE_URL" in text
|
||||
assert "docker run --rm -i postgres:16-alpine" in text
|
||||
assert "set +x" in text
|
||||
assert "cat \"$SQL_FILE\" |" in text
|
||||
|
||||
|
||||
def test_profile_migration_workflow_includes_chart_library_and_birth_time_profile_migrations() -> None:
|
||||
text = WORKFLOW.read_text(encoding="utf-8")
|
||||
|
||||
assert "20260718050000_profiles_service_role_upsert_grants.sql" in text
|
||||
assert "20260718060000_profiles_service_role_least_privilege.sql" in text
|
||||
assert "20260718070000_profiles_service_role_upsert_id.sql" in text
|
||||
assert "20260718080000_profiles_service_role_account_upsert_selects.sql" in text
|
||||
assert "20260718100000_repair_missing_chart_profiles.sql" in text
|
||||
assert "20260718102000_recover_missing_profile_rows.sql" in text
|
||||
assert "20260718103000_profile_birth_time_declaration_grants.sql" in text
|
||||
assert "20260718104000_chart_profiles_upsert_id_grant.sql" in text
|
||||
|
||||
|
||||
def test_profile_migration_workflow_does_not_reference_missing_sql_files() -> None:
|
||||
text = WORKFLOW.read_text(encoding="utf-8")
|
||||
assert "20260718010000_recover_missing_profile_rows.sql" not in text
|
||||
assert "20260718020000_profiles_service_role_upsert_grants.sql" not in text
|
||||
Reference in New Issue
Block a user