Jeonghan Daily Review Bot #3543
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: Jeonghan Daily Review Bot | |
| on: | |
| push: | |
| branches: | |
| - main | |
| - "agent/**" | |
| paths: | |
| - "app/**" | |
| - "config/**" | |
| - "data/translation_benchmark_cases.json" | |
| - "docs/**" | |
| - "tests/**" | |
| - "tools/**" | |
| - "requirements.txt" | |
| - "requirements-optional-media.txt" | |
| - ".github/workflows/main.yml" | |
| - ".github/workflows/daily-watchdog.yml" | |
| pull_request: | |
| branches: | |
| - main | |
| paths: | |
| - "app/**" | |
| - "config/**" | |
| - "data/translation_benchmark_cases.json" | |
| - "docs/**" | |
| - "tests/**" | |
| - "tools/**" | |
| - "requirements.txt" | |
| - "requirements-optional-media.txt" | |
| - ".github/workflows/main.yml" | |
| - ".github/workflows/daily-watchdog.yml" | |
| workflow_dispatch: | |
| inputs: | |
| mode: | |
| description: "فقط تست یا اجرای واقعی" | |
| required: true | |
| default: check | |
| type: choice | |
| options: | |
| - check | |
| - live | |
| - x-source-diagnostic | |
| schedule: | |
| # Offset from the top of the hour because GitHub documents heavier schedule | |
| # congestion around minute 0. Five minutes is GitHub's minimum cron interval. | |
| # Keep 18:27–18:59 UTC quiet so a pending 18:30 nightly fic job cannot be | |
| # replaced by a newer scheduled monitor in GitHub's one-pending-run queue. | |
| - cron: "2,7,12,17,22,27,32,37,42,47,52,57 0-17,19-23 * * *" | |
| - cron: "2,7,12,17,22 18 * * *" | |
| permissions: | |
| contents: read | |
| # Actions write is required for the existing due Fanfic dispatch and for the | |
| # automated-recovery-only watchdog re-arm after a successful live recovery. | |
| actions: write | |
| concurrency: | |
| # Never run two live monitors against the same Telegram/X state concurrently. | |
| group: jeonghan-daily-review-bot-${{ ((github.event_name == 'push' && github.ref != 'refs/heads/main') || github.event_name == 'pull_request') && 'validation' || 'runtime' }} | |
| cancel-in-progress: ${{ (github.event_name == 'push' && github.ref != 'refs/heads/main') || github.event_name == 'pull_request' }} | |
| jobs: | |
| validate-and-run: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| env: | |
| PYTHONUNBUFFERED: "1" | |
| PIP_DISABLE_PIP_VERSION_CHECK: "1" | |
| STATE_BACKUP_KEY: ${{ secrets.STATE_BACKUP_KEY }} | |
| LIVE_RUN: ${{ github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.mode == 'live') || (github.event_name == 'push' && github.ref == 'refs/heads/main') }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| - name: Python | |
| uses: actions/setup-python@v6 | |
| with: | |
| python-version: "3.11" | |
| cache: pip | |
| - name: FFmpeg | |
| timeout-minutes: 8 | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| verify_ffmpeg() { | |
| command -v ffmpeg >/dev/null 2>&1 && | |
| command -v ffprobe >/dev/null 2>&1 && | |
| ffmpeg -version | head -n 1 && | |
| ffprobe -version | head -n 1 | |
| } | |
| if verify_ffmpeg; then | |
| exit 0 | |
| fi | |
| echo "FFmpeg is not preinstalled on this runner image." | |
| for attempt in 1 2; do | |
| echo "Installing FFmpeg with apt (attempt $attempt/2)." | |
| if timeout 90s sudo apt-get \ | |
| -o Acquire::Retries=2 \ | |
| -o Acquire::ForceIPv4=true \ | |
| -o Acquire::http::Timeout=15 \ | |
| -o Acquire::https::Timeout=15 \ | |
| update -qq && \ | |
| timeout 90s sudo apt-get \ | |
| -o Dpkg::Use-Pty=0 \ | |
| install -y -qq --no-install-recommends ffmpeg && \ | |
| verify_ffmpeg; then | |
| exit 0 | |
| fi | |
| echo "FFmpeg apt attempt $attempt failed or timed out." | |
| sleep 3 | |
| done | |
| echo "::warning::apt-based FFmpeg install failed; trying Homebrew bottle fallback." | |
| BREW_BIN="/home/linuxbrew/.linuxbrew/bin/brew" | |
| if [ -x "$BREW_BIN" ]; then | |
| eval "$("$BREW_BIN" shellenv)" | |
| export HOMEBREW_NO_AUTO_UPDATE=1 | |
| export HOMEBREW_NO_INSTALL_CLEANUP=1 | |
| if timeout 240s brew install ffmpeg && verify_ffmpeg; then | |
| echo "$(dirname "$(command -v ffmpeg)")" >> "$GITHUB_PATH" | |
| exit 0 | |
| fi | |
| fi | |
| echo "::error::FFmpeg/ffprobe bootstrap failed after apt and Homebrew fallbacks." | |
| exit 1 | |
| - name: Install | |
| shell: bash | |
| run: | | |
| python -m pip install --upgrade "pip>=26.1.2" | |
| python -m pip install -r requirements.txt | |
| python -m pip check | |
| if ! python -m pip install -r requirements-optional-media.txt; then | |
| echo "::warning::Optional gallery-dl install failed; existing direct/yt-dlp/FFmpeg fallbacks remain available." | |
| fi | |
| - name: Derive stable encrypted recovery key | |
| if: >- | |
| env.LIVE_RUN == 'true' && | |
| 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: >- | |
| env.LIVE_RUN == 'true' && | |
| env.STATE_BACKUP_KEY == '' | |
| run: echo "::warning::Encrypted recovery snapshots are disabled because STATE_BACKUP_KEY is not configured; the rolling Actions caches still preserve normal state." | |
| - name: Restore bot state cache | |
| if: env.LIVE_RUN == 'true' | |
| uses: actions/cache/restore@v5 | |
| with: | |
| path: .state/state.json | |
| key: jeonghan-state-v2-${{ runner.os }}-${{ github.run_id }} | |
| restore-keys: | | |
| jeonghan-state-v2-${{ runner.os }}- | |
| - name: Restore private review database cache | |
| if: env.LIVE_RUN == 'true' | |
| 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 backup when cache is missing | |
| if: >- | |
| env.LIVE_RUN == 'true' && | |
| env.STATE_BACKUP_KEY != '' && | |
| (hashFiles('.state/state.json') == '' || 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; cache/first-run state will be used." | |
| 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 state.json 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 project | |
| if: >- | |
| github.event_name == 'push' || | |
| github.event_name == 'pull_request' || | |
| (github.event_name == 'workflow_dispatch' && | |
| (inputs.mode == 'check' || inputs.mode == 'x-source-diagnostic')) | |
| run: | | |
| python -m compileall -q app tests tools | |
| python -m app --check | |
| python -m unittest discover -s tests -p "test_*.py" -v | |
| - name: Diagnose configured X source without delivery | |
| if: github.event_name == 'workflow_dispatch' && inputs.mode == 'x-source-diagnostic' | |
| env: | |
| X_COOKIE: ${{ secrets.X_COOKIE }} | |
| run: python -m tools.x_profile_diagnostic flamehanie | |
| - name: Runtime smoke check | |
| if: env.LIVE_RUN == 'true' | |
| run: | | |
| python -m compileall -q app tools | |
| python -m app --check | |
| - name: Check live production providers | |
| if: env.LIVE_RUN == 'true' | |
| 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.production_preflight | |
| - name: Run one complete automatic monitor pass | |
| if: env.LIVE_RUN == 'true' | |
| timeout-minutes: 15 | |
| 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 | |
| SENTRY_DSN: ${{ secrets.SENTRY_DSN }} | |
| ASSISTANT_RUNTIME_MODE: github_actions_polling | |
| shell: bash | |
| run: | | |
| set +e | |
| python -m app | |
| code=$? | |
| set -e | |
| if [ "$code" -eq 0 ]; then | |
| echo "Automatic monitor pass completed." | |
| exit 0 | |
| fi | |
| if [ "$code" -eq 2 ]; then | |
| echo "::error::Fatal configuration error; automatic monitoring cannot run until configuration is fixed." | |
| else | |
| echo "::error::Automatic monitor pass failed with exit code $code; the next five-minute schedule will retry from durable state." | |
| fi | |
| exit "$code" | |
| - name: Checkpoint private review database | |
| if: >- | |
| always() && | |
| env.LIVE_RUN == 'true' && | |
| hashFiles('.state/private-review.sqlite3') != '' | |
| run: | | |
| python - <<'PY' | |
| import sqlite3 | |
| from pathlib import Path | |
| path = Path('.state/private-review.sqlite3') | |
| if path.exists(): | |
| 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: Decide encrypted recovery snapshot cadence | |
| id: recovery | |
| if: >- | |
| always() && | |
| env.LIVE_RUN == 'true' && | |
| env.STATE_BACKUP_KEY != '' | |
| env: | |
| RUN_NUMBER: ${{ github.run_number }} | |
| EVENT_NAME: ${{ github.event_name }} | |
| ACTOR: ${{ github.actor }} | |
| shell: bash | |
| run: | | |
| snapshot=false | |
| if [ "$EVENT_NAME" = "push" ]; then | |
| snapshot=true | |
| elif [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "$ACTOR" != "github-actions[bot]" ]; then | |
| snapshot=true | |
| elif [ $((RUN_NUMBER % 24)) -eq 0 ]; then | |
| snapshot=true | |
| fi | |
| echo "snapshot=$snapshot" >> "$GITHUB_OUTPUT" | |
| echo "Encrypted recovery snapshot this run: $snapshot" | |
| - name: Create authenticated encrypted recovery backup | |
| if: >- | |
| always() && | |
| steps.recovery.outputs.snapshot == 'true' && | |
| (hashFiles('.state/state.json') != '' || 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() && | |
| steps.recovery.outputs.snapshot == 'true' && | |
| 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: Upload production outcome artifact | |
| if: >- | |
| always() && | |
| hashFiles('production-outcome.json') != '' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: production-outcome | |
| path: production-outcome.json | |
| if-no-files-found: ignore | |
| retention-days: 14 | |
| compression-level: 6 | |
| - name: Save bot state cache | |
| if: >- | |
| always() && | |
| env.LIVE_RUN == 'true' && | |
| hashFiles('.state/state.json') != '' | |
| uses: actions/cache/save@v5 | |
| with: | |
| path: .state/state.json | |
| key: jeonghan-state-v2-${{ runner.os }}-${{ github.run_id }} | |
| - name: Save private review database cache | |
| if: >- | |
| always() && | |
| env.LIVE_RUN == 'true' && | |
| 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: Queue due nightly fanfic digest | |
| if: >- | |
| success() && | |
| env.LIVE_RUN == 'true' && | |
| 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 | |
| } | |
| now_hm="$(date -u +%H%M)" | |
| if [ "$((10#$now_hm))" -lt 1830 ]; then | |
| echo "Nightly fanfic window has not opened yet." | |
| exit 0 | |
| fi | |
| today_start="$(date -u +%Y-%m-%dT00:00:00Z)" | |
| covered="$( | |
| gh_api_retry -H 'Accept: application/vnd.github+json' \ | |
| "/repos/${GITHUB_REPOSITORY}/actions/workflows/fic-digest.yml/runs?per_page=100" | | |
| jq --arg today "$today_start" ' | |
| [.workflow_runs[] | | |
| select(.head_branch == "main" and .created_at >= $today) | | |
| select(.event == "schedule" or | |
| (.event == "workflow_dispatch" and .actor.login == "github-actions[bot]")) | | |
| select(.status != "completed" or .conclusion == "success") | |
| ] | length' | |
| )" | |
| if [ "$covered" -gt 0 ]; then | |
| echo "Today's nightly fanfic digest is already queued, running, or complete." | |
| exit 0 | |
| fi | |
| gh_workflow_retry fic-digest.yml --ref main | |
| echo "Queued today's missing nightly fanfic digest." | |
| - name: Re-arm Daily watchdog after automated recovery | |
| if: >- | |
| success() && | |
| env.LIVE_RUN == 'true' && | |
| github.ref == 'refs/heads/main' && | |
| github.event_name == 'workflow_dispatch' && | |
| github.actor == 'github-actions[bot]' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| gh workflow run daily-watchdog.yml \ | |
| --ref main \ | |
| -f source_run_id="${GITHUB_RUN_ID}" \ | |
| -f source_run_number="${GITHUB_RUN_NUMBER}" \ | |
| -f source_event="${GITHUB_EVENT_NAME}" \ | |
| -f source_conclusion="success" \ | |
| -f source_actor="${GITHUB_ACTOR}" | |
| echo "Re-armed bounded Daily watchdog after successful automated recovery." |