Nightly Jeonghan Fanfic Digest #715
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Nightly Jeonghan Fanfic Digest | |
| on: | |
| workflow_dispatch: | |
| schedule: | |
| # 22:00 Tehran = 18:30 UTC | |
| - cron: "30 18 * * *" | |
| push: | |
| branches: | |
| - main | |
| - "agent/**" | |
| paths: | |
| - "app/fic_digest.py" | |
| - "app/fic_state.py" | |
| - "app/ai.py" | |
| - "app/message_delivery.py" | |
| - "app/telegram_cloud_state.py" | |
| - "app/x_client.py" | |
| - "app/source_modes.py" | |
| - "app/config.py" | |
| - "config/sources.json" | |
| - "config/jeonghan_priority_x_sources.json" | |
| - "app/telegram.py" | |
| - "tools/state_backup.py" | |
| - "docs/**" | |
| - "tests/**" | |
| - "requirements.txt" | |
| - ".github/workflows/fic-digest.yml" | |
| pull_request: | |
| branches: | |
| - main | |
| paths: | |
| - "app/fic_digest.py" | |
| - "app/fic_state.py" | |
| - "app/ai.py" | |
| - "app/message_delivery.py" | |
| - "app/telegram_cloud_state.py" | |
| - "app/x_client.py" | |
| - "app/source_modes.py" | |
| - "app/config.py" | |
| - "config/sources.json" | |
| - "config/jeonghan_priority_x_sources.json" | |
| - "app/telegram.py" | |
| - "tools/state_backup.py" | |
| - "docs/**" | |
| - "tests/**" | |
| - "requirements.txt" | |
| - ".github/workflows/fic-digest.yml" | |
| permissions: | |
| contents: read | |
| actions: write | |
| concurrency: | |
| group: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && 'jeonghan-daily-review-bot-runtime' || format('nightly-jeonghan-fic-validation-{0}', github.ref) }} | |
| cancel-in-progress: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }} | |
| jobs: | |
| send-digest: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 25 | |
| env: | |
| PYTHONUNBUFFERED: "1" | |
| PIP_DISABLE_PIP_VERSION_CHECK: "1" | |
| STATE_BACKUP_KEY: ${{ secrets.STATE_BACKUP_KEY }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| - name: Python | |
| uses: actions/setup-python@v6 | |
| with: | |
| python-version: "3.11" | |
| cache: pip | |
| - name: Install | |
| run: | | |
| python -m pip install --upgrade "pip>=26.1.2" | |
| python -m pip install -r requirements.txt | |
| python -m pip check | |
| - name: Derive stable encrypted recovery key | |
| if: >- | |
| (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && | |
| env.STATE_BACKUP_KEY == '' | |
| env: | |
| TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| key="$(python - <<'PY' | |
| import os | |
| from app.telegram_cloud_state import ensure_process_backup_key | |
| token = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip() | |
| if not token: | |
| raise SystemExit("TELEGRAM_BOT_TOKEN is required to derive the encrypted recovery key") | |
| ensure_process_backup_key(token) | |
| print(os.environ["STATE_BACKUP_KEY"]) | |
| PY | |
| )" | |
| echo "::add-mask::$key" | |
| echo "STATE_BACKUP_KEY=$key" >> "$GITHUB_ENV" | |
| - name: Report encrypted recovery disabled | |
| if: >- | |
| (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && | |
| env.STATE_BACKUP_KEY == '' | |
| run: echo "::warning::Encrypted state recovery is disabled because STATE_BACKUP_KEY is not configured; Actions cache remains best-effort only." | |
| - name: Restore shared private review database cache | |
| if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' | |
| uses: actions/cache/restore@v5 | |
| with: | |
| path: .state/private-review.sqlite3 | |
| key: jeonghan-private-review-v1-${{ runner.os }}-${{ github.run_id }} | |
| restore-keys: | | |
| jeonghan-private-review-v1-${{ runner.os }}- | |
| - name: Restore newest valid encrypted recovery database when cache is missing | |
| if: >- | |
| (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && | |
| env.STATE_BACKUP_KEY != '' && | |
| hashFiles('.state/private-review.sqlite3') == '' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| work=/tmp/private-state-restore | |
| zip=/tmp/private-state-backup.zip | |
| trap 'rm -rf "$work" "$zip"' EXIT | |
| mapfile -t artifact_ids < <(gh api -H 'Accept: application/vnd.github+json' \ | |
| "/repos/${GITHUB_REPOSITORY}/actions/artifacts?name=private-state-backup&per_page=100" \ | |
| --jq '[.artifacts[] | select(.expired == false)] | sort_by(.created_at) | reverse | .[].id') | |
| if [ "${#artifact_ids[@]}" -eq 0 ]; then | |
| echo "No encrypted recovery artifact exists yet; starting from cache/empty fic state." | |
| exit 0 | |
| fi | |
| selected="" | |
| for artifact_id in "${artifact_ids[@]}"; do | |
| rm -rf "$work" "$zip" | |
| mkdir -p "$work" | |
| if ! gh api -H 'Accept: application/vnd.github+json' \ | |
| "/repos/${GITHUB_REPOSITORY}/actions/artifacts/${artifact_id}/zip" > "$zip"; then | |
| echo "::warning::Could not download encrypted recovery artifact ${artifact_id}; trying older backup." | |
| continue | |
| fi | |
| if ! unzip -q "$zip" -d "$work"; then | |
| echo "::warning::Could not unpack encrypted recovery artifact ${artifact_id}; trying older backup." | |
| continue | |
| fi | |
| candidate="$work/private-state-backup.enc" | |
| if [ -f "$candidate" ] && python -m tools.state_backup validate --input "$candidate" --require private-review.sqlite3; then | |
| selected="$candidate" | |
| echo "Selected newest valid encrypted recovery artifact ${artifact_id}." | |
| break | |
| fi | |
| echo "::warning::Encrypted recovery artifact ${artifact_id} did not validate; trying older backup." | |
| done | |
| if [ -z "$selected" ]; then | |
| echo "::error::Encrypted recovery artifacts exist but none authenticated successfully; refusing to run with potentially lost private state." | |
| exit 1 | |
| fi | |
| python -m tools.state_backup restore --input "$selected" --state-dir .state | |
| - name: Validate fanfic and shared bot code | |
| run: | | |
| python -m compileall -q app tests tools | |
| python -m app --check | |
| python -m unittest discover -s tests -p "test_*.py" -v | |
| - name: Build and send two separate nightly lists | |
| if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' | |
| env: | |
| TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} | |
| TELEGRAM_ADMIN_USER_ID: ${{ secrets.TELEGRAM_ADMIN_USER_ID }} | |
| TELEGRAM_REVIEW_CHAT_ID: ${{ secrets.TELEGRAM_REVIEW_CHAT_ID }} | |
| X_COOKIE: ${{ secrets.X_COOKIE }} | |
| GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} | |
| GEMINI_MODEL: gemini-3.5-flash-lite | |
| run: python -m app.fic_digest | |
| - name: Checkpoint shared private review database | |
| if: >- | |
| always() && | |
| (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && | |
| hashFiles('.state/private-review.sqlite3') != '' | |
| run: | | |
| python - <<'PY' | |
| import sqlite3 | |
| from pathlib import Path | |
| path = Path('.state/private-review.sqlite3') | |
| conn = sqlite3.connect(path, timeout=15) | |
| try: | |
| result = conn.execute('PRAGMA quick_check').fetchone() | |
| if not result or str(result[0]).lower() != 'ok': | |
| raise SystemExit('private-review.sqlite3 failed quick_check') | |
| conn.execute('PRAGMA wal_checkpoint(TRUNCATE)').fetchall() | |
| finally: | |
| conn.close() | |
| PY | |
| - name: Create authenticated encrypted recovery backup | |
| if: >- | |
| always() && | |
| (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && | |
| env.STATE_BACKUP_KEY != '' && | |
| hashFiles('.state/private-review.sqlite3') != '' | |
| run: python -m tools.state_backup encrypt --state-dir .state --output .state/private-state-backup.enc | |
| - name: Upload encrypted recovery backup | |
| if: >- | |
| always() && | |
| (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && | |
| env.STATE_BACKUP_KEY != '' && | |
| hashFiles('.state/private-state-backup.enc') != '' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: private-state-backup | |
| path: .state/private-state-backup.enc | |
| if-no-files-found: error | |
| retention-days: 3 | |
| compression-level: 0 | |
| - name: Save shared private review database cache | |
| if: >- | |
| always() && | |
| (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && | |
| hashFiles('.state/private-review.sqlite3') != '' | |
| uses: actions/cache/save@v5 | |
| with: | |
| path: .state/private-review.sqlite3 | |
| key: jeonghan-private-review-v1-${{ runner.os }}-${{ github.run_id }} | |
| - name: Resume live assistant after fanfic digest | |
| if: >- | |
| success() && | |
| (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && | |
| github.ref == 'refs/heads/main' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| gh_api_retry() { | |
| local attempt output | |
| for attempt in 1 2 3; do | |
| if output="$(gh api "$@")"; then | |
| printf '%s' "$output" | |
| return 0 | |
| fi | |
| echo "GitHub API attempt ${attempt}/3 failed; retrying." >&2 | |
| sleep $((attempt * 2)) | |
| done | |
| return 1 | |
| } | |
| gh_workflow_retry() { | |
| local attempt | |
| for attempt in 1 2 3; do | |
| if gh workflow run "$@"; then | |
| return 0 | |
| fi | |
| echo "GitHub workflow dispatch attempt ${attempt}/3 failed; retrying." >&2 | |
| sleep $((attempt * 2)) | |
| done | |
| return 1 | |
| } | |
| active=0 | |
| for status in pending queued in_progress; do | |
| count="$( | |
| gh_api_retry -H 'Accept: application/vnd.github+json' \ | |
| "/repos/${GITHUB_REPOSITORY}/actions/workflows/main.yml/runs?status=${status}&per_page=100" | | |
| jq '[.workflow_runs[] | select(.head_branch == "main")] | length' | |
| )" | |
| active=$((active + count)) | |
| done | |
| if [ "$active" -gt 0 ]; then | |
| echo "A live assistant pass is already queued or running." | |
| exit 0 | |
| fi | |
| gh_workflow_retry main.yml --ref main -f mode=live | |
| echo "Queued the next live assistant pass after the fanfic digest." |