Development workflow
How changes are made, verified, and shipped in this repo. GitHub is the single source of truth;
everything here is committed. AI assistants encode these as playbooks under .claude/skills/
(see AGENTS.md / CLAUDE.md).
The inner loop
Run the app you're changing with rask dev. It wraps dotnet watch run, so an edit to a component's
Render(), a scoped .css/.ts, a [Route] template or a CQRS handler is applied to the running
process and every open session repaints in place — a "Hot reload applied" pill confirms it. Edits the
runtime can't apply (adding a type, changing a signature) restart the app instead, and the page reloads
itself. What hot-reloads has the full list, including what doesn't. WASM is
covered — the host serves the client's build output for the session, since a published bundle is trimmed
and could never apply an update.
When something breaks, it says what broke. A save that doesn't compile shows the compiler errors in the page — not "Reconnecting…" — and clears itself once the code builds; an exception from a handler or an async lifecycle hook shows over the running app, which stays mounted with its state intact. See when the build fails.
The framework side of that loop has its own gate, scripts/run-watch-e2e.sh — it scaffolds an app, runs
it under a real dotnet watch, edits a file, and asserts the change reached the open live session
without it being torn down. Run it when you touch the hot-reload coordinator, the scoped-asset
registry, the generated registries, or rask dev. CI runs it before a release and on a
ci/release/** branch, not on every push (see CI).
There used to be a browser half, scripts/run-wasm-watch-e2e.sh, driving a WASM sample under
dotnet watch. It is gone with the app it edited, so Mono applying a metadata delta to a live WASM
runtime is covered by nothing.
The definition-of-done gate
Every change passes this gate before it lands on main (the rask-ship skill). No hook enforces
it: CI runs the format check, the build, the tests and the browser journeys after the push (see
CI), so what you skip here you find out there, on main.
Format + analyzers —
dotnet format Rask.slnxthen--verify-no-changes. CI'sformat srcandformat testsjobs run the same verify after the push.Clean build, warnings-as-errors —
dotnet build Rask.slnx -c Release -warnaserror -p:EnforceCodeStyleInBuild=true. Enforced inDirectory.Build.props(TreatWarningsAsErrors,EnableNETAnalyzers,EnforceCodeStyleInBuild), so a plain build enforces it too. See code-analysis.md. The same build runs the public-API gate: anything public you added, renamed or removed fails until it is recorded insrc/<Project>/PublicAPI/<tfm>/PublicAPI.Unshipped.txt. That diff is the API review — read it against api-style.md before you commit.Tests — unit-test every feature/fix (
tests/Rask.*.Tests); add E2E only when a unit test can't reach the path. Everysrc/Rask.Sitechange gets an E2E journey update (tests/Rask.Site.E2E.Tests). Inner loop — build once, then test with--no-buildso each run doesn't rebuild the whole solution (test execution itself is fast; the build dominates):dotnet build Rask.slnx -c Release dotnet test Rask.slnx -c Release --no-build --filter "FullyQualifiedName!~Rask.Site.E2E"Narrow further to one project (
dotnet test tests/Rask.Core.Tests --no-build) or one class (--filter FullyQualifiedName~ATests) while iterating. The build runs in parallel by default — don't add-m:1(the former WASM copy-race workaround is fixed at the source inRask.Spa.Hosting.targets, which skips a referenced client's nested publish underRaskWasm=false).Directory.Build.rspturns MSBuild node reuse off for every build started from the repository root. That is deliberate and load-bearing: the scoped-asset bake is not safe across reused workers (#650), so with reuse on, publishing two different WASM apps in a row fails the second one. It costs about a fifth of a second per incremental build. If you are working onRask.Wasm.Tasksitself, note that a reused node also pins the task assembly — rundotnet build-server shutdownbefore judging any change to it, or you are measuring the previous build's DLL.Benchmarks — any render/live-runtime hot-path change runs
tests/Rask.Benchmarksbefore/after and quotes theAllocateddelta in the commit body.Docs & the site — user-facing changes update
src/Rask.Site, the relevantdocs/*.md,README.md,NUGET.md,llms.txt, anddocs/ai-agents.md. Add aCHANGELOG.md[Unreleased]entry (Keep a Changelog).Review — security, performance, and memory held together with UX; prefer standard .NET APIs over hand-rolled code; refactor duplication you touch (the
rask-reviewskill).Land on
main— Conventional Committype(scope): subject(enforced by commitlint), then mergeorigin/mainin andgit push origin HEAD:main. Own work never goes through a pull request — PRs are reserved for external contributions, which arrive from forks.ci.ymlruns on the push; a redmainis fixed forward. See theland-on-mainskill.
Versioning & releases
Versions come from git tags via MinVer (
vX.Y.Z); assemblies carryAssemblyVersion,FileVersion, andInformationalVersionautomatically.Stable release: promote
CHANGELOG.md[Unreleased]to a dated section, tagvX.Y.Z, push —release.ymlruns every gate, the release-only ones included, and only then packs the NuGets and publishes to nuget.org + a GitHub release (thecut-releaseskill). Try those gates ahead of the tag on aci/release/**branch.Nightly:
nightly.ymlruns whenfull(the whole run) ends, and takes the newest commit it has passed onmain— it packs the MinVer prerelease versions and publishes them to nuget.org (prerelease) and GitHub Packages. A commitfulldid not pass publishes nothing.Both publish through one script, dependencies first —
scripts/lib/publish_packages.py; see How a version reaches nuget.org.The released version is the only one left listed. After publishing,
release.ymlrunsscripts/unlist-old-versions.sh, which unlists every older version of each package it just pushed — previous stables and the nightly prereleases alike. A nightly cadence puts hundreds of-alphaversions on the gallery between releases (Rask.Serverreached 478 versions, only 23 of them stable), and a version list nobody would install is noise on every package page.Unlisting is not deletion. nuget.org gives an owner no way to delete a published version, by design, so an unlisted version still restores by exact reference and a pinned
PackageReferencekeeps building — it is removed from search and the gallery, not from the feed.Two deliberate limits. The step is
continue-on-errorand every path in the script exits 0: the packages are already pushed by the time it runs, and a tidy-up must never red a released tag. And it spends a budget of ~240 calls then stops, because nuget.org rate-limits unlisting to roughly 250 before a 403 whose retry-after runs to tens of minutes — the remainder is picked up by the next release, which supersedes it anyway. The key inNUGET_API_KEYneeds the Unlist scope; a push-only key makes the step a no-op with a warning.Which versions are superseded is decided by
scripts/lib/unlist_select.pyunder real semver ordering, table- tested inscripts/tests/unlist-old-versions.test.sh. Two rules there are load-bearing: nothing newer than the released version is ever touched, and a prerelease never retires a stable (by semver0.21.0is older than0.21.1-alpha.0.1, so without that rule a prerelease tag would unlist the current release).Candidates come from
scripts/lib/listed_versions.py, which reads the registration index, not the obviousv3-flatcontainer/<id>/index.json. Flat-container reports every version ever pushed, unlisted ones included —rask.nativehas all 209 of its versions unlisted and flat-container still returns all 209 — so selecting from it would spend the entire quota budget re-unlisting finished work and never reach the rest of the backlog. Only the registration index carrieslistedper version, and a missinglistedfield means listed.
CI
Nothing blocks a commit or a push. The git hooks take seconds and build nothing; the gates run in
GitHub Actions after the push, each as its own job. Own work still lands on main directly, and
ci.yml says afterwards whether it should have. A red main is fixed forward.
They used to run in the hooks, under a one-minute budget that only held on an idle machine: with several worktrees gating at once each run shrank to two cores and took ten minutes and more. CI is free for a public repository and nobody waits on it.
What runs where
| When | What |
|---|---|
commit-msg hook |
Conventional Commits (commitlint) and the attribution guard. |
pre-commit hook |
scripts/tests/front-doors.test.sh, only when README.md or NUGET.md is staged. |
pre-push hook |
The attribution guard again, over the commits being pushed. |
CI, every push to main and every pull request — ci.yml, SCOPED |
The gates of the push set the change can reach: build and unit always, format when a .cs changed, the browser journeys when what they draw is reached, the CLI build and templates when the CLI, a template or a project file changed, a front-end template's own job when its tree changed (all seven when the SPA host, the TypeScript emitter or the scaffolder did). scripts/lib/affected_gates.py decides, from the last commit ci or full passed; a change to the CI itself, a gate script or the package pins runs the whole set. |
CI, behind every push to main, one run at a time — full.yml; also ci.yml on a ci/** branch |
The whole push set, unscoped: build, unit (two shards), format (two halves) (run-unit-local.sh), browser E2E in five shards and the Rask.Server journeys (run-e2e-local.sh), devtools E2E, browser SQLite E2E, data demo E2E, CLI build (run-cli-build-e2e.sh), templates (run-template-e2e.sh), each front-end template as a job of its own (run-template-e2e.sh --front-end=<key>), the benchmark byte and allocation budgets (run-benchmarks-local.sh). |
CI, before a release — release.yml on a v* tag, and ci.yml on a ci/release/** branch |
The push set plus the release set, which needs containers or a real host: watch hot reload (run-watch-e2e.sh), deploy, storage providers, installer, providers. |
| Only when you ask | BenchmarkDotNet timings, the SQLite load gate (run-sqlite-load-local.sh), the Linux dev-host gate (run-devhost-linux-local.sh). No hook, no workflow. |
The list of gates lives once, in .github/workflows/gates.yml, which ci.yml, full.yml, soak.yml
and release.yml all call, so they cannot drift. Jobs do not fail fast: one red gate says nothing about the others, and
every one reports.
Enable the hooks with git config core.hooksPath .githooks (the first dotnet build does it for
you); bypass one with --no-verify.
A red job
Every job runs one script from scripts/, the same one you run by hand. The job's last step
is that command; run it in a worktree at that commit:
scripts/run-unit-local.sh # the build, unit and format jobs (RASK_UNIT_PART=build | tests-1/2 | format-src … runs one piece)
scripts/run-e2e-local.sh # the "browser E2E" job
scripts/run-template-e2e.sh --front-end=vue # the "front end vue" job
scripts/run-all-gates.sh --only 'E2E|CLI' # several, by label
scripts/run-all-gates.sh --list # every gate, and what it needs
On main every push gets its own run, started at once and never cancelled, so "which push broke it" has an answer.
Trying a change first
Push to a ci/** branch to run the push set without landing anything; only the newest push to a
branch is kept running. A ci/release/** branch runs the release set too — the way to find out
before tagging rather than from a failed release.
git push origin HEAD:refs/heads/ci/my-change
gh run watch # pick the run
git push origin --delete ci/my-change
What a red main stops
Publishing. pages.yml triggers when full completes on main and does nothing unless it concluded
success; nightly.yml triggers on the same completion and packs the newest commit full passed, once (an hourly
schedule is its backstop). So neither a
prerelease package nor rask.sh is built from a commit the WHOLE set did not pass — a scoped run's green
publishes nothing. full runs behind every push, one run at a time with the newest push waiting, so a
site change is live about twenty minutes after its push. A package takes longer: its publish waits for
nuget.org level by level (below).
The price of a scoped push is named here once: a break the scoped run could not see is found by the
next whole run, minutes later, with green pushes on top of it. full red and ci green means
exactly that. release.yml's publish job needs its gates job (both sets). A manual
workflow_dispatch of nightly or pages publishes whatever main holds, verdict or not.
How a version reaches nuget.org
nightly.yml and release.yml pack the solution and hand ./artifacts to
scripts/lib/publish_packages.py. Nothing else pushes a package.
python3 scripts/lib/publish_packages.py plan ./artifacts --projects src # level, id, version — pushes nothing
python3 scripts/lib/publish_packages.py push ./artifacts --source … --api-key-env NUGET_API_KEY --gate-index nuget.org
python3 scripts/lib/publish_packages.py verify ./artifacts # waits until every package is served
- The set is checked first. One version, one commit, every dependency of ours packed, and
(
--projects src) exactly the projects undersrc/that are not<IsPackable>false</IsPackable>. A set that fails is not pushed at all (exit 2). - Order. Read from the packed nuspecs: a package with no dependency in the set is level 0, and
every other one sits a level above the deepest thing it depends on. Today that is eight levels, from
Rask.WiretoRask.Server. There is no list to keep. - Gate. A level is pushed only when nuget.org serves everything it depends on. Accepted is not
served: validation takes four to eleven minutes and finishes in no particular order, so without the
gate
Rask.Servercan be restorable beforeRaskis. This is what makes a publish take the better part of an hour; removing--gate-indexfrom the workflow trades it back for that window. - Retry. One package per
dotnet nuget push,--timeout 900, five attempts with a doubling wait. A timeout, a 5xx or anything unrecognised is tried again; a 429 waits what the feed asks, or a minute; a 400/401/403/413 fails at once; a duplicate is success. The API key travels in the environment and is struck out of everything printed. - Verify. The job is green only when the flat container — what restore reads to resolve a version and to download; search and registration lag it — lists every package at the new version. Otherwise it fails and names what is missing.
- A failed publish is finished by running it again. Re-run failed jobs packs the same commit, so
MinVer gives the same version, and
--skip-duplicatepasses over what is already there. Whatever stopped it, nothing above the failure was pushed, so the half that exists restores. A nightly that is left unfinished is not waited for: the rollingnightlyrelease moves only after the verify step, so the next run publishes the newest green commit, whole.
Which commit is version X? A nightly's number is the commit height since the last tag, which no run
list shows — and a nightly run is listed under main's head, not under the commit it packed. Three
places say it:
# the package itself: <repository commit="…"> in its nuspec
curl -s https://api.nuget.org/v3-flatcontainer/rask/0.23.1-alpha.0.271/rask.nuspec | grep -o 'commit="[^"]*"'
gh release view nightly --json name,targetCommitish # the newest nightly: "Nightly <version> (<sha>)"
gh run view <run-id> # the run's summary: version, commit, and why it was picked
And the other way round — does a nightly hold commit X: take that version's commit from its nuspec and
ask git, git merge-base --is-ancestor X <commit> && echo yes. The number alone does not say: MinVer's
height is not what git describe counts.
2026-10-12: a nightly that left Rask.Server ahead of Rask
0.23.1-alpha.0.273 was on nuget.org for Rask.Server, Rask.Dashboard, Rask.Postgres and three
more while Rask, Rask.Ui, Rask.Data and the rest were still at .271. Every app floating
0.*-* failed restore with NU1102, a production deploy among them. Three things made it possible:
| Cause | What prevents it now |
|---|---|
One dotnet nuget push "./artifacts/*.nupkg": file-name order, so hosts went before what they depend on. Even a clean nightly served Rask.Server 37 seconds before Rask (.271). |
Order from the nuspecs, and the gate: nothing is pushed until its dependencies are served. |
| No retry, and the CLI's 300-second default: one stalled request ended the publish where it stood. | A push per package, a longer timeout, bounded retries; a failure stops below everything that depends on it. |
| "Done" meant the push command exited. Nothing asked whether the version was whole, and no run said which version or commit it was. | The verify step, the summary on the run page, and a nightly release that moves only after both. |
The rule that a nightly comes only from a commit full passed held throughout, on the schedule as on
the completed run: both ask the same question in the pick job. The scheduled run that looked like a
publish from a red commit had published nothing — the commit had passed full on a second, scheduled
try three days earlier, and its nightly already existed. pick now writes what it decided on the run page.
The workflows
ci.yml— the gates a change reaches, on every push tomainand every pull request; the whole push set on aci/**branch. By hand:gh workflow run ci.yml --ref <branch> -f only='CLI build'(onlyis a pattern over job names:'front end'is all seven front ends,'front end vue$'one).full.yml— the whole push set againstmain, behind every push (one run at a time, fourteen jobs at once so a push's own run finds runners) and hourly as the backstop. Publishing follows it.gates.yml— the reusable list of gate jobs the others call.soak.yml— the release set againstmainevery three hours, so an image or a download that disappears is found within hours and not on the day of a release.commitlint.yml— Conventional Commits and the attribution guard on PRs: the commits and the PR title, which is the squash subject and passes through no local hook.nightly.yml— prerelease publish from a commitfullpassed.pages.yml— rask.sh, from a commitfullpassed.release.yml— tag-triggered: every gate, then the stable publish.upstream.yml— daily; follows what Rask is generated from.scripts/upstream/follow.shmoves the MDN snapshot to the latest stable data, records the public surface that moved with it (scripts/public-api/record.py), moves the stated Node line to the Active LTS, and moves every pin Dependabot cannot read to its newest stable release (the followers listed under What updates itself); the result is gated and THEN landed onmain, with nobody watching. Itsfront-endsmatrix scaffolds every front-end template on the newest npm setrask newchooses and runsscripts/run-template-e2e.sh --front-end=<key>on it. It opens an issue only when it needs a person: the gates refused what upstream shipped, a template no longer builds on the newest npm packages, or Flux UI moved. The Flux lock (tests/Rask.Ui.Tests/Flux/flux.lock.json) is measured on its runner, so it is relocked there:gh workflow run upstream.yml -f relock=true.dependabot-merge.yml— merges a Dependabot pull request oncecihas passed it. What must not move on its own is in.github/dependabot.yml's ignore lists.
The gate scripts
True of the scripts wherever they run — a CI job or your terminal.
- Format + unit tests:
scripts/run-unit-local.sh. It builds the solution once, then runs the fulldotnet format Rask.slnx --verify-no-changes(whitespace + style + analyzers) concurrently with every test except the browser E2E, and reports both statuses: a run that is red for formatting still tells you whether your tests pass. The full pass earns its place — import ordering is enforced bydotnet formatalone, not by the warnings-as-errors build (#584). Before formatting it buildssrc/*.Generatorsin Debug:dotnet formatevaluates the solution in the default configuration and resolves the analyzer references tobin/Debug/, and without those DLLs no source generator runs and the routing tests fail to bind with CS1503. The gate's own bash tests (scripts/tests/*.test.sh) run first, and every run ends with one line per phase saying where the time went. dotnet formatnever gets--no-restore. With it,dotnet format Rask.slnx --verify-no-changesmodified 57 files it was only asked to check: without a restore the workspace cannot resolve the source generators, every generated symbol goes missing, the usings look dead, and it writes. Checkgit statusafter any format run.- A hand run can be scoped to what changed.
RASK_TEST_SCOPE=affected scripts/run-unit-local.shbuilds and tests only the projects the staged files can reach (or a range's, withRASK_SCOPE_RANGE=origin/main...HEAD);RASK_FORMAT_SCOPE=staged|rangenarrows the formatter the same way.scripts/lib/affected_projects.pywalksProjectReference, the..\…items a project pulls in, and every test's<RaskTestReads/>declarations, and answers FULL for anything it cannot map precisely (a repo-root import, the solution, a gate script, a hook,.editorconfig, a file no project owns). The run prints which it chose and why. A project that passed is stamped with the tree it passed on (scripts/lib/gate_stamps.py), and the next scoped run skips it when nothing in reach changed;RASK_GATE_REUSE=0runs everything. Unscoped is the default, and it is what CI runs. - A test that reads a file from disk declares it. A contract test on another project's
rask.ts, a walk ofdocs/**/*.md: no reference carries that edge, so the test's csproj names it —
The group is<ItemGroup Condition="false"> <RaskTestReads Include="..\..\src\Rask.Server\Resources\**" /> </ItemGroup>Condition="false"so MSBuild never expands the glob (the scoper reads it as text;scripts/tests/affected-projects.test.shenforces this). Forget the declaration and a scoped run skips the test that pins the file. The gate's own tests declare theirs on a# gate-inputs:header line, an ERE over repo-relative paths; a change underscripts/or.githooks/runs all of them. - Every script finds Node and opts out of .NET CLI telemetry.
scripts/lib/node-path.shtakes the newest version under~/.nvm/versions/nodewhennodeis not on PATH (a shell started by an IDE or an agent), instead of failing the islands build with RASKISLAND001.scripts/lib/dotnet-env.shexportsDOTNET_CLI_TELEMETRY_OPTOUT=1: the CLI walks and locks its spool under~/.dotnet/TelemetryStorageServiceon every exit, and a gate's hundreds ofdotnetprocesses queue on a large one with the cores idle. - The scaffold templates compile in the unit gate.
src/Rask.Templatesis in no project the solution builds, sotests/Rask.Generators.Tests/TemplatesCompileTests.csmaterialises each template the wayrask newdoes and compiles the C# in memory with the real generators. Restore, the project file, the MSBuild targets and publish stay withscripts/run-cli-build-e2e.sh. A using that a package'sbuild/*.propsadds for an app is listed in that test by hand. run-all-gates.shis every gate in one local run.--only 'E2E|CLI'narrows it to the gates whose label matches.--parallelruns two lanes: the CLI build, template and watch gates pack with MinVer on and everything else builds withMinVerSkip, so in one tree they recompile each other'sobj/Release; it checks out a second worktree at HEAD for that group and refuses a dirty tree.- A red gate names the culprit it actually found. The CLI build and browser gates build browser
targets, so they can fail for a reason that has nothing to do with your branch — most often
NETSDK1147, thewasm-toolsworkload resolving as missing.scripts/lib/build-failure.shclassifies the build log —code(error CS, your branch),workload,sdk,unknown— and the gate prints the matching explanation.CSwins when both appear. - Attribution trailers are rejected, at both boundaries. Commit messages carry no
Co-authored-by:, noClaude-Session:and no "Generated with …" footer: GitHub's contributor list credits co-authors, and taking two such accounts back off cost a rewrite of all 970 commits. The rule lives once, inscripts/lib/attribution.sh, consulted by.githooks/commit-msgand again by.githooks/pre-pushover the commits being pushed — the first only runs oncecore.hooksPathis set, which a fresh clone has not done. It is the one check CI cannot take over for own work: by the time CI sees the commit it is onmain. A humanSigned-off-by:passes.commitlint.ymlmakes one allowance the hooks do not: on a pull request Dependabot opened, Dependabot's own sign-off is dropped before the check. - Hooks in a worktree run the worktree's own copy.
core.hooksPathis the relative path.githooks, resolved against the pushing worktree's top level. If a branch does not contain.githooks/at all, no hook runs and nothing says so. - A public rename is one command.
scripts/tools/RaskRenamerenames a symbol acrossRask.slnxthe way an IDE does, then rewritesType.Oldin the templates, docs and agent guides..claude/skills/rename-public-memberis the playbook.
The gates, one by one
- Browser E2E —
scripts/run-e2e-local.sh(push set). The Playwright journeys intests/Rask.Site.E2E.Testsandtests/Rask.Server.E2E.Tests. It publishessrc/Rask.Siteand builds the leaf test projects — the graph the suite runs, not the solution; that the whole solution compiles is the unit gate's claim. It passesMinVerSkip=truelike the unit gate, so the two do not recompile each other'sobj/; that is safe only while no fixture launches a published host out of process. While iterating on one journey, narrow it:RASK_E2E_FILTER='FullyQualifiedName~WasmExampleTests' scripts/run-e2e-local.sh— it says loudly that the run was filtered, because a narrowed green is not the gate. - Devtools E2E —
scripts/run-devtools-e2e-local.sh(push set). Rask DevTools against real pages (tests/Rask.DevTools.E2E.Tests): the pill, the dock and every tab. A gate of its own because the devtools exist only in a Debug build. It needs node onPATH(a real Lit island is bundled) and setsDOTNET_MODIFIABLE_ASSEMBLIES=debug; a Release build, or a run without the variable, fails naming the script rather than passing on nothing. - Browser SQLite E2E —
scripts/run-browser-sqlite-e2e-local.shand data demo E2E —scripts/run-data-demo-e2e-local.sh(push set). The only gates that linke_sqlite3natively into a WebAssembly bundle, so they need thewasm-toolsworkload and refuse to start without it. The first drives full-text search overtests/Rask.SQLite.Browser.Fixture.Wasm; the second drivessrc/Rask.Site.DataDemo, the notes demo served at/demos/data/. - CLI build —
scripts/run-cli-build-e2e.sh(push set). The only thing proving the code the CLI writes compiles: it packs this commit's packages to a local feed, scaffolds everyrask newflag combination and the tutorial walk-through, and builds each with-warnaserror. The script exportsRASK_CLI_BUILD_E2E=1; without it every case reports SKIPPED rather than passing silently. - Templates —
scripts/run-template-e2e.sh(push set). Scaffoldsserver,wasmandwasm-hostedthrough the dispatchrask newuses and builds what it wrote. It also scaffolds a server app per npm island runtime and runs the real island build (npm installand Vite), asserting the manifest lists the scaffolded island: a compile-only row cannot see a scaffold that does not bundle. ExportsRASK_TEMPLATE_E2E=1, with the same SKIPPED rule. - Front ends —
scripts/run-template-e2e.sh --front-end=<key>(push set, one job per template:front end react,front end vue, …). Scaffolds that template with every battery on and runs the one command a deploy runs,dotnet publish:npm install(a template ships no lockfile), the typed client written intoclient/src/rask/from the host's message records, and the bundle. Then the client checks itself —npm run check(Vite+: format, lint and type check) andnpm test, or Angular'snpm run lintandnpm run format:check— and the published app is started and asked for/(the bundle'sindex.html, and the script it loads) and for the starter's query, by the wire name the generated client carries. No browser. The jobs come from the trees: a template with aclient/package.jsonis a front end (--list-front-ends), so adding one adds its job, andTemplateTreeContractTestsholds that list to the onerask newoffers. About 2.5 minutes for one on a ten-core machine. - Watch hot reload —
scripts/run-watch-e2e.sh(release set). See the inner loop. - Deploy —
scripts/run-deploy-e2e-local.sh(release set). Points the realrask deployat a throwaway privileged container standing in for a bare VPS and asserts on the host: an image built over SSH, a container answering its health check, a blue-green swap, a Caddyfile a real Caddy accepted, a volume that outlived its container. Every other deploy test is mocked. Not covered: real DNS and Let's Encrypt — the gate uses a.testdomain. - Installer —
scripts/run-install-e2e-local.sh(release set). Runs the working tree'srask.shin containers that lack a .NET SDK, Node and tools, then asserts a workingrask,dotnet-efand Node, plus a scaffolded project that builds. Its cheap half,scripts/tests/install-script.test.sh, is one of the unit gate's own tests: the pure helpers, POSIXsh, truncation safety (by running prefixes of the file) and the install URL byte-identical everywhere it is written. Not covered: a real Windows host. - Storage providers —
scripts/run-storage-providers-local.shand providers —scripts/run-providers-local.sh(release set). Rask.Storage's S3 and Azure signing against MinIO and Azurite, and the database batteries against real servers, all in containers; a provider fact whose server is not reachable reports SKIPPED, never PASSED. - Linux dev host —
scripts/run-devhost-linux-local.sh(on request,RASK_DEVHOST_E2E=1, in no CI job). Verifies the Linux half ofhttps://<name>.testin a throwaway container where the CA anchors, NSS databases,/etc/hostsand the port sysctl are really modified, ending with acurlTLS handshake with no--cacert. Not covered: macOS and Windows. - Benchmarks —
scripts/run-benchmarks-local.sh(CI, every push). Checks both wire-byte baselines — the standalone codec and the head-to-head against Blazor — byte-for-byte, holds a live update to its allocation budget (Baselines/allocation-budget.csv, exact; measured withDOTNET_TieredCompilation=0 DOTNET_TieredPGO=0, which the script sets on that one process, so the count is the same on every run), and smoke-runs the three live-session capacity reports;session-churn --smokeasserts that 100 create→dispose cycles leave nothing behind. Every check runs even when an earlier one fails. A regression means one of two opposite things: the render/diff path got heavier (fix the code, leave the baseline), or a scenario's own markup changed (refresh the baseline in the same commit and say why) — the vs-Blazor report tells them apart, because Blazor's numbers move by the same amount in the second case. Build before--check: the baseline is read frombin/.
Sharing one machine
A CI runner has its machine to itself and skips all of this. By hand, several worktrees share one
box, so scripts/lib/machine-lane.sh gives it a budget of 10 slots (the performance cores) that
the unit and browser gates claim against:
| gate | what it does | waits? |
|---|---|---|
run-unit-local.sh |
takes whatever is left, floor 2, and shrinks into it; prints the size it chose | never |
| a browser gate — build and publish | a partial claim; several worktrees may build at once | never |
| a browser gate — the suite | claims the whole budget; two browser suites never overlap | yes, oldest first |
A unit gate younger than a waiting browser suite is not waited for, so "exclusive" is not "an empty
machine". Contention produces boot timeouts and dead fixtures, never a false assertion pass: a
green under contention is trustworthy; a red is not — re-run it alone
(ps -Ao pid,etime,command | grep -E '[r]un-(e2e|unit)-local' shows what else is live). Every other
gate, and any plain dotnet build, is invisible to the budget; a red browser suite that finds a
heavy build still running names it (#850).
| variable | effect |
|---|---|
RASK_LANE_SLOTS |
the budget (default 10). |
RASK_LANE_DISABLE=1 |
switch the mechanism off: every gate gets its maximum and nothing waits. |
RASK_E2E_QUEUE_TIMEOUT |
seconds to wait before giving up (default 5400); the gate then exits 1 and names what holds the slots. |
RASK_E2E_QUEUE_POLL |
seconds between checks (default 20). |
RASK_E2E_QUEUE=0 |
do not wait; refuse immediately. |
RASK_E2E_ALLOW_CONCURRENT=1 |
do not wait; run alongside. Treat a red from that run as suspect until re-run alone. |
Dependencies
The standing rule is the latest LTS — Node, .NET, and the front-end toolchains. A scaffolder is a recommendation every new project inherits, so a maintenance-only pin hands users a runtime that has stopped getting security patches.
What updates itself. .github/dependabot.yml covers, weekly: NuGet (the repo's central versions
and scripts/tools/RaskRename's own), GitHub Actions (the workflows and the shared
.github/actions/setup), the npm lockfiles (the site, the rich text editor's engine in
src/Rask.Ui/Resources/editor, the devtools E2E island bundler) and the base images of every template
Dockerfile and scripts/devhost (.NET held to its 10.0 LTS line). Everything Dependabot cannot read is
moved daily by a follower under scripts/upstream/, run by follow.sh in upstream.yml, each with a
self-test over saved upstream answers (scripts/tests/upstream-*.test.sh):
| Follower | Pins | Upstream |
|---|---|---|
node-lts.sh |
ScaffoldLine, the build floors, setup_NN.x, @types/node, Angular's packageManager npm |
nodejs.org's index |
images.py |
CurlImage, the backup's alpine, the E2E's images, the scripts' postgres:NN-alpine — each to the newest tag of the same shape |
Docker Hub |
litestream.py |
RaskLitestreamVersion, its seven SHA-256s (downloaded and checked against checksums.txt), the templates' litestream/litestream image |
GitHub releases |
tools.py |
RaskEsbuildVersion, RaskTsgoVersion (TypeScript 7's native compiler), RaskExternalTypeScriptVersion (newest 6.x — the JS API), RaskTailwindVersion, with every digest in TypeScriptToolPins.cs / TailwindPins.cs recomputed from the download; a new Tailwind major moves the templates' ranges |
npm, GitHub releases |
fixtures.py |
the adapter fixtures in Rask.External.Tests.csproj (rxjs: the newest Angular's peer range takes) |
npm |
actions.py |
the action majors in the deploy workflow rask deploy scaffolds |
GitHub releases |
Not followed, on purpose: Roslyn (held to the SDK's compiler).
The runner image (runs-on: ubuntu-26.04) is moved by hand: the workflow token may not push a change
to .github/workflows.
The front-end templates are not in it, because they name no versions: every package
in a template client and in _islands/*/island.json is latest, and rask new resolves them against
the registry to the newest set that installs together — peers that agree, an engines.node that takes
the newest LTS — written as ^x.y.z. No template ships a lockfile; the app writes its own on the first
build. The holds are tailwindcss / @tailwindcss/* (to RaskTailwindVersion) and @types/node (the
LTS major). upstream.yml's daily front-ends matrix proves that newest set builds. Families
that must move together are grouped (microsoft-extensions, test-tooling, spectre-console,
sqlitepclraw); the three Microsoft.CodeAnalysis.CSharp* packages are ignored on purpose, because
an analyzer referencing a newer Roslyn than the running csc is CS9057 and raises the compiler floor
for every downstream consumer.
What does not. The runner image, by hand (above); the holds named in .github/dependabot.yml's
ignore lists; and anything new. A pin added outside both is a pin nobody moves, so give it a follower
(or a Dependabot entry) in the same change — the check-dependency-updates skill lists what exists.
What keeps the copies honest. A version stated twice is a version that can drift, so the pairs that matter most are asserted by an offline unit test rather than by a comment:
| Test | Holds together |
|---|---|
NodeRequirementTests |
ScaffoldLine vs. rask.sh, rask.ps1, and docs/installation.md (both platform columns, the ≥ NN.NN sentence, the "Node NN LTS" summary); the CLI's build floor vs. RaskExternalMinimumNode and RaskSpaMinimumNode |
TemplateNodePinTests |
no template ships a lockfile; every template package is latest except the declared holds; @types/node vs. the LTS major; no template lowers the Node floor; each front-end template's Dockerfile setup_NN.x vs. ScaffoldLine's major |
TailwindVersionPinTests |
the tailwindcss range in every template client vs. RaskTailwindVersion |
PackagePinFamilyTests |
the Spectre and SQLitePCLRaw pairs, the one-version platform stack, and that every SQLite project can still reach the patched SQLitePCLRaw |
EfToolProbeTests |
the dotnet-ef floor the CLI checks for vs. the EF Core version in Directory.Packages.props |
ResolveTypeScriptToolTaskTests |
reads RaskTsgoVersion/RaskEsbuildVersion out of Rask.Core.targets instead of restating them |
They need no network and run in the ordinary unit gate — CI's unit jobs, on every push and
every pull request — so the gate that runs for a version bump is the one that checks it was complete.
Not everything is covered, and pretending otherwise is the same bug. Prose mentions of the Node
line elsewhere — the build-floor figures quoted in docs/islands.md, docs/spa.md and
docs/diagnostics.md, and the codename in NodeRequirement's own doc comment — are still only prose.
scripts/upstream/node-lts.sh, run daily by upstream.yml, moves ScaffoldLine on every new LTS
release; on a new major it also moves the build floor to that line's first LTS release, the templates'
setup_NN.x lines and @types/node.
Re-importing a front-end template is done by hand. scripts/refresh-templates.sh used to re-run each
framework's creator and print the diff against the committed tree. It is not coming back: every file in a
client now carries Rask's own layer (the Tailwind starter, the lint and format configs, the dev proxy,
the battery markers), so that diff is the same wall on every run and a real change in the creator's
output is one line lost in it. Versions are chosen at rask new. For the shape — a new tsconfig option, a
creator that changed its linter — run the creator into a scratch directory and read it beside the tree:
cd "$(mktemp -d)"
npx --yes create-vite@latest client --template preact-ts # react-ts, vue-ts, solid-ts, svelte-ts, lit-ts
npx --yes @angular/cli@latest new client --directory client --style css --ssr false --skip-git --skip-install
diff -ru --exclude=src --exclude=public --exclude=package-lock.json <repo>/src/Rask.Templates/preact/client client
Take what matters into src/Rask.Templates/<key>/client by hand — a new package as latest, no
lockfile — and run dotnet test tests/Rask.Cli.Tests and
scripts/run-template-e2e.sh --front-end=<key>.
Landing a Dependabot PR. ci.yml runs the short deps set on the pull request (format, unit, CLI build, templates — the browser suites run on main after the merge), but main has no
required checks, so a red run does not disable the merge button: read the run first. Then land it
locally, not from the web UI — check the branch out and push it.
A green commitlint check on a Dependabot PR means its title and commits are Conventional and carry
no trailer other than Dependabot's own sign-off, which CI lets through on those PRs only. The hooks
do not: pre-push refuses the commit until that line is gone, so landing one starts with
git commit --amend --reset-author and deleting the Signed-off-by: line.
Vulnerability scanning is manual. dotnet list Rask.slnx package --vulnerable --include-transitive
is the only scan that runs anywhere; no workflow runs it.