Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,6 @@ flask_session
clerk_events.jsonl
.claude
nohup.out
# CI/build artifacts — never needed inside the image
build
.github
26 changes: 26 additions & 0 deletions .flake8
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[flake8]
# Line length is not policed: this repo's comments carry a lot of explanation
# and reflowing them to 79 columns would make them harder to read, not easier.
max-line-length = 120
extend-ignore = E203, W503, E501
exclude =
.git,
.venv,
venv,
__pycache__,
node_modules,
vendor,
dist,
build,
.idea,
dash_mui_scheduler,
src,
docs/*/,
per-file-ignores =
# run.py's wiring is ordered on purpose: load_dotenv() and the backend
# resolution must precede first-party imports, and the reporter/bulletin
# imports sit after the app is fully wired.
run.py: E402
usage.py: E402
# pytest fixtures look like shadowed names to flake8
tests/*: F811
114 changes: 114 additions & 0 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
name: CD

# Deploys muischeduler.2plot.dev, then checks the live site.
#
# The deploy step POSTs to a Render deploy hook held in the
# RENDER_DEPLOY_HOOK_URL secret. Without that secret the step is skipped and
# the workflow goes straight to verification — Render is auto-deploying from
# GitHub on its own, so absence of the secret is a working configuration, not
# a failure.
on:
push:
branches: [main]
workflow_dispatch:
inputs:
target_url:
description: Site to verify (skips the deploy when set to another host)
required: false
type: string

permissions:
contents: read

concurrency:
group: cd-production
cancel-in-progress: false

env:
PIP_DISABLE_PIP_VERSION_CHECK: "1"
SITE_URL: ${{ inputs.target_url || 'https://muischeduler.2plot.dev' }}

jobs:
test:
name: ci
uses: ./.github/workflows/ci.yml

deploy:
name: deploy to render
needs: [test]
runs-on: ubuntu-latest
# Long enough for the wait loop below (a 120s settle plus up to 40 × 15s)
# and no longer — without it the job inherits GitHub's six-hour default,
# which is how a platform that never comes back healthy holds the
# `cd-production` concurrency group all day.
timeout-minutes: 20
environment:
name: production
url: https://muischeduler.2plot.dev
outputs:
deployed: ${{ steps.hook.outputs.deployed }}
steps:
- name: Trigger the Render deploy hook
id: hook
env:
HOOK: ${{ secrets.RENDER_DEPLOY_HOOK_URL }}
run: |
if [ -z "$HOOK" ]; then
echo "::notice::RENDER_DEPLOY_HOOK_URL is not set. Skipping the deploy trigger and verifying whatever is currently live."
echo "deployed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
curl -fsS -X POST "$HOOK" > /dev/null
echo "deployed=true" >> "$GITHUB_OUTPUT"

- name: Wait for the new build to serve traffic
if: steps.hook.outputs.deployed == 'true'
run: |
# Render swaps instances rather than restarting in place, so the old
# build answers /healthz throughout. Waiting for a single 200 proves
# nothing; give the build time, then require SUSTAINED health.
sleep 120
ok=0
for _ in $(seq 1 40); do
if curl -fsS "$SITE_URL/healthz" > /dev/null; then
ok=$((ok + 1))
[ "$ok" -ge 5 ] && break
else
ok=0
fi
sleep 15
done
if [ "$ok" -lt 5 ]; then
echo "::error::$SITE_URL never became reliably healthy"
exit 1
fi

verify:
name: verify the live site
needs: [deploy]
if: always() && needs.deploy.result != 'cancelled'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"

# The network battery first: the same script, with the same check
# names, that CI ran against the container this deploy shipped. A name
# that passed in CI and fails here isolates the fault to the deploy.
- name: Network smoke battery
run: python scripts/network_smoke.py --base-url "$SITE_URL"

# Then the satellite-specific checks the battery does not make: every
# canonical, every crawler body, the CDN card's real pixels, and every
# peer llms.txt in the directory actually resolving (peers WARN, this
# host FAILS).
- name: Smoke-test the deployment
run: python scripts/smoke_live.py "$SITE_URL"

- name: Report
if: failure()
run: |
echo "::error::Live verification failed for $SITE_URL. Every failure these check for is silent in production: a site identity fallen back to a framework default, a stale dash-improve-my-llms artifact, a canonical on the wrong host, a page serving the JavaScript stub, a 404ing or reshaped social card, a missing network directory, and dead peer llms.txt links."
Loading
Loading