Skip to content

Jeonghan Daily Review Bot #3587

Jeonghan Daily Review Bot

Jeonghan Daily Review Bot #3587

Workflow file for this run

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."