diff --git a/.gitea/workflows/apply-supabase-profile-migrations.yml b/.gitea/workflows/apply-supabase-profile-migrations.yml deleted file mode 100644 index d804daab..00000000 --- a/.gitea/workflows/apply-supabase-profile-migrations.yml +++ /dev/null @@ -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 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml deleted file mode 100644 index dc13e1b1..00000000 --- a/.gitea/workflows/ci.yml +++ /dev/null @@ -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 diff --git a/.gitea/workflows/publish-pypi.yml b/.gitea/workflows/publish-pypi.yml deleted file mode 100644 index f127b587..00000000 --- a/.gitea/workflows/publish-pypi.yml +++ /dev/null @@ -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/* diff --git a/.gitea/workflows/release-quality-gate.yml b/.gitea/workflows/release-quality-gate.yml index 7b00cd31..ddf06cae 100644 --- a/.gitea/workflows/release-quality-gate.yml +++ b/.gitea/workflows/release-quality-gate.yml @@ -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 diff --git a/.gitea/workflows/reset-staging-account.yml b/.gitea/workflows/reset-staging-account.yml new file mode 100644 index 00000000..ed48b3d6 --- /dev/null +++ b/.gitea/workflows/reset-staging-account.yml @@ -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 " >&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 diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml deleted file mode 100644 index 9e9c3dfd..00000000 --- a/.gitea/workflows/test.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/apply-production-rectification-migrations.yml b/.github/workflows/apply-production-rectification-migrations.yml deleted file mode 100644 index 0ed56f33..00000000 --- a/.github/workflows/apply-production-rectification-migrations.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/apply-supabase-profile-migrations.yml b/.github/workflows/apply-supabase-profile-migrations.yml deleted file mode 100644 index db86d9ea..00000000 --- a/.github/workflows/apply-supabase-profile-migrations.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/backend-quality-gate.yml b/.github/workflows/backend-quality-gate.yml deleted file mode 100644 index 5c60f89a..00000000 --- a/.github/workflows/backend-quality-gate.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index aa940886..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml deleted file mode 100644 index 5da0a860..00000000 --- a/.github/workflows/deploy-production.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml deleted file mode 100644 index c5c72c42..00000000 --- a/.github/workflows/deploy-staging.yml +++ /dev/null @@ -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'" diff --git a/.github/workflows/migrate-staging-database.yml b/.github/workflows/migrate-staging-database.yml deleted file mode 100644 index 0636a91a..00000000 --- a/.github/workflows/migrate-staging-database.yml +++ /dev/null @@ -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'" diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml deleted file mode 100644 index ebb83717..00000000 --- a/.github/workflows/publish-pypi.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/release-quality-gate.yml b/.github/workflows/release-quality-gate.yml deleted file mode 100644 index 7dc92841..00000000 --- a/.github/workflows/release-quality-gate.yml +++ /dev/null @@ -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/ diff --git a/.github/workflows/reset-staging-account.yml b/.github/workflows/reset-staging-account.yml deleted file mode 100644 index d692a1e6..00000000 --- a/.github/workflows/reset-staging-account.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 2779297b..00000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 89dca20d..37bcc14d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,7 +138,7 @@ Deployment safety rules: 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 diff --git a/README.md b/README.md index eed91019..bbe50e77 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/deploy/README.md b/deploy/README.md index 11b0c18f..e430b0ad 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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 `. 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: diff --git a/frontend/tests/account-api.test.ts b/frontend/tests/account-api.test.ts index dd94cd7d..0393b7ff 100644 --- a/frontend/tests/account-api.test.ts +++ b/frontend/tests/account-api.test.ts @@ -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", () => { diff --git a/frontend/tests/health-deployment.test.ts b/frontend/tests/health-deployment.test.ts index 7a65c71e..36c2ed53 100644 --- a/frontend/tests/health-deployment.test.ts +++ b/frontend/tests/health-deployment.test.ts @@ -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/); diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts index a44555de..47589d88 100644 --- a/frontend/tests/staging-backend-workflows.test.ts +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -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")), []); }); @@ -472,9 +439,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 +459,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 +591,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 +605,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 +729,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 +782,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 +794,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 +924,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 +959,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 +978,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 +1001,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 +1024,7 @@ test("deployment scripts pass shell syntax validation", () => { for (const script of [ deployScript, migrationScript, + resetStagingAccountScript, syncScript, reclaimRunnerDiskScript, productionDeployScript, diff --git a/scripts/run_quality_gate.py b/scripts/run_quality_gate.py index 76d5858b..bbf23222 100644 --- a/scripts/run_quality_gate.py +++ b/scripts/run_quality_gate.py @@ -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", diff --git a/tests/test_supabase_profile_migration_workflow.py b/tests/test_supabase_profile_migration_workflow.py deleted file mode 100644 index 1b518975..00000000 --- a/tests/test_supabase_profile_migration_workflow.py +++ /dev/null @@ -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