Portfolio Monitor — intraday #711
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: Portfolio Monitor | |
| # SCHEDULING LIVES IN scheduler/ (a Cloudflare Worker), NOT HERE. | |
| # | |
| # GitHub's `schedule:` event was measured firing this workflow's 22:00 UTC daily | |
| # brief 5–8 hours late, and dropping it entirely on 2026-08-26. That is | |
| # documented GitHub behaviour ("may be delayed during periods of high load…"), | |
| # not an outage, and GitHub staff acknowledged the worsening drift in community | |
| # discussion #196910 with no committed fix. The Worker fires these runs via | |
| # `repository_dispatch` instead; see scheduler/README.md. | |
| # | |
| # The `schedule:` triggers were REMOVED rather than kept as a fallback. GitHub | |
| # does eventually deliver a late cron, which would then run a second, duplicate | |
| # brief — and `daily`/`intraday` post publicly to X / Facebook / LinkedIn / | |
| # Threads, so a duplicate means duplicate public posts, not just a second email. | |
| # `workflow_dispatch` below is the manual recovery path if the Worker ever fails. | |
| # | |
| # NOTE: repository_dispatch always runs the workflow from the DEFAULT BRANCH, | |
| # regardless of what the payload says. That suits this repo (all work lands on | |
| # main) but means changes here only take effect once pushed to main. | |
| # Every run used to be titled "Portfolio Monitor" in the Actions UI, which hid | |
| # exactly the information needed to diagnose the drift above — which cron fired, | |
| # and what mode it resolved to. Surface it in the run title instead. | |
| run-name: >- | |
| Portfolio Monitor — | |
| ${{ github.event_name == 'repository_dispatch' && github.event.action | |
| || github.event_name == 'workflow_dispatch' && format('{0} (manual)', github.event.inputs.mode) | |
| || github.event_name }} | |
| on: | |
| repository_dispatch: | |
| # The event type IS the run mode — see "Determine mode" below. Anything not | |
| # listed here cannot trigger this workflow at all, which is what keeps | |
| # `github.event.action` a closed set of known-safe values. | |
| types: [daily, intraday, weekly] | |
| workflow_dispatch: | |
| inputs: | |
| mode: | |
| description: "Run mode" | |
| required: false | |
| default: "daily" | |
| type: choice | |
| options: | |
| - daily | |
| - weekly | |
| - intraday | |
| - refresh | |
| ticker: | |
| description: "Ticker for refresh mode (e.g., SMH)" | |
| required: false | |
| type: string | |
| # Workflow-level env applies to every step in every job. The user-facing | |
| # Actions Variable is `TIME_ZONE` (matches the rest of richfolio's env | |
| # naming style); we map it to POSIX `TZ` here, which Node.js natively | |
| # respects so all date formatting in emails/Telegram renders in the | |
| # configured zone with zero code changes. Defaults to UTC if unset. | |
| env: | |
| TZ: ${{ vars.TIME_ZONE || 'UTC' }} | |
| jobs: | |
| send-brief: | |
| runs-on: ubuntu-latest | |
| # The subscription transport spawns a Claude Code subprocess per AI stage, | |
| # which runs far longer than the 1-2 minutes the api-key path took. A full | |
| # daily run has been observed taking 40+ minutes even while failing | |
| # partway, so 45 (sized from a ~13-minute refresh run) was too tight. | |
| timeout-minutes: 90 | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: 22 | |
| cache: npm | |
| - run: npm ci | |
| # Write config.json from variable (gitignored, contains portfolio data) | |
| - run: printenv CONFIG_JSON > config.json | |
| env: | |
| CONFIG_JSON: ${{ vars.CONFIG_JSON }} | |
| # Restore intraday state cache (morning baseline for comparison) | |
| - uses: actions/cache@v5 | |
| with: | |
| path: state/ | |
| key: intraday-state-${{ github.run_id }} | |
| restore-keys: | | |
| intraday-state- | |
| # Determine run mode from the dispatch event type or manual input. | |
| # | |
| # Values reach the shell through env rather than `${{ }}` interpolation so | |
| # they can never be spliced into the script itself. | |
| - name: Determine mode | |
| id: mode | |
| env: | |
| EVENT_NAME: ${{ github.event_name }} | |
| DISPATCH_TYPE: ${{ github.event.action }} | |
| INPUT_MODE: ${{ github.event.inputs.mode }} | |
| run: | | |
| case "$EVENT_NAME" in | |
| repository_dispatch) MODE="$DISPATCH_TYPE" ;; | |
| workflow_dispatch) MODE="$INPUT_MODE" ;; | |
| *) MODE="daily" ;; | |
| esac | |
| echo "mode=$MODE" >> $GITHUB_OUTPUT | |
| echo "Resolved mode: $MODE" | |
| # Daily morning brief | |
| - run: npm run start | |
| if: steps.mode.outputs.mode == 'daily' | |
| env: | |
| RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} | |
| NEWS_API_KEY: ${{ secrets.NEWS_API_KEY }} | |
| GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} | |
| CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} | |
| MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }} | |
| MISTRAL_MODEL: ${{ vars.MISTRAL_MODEL }} | |
| CLAUDE_MODEL: ${{ vars.CLAUDE_MODEL }} | |
| AI_DETAILED_PROVIDER: ${{ vars.AI_DETAILED_PROVIDER }} | |
| RECIPIENT_EMAIL: ${{ vars.RECIPIENT_EMAIL }} | |
| TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} | |
| TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} | |
| # Social posting (all optional — each platform skips if its keys are unset) | |
| X_API_KEY: ${{ secrets.X_API_KEY }} | |
| X_API_SECRET: ${{ secrets.X_API_SECRET }} | |
| X_ACCESS_TOKEN: ${{ secrets.X_ACCESS_TOKEN }} | |
| X_ACCESS_TOKEN_SECRET: ${{ secrets.X_ACCESS_TOKEN_SECRET }} | |
| FACEBOOK_PAGE_ID: ${{ secrets.FACEBOOK_PAGE_ID }} | |
| FACEBOOK_PAGE_TOKEN: ${{ secrets.FACEBOOK_PAGE_TOKEN }} | |
| LINKEDIN_ACCESS_TOKEN: ${{ secrets.LINKEDIN_ACCESS_TOKEN }} | |
| LINKEDIN_ORG_URN: ${{ secrets.LINKEDIN_ORG_URN }} | |
| THREADS_USER_ID: ${{ secrets.THREADS_USER_ID }} | |
| THREADS_ACCESS_TOKEN: ${{ secrets.THREADS_ACCESS_TOKEN }} | |
| # Weekly rebalancing report | |
| - run: npm run weekly | |
| if: steps.mode.outputs.mode == 'weekly' | |
| env: | |
| RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} | |
| RECIPIENT_EMAIL: ${{ vars.RECIPIENT_EMAIL }} | |
| TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} | |
| TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} | |
| # Intraday check — no NEWS_API_KEY (saves quota) | |
| - run: npm run intraday | |
| if: steps.mode.outputs.mode == 'intraday' | |
| env: | |
| RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} | |
| GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} | |
| CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} | |
| MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }} | |
| MISTRAL_MODEL: ${{ vars.MISTRAL_MODEL }} | |
| CLAUDE_MODEL: ${{ vars.CLAUDE_MODEL }} | |
| AI_DETAILED_PROVIDER: ${{ vars.AI_DETAILED_PROVIDER }} | |
| RECIPIENT_EMAIL: ${{ vars.RECIPIENT_EMAIL }} | |
| TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} | |
| TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} | |
| # Social posting (all optional — each platform skips if its keys are unset) | |
| X_API_KEY: ${{ secrets.X_API_KEY }} | |
| X_API_SECRET: ${{ secrets.X_API_SECRET }} | |
| X_ACCESS_TOKEN: ${{ secrets.X_ACCESS_TOKEN }} | |
| X_ACCESS_TOKEN_SECRET: ${{ secrets.X_ACCESS_TOKEN_SECRET }} | |
| FACEBOOK_PAGE_ID: ${{ secrets.FACEBOOK_PAGE_ID }} | |
| FACEBOOK_PAGE_TOKEN: ${{ secrets.FACEBOOK_PAGE_TOKEN }} | |
| LINKEDIN_ACCESS_TOKEN: ${{ secrets.LINKEDIN_ACCESS_TOKEN }} | |
| LINKEDIN_ORG_URN: ${{ secrets.LINKEDIN_ORG_URN }} | |
| THREADS_USER_ID: ${{ secrets.THREADS_USER_ID }} | |
| THREADS_ACCESS_TOKEN: ${{ secrets.THREADS_ACCESS_TOKEN }} | |
| # Refresh analysis for a single ticker | |
| - run: npm run refresh -- ${{ github.event.inputs.ticker }} | |
| if: steps.mode.outputs.mode == 'refresh' | |
| env: | |
| RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} | |
| GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} | |
| CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} | |
| MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }} | |
| MISTRAL_MODEL: ${{ vars.MISTRAL_MODEL }} | |
| CLAUDE_MODEL: ${{ vars.CLAUDE_MODEL }} | |
| AI_DETAILED_PROVIDER: ${{ vars.AI_DETAILED_PROVIDER }} | |
| RECIPIENT_EMAIL: ${{ vars.RECIPIENT_EMAIL }} | |
| TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} | |
| TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} | |
| # The `send-weekly` job that used to live here is gone. It ran on EVERY | |
| # schedule tick and asked the runner for the weekday (`date -u +%u`), sending | |
| # the weekly report only when that returned 7 — which made it silently | |
| # dependent on the cron arriving on the day it was scheduled for. Under the | |
| # drift documented at the top of this file it stopped being reliable: a Sunday | |
| # 22:00 UTC cron delivered at Monday 03:00 UTC computes day=1 and skips the | |
| # weekly report altogether, with nothing in the logs suggesting anything was | |
| # missed. | |
| # | |
| # The Cloudflare Worker now dispatches a `weekly` event at a known time | |
| # (Sunday 22:30 UTC), and the `npm run weekly` step in send-brief above — which | |
| # already existed for `workflow_dispatch` — handles it. The weekday is named by | |
| # the caller rather than inferred by the callee, so a late dispatch still sends | |
| # the right report. |