|
| 1 | +# Scheduled invocation of the app's /api/cron/* routes (issue #513). |
| 2 | +# |
| 3 | +# Production runs on Dokploy, which does not read `vercel.json` — so the seven |
| 4 | +# cron routes declared there had no scheduler at all and every non-payment |
| 5 | +# lifecycle job (access-cutoff enforcement, platform-subscription expiry, daily |
| 6 | +# digest, league rollover, payment reconciliation) was inert in production. |
| 7 | +# |
| 8 | +# Each route authenticates with the same `Authorization: Bearer $CRON_SECRET` |
| 9 | +# header it already implements, so no application code changed to support this. |
| 10 | +# |
| 11 | +# Required repository settings (Settings → Secrets and variables → Actions): |
| 12 | +# secret CRON_SECRET — must match the CRON_SECRET env var on the Dokploy app |
| 13 | +# variable CRON_BASE_URL — the production origin, e.g. https://preciopana.com |
| 14 | +# Both are checked at runtime; a missing one fails the run loudly rather than |
| 15 | +# silently doing nothing, which is the failure mode this workflow exists to fix. |
| 16 | +# |
| 17 | +# Caveats worth knowing before relying on this: |
| 18 | +# * GitHub delays scheduled runs under load, and drops high-frequency ones |
| 19 | +# first — the */10 reconcilers are the most affected. If exact cadence |
| 20 | +# matters for those, prefer a Dokploy scheduled task (see docs/DEPLOYMENT.md). |
| 21 | +# * GitHub disables scheduled workflows after 60 days with no repository |
| 22 | +# activity. Re-enable from the Actions tab if that happens. |
| 23 | +# * Run ONE mechanism. If a Dokploy schedule is added later, remove the |
| 24 | +# corresponding schedule here so routes don't fire twice. |
| 25 | + |
| 26 | +name: Scheduled cron routes |
| 27 | + |
| 28 | +on: |
| 29 | + schedule: |
| 30 | + # Keep these in sync with vercel.json and with the case block below. |
| 31 | + - cron: '*/10 * * * *' # solana-reconcile + binance-personal-reconcile |
| 32 | + - cron: '0 * * * *' # daily-digest — hourly by design (each tenant sends at its own local hour) |
| 33 | + - cron: '0 0 * * *' # expire-subscriptions |
| 34 | + - cron: '0 1 * * 1' # league-rollover (pg_cron also runs it Mondays 00:05; this is the fallback) |
| 35 | + - cron: '0 2 * * *' # expire-platform-subscriptions |
| 36 | + - cron: '0 3 * * *' # enforce-plan-limits |
| 37 | + workflow_dispatch: |
| 38 | + inputs: |
| 39 | + route: |
| 40 | + description: 'Cron route to run now' |
| 41 | + required: true |
| 42 | + type: choice |
| 43 | + options: |
| 44 | + - enforce-plan-limits |
| 45 | + - expire-platform-subscriptions |
| 46 | + - expire-subscriptions |
| 47 | + - daily-digest |
| 48 | + - league-rollover |
| 49 | + - solana-reconcile |
| 50 | + - binance-personal-reconcile |
| 51 | + # Never scheduled: solana-pull submits on-chain USDC transfers and has |
| 52 | + # never had a scheduler in vercel.json either. Available here for |
| 53 | + # manual runs only — putting it on a timer needs its own review. |
| 54 | + - solana-pull |
| 55 | + |
| 56 | +permissions: |
| 57 | + contents: read |
| 58 | + |
| 59 | +concurrency: |
| 60 | + # Serialize per trigger so a slow run can't overlap the next tick. |
| 61 | + group: cron-${{ github.event.schedule || inputs.route }} |
| 62 | + cancel-in-progress: false |
| 63 | + |
| 64 | +jobs: |
| 65 | + invoke: |
| 66 | + name: Invoke cron route(s) |
| 67 | + runs-on: ubuntu-latest |
| 68 | + timeout-minutes: 15 |
| 69 | + steps: |
| 70 | + - name: Resolve routes for this trigger |
| 71 | + id: resolve |
| 72 | + env: |
| 73 | + SCHEDULE: ${{ github.event.schedule }} |
| 74 | + DISPATCH_ROUTE: ${{ inputs.route }} |
| 75 | + run: | |
| 76 | + set -euo pipefail |
| 77 | + if [ -n "${DISPATCH_ROUTE:-}" ]; then |
| 78 | + routes="$DISPATCH_ROUTE" |
| 79 | + else |
| 80 | + case "${SCHEDULE:-}" in |
| 81 | + '*/10 * * * *') routes='solana-reconcile binance-personal-reconcile' ;; |
| 82 | + '0 * * * *') routes='daily-digest' ;; |
| 83 | + '0 0 * * *') routes='expire-subscriptions' ;; |
| 84 | + '0 1 * * 1') routes='league-rollover' ;; |
| 85 | + '0 2 * * *') routes='expire-platform-subscriptions' ;; |
| 86 | + '0 3 * * *') routes='enforce-plan-limits' ;; |
| 87 | + *) |
| 88 | + echo "::error::Schedule '${SCHEDULE:-}' has no route mapping. Add it to the case block in .github/workflows/cron.yml." |
| 89 | + exit 1 |
| 90 | + ;; |
| 91 | + esac |
| 92 | + fi |
| 93 | + echo "Resolved routes: $routes" |
| 94 | + echo "routes=$routes" >> "$GITHUB_OUTPUT" |
| 95 | +
|
| 96 | + - name: Invoke |
| 97 | + env: |
| 98 | + CRON_SECRET: ${{ secrets.CRON_SECRET }} |
| 99 | + CRON_BASE_URL: ${{ vars.CRON_BASE_URL }} |
| 100 | + ROUTES: ${{ steps.resolve.outputs.routes }} |
| 101 | + run: | |
| 102 | + set -euo pipefail |
| 103 | +
|
| 104 | + if [ -z "${CRON_BASE_URL:-}" ]; then |
| 105 | + echo "::error::Repository variable CRON_BASE_URL is not set (expected e.g. https://preciopana.com)." |
| 106 | + exit 1 |
| 107 | + fi |
| 108 | + if [ -z "${CRON_SECRET:-}" ]; then |
| 109 | + echo "::error::Repository secret CRON_SECRET is not set. It must match the CRON_SECRET env var on the Dokploy app." |
| 110 | + exit 1 |
| 111 | + fi |
| 112 | +
|
| 113 | + base="${CRON_BASE_URL%/}" |
| 114 | + failed=0 |
| 115 | +
|
| 116 | + for route in $ROUTES; do |
| 117 | + url="$base/api/cron/$route" |
| 118 | + echo "--- GET $url" |
| 119 | + # The secret is passed via env into the header and never printed; |
| 120 | + # GitHub also masks it in logs. Body is capped so a large digest |
| 121 | + # response can't flood the log. |
| 122 | + code="$(curl -sS -o /tmp/cron-body.txt -w '%{http_code}' \ |
| 123 | + --max-time 600 --retry 2 --retry-delay 15 --retry-connrefused \ |
| 124 | + -H "Authorization: Bearer $CRON_SECRET" \ |
| 125 | + "$url")" || code='000' |
| 126 | +
|
| 127 | + echo "HTTP $code" |
| 128 | + head -c 2000 /tmp/cron-body.txt || true |
| 129 | + echo |
| 130 | +
|
| 131 | + if [ "$code" != '200' ]; then |
| 132 | + echo "::error::$route returned HTTP $code" |
| 133 | + failed=1 |
| 134 | + fi |
| 135 | + done |
| 136 | +
|
| 137 | + exit "$failed" |
0 commit comments