Staging on Scarlett¶
Parlay Rewards staging runs as its own Docker Compose project on Scarlett, joined to the shared services-net network from the services stack (Caddy, Postgres, Redis).
Endpoints¶
| Service | URL |
|---|---|
| API | https://pr-api-staging.jvsassoc.com |
| Live play WS | wss://pr-api-staging.jvsassoc.com/api/play/stream (same API upstream) |
| srsim | https://pr-srsim-staging.jvsassoc.com |
| Docs | https://pr-docs-staging.jvsassoc.com |
| Flower | https://pr-flower-staging.jvsassoc.com (Celery monitoring; basic auth) |
Caddy (services repo) should expose a dedicated matcher for /api/play/stream*
with WebSocket upgrade to the same pr-fastapi upstream as /api/*, so prod
ALB can mirror path-based routing later. See
Environment configuration — Live play realtime.
One-time host setup (Scarlett)¶
-
Shared stack (from the
servicesrepo): ensure network is namedservices-net, reload Caddy with the Parlay routes, create the DB. Read the Postgres user from the container env so you don't hitrole "root" does not exist: -
Self-hosted GitHub Actions runner for this repo (Scarlett is a Mac):
- Follow GitHub’s runner docs and pick the macOS / ARM64 runner (Apple Silicon).
- The workflow uses
runs-on: [self-hosted, macOS, ARM64], which matches the default macOS runner labels — no custom label needed. (If you add one, make sureruns-onstill matches.) - Install as a service:
./svc.sh install && ./svc.sh start(nosudoneeded on macOS for a per-user service). - Docker permissions on macOS: there is no
dockergroup step. The runner runs as your user; just confirmdocker psworks in that user's shell and set Docker Desktop to start at login. - Prefer a private repo; the workflow only triggers on push to
staging(notpull_request). - Sentry release tracking (optional but recommended): add repo Actions secrets
SENTRY_AUTH_TOKEN,SENTRY_ORG, andSENTRY_PROJECTorSENTRY_PROJECTS(space-separated Sentry project slugs for API / srsim / celery / listener). After each successful deploy the workflow creates a Sentry release (git SHA) and astagingdeploy, and passesSENTRY_RELEASEinto Compose so events attach to that release. See Sentry. - Sentry Performance (API latency):
.env.staging.examplesetsSENTRY_TRACES_SAMPLE_RATE=0.1. Copy that into server.env.stagingto sample ~10% of API requests for P90/P95 (and DB spans on those requests). See Sentry Performance. - Staging release notes → Slack (optional): a separate workflow
(
.github/workflows/staging-release-notes.yml) runs after Deploy staging completes. It does not affect deploy success/failure. Add:- Actions secret
CURSOR_API_KEY(Cursor user or service-account API key) - Actions secret
SLACK_BOT_TOKEN(bot withchat:writein the target channel) - Actions variable or secret
SLACK_CHANNEL_ID(e.g.C0123…) - Optional Actions variable
CURSOR_MODEL(defaultgrok-4.6, not Fast) The runner user on Scarlett must be able to launch the Cursor local agent bridge used bycursor-sdk(uv run --with cursor-sdk …). Install/login Cursor tooling for that user if the notes job logs bridge/auth errors. See Slack release notes below.
- Actions secret
- Staging daily pytest → Slack (optional):
.github/workflows/staging-daily-pytest.ymlrunsuv run pytestnightly at 2am EDT againststaging. Failure/timeout posts a Slack root (Actions + Fix with AI) and a thread of failed tests. ReusesSLACK_BOT_TOKENandSLACK_CHANNEL_ID. Cron is registered from the default-branch workflow file (develop). See Staging daily pytest below. -
Slack agents (optional): shared Signing Secret verifies slash commands, Events API, and Interactivity. Add to staging
.env.staging(API process):SLACK_SIGNING_SECRET(Slack app Signing Secret)SLACK_BOT_TOKEN(bot OAuth token — thread acks from the API)GITHUB_DISPATCH_TOKEN(PAT / fine-grained withactions:write)- Optional
GITHUB_REPOSITORY(defaultjordanvs/parlaysports-backend) - For QD editor:
SLACK_QD_EDITOR_USER_IDS,SLACK_QD_EDITOR_API_KEY(session state uses the normalREDIS_URL) - For daily pytest Fix with AI:
SLACK_PYTEST_FIX_USER_IDS(click allowlist; empty denies all). Does not useSLACK_QD_EDITOR_API_KEY. Reuse Actions secretsCURSOR_API_KEY+SLACK_BOT_TOKEN. Optional Actions variableCURSOR_MODEL(defaultgrok-4.6, not Fast). Jobs source/opt/parlay-rewards/.env.stagingfor S3/DB credentials. See Slack agents, investigate, and QD editor below.
-
Deploy directory and filesystem data (run as the runner user):
sudo mkdir -p /opt/parlay-rewards sudo chown -R "$(whoami)" /opt/parlay-rewards mkdir -p "$HOME/parlay-rewards-data"/{listener,localstack,prometheus} cp .env.staging.example /opt/parlay-rewards/.env.staging # Edit .env.staging: set DATABASE_URL with shared Postgres user/password, API keys, etc. # App default filesystem_container_data_dir=/app/data (no env needed). # Compose hardcodes host $HOME/parlay-rewards-data → /app/data. # Listener recordings: ~/parlay-rewards-data/listener/{mlb,nhl,...}/
If you previously used /opt/parlay-rewards-data, copy listener/ and
localstack/ into $HOME/parlay-rewards-data once before the next deploy
(Docker Desktop on Mac often cannot share /opt).
- First deploy: push to
staging, or from/opt/parlay-rewardsafter a manual sync:
Ongoing¶
- Pushes to
stagingrsync into/opt/parlay-rewards(excluding.env.staging,.git,.venv,data/, andterraform/) and rebuild. Runtime data is not under the deploy tree:
| Side | Path |
|---|---|
| Host (compose bind) | $HOME/parlay-rewards-data |
| Container / app | /app/data (filesystem_container_data_dir default) |
- Update
/opt/parlay-rewards/.env.stagingwhen secrets change — Actions will not overwrite it. - Keep
$HOME/parlay-rewards-data/localstackif you need LocalStack S3 persistence across deploys. - Keep
$HOME/parlay-rewards-data/listenerfor match event recordings fromcelery-listener-worker. - Manual migrations (do not run host
uv run alembic— that misses.env.stagingand container/app/data): from/opt/parlay-rewards,make alembic_upgrade ENV=staging. - After each deploy, the workflow abandons open listener rows immediately
(
sweep_stale_match_listeners(heartbeat_stale_seconds=0), exit 14) and runsdispatch_upcoming_match_listenersso in-progress matches restart without waiting for the normal ~3m heartbeat stale window. Do not use Admin Stop listener for deploy recovery (that turns auto-dispatch off). - After a successful deploy, the workflow also creates a Sentry release for the
push SHA and records a deploy to environment
staging(requires the Sentry Actions secrets/variable above). Containers receive the same SHA asSENTRY_RELEASE. - After Deploy staging completes, the separate Staging release notes workflow may post to Slack (see below). That job is best-effort and never gates the deploy run.
- To copy those recordings into local develop (
./data/listener/), runmake listener_sync_staging_to_develop(rsync over SSH; does not delete local-only files).
Slack release notes¶
Workflow: .github/workflows/staging-release-notes.yml
Trigger: workflow_run of Deploy staging (completed), or manual
workflow_dispatch.
| Path | Slack shape |
|---|---|
| Deploy succeeded | Channel root: Block Kit; header ✅ Deployment Notification; Environment (🟡 staging / 🔵 main) + Commit; module context; Compare. Sidebar color: amber #ECB22E (staging) vs blue #1D9BD1 (main). Thread: one Block Kit reply per module (api / listener / terraform / srsim / other) via .cursor/skills/release-notes/SKILL.md. |
| Deploy failed | Channel root only; header 🔴 Deployment Notification; Deploy run button. Sidebar: staging #E01E5A, main #9B111E. No agent. |
Implementation notes:
- Uses Slack Web API
chat.postMessage(bot token) forthread_tsand attachment color bars. Root wraps blocks inattachments[{color, blocks}]; thread replies use top-levelblocks. Fallback plain mrkdwn if module headings cannot be parsed. - Script:
scripts/ci/post_release_notes.pywithAPP_ENV=staging(--soft-failalways in CI). Same script acceptsAPP_ENV=mainfor a future main-deploy notes workflow (blue#1D9BD1success bar; same✅ Deployment Notificationheader — env still in the Environment field). - Agent reads
.cursor/skills/release-notes/SKILL.mdwithsetting_sources=["project"]. - Commit range is previous successful Deploy staging tip → this deploy’s
SHA (via
curlon the GitHub Actions API). If that cannot be resolved, the script falls back to the deploy commit’s parent (after~1), not a multi-commit window. - Job has
continue-on-error: true. Missing secrets → log and exit 0. - Manual re-post: Actions → Staging release notes → Run workflow (optional
after_sha/before_sha, orfailurefor a failure-style root).
Staging daily pytest¶
Workflow: .github/workflows/staging-daily-pytest.yml
Trigger: cron 0 6 * * * (06:00 UTC = 2am EDT / 1am EST), or manual
workflow_dispatch.
Always checks out staging and runs the default suite
(uv run pytest --junitxml=pytest-results.xml, -m 'not external') on
Scarlett. Success is silent. Failure or the 90-minute job timeout posts a Slack
root (header Staging daily pytest failed; staging + short SHA;
Open Actions run + Fix with AI) and a thread reply listing failing
tests and short reasons from junit (capped; remainder points at Actions).
Fix with AI is visible to everyone in the channel. Clicks are gated by
SLACK_PYTEST_FIX_USER_IDS on the staging API. Allowlisted users dispatch
.github/workflows/fix-daily-pytest.yml (ref=develop), which launches a
Cursor Cloud Agent (autoCreatePR, startingRef: develop) that follows
.cursor/skills/fix-daily-pytest/SKILL.md and posts the PR URL in the thread.
Implementation notes:
- Reuses Actions secret
SLACK_BOT_TOKENand variable or secretSLACK_CHANNEL_ID(same as Slack release notes). - Notify is
if: failure()withcontinue-on-error: trueso a Slack miss does not hide the pytest failure. Missing token/channel → log and skip. - Script:
scripts/ci/notify_daily_pytest_failure.py(uv run; nogh/jqon the runner PATH). Junit is also uploaded as artifactpytest-results. - Slack SHA is
git rev-parse HEADafter checkout (staging tip), notgithub.sha(schedule events point at the default-branch commit). - Pytest step creates
$GITHUB_WORKSPACE/data/listenerand sets placeholderDATABASE_URL/REDIS_URL/ keys /FILESYSTEM_CONTAINER_DATA_DIR. Import-timeBaseEnvironment(viadb_sessionmanager) requires those fields; a clean Actions checkout has no.env, and/app/datadoes not exist on the host. Testcontainers still replaceDATABASE_URLandREDIS_URL(do not point tests at Scarlett's staging Redis).pytest_envsuppliesLISTENER_SR_URL_LEAGUE. Do not source.env.staginginto the pytest process (staging DB/Redis). The job setsLISTENER_SR_URL_BASEto the staging srsim;requires_srsimtests skip when that host is unreachable. - GitHub registers cron from the default-branch workflow file (
develop); the job then checks outstaging. Merge the YAML todevelopbefore expecting the nightly run. - Manual: Actions → Staging daily pytest → Run workflow (still tests
staging).
Slack agents¶
Shared ingress on the staging API (signature-verified):
| Endpoint | Purpose |
|---|---|
POST /api/webhooks/slack/commands |
Slash commands (routed by command name) |
POST /api/webhooks/slack/events |
Events API (url_verification, app_mention) |
POST /api/webhooks/slack/interactions |
Block Kit Apply / Cancel and daily-pytest Fix with AI |
Mention traffic goes through a skill registry (src/lib/slack_agents/):
new thread → classify intent (prefix, then keywords) → bind skill_id on a
Redis session keyed by channel_id:thread_ts → skill dispatch. Thread replies
stay on that skill. Adding a future mention skill = register an entry +
workflow/handler; Events/Interactions routes stay shared.
Registered skills:
qd_editor(prefixesqd,question-def, …) — mention → propose → Apply/Cancelpytest_fix— interaction-only (pytest_fixbutton on daily pytest failure alerts). Not a mention skill.
Issuance forensics remains slash-only until registered as a mention skill.
Slack app setup (shared)¶
- Create (or reuse) a Slack app.
- Slash commands Request URL:
https://pr-api-staging.jvsassoc.com/api/webhooks/slack/commands - Event Subscriptions Request URL:
https://pr-api-staging.jvsassoc.com/api/webhooks/slack/eventsSubscribe to bot eventapp_mentiononly (skill turns require an @mention; bare thread chatter must not re-propose). - Interactivity Request URL:
https://pr-api-staging.jvsassoc.com/api/webhooks/slack/interactions - Bot scopes:
chat:write,app_mentions:read. Invite the bot to target channels. - Copy Signing Secret →
.env.stagingSLACK_SIGNING_SECRET. Put the bot token in.env.stagingSLACK_BOT_TOKENand Actions secretSLACK_BOT_TOKEN. Redeploy / restart the API.
Slack investigate issuance¶
Workflow: .github/workflows/investigate-issuance.yml
Trigger: Slack slash command /investigate-staging → staging FastAPI
POST /api/webhooks/slack/commands (command-name routed) → GitHub
workflow_dispatch.
| Step | Slack shape |
|---|---|
| Slash command ACK | Ephemeral “Queued…” to the invoking user |
| Job start | Channel root (Block Kit): header, Asked, context “investigating…”, View Actions run |
| Job done | Root updated via chat.update to “details in thread”; thread has Match + Timing field grids then story (What happened / Why / Bottom line / Recommendations / optional IDs). Cursor investigates via investigate-listener-resolution |
| Job failed | Root marked failed; thread warning Block Kit (+ Actions run button) |
Usage¶
/investigate-staging <issuance-uuid>
/investigate-staging shortcode=abcd1234-efgh5678 why did "Will it be a hit?" resolve no for Player X
Default environment for the agent is staging unless the query says otherwise.
Implementation notes:
- Script:
scripts/ci/investigate_issuance.py. - Uses the same Cursor local-agent bridge as release notes (
cursor-sdkon the self-hosted Scarlett runner). Default model isgrok-4.6(not Fast). - Credentials: job
sources/opt/parlay-rewards/.env.staging(AWS keys,AWS_S3_MATCH_ARCHIVE_BUCKET, etc.) into the step env so the agent canaws s3against the staging match-archive bucket. UnsetsAWS_PROFILEso those keys win. - Staging DB:
DATABASE_URLhostpostgresis Docker-network-only — the agent is instructed to query viadocker compose … execfrom/opt/parlay-rewards, not host-local Postgres. - Manual re-run: Actions → Investigate issuance → Run workflow (pass
query+channel_id).
Slack QD editor¶
Workflow: .github/workflows/edit-question-definition.yml
Trigger: @bot qd <request> (or @bot … again in the same thread) → Events API
→ Redis session → workflow_dispatch propose turn → Slack Apply/Cancel →
Interactions → staging PUT/POST/clone/archive on
/api/question-definitions with SLACK_QD_EDITOR_API_KEY.
Bare thread replies without @mentioning the bot are ignored (so asking a colleague to approve does not start another propose).
| Step | Behavior |
|---|---|
| Mention | Allowlist check (SLACK_QD_EDITOR_USER_IDS); staging-only gate |
| Propose | Cursor local agent (grok-4.6, not Fast) + author-question-definitions skill; propose only |
| Confirm | Block Kit Apply / Cancel (qd_apply / qd_cancel) |
| Apply | Full writable payload on update; archive only (no hard delete); snapshot reminder |
Extra setup¶
- Create a dedicated staging admin
User+ API key; put the plaintext key in.env.stagingasSLACK_QD_EDITOR_API_KEY. - Set
SLACK_QD_EDITOR_USER_IDSto comma-separated Slack user IDs. - Propose script uses the same
REDIS_URLas the API (sourced from.env.staging). When the Redis hostname is Docker-only (redis,services-redis, or otherwise unresolvable on the runner), session get/set runs viadocker compose … run fastapionservices-net.
Usage¶
@bot qd fix MLB "Will it be a hit?" No path for fielder's choice
@bot qd archive definition named "Old prop"
Thread replies after the first qd mention stay on the QD skill when you
@bot again (no need to repeat qd). Messages that do not @mention the bot
are ignored. Start a new thread to switch skills later.
Slack Fix with AI (daily pytest)¶
Workflow: .github/workflows/fix-daily-pytest.yml
Trigger: Fix with AI on the daily pytest failure root → Interactions →
allowlist (SLACK_PYTEST_FIX_USER_IDS) → workflow_dispatch on develop
→ Scarlett bridge → Cursor Cloud Agents API (POST /v1/agents,
autoCreatePR, base develop).
The Cloud Agent uses the same project skill
.cursor/skills/fix-daily-pytest/SKILL.md with OPEN_PR=1. Invoking that
skill in Cursor (IDE) stays local-only (no commit/PR).
| Extra setup | Where |
|---|---|
SLACK_PYTEST_FIX_USER_IDS |
/opt/parlay-rewards/.env.staging (API). Redeploy/restart API. |
CURSOR_API_KEY |
Actions secret (same as investigate / QD). |
Optional CURSOR_CLOUD_ENV |
Actions variable: named Cloud Agents Environment if the default VM cannot uv run pytest. |
| GitHub + Cloud Agents | Cursor Dashboard → Integrations (GitHub) and Cloud Agents → Environments. |
| Workflow YAML | Merge to develop before the button can dispatch. |
Does not use SLACK_QD_EDITOR_API_KEY. Interactivity Request URL is
already the staging API (same as QD Apply/Cancel).
Adding another mention skill later¶
Register a SlackSkill in src/lib/slack_agents/ (prefixes, allowlist env,
action_id_prefix, workflow, apply handler). Shared Events/Interactions URLs
do not change. Button-only skills (like pytest_fix) omit prefixes and handle
a start action in interactions.py.
Gzip listener archives (one-time)¶
New listener archives are uploaded gzip-compressed (Content-Encoding: gzip)
under the same keys, and reads decode transparently, so no ongoing action is
needed. To compress recordings that were archived before this change, run the
one-time script inside staging (it reads AWS_S3_MATCH_ARCHIVE_BUCKET and
credentials from .env.staging):
# Dry run — lists what would change, writes nothing:
docker compose -f docker-compose.staging.yml --env-file .env.staging \
run --rm fastapi uv run python scripts/s3/gzip_listener_archives.py
# Apply — rewrites objects as gzip and purges old versions:
docker compose -f docker-compose.staging.yml --env-file .env.staging \
run --rm fastapi uv run python scripts/s3/gzip_listener_archives.py --apply
Each object is rewritten as gzip and read back to confirm it decodes to the original bytes before the old versions are removed, so an interrupted run is safe to rerun.
Warning:
--applyPERMANENTLY deletes the prior uncompressed object versions (this cannot be undone) and requiress3:ListBucketVersionsands3:DeleteObjectVersionon the match-archive bucket. Grant those to the IAM user whose keys are in.env.stagingbefore running--apply. Without them the gzip rewrite still succeeds and verifies; only the old-version cleanup fails (reported per key, non-fatal), and those versions age out via the bucket's Glacier lifecycle instead.
Compose notes¶
- No host port publishes; Caddy reverse-proxies by hostname.
- Shared Postgres DB:
parlay_staging. Shared Redis: DB 0 (app +celerylocktask locks), DB 1 (Celery redbeat), DB 2 (Celery broker). Brokervisibility_timeoutis 12h so long-runningmatch_listenerstasks are not redelivered mid-game. - LocalStack stays in this project (
PERSISTENCE=1, bind mount under$HOME/parlay-rewards-data/localstack). - MkDocs is built into an image (not bind-mounted). Docker Desktop on Scarlett
often cannot see
/opt/parlay-rewards, which mademkdocs.ymllook missing. The same/optlimitation is why filesystem data lives under$HOME. - Flower runs by default (alias
pr-flower, public atpr-flower-staging.jvsassoc.com) with basic auth via--basic-auth; update the credentials indocker-compose.staging.yml. - Staging compose bind-mounts
${HOME}/parlay-rewards-data:/app/data. App defaultsfilesystem_container_data_dirto/app/data(omit from.env.staging). - Optional profiles:
monitoring(Prometheus),listener. celery-listener-workerconsumes the dedicatedmatch_listenersqueue (long-running match listeners). Beat runsdispatch_upcoming_match_listenersevery minute (APP_ENV != develop). Develop instead schedules test-clone dispatch only. See Match listener startup.