Rask
Rask
v0.23.1-alpha.0.496
You're reading the Rask v0.23.1-alpha.0.496 guides. Updated .View source
All guides

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.

  1. Format + analyzers — dotnet format Rask.slnx then --verify-no-changes. CI's format src and format tests jobs run the same verify after the push.

  2. Clean build, warnings-as-errors — dotnet build Rask.slnx -c Release -warnaserror -p:EnforceCodeStyleInBuild=true. Enforced in Directory.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 in src/<Project>/PublicAPI/<tfm>/PublicAPI.Unshipped.txt. That diff is the API review — read it against api-style.md before you commit.

  3. Tests — unit-test every feature/fix (tests/Rask.*.Tests); add E2E only when a unit test can't reach the path. Every src/Rask.Site change gets an E2E journey update (tests/Rask.Site.E2E.Tests). Inner loop — build once, then test with --no-build so 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 in Rask.Spa.Hosting.targets, which skips a referenced client's nested publish under RaskWasm=false).

    Directory.Build.rsp turns 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 on Rask.Wasm.Tasks itself, note that a reused node also pins the task assembly — run dotnet build-server shutdown before judging any change to it, or you are measuring the previous build's DLL.

  4. Benchmarks — any render/live-runtime hot-path change runs tests/Rask.Benchmarks before/after and quotes the Allocated delta in the commit body.

  5. Docs & the site — user-facing changes update src/Rask.Site, the relevant docs/*.md, README.md, NUGET.md, llms.txt, and docs/ai-agents.md. Add a CHANGELOG.md [Unreleased] entry (Keep a Changelog).

  6. Review — security, performance, and memory held together with UX; prefer standard .NET APIs over hand-rolled code; refactor duplication you touch (the rask-review skill).

  7. Land on main — Conventional Commit type(scope): subject (enforced by commitlint), then merge origin/main in and git push origin HEAD:main. Own work never goes through a pull request — PRs are reserved for external contributions, which arrive from forks. ci.yml runs on the push; a red main is fixed forward. See the land-on-main skill.

Versioning & releases

  • Versions come from git tags via MinVer (vX.Y.Z); assemblies carry AssemblyVersion, FileVersion, and InformationalVersion automatically.

  • Stable release: promote CHANGELOG.md [Unreleased] to a dated section, tag vX.Y.Z, push — release.yml runs every gate, the release-only ones included, and only then packs the NuGets and publishes to nuget.org + a GitHub release (the cut-release skill). Try those gates ahead of the tag on a ci/release/** branch.

  • Nightly: nightly.yml runs when full (the whole run) ends, and takes the newest commit it has passed on main — it packs the MinVer prerelease versions and publishes them to nuget.org (prerelease) and GitHub Packages. A commit full did 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.yml runs scripts/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 -alpha versions on the gallery between releases (Rask.Server reached 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 PackageReference keeps building — it is removed from search and the gallery, not from the feed.

    Two deliberate limits. The step is continue-on-error and 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 in NUGET_API_KEY needs 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.py under real semver ordering, table- tested in scripts/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 semver 0.21.0 is older than 0.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 obvious v3-flatcontainer/<id>/index.json. Flat-container reports every version ever pushed, unlisted ones included — rask.native has 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 carries listed per version, and a missing listed field 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 under src/ 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.Wire to Rask.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.Server can be restorable before Rask is. This is what makes a publish take the better part of an hour; removing --gate-index from 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-duplicate passes 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 rolling nightly release 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 to main and every pull request; the whole push set on a ci/** branch. By hand: gh workflow run ci.yml --ref <branch> -f only='CLI build' (only is a pattern over job names: 'front end' is all seven front ends, 'front end vue$' one).
  • full.yml — the whole push set against main, 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 against main every 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 commit full passed.
  • pages.yml — rask.sh, from a commit full passed.
  • release.yml — tag-triggered: every gate, then the stable publish.
  • upstream.yml — daily; follows what Rask is generated from. scripts/upstream/follow.sh moves 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 on main, with nobody watching. Its front-ends matrix scaffolds every front-end template on the newest npm set rask new chooses and runs scripts/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 once ci has 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 full dotnet 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 by dotnet format alone, not by the warnings-as-errors build (#584). Before formatting it builds src/*.Generators in Debug: dotnet format evaluates the solution in the default configuration and resolves the analyzer references to bin/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 format never gets --no-restore. With it, dotnet format Rask.slnx --verify-no-changes modified 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. Check git status after any format run.
  • A hand run can be scoped to what changed. RASK_TEST_SCOPE=affected scripts/run-unit-local.sh builds and tests only the projects the staged files can reach (or a range's, with RASK_SCOPE_RANGE=origin/main...HEAD); RASK_FORMAT_SCOPE=staged|range narrows the formatter the same way. scripts/lib/affected_projects.py walks ProjectReference, 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=0 runs 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 of docs/**/*.md: no reference carries that edge, so the test's csproj names it —
    <ItemGroup Condition="false">
      <RaskTestReads Include="..\..\src\Rask.Server\Resources\**" />
    </ItemGroup>
    The group is Condition="false" so MSBuild never expands the glob (the scoper reads it as text; scripts/tests/affected-projects.test.sh enforces 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 under scripts/ or .githooks/ runs all of them.
  • Every script finds Node and opts out of .NET CLI telemetry. scripts/lib/node-path.sh takes the newest version under ~/.nvm/versions/node when node is not on PATH (a shell started by an IDE or an agent), instead of failing the islands build with RASKISLAND001. scripts/lib/dotnet-env.sh exports DOTNET_CLI_TELEMETRY_OPTOUT=1: the CLI walks and locks its spool under ~/.dotnet/TelemetryStorageService on every exit, and a gate's hundreds of dotnet processes queue on a large one with the cores idle.
  • The scaffold templates compile in the unit gate. src/Rask.Templates is in no project the solution builds, so tests/Rask.Generators.Tests/TemplatesCompileTests.cs materialises each template the way rask new does and compiles the C# in memory with the real generators. Restore, the project file, the MSBuild targets and publish stay with scripts/run-cli-build-e2e.sh. A using that a package's build/*.props adds for an app is listed in that test by hand.
  • run-all-gates.sh is every gate in one local run. --only 'E2E|CLI' narrows it to the gates whose label matches. --parallel runs two lanes: the CLI build, template and watch gates pack with MinVer on and everything else builds with MinVerSkip, so in one tree they recompile each other's obj/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, the wasm-tools workload resolving as missing. scripts/lib/build-failure.sh classifies the build log — code (error CS, your branch), workload, sdk, unknown — and the gate prints the matching explanation. CS wins when both appear.
  • Attribution trailers are rejected, at both boundaries. Commit messages carry no Co-authored-by:, no Claude-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, in scripts/lib/attribution.sh, consulted by .githooks/commit-msg and again by .githooks/pre-push over the commits being pushed — the first only runs once core.hooksPath is 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 on main. A human Signed-off-by: passes. commitlint.yml makes 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.hooksPath is 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/RaskRename renames a symbol across Rask.slnx the way an IDE does, then rewrites Type.Old in the templates, docs and agent guides. .claude/skills/rename-public-member is the playbook.

The gates, one by one

  • Browser E2E — scripts/run-e2e-local.sh (push set). The Playwright journeys in tests/Rask.Site.E2E.Tests and tests/Rask.Server.E2E.Tests. It publishes src/Rask.Site and 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 passes MinVerSkip=true like the unit gate, so the two do not recompile each other's obj/; 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 on PATH (a real Lit island is bundled) and sets DOTNET_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.sh and data demo E2E — scripts/run-data-demo-e2e-local.sh (push set). The only gates that link e_sqlite3 natively into a WebAssembly bundle, so they need the wasm-tools workload and refuse to start without it. The first drives full-text search over tests/Rask.SQLite.Browser.Fixture.Wasm; the second drives src/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 every rask new flag combination and the tutorial walk-through, and builds each with -warnaserror. The script exports RASK_CLI_BUILD_E2E=1; without it every case reports SKIPPED rather than passing silently.
  • Templates — scripts/run-template-e2e.sh (push set). Scaffolds server, wasm and wasm-hosted through the dispatch rask new uses and builds what it wrote. It also scaffolds a server app per npm island runtime and runs the real island build (npm install and Vite), asserting the manifest lists the scaffolded island: a compile-only row cannot see a scaffold that does not bundle. Exports RASK_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 into client/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) and npm test, or Angular's npm run lint and npm run format:check — and the published app is started and asked for / (the bundle's index.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 a client/package.json is a front end (--list-front-ends), so adding one adds its job, and TemplateTreeContractTests holds that list to the one rask new offers. 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 real rask deploy at 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 .test domain.
  • Installer — scripts/run-install-e2e-local.sh (release set). Runs the working tree's rask.sh in containers that lack a .NET SDK, Node and tools, then asserts a working rask, dotnet-ef and 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, POSIX sh, 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.sh and 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 of https://<name>.test in a throwaway container where the CA anchors, NSS databases, /etc/hosts and the port sysctl are really modified, ending with a curl TLS 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 with DOTNET_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 --smoke asserts 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 from bin/.

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.

Confirm