Dedicated CI Runner — Plan¶
This is a planning doc, not implementation. It captures the proposed shape of moving PR test execution off any individual engineer's laptop onto a dedicated Azure VM, plus the storage isolation refactor that needs to land first. Comments and pushback welcome before any of the referenced implementation PRs open.
The plan covers three implementation PRs that will land in sequence:
- Phase 0B (PR 1) — small repo refactor that extracts the shared
storage
<add key>entries fromWeb.config/App.configinto per-projectappSettings.local.configsidecars. Affects every dev's daily flow; details below. - Phase 1 (PR 2) — adds
.github/workflows/pr-ci.yml. CI runs on every PR against a dedicated Azure Windows VM registered as a self-hosted GitHub Actions runner. - Phase 3 (PR 3) — adds
.github/workflows/auto-review.ymlfor Claude + Codex automated PR reviews triggered byrakletaireviewer request or@rakletaimention.
Problem statement¶
The browse smoke pipeline that landed in #13715 / #13720 / #13734 needs
a running Raklet site (IIS + SQL + wildcard DNS + grunt-built assets)
to drive a browser against. Today the only machine with that
environment is whichever engineer happens to be running
scripts/dev/pr-ready-check.ps1 — which means that engineer's IIS site
is busy serving tests instead of being usable for their own dev work.
Adding more automation (Claude/Codex PR review, additional smoke
suites) compounds the bottleneck.
A second, related bottleneck:
Application/Web.config:22-120 and
seven other projects share five Azure storage accounts
(rakletlocalfiles, rakletlocalwebjobs, emailblasterlocalstorage,
emailblasterlocalqueues, rakletv0storage) whose keys are committed
in source. Every dev's local app and every future CI run hits the same
queues and blob containers, so test data and queue messages from one
machine bleed into another's runs.
Constraints¶
- Old-style ASP.NET Framework solution (~50 csproj). No SDK-style projects, no .NET 8, no straightforward containerization.
- Wildcard DNS + IIS host headers are required for tenant routing.
GitHub-hosted runners cannot stand this up per-run; see the comment
in
.github/workflows/browse-ui-tests.yml:4-9. - TLDs are environment signals:
.org= local/dev,.net= test,.com= prod. CI uses.orgvia a sealed DNS view on the VM. Application/Infrastructure/SubdomainRoute.cs:23-47parses the host up to the first dot as the org permalink. Nested subdomains (e.g.pr-13735.ci.raklet.org) break routing, so the CI VM uses the canonical*.raklet.orghostnames in its sealed view.- The GitHub Actions plan has a 3,000 hosted-runner minutes/month quota, currently exhausted. Self-hosted runners do not consume from that quota, so the new workflows add zero billable minutes.
Premises¶
- The bottleneck is "any one engineer's laptop is the test host" — moving the test host anywhere else solves it.
- GitHub-hosted runners can't host the Raklet stack.
- Tests must use canonical hostnames (
admin.raklet.org, etc.) — the routing layer assumes that shape. - One VM = one runner = one slot is acceptable for current throughput (3 devs, ~15 open PRs at peak). Adding parallelism = adding VMs, not stacking runners on one VM.
- Per-PR branch previews (the topic of
docs/agents/iis-worktree-branch-previews.md) are real engineering. They requireSubdomainRoutechanges and are explicitly deferred.
Architecture¶
+--------------------------------------------------------+
| Azure VM (Standard D4s_v5, always-on, ~$180/mo) |
| raklet-ci-resource-group / West Europe |
| |
| +--------------------------------------------------+ |
| | GH Actions self-hosted runner agent | |
| | labels: self-hosted, windows, raklet-ci, slot-1 | |
| +--------------------------------------------------+ |
| | |
| | (jobs pushed from github.com) |
| v |
| +--------------------------------------------------+ |
| | Acrylic DNS (sealed): *.raklet.org -> 127.0.0.1 | |
| | IIS site: *.raklet.org bindings + self-signed | |
| | SQL Server Developer (gold .bak restored) | |
| | Source checkout @ C:\repos\rakletv3 (worktree) | |
| | appSettings.local.config files (slot-1 keys) | |
| +--------------------------------------------------+ |
| | |
| | (reads/writes via Azure SDK) |
| v |
+--------------------------------------------------------+
|
v
+--------------------------------------------------------+
| raklet-ci-resource-group (West Europe) |
| +------------------------+ +-----+ +------------+ |
| | rakletci01storage | | 02 | | 03 | |
| | (active, slot-1) | |idle | |idle | |
| +------------------------+ +-----+ +------------+ |
+--------------------------------------------------------+
When parallelism is genuinely needed: provision VM 2, label it
slot-2, bake slot-2 keys into its sidecar configs. Slot-2 storage
already exists.
Key decisions¶
- Pre-created pool of 3 Azure storage accounts (
rakletci01storage,rakletci02storage,rakletci03storage), one per future VM. Slots 2 and 3 sit idle on day one (pennies/month). Real Azure accounts, not emulator, so zero compatibility risk with the existing SDK code path. - CI mechanism: GitHub Actions self-hosted runner, not a polling
cron. Push-based triggers, native PR Checks UI, built-in concurrency
groups with
cancel-in-progress: trueso rapid push-rebases preempt in-flight runs. - Slot identity is a property of the VM, not a runtime workflow
parameter. The VM's gitignored
appSettings.local.configfiles are written once at provisioning time with that slot's keys. Workflow doesn't pick a slot; it inherits. - Always-on VM (~$180/mo) rather than auto-shutdown. The team's async-dev pattern produces PRs outside business hours; CI sleeping through them would block the workflow the runner is meant to unblock.
- Per-run cleanup clears blob and queue contents in place —
never deletes the containers themselves. Avoids Azure's tombstone
period that can cause
409 Conflicton rapid delete-then-create. - No DB reset between runs in v1. Trade-off documented; mitigation
is the standard pattern of using unique per-run identifiers in tests
(e.g.
pr-${PR_NUMBER}-event-${TIMESTAMP}) and not destroying gold-state seed data. If accumulated state causes flake, the fall-back is SQL Server snapshot revert per run (~half day to add). - Migration PRs get special handling. See "Migration PR handling" below.
Implementation phases¶
Phase 0A — Provision the VM (one-time, ~half day)¶
- Create resource group
raklet-ci-resource-groupin West Europe. - Provision
rakletci01storage,rakletci02storage,rakletci03storage(slot 1 active, 2-3 idle standbys). - Provision the VM: Windows Server 2022, Standard D4s_v5, Premium SSD, always-on (no auto-shutdown), in the same resource group.
- Install: SQL Server Developer, IIS (matching
docs/local-iis-setup.md), .NET Framework SDK + dev pack, Node 22, Bun 1.3.10, Chrome, Git,ghCLI, VS Build Tools. - Configure Acrylic DNS:
*.raklet.org→127.0.0.1. - Install self-signed
*.raklet.orgcert inLocalMachine\Myand the trust store, perdocs/local-iis-setup.md§1. - Restore the prod-anonymized SQL
.bakas the gold reference. - Register the VM as a self-hosted GH Actions runner with labels
self-hosted, windows, raklet-ci, slot-1. - Install
claudeandcodexCLIs under a runner-specific identity.
Exit criteria: the VM serves a working Raklet site at canonical
*.raklet.org hostnames using slot-1 storage; the runner shows online
in repo Settings → Actions → Runners.
Phase 0B — appSettings refactor PR (lands before Phase 1)¶
This is the change every dev should review carefully because it touches the daily flow.
For each of 8 projects (Application, Raklet.Backend,
Raklet.Login, Raklet.Crm, Raklet.WebJobs,
Raklet.WebJobs.Secondary, Raklet.WebJobs.Scheduled5mins,
Raklet.WebJobs.ScheduledHourly):
- Move the storage
<add key>entries out ofWeb.config/App.configinto a newappSettings.local.configfile alongside the existingconnectionStrings.local.config. - Add a small structural change in the project's main config so those
keys are loaded via
configSource="appSettings.local.config"(or equivalent — the exact mechanic depends on whether the project can use a top-level<appSettings configSource>or needs a sub-section pattern). - Commit an
appSettings.local.config.templatesibling so new clones know what shape to use.
Repo changes:
- Add
**/appSettings.local.configto.gitignore. git rm --cachedthe existing committed copies (they remain on disk for current devs; new clones get them via the.templatefiles).
What this means for devs after the merge:
- The first time you pull, your local
appSettings.local.configfiles will become "untracked but present." Git won't touch them on checkouts. - If you do a fresh clone after this merges, copy each
appSettings.local.config.templatetoappSettings.local.configin the same directory and fill in your credentials (the same dev storage keys that are committed inWeb.configtoday work fine for local dev).
Mandatory regression check before this PR lands: for each of the 8
projects, actually boot the project (or smoke-test storage resolution
by constructing an Azure SDK client from
ConfigurationManager.AppSettings["StorageConnectionString"]) and
verify the client can list blobs in the expected account. "Returns
non-null from AppSettings" is not enough; the SDK has to actually
resolve and connect.
Phase 1 — CI on every PR (~3 days)¶
New workflow .github/workflows/pr-ci.yml:
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
concurrency:
group: pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
ci:
runs-on: [self-hosted, windows, raklet-ci]
steps:
- uses: actions/checkout@v4
- run: scripts/ci/apply-ci-storage-config.ps1
- run: scripts/ci/validate-slot-config.ps1
- run: scripts/ci/sync-db-to-master.ps1
- run: scripts/ci/clear-slot-storage.ps1
- run: scripts/ci/apply-pr-migrations.ps1 # no-op for non-migration PRs
- run: scripts/dev/build-fast.ps1
- run: scripts/dev/invoke-backend-grunt.ps1
- run: scripts/ci/swap-iis-to-checkout.ps1
- run: scripts/dev/pr-ready-check.ps1
- run: dotnet test
- if: always()
run: scripts/ci/revert-pr-migrations.ps1
- uses: actions/upload-artifact@v4
with:
name: ci-artifacts
path: |
screenshots/
logs/
A small follow-up commit removes the "GitHub-hosted runner has no
Raklet app" comment block at the top of
.github/workflows/browse-ui-tests.yml
since the constraint goes away.
Phase 3 — Auto PR review (~2 days)¶
New workflow .github/workflows/auto-review.yml triggered by:
pull_request: ready_for_reviewpull_request: review_requested(filtered torakletai)issue_comment: created(filtered to comments mentioning@rakletai)
The workflow runs Claude and Codex against the diff with locked-down
tool access: claude -p --allowedTools="" (no Bash, no Write, no
network beyond the model call) and codex --sandbox read-only. The
only thing either engine can produce is text, and the only thing the
workflow does with that text is post it as a PR comment via the
GitHub API. No merge, no approve, no push.
A consolidated comment is posted per review and edited in place on subsequent runs so the PR doesn't accumulate stale auto-review threads.
On first activation, the workflow records HEAD SHAs for all open PRs without reviewing — otherwise the first run would post auto-reviews on ~15 already-ready PRs at once. After that, only new SHAs trigger reviews. Hard cap of 3 reviews per cycle and 20 per day as belt-and- suspenders against runaway behavior.
Concurrency model¶
One self-hosted runner = one job at a time (GitHub Actions native
semantics). With cancel-in-progress: true:
- Two different PRs → second queues until first finishes.
- Same PR, new push → previous run cancels, new run takes its slot.
- CI workflow and auto-review workflow share the runner, so they serialize against each other too.
This is intentional. Tests run against a single shared SQL DB and a single IIS site at canonical hostnames — running two PRs in parallel against shared infrastructure would be a flake-fest. Serialization is the feature.
Adding parallelism = adding a VM. The slot-2 storage account is already provisioned; the only work is VM 2 provisioning + registering its runner agent.
Migration PR handling¶
CI's DB tracks master's schema continuously. The polling step at the
start of every workflow runs dotnet ef database update against the
current master state, applying any newly-merged migrations.
For PRs that add a migration (detected via
git diff --name-only ... | grep -E 'Migrations/'):
- Sync DB to master (above).
- Apply the PR's new migration:
dotnet ef database update. - Run tests.
- Revert the PR's migration (always, even on failure):
dotnet ef database update -TargetMigration <master-latest>.
Open verification before this is relied on: the revert step
requires EF Down methods to be correct and reversible. Many teams
stop maintaining Down methods because day-to-day flow is
forward-only. A quick spot-check of three recent Raklet migrations
should confirm whether reverts actually work cleanly. If they don't,
the fallback is to detect migration changes and post a "validate via
migration release process" status check instead of running CI for
those PRs, matching the existing migration-branch-first policy.
Hardware¶
- VM: Azure Standard D4s_v5, 4 vCPU, 16 GB RAM, Premium SSD.
- Cost: ~$180/mo always-on.
- DNS: Acrylic,
*.raklet.org→ 127.0.0.1 on the VM (sealed view, no leakage to prod DNS or other dev machines). - Cert: self-signed
*.raklet.org, same approach asdocs/local-iis-setup.md§1. - Storage: 3 accounts in
raklet-ci-resource-group. Idle cost is pennies/month; usage cost is pennies/month. - GH Actions minutes: zero from quota (self-hosted runners are free from the 3,000-min budget).
Out of scope¶
These problems are acknowledged but explicitly deferred:
- Local dev storage isolation. Devs continue sharing the
rakletlocal*accounts in day-to-day work. The Phase 0B refactor sets up the structural fix (per-machineappSettings.local.config) so this is solvable later without another refactor — but actually provisioning per-dev storage accounts is its own decision. - Per-PR branch previews. Covered in
docs/agents/iis-worktree-branch-previews.md. RequiresSubdomainRoute+RegexHelper+UrlServicechanges and a per-PR IIS-site provisioner. Real engineering, separate project. - Containerizing the app. Windows containers on .NET Framework is a multi-month project that doesn't move toward .NET 8 modernization. Strategic decision for another day.
- Removing
AccountKey=...values fromWeb.configsource control. Real cleanup, separate effort. The new CI accounts created here will NOT be committed; their keys live only in the VM's gitignored sidecars and Azure. - Migrating existing GH-hosted workflows to the self-hosted
runner. A real Phase 4 opportunity —
tests.ymlis the most likely candidate to recoup the metered GH Actions overage — but defer until the new runner has been stable for a couple of weeks.
Open items and watch-outs¶
- EF
Downmigration reliability. Spot-check three recent migrations before the migration-handling logic in Phase 1 is relied on. IfDownmethods are broken, fall back to "skip CI for migration PRs." - Non-migration test pollution. Without a per-run DB reset, shared state across runs is possible. Mitigations: tests use per-run unique identifiers, never destroy gold-state seed data. Plan B is to add SQL Server snapshot revert (~half day) if flake appears.
- Self-signed cert + outbound HTTPS to webhook sandboxes
(Stripe/Iyzico/PayPal). Same risk profile as dev laptops today;
the cert in the system trust store should resolve it. If a
webhook test fails with
RemoteCertificateNameMismatch, the fix is verifying the trust store install on Phase 0. - Storage cleanup wall time. If clearing 5 containers + 3 queues per run grows past ~30s, switch from "delete blobs" to a different pattern.
- First-build cost after a checkout that clears
bin/obj/. Mitigation is one-line YAML (clean: falseon the checkout step) if it bites.
Success criteria¶
- No engineer's dev box runs a scheduled Raklet job after Phase 3 ships.
- A PR opened by any engineer gets a CI status check within
build + test + queue_wait; queue wait is <10 min under normal load. - Median time from
git push→ CI status posted is <30 min for a full-suite PR. - Auto-review posts a comment within ~5 min of trigger.
- Zero regressions in production member portal behavior caused by this work.
- Adding a second runner takes <1 hour end-to-end (VM 2 provisioned
- registered + slot 2 keys baked).
Dependencies¶
- Azure tenant access (create resource group, VM, storage accounts).
- GitHub repo admin rights (register self-hosted runner, manage secrets).
- A prod-anonymized SQL
.baksnapshot. pr-ready-check.ps1working in headless mode (already does, per #13734).claudeandcodexCLI subscriptions on the runner's identity.- Azure CLI installed on the runner.
Phase 0 acceptance checks¶
Before Phase 0B and Phase 1 implementation start, three small de-risking steps:
- Provision the resource group + three storage accounts via
azCLI. ~5 minutes. - Verify EF
Downmigrations work on three recent Raklet migrations. RunUpdate-Databasethen-TargetMigration <previous>; confirm clean undo, no orphan tables, no errors. - Smoke-test the appSettings refactor mechanic on one project
(
Applicationis fine). Move the storage<add key>entries to a newappSettings.local.config, setconfigSource, run the app, confirm storage still resolves.
If step 2 fails, the migration-handling design needs to change to "skip CI for migration PRs" before any other work starts. If step 3 fails, the configSource mechanic needs to be debugged before doing the other 7 projects.