Compare commits

...

22 Commits

Author SHA1 Message Date
Jesse_Chen bd79689f66 docs(design): restore paragraph break in the waiting section
Independent Staging Quality Gate / validate (push) Successful in 7m37s
Independent Staging Quality Gate / publish (push) Successful in 1m46s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-02 04:45:47 +00:00
Jesse_Chen f42a886f87 Merge remote-tracking branch 'origin/staging' into codex/unified-loading-20260902
Independent Staging Quality Gate / publish (push) Has been cancelled
Independent Staging Quality Gate / validate (push) Successful in 8m17s
# Conflicts:
#	docs/BUG_HISTORY.md
#	frontend/DESIGN.md
#	frontend/src/app/page.tsx
2026-09-02 04:44:40 +00:00
Jesse_Chen 482796fc52 fix(chat): reveal the home page once after a two-phase bootstrap
The loading screen now owns every wait: starter questions, today's
starlanguage and the rectification entry summary are fetched in a prepare
phase before reveal (4s budget, static fallbacks), the rectification chunk is
warmed, recent sessions are prefetched, and no component spinner remains
after the page appears. BUG-479.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-02 04:39:04 +00:00
Jesse_Chen 4dc0c8c7b3 Merge codex/streaming-ux-20260901: frame-coalesced streaming, stable settlement, one activity UI and one composer across both chat surfaces
Independent Staging Quality Gate / validate (push) Successful in 8m32s
Independent Staging Quality Gate / publish (push) Successful in 3m11s
BUG-473..478. Verified: tsc 0, focused suites 188/188, full suite 2484/2450 with the 24 known no-Docker failures, next build 0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-02 04:32:57 +00:00
Jesse_Chen 2e76af4057 docs(rectification): align the surface brief with the unified-loading ruling and record cross-brief ordering
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-02 04:29:46 +00:00
Jesse_Chen 7557e0d2ed docs(rectification): align the surface brief with the unified-loading ruling and record cross-brief ordering
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-02 04:29:01 +00:00
Jesse_Chen f56f62a1f4 docs(chat): final test numbers after the second rebase
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-02 04:29:01 +00:00
Jesse_Chen 2733c50295 docs(chat): note the second rebase and renumbering; re-lock the question-stem describedby on the shared composer
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-02 04:29:01 +00:00
Jesse_Chen 152acbef8d docs(chat): record task 3 in progress and re-lock the starter-home scroll guard on the hook
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-02 04:29:01 +00:00
Jesse_Chen f542c02490 refactor(chat): share composer, scroll follow and jump control across both chat surfaces
The rectification session rendered its own textarea (no length ceiling,
no count), its own rAF scroll follow with a sticky-scroll helper, and its
own jump chip styled differently from the main chat's inline Tailwind
button, while the main chat re-ran scrollTo on every streamed token and
added a smooth scroll on settle. ChatComposer now accepts a controlled
value so the rectification surface reuses it without touching the main
draft store; useConversationScrollAnchor owns the follow through a
ResizeObserver, one frame per content change, for both surfaces; a
single JumpToLatestButton replaces both chips; the 720px transcript
override is gone. DESIGN.md records the shared composer, the jump
control, the reading width and the two shadow tokens.

BUG-477 BUG-478

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-02 04:29:01 +00:00
Jesse_Chen eeb82dfdc4 docs(chat): record streaming-ux progress, block task 3 on batch three, align DESIGN.md
DESIGN.md gains the streaming-states table, the thinking-collapse and
label-swap motion rows, the 17px body correction and the single live
marker rule; task 3 is registered in BLOCKED.md until the home split
batch three lands; PROGRESS lists every touched assertion with its
former value.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-02 04:29:01 +00:00
Jesse_Chen 6356d7b549 refactor(chat): render rectification activity through the shared step timeline
The rectification surface had its own activity pipeline: a trace panel
with a 20px canvas orb clipped inside a 14px marker, a list that never
collapsed, a second receipt disclosure under every reply, an inline
failure banner and a staged label while regenerating. Its trace and
receipt are now projected onto ConsultationTimelineRow so both surfaces
render one ConsultationRunTimeline with one live marker and one settled
summary; receipt methods become source chips on the last completed row.
The unreachable sections-report/step-tree path, the trace panel, the
receipt component and the thinking-orbs dependency are removed. The
server-side drop of rectification thinking deltas is untouched.

BUG-476

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-02 04:29:01 +00:00
Jesse_Chen aff9d19343 fix(chat): keep the latest reply mounted through settlement and animate the timeline collapse
The streaming and settled versions of the trailing assistant reply were
two components, so settling unmounted one and mounted the other and the
entrance tween replayed over text the reader was already on. One
LatestAssistantEntry now owns that row under a single key, and the
history list excludes it. The CSS entrance keyframe that doubled the
GSAP tween is gone and the tween matches the documented 160ms. The step
timeline no longer remounts on settle: it is a button-controlled
disclosure with a 180ms grid-rows transition, the reader's own toggle
wins over the live default, and an in-flight request with no events yet
shows a queued row instead of an empty shell.

BUG-474 BUG-475

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-02 04:29:01 +00:00
Jesse_Chen ad9dba5c79 perf(chat): coalesce stream events per frame and pace text release
Every NDJSON event used to commit its own React update and re-parse the
whole partial answer through react-markdown, so long replies grew
quadratically slower. Stream events now land in a frame buffer that
flushes at most once per animation frame, releases answer and thinking
text at a steady pace with a twelve-frame catch-up, and settles
synchronously on completion, failure and abort. Streaming markdown is
split at the last completed block so only the tail is re-parsed each
frame. Applied to both the consultation hook and the rectification chat.

BUG-473

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-02 04:29:01 +00:00
Jesse_Chen 02f06255c1 docs(rectification): add blank-state and interaction-friction brief for the rectification surface
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-02 04:21:09 +00:00
Jesse_Chen 8e04dcb061 docs(chat): add unified home loading task brief
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-02 04:20:35 +00:00
Jesse_Chen 701d4f92a2 docs(rectification): add walkthrough polish task brief
首次真实走查确认三轮修复生效;本轮修五个抛光项:选择卡题干
fail-closed、题干时序与 UI 断言措辞、同域重问去重、采用后切入
前事核对、coverage 门与 offer 链路一致性(需产品选边,默认对齐引擎)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155nFCgCHtoA7jhSDGmZmMu
2026-09-02 04:07:36 +00:00
Jesse_Chen f7428909d2 ci(gate): drop the removed GitHub workflow directory from the gated path list
Independent Staging Quality Gate / validate (push) Successful in 21m36s
Independent Staging Quality Gate / publish (push) Successful in 1m56s
The mirror workflows were deleted in the same release, so the gated
path list, both trigger filters and the contract test no longer name
.github/workflows/**.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VawU7Xfd5jS9wUEXz1XYmS
2026-09-02 04:02:04 +00:00
Jesse_Chen 68414a8223 ci(gate): check out the exact SHA from a host-persistent mirror with remote fallback
Both gate jobs spent 4-8 minutes (validate) plus 3-5 minutes (publish) on
`git fetch --depth=1` of a 105 MB tree over the WAN, and run 2270 burned
15 minutes on three timed-out attempts. act_runner's hostexecutor discards
the workspace between runs but keeps the host filesystem, so keep a bare
mirror at /root/.cache/jyotisha-mirror.git: take an flock on it (validate,
publish, or overlapping runs may race), clone it once or `fetch --prune`
only when it lacks the requested commit, fetch the exact SHA from local
disk, then point origin back at Gitea for every later step.

The mirror is an accelerator, not a dependency: an unwritable path, a busy
lock, a failed clone/fetch, a non-repository directory, or a stale git lock
file all fall through to the unchanged bounded three-attempt remote fetch,
and the SHA-format, checkout, `git clean -ffdx`, HEAD-equals-SHA, and
clean-tree assertions are untouched. Rehearsed locally against a local
remote for fresh, reused, prune-refresh, unwritable, corrupt, stale-lock,
and lock-contention cases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VawU7Xfd5jS9wUEXz1XYmS
2026-09-02 04:02:04 +00:00
Jesse_Chen 534f5e617c ci(gate): skip the staging gate for docs-only pushes without releasing stale code
Twenty-four of the last sixty staging pushes were pure documentation, yet each
one ran (and cancelled) the full gate and image publish. Introduce
deploy/gated-paths.txt as the single source of truth for what must rerun the
gate: every Dockerfile COPY source, the Python package inputs, the workflow and
build-context files, and the repository files frontend/tests read at gate time.
Both triggers of backend-quality-gate.yml now carry that exact list; pushes
that touch none of it neither run the gate nor cancel a running code gate.

Because staging head may then legitimately sit ahead of the last tested SHA,
add deploy/is-docs-only-range.sh: it proves <base> is an ancestor of <head>
and that no changed path matches a gated glob, from local history when it is
available and otherwise from the Gitea compare API (per-commit `files`, parent
walk for ancestry, total_commits cross-checked). The publish dispatch and the
deploy-staging head checks accept an advanced head only when that script
succeeds; diverged, older, or code-bearing heads are still refused. In
deploy-staging the check runs after the gate-attested controller bundle is
extracted so only the tested checker and path list are ever executed; the
manual rollback branch is unchanged.

AGENTS.md §6.3/§6.4 describe the new contract: `.deployment.gitCommit` must
equal the latest staging commit that touched a gated path, not staging head.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VawU7Xfd5jS9wUEXz1XYmS
2026-09-02 04:02:04 +00:00
Jesse_Chen 75e288b0c6 ci(gate): keep BuildKit cache unless the runner is actually short on disk
deploy/reclaim-runner-disk.sh ran `docker builder prune --force --all` at the
start of every gate job, so each publish rebuilt the Dockerfile `npm ci` layer
from scratch behind the registry mirror (7-minute image builds became 47).
Reclaim build cache in tiers instead: prune entries unused for 72h, re-measure
free space, and escalate to `--all` only while still below MINIMUM_FREE_GIB.
The final threshold check and the "only unheld resources" rules are unchanged.

Contract test asserts the aged prune runs first and that `--all` is only
reachable inside the re-measured conditional.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VawU7Xfd5jS9wUEXz1XYmS
2026-09-02 04:02:04 +00:00
Jesse_Chen fb69e43c90 ci: remove GitHub mirror workflows and dead Gitea manual workflows
Gitea (git.copse.top) is the only CI/CD control plane; GitHub is a
read-only mirror whose Actions are being disabled in repository settings.

- Delete all 11 `.github/workflows/*.yml` (stale copies of the old design).
- Delete unused Gitea manual workflows `ci.yml`, `test.yml`,
  `publish-pypi.yml`, `apply-supabase-profile-migrations.yml`
  (0-1 historical runs, no remaining target).
- Fold the full `python -m pytest` tree and `tests/run_all.py` into
  `release-quality-gate.yml`, which previously only ran the curated
  release profile; update the `run_quality_gate.py` comment accordingly.
- Port `reset-staging-account.yml` to Gitea: `runs-on: xiaoxin`, bounded
  exact-SHA checkout, `refs/heads/staging` only, `staging-mutation`
  concurrency, same email/confirmation/host/port/user/path assertions.
- Repoint frontend workflow tests at `.gitea/workflows/`, drop the
  GitHub-only assertions, add coverage for the new reset workflow, and
  remove `tests/test_supabase_profile_migration_workflow.py`.
- Update AGENTS.md §6.8, README.md, and deploy/README.md to the current
  production/mirror facts and document the staging account reset.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VawU7Xfd5jS9wUEXz1XYmS
2026-09-02 04:02:04 +00:00
43 changed files with 1635 additions and 1662 deletions
@@ -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
+162 -31
View File
@@ -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")"
-46
View File
@@ -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
+47 -3
View File
@@ -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
-43
View File
@@ -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
+103
View File
@@ -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
-42
View File
@@ -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
-145
View File
@@ -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
-68
View File
@@ -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
-16
View File
@@ -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
-254
View File
@@ -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'"
-33
View File
@@ -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
-47
View File
@@ -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
+3 -3
View File
@@ -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>` 必须退出 0publish 与 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
+46
View File
@@ -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 error72 warning,全部既有;本轮改动文件无新增 warning)。
- `npm test`2493 条,pass 2459 / fail 24 / skipped 1024 条失败清单与 `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 时会一并带入该分支(其独立验收见该轮记录)。
+9 -9
View File
@@ -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.
+182
View File
@@ -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` 拉回 turnskey 翻成 `-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 最大 472streaming 分支占 473478**本轮从 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 会话无起点)。
---
## 任务 1P1)· 交互摩擦
### 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(盘面首态空)。
---
## 任务 2P2)· 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 记入 droppedreason 如 `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。
## 问题 EP1,需产品选边)· 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. 对齐 TSoffer 工具服务端校验 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=0BUG_HISTORY 追加条目(编号接现有最大号之后,先 grep 确认,避免再撞号)。
5. 建议真实环境复测清单(人工):完整走一遍并确认——首道选择卡有题干;无"界面上有下一问"式断言;同域重问换措辞;采用后立即出现前事核对问题且在对话流内。
+82
View File
@@ -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`485487 的 effect)才开始拉,消息区先显示 `session-messages-loading` + `InlineSpinner`(1867–1868)。**这是用户最先看见的第二层等待**。
- **starter 推荐问题**profile 完整时 11531187 的 effect 拉 `/api/onboarding`,期间显示 `starter-loading`1836)。
- **每日星语**1205 起拉 `fetchDailyStarlanguage`,卡片 `aria-busy``starter-home.tsx:88``dailyStarlanguageBusy` 630634)。
- **校正入口摘要**523 拉 `entry-summary`,影响校正卡文案;打开校正时 `BirthTimeRectification` 动态分包还有一行"正在加载出生时间评估..."230235)。
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 errorBUG-470 后 react-hooks 规则已能分析 Homeeffect 内同步 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` 启动 effect840960)、`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 落各自的静态最终态。
- 保留:流式生成中的状态;校正分包兜底文案。
### 任务 3P1)· 切换预取
- 揭幕后按侧栏顺序串行预取最近 5 条会话消息;缓存未命中时的静态占位(决策记录 4)。
### 任务 4P1)· 合同测试
- 锁:阶段二包含四项请求与预热;4 秒上限常量存在;揭幕后 `page.tsx` + `starter-home.tsx``InlineSpinner`/`role="status"` 加载态(流式区除外);`stale-client-recovery` 依赖的选择器仍在。
## 总验收
1. tsc / lint / 测试基线 / `/` Static / gzip(红线 45)。
2. 首屏时序说明写进 PROGRESS:阶段一、阶段二各自耗时(本地测量即可),并附网络面板截图或请求清单证明无重复请求。
3. 行为:登录后从加载屏到完整界面**只有一次揭幕**,消息、推荐问题、每日星语、校正卡同时就位;断网或慢速(DevTools 节流 Slow 3G)下 4 秒后揭幕且各项为静态兜底态、无任何转圈。无登录态环境则合同测试覆盖并如实标注。
## 明确不做
- 不改流式回答的生成中表现(streaming-ux 轮已定)。
- 不改校正会话面内部的加载/busy 语义(`rectification-agentic-chat``aria-busy` 是生成中,不是加载)。
- 不做骨架屏——产品要的是"不显示,直到完整"。
- 不动报告页、会员页的加载表现(另议)。
+7 -3
View File
@@ -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:
+31
View File
@@ -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/**
+239
View File
@@ -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
+15 -1
View File
@@ -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"
+16
View File
@@ -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-470effect 内 setState 规则)
- 复发自:无
- 修复版本:待发布
+2 -2
View File
@@ -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>` 是独立设计系统,不在此表。
-9
View File
@@ -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
View File
@@ -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>
) : (
-3
View File
@@ -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>
+63
View File
@@ -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;
}
+10 -6
View File
@@ -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", () => {
+7 -3
View File
@@ -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/);
+4 -31
View File
@@ -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("));
});
+353 -110
View File
@@ -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",
]);
}
});
+4 -1
View File
@@ -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);
+4 -3
View File
@@ -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