A Setup Script Is Not Enough
The naive answer to onboarding pain is "write a setup script."
That helps for a week. Then the script grows. It installs everything, configures everything, checks almost nothing, handles special cases with flags, and becomes too risky to change. The company has replaced the wiki with executable mud.
Setup-as-code has to be more disciplined than that.
Installation should behave like CI:
- it starts from a known state
- it runs the same way repeatedly
- it fails fast when prerequisites are missing
- it emits diagnostics
- it proves the actual project can build
- it records evidence
- it can be replayed on fresh instances
The exit code is not the proof. The proof is the readiness evidence.
The Pipeline Shape
A useful onboarding pipeline has phases:
| Phase | Intent |
|---|---|
contract |
load, validate, and resolve the setup contract |
discover |
identify OS, runner type, network, package manager, current tools |
preflight |
test physical and institutional boundaries before installing |
install |
converge missing tools to the pinned baseline |
configure |
certificates, Git, editor, shell, environment variables |
probe |
verify every tool and boundary independently |
project |
clone, restore, build, test, run, open the starter feature |
evidence |
write the readiness report with versions, probes, logs, and failures |
In DSL language:
during setup
load SetupContract
preflight PhysicalAccess
preflight IdentityAccess
install ToolchainBaseline
configure WorkstationBootstrap
verify ProjectReadiness
emit ReadinessEvidenceduring setup
load SetupContract
preflight PhysicalAccess
preflight IdentityAccess
install ToolchainBaseline
configure WorkstationBootstrap
verify ProjectReadiness
emit ReadinessEvidenceThe sequence is intentionally boring. Boring is good. A newcomer setup path should not be clever.
SOLID in Setup-as-code
Setup code needs SOLID because setup code changes constantly.
New SDK. New Git server. New VPN. New package mirror. New IDE extension. New certificate policy. New container runtime. New security scanner. New operating system image.
Without design, every change edits the same file.
| SOLID principle | Setup-as-code application |
|---|---|
| Single Responsibility | GitProbe checks Git; DotnetInstaller installs .NET; VsCodeProfile configures the editor |
| Open/Closed | add an ApkPackageManagerAdapter or PodmanRuntimeProbe without rewriting the setup contract |
| Liskov Substitution | VM, container, LXC, and real-device runners honor the same probe contract where their capabilities overlap |
| Interface Segregation | a network probe does not depend on package-manager, Git, or editor methods |
| Dependency Inversion | setup phases depend on ports such as package manager, process runner, Git server, identity, and evidence sink |
| Modular | a DotnetInstaller ships in a versioned module a profile imports by reference; new tools arrive as new modules, not edits to the bootstrap |
This is not architecture astronautics. It is the difference between safe iteration and fear. The last row is the one that scales across teams, and Part VII gives it a syntax.
The right abstractions are small and contract-shaped:
SetupContract
RunnerProfile
RunnerCapability
PackageManagerPort
GitServerPort
IdentityProviderPort
HumanBoundaryPort
FilesystemPort
ProcessRunnerPort
EditorProfilePort
ReadinessEvidenceSinkSetupContract
RunnerProfile
RunnerCapability
PackageManagerPort
GitServerPort
IdentityProviderPort
HumanBoundaryPort
FilesystemPort
ProcessRunnerPort
EditorProfilePort
ReadinessEvidenceSinkOnce the ports exist, the setup domain becomes testable. You can fake the Git server. You can test diagnostics. You can run the installer against a temporary filesystem. You can prove a failed certificate probe produces the right message. The contract stays stable while adapters change.
OS-Specificity Belongs in Platform Adapters
Setup has to touch the operating system. That is not a reason to make the model OS-specific.
The mistake is to let if windows ... elif macos ... spread through the whole pipeline until the domain logic and the platform logic are the same tangle. The bootstrap becomes a per-OS fork, and every readiness rule has to be re-read three times to be trusted once.
The discipline is the opposite. The domain stays language-agnostic and platform-neutral. The contract says what must be true; adapters decide how to make it true.
toolchain:
dotnet:
version: "10.0.100"
versionPolicy: exact
install:
capability: package.install
packages:
winget: Microsoft.DotNet.SDK.10
apt: dotnet-sdk-10.0
apk: dotnet10-sdk
brew: dotnet-sdktoolchain:
dotnet:
version: "10.0.100"
versionPolicy: exact
install:
capability: package.install
packages:
winget: Microsoft.DotNet.SDK.10
apt: dotnet-sdk-10.0
apk: dotnet10-sdk
brew: dotnet-sdkThat snippet is not a script. It is desired state. A C# runner, a TypeScript runner, or a shell wrapper can all read it. The Windows adapter maps package.install to winget. The Debian adapter maps it to apt. The Alpine adapter maps it to apk. The macOS adapter maps it to brew. Outside the adapter, the domain only sees ToolchainPinned or a typed failure.
This is the mechanism behind the series' OS-agnostic stance. The claim is not "the setup never runs Windows-specific code." Of course it does. The claim is that Windows-specific code lives behind a platform adapter, so the setup contract, readiness evidence, and DSL do not have to be rewritten when another platform is supported.
From the Field: The Machine Part at a CAC40 Group
This is not a thought experiment. The most concrete version of this system that I shipped ran inside a large CAC40 industrial group, on its developer onboarding for an industrial software platform. My part was the machine part: everything that happens after a developer logs into a fresh corporate workstation for the first time.
It was a fully scripted, version-controlled Windows bootstrap. Once the developer was authenticated on the machine, a set of provisioning scripts converged it to a known state: package manager, developer tools, SDKs, editor, environment, and the corporate specifics that a real enterprise workstation carries. It shipped with onboarding documentation for newcomers and, crucially, a continuous update strategy — because a baseline cannot be allowed to rot into a museum the moment it is written. New developers were meant to start from a machine that was already proven, not to debug it live.
Seen through this series, that system was one platform adapter. It was Windows, PowerShell, and the platform's package manager, and it was right for that context. What it taught me is everything around that adapter. The domain -- "a workstation is ready when the real project builds from a fresh clone" -- had nothing to do with Windows. The package manager was an implementation detail behind a port. And the part that no script could cross was real: badges, group membership, certificate enrollment, the human approvals that gate access. That is where I learned to treat the human handoff as a first-class boundary, not as a bug in the automation.
The sharpest of those constraints was privilege, and it is the detail most onboarding write-ups quietly skip. In a group that size the developer does not have free admin. There are grace periods and temporary local-admin windows, elevation through a just-in-time request or an explicitly elevated VS Code, WinGet hemmed in by AppLocker and execution policy, and a constant choice between per-user and per-machine installs. In front of all of it sits a corporate proxy and a root-certificate chain that every download has to satisfy. A setup that assumes a clean admin shell on the open internet is not wrong by a little in that world; it simply does not run. This is exactly why the welcome doctor --fix guardrails later in this part insist it "must respect privilege boundaries instead of silently escalating" — the lived machine is where that line stops being a nicety and becomes the difference between a setup that works and one that gets a developer's account flagged.
I also met the failure modes this series argues against. A single bootstrap script grows until changing it is frightening, unless it is decomposed behind ports and given the same SOLID discipline as production code. And an update is never "just a new version number" — it is a transition that has to be tested on fresh instances before it reaches a newcomer, which is the whole subject of Part VI.
So the OS-agnostic framing of this series is not a retroactive boast. The lived system was OS-specific on purpose. The OS-agnosticism is the lesson extracted from it: keep the domain platform-neutral, push every platform decision into an adapter behind a port, and the next OS becomes a new provider for an existing contract instead of a rewrite.
DRY Is the Version Contract
DRY matters most where drift is expensive.
Tool versions should not be repeated in:
- a wiki page
- a setup script
- a CI job
- a Dockerfile
- a VS Code devcontainer
- a support document
- a "known good versions" spreadsheet
There should be one contract file, or one generated artifact from that contract. This is the same welcome.yaml Part VII develops in full — here is the toolchain-and-project core of one profile:
version: welcome/v1
variables:
repoHost: git.company.local
profiles:
backend-developer:
toolchain:
git:
version: "2.51.0"
versionPolicy: exact
node:
version: "^22.20.0"
versionPolicy: semver
dotnet:
version: "10.0.100"
versionPolicy: exact
docker:
version: "29.1.3"
versionPolicy: exact
project:
repository: "git@${repoHost}:product/api.git"
readiness:
- restore
- build
- test
- smokeversion: welcome/v1
variables:
repoHost: git.company.local
profiles:
backend-developer:
toolchain:
git:
version: "2.51.0"
versionPolicy: exact
node:
version: "^22.20.0"
versionPolicy: semver
dotnet:
version: "10.0.100"
versionPolicy: exact
docker:
version: "29.1.3"
versionPolicy: exact
project:
repository: "git@${repoHost}:product/api.git"
readiness:
- restore
- build
- test
- smokeEvery generated install command, readiness probe, CI job, and support report should read from that contract or from a derived artifact. Part VII adds the rest of the same document — boundaries, runner matrices, package mappings, and evidence policy.
This connects directly to GitLab.Ci.Yaml: hand-written CI and hand-written setup both drift. Typed or generated surfaces reduce that drift because there is one source of truth and a compiler or validator between intention and execution.
The Onboarding Doctor
Every other tool a developer trusts ships a doctor. brew doctor. flutter doctor. npm doctor. Onboarding deserves the same: a single command, welcome doctor, that reads the baseline and tells a human — or a CI job — exactly how ready a machine is.
The Doctor is the read-only projection of the pipeline. It runs the probe and evidence phases without installing or changing anything. It does not converge the machine; it reports on it. Each probe is a typed concern, not a string match:
| Probe | Answers |
|---|---|
OsProbe |
which OS, version, and architecture is this, and is it a supported platform |
ToolchainProbe |
do installed tool versions match the pinned baseline |
NetworkProbe |
DNS, TLS chain, proxy, package mirror, and Git server reachability |
IdentityProbe |
account, MFA, SSH key, certificate, and repository group membership |
ProjectReadinessProbe |
can this machine clone, restore, build, and smoke-test the real project |
SecurityProbe |
corporate root certificate, required scanners, and pre-commit hooks |
EvidenceProbe |
does a complete readiness report exist for this run |
Each probe returns a typed result with at least three fields:
health Ok | Warn | Fail | Blocked
remediation AutoFixable | ManualOwned | ObserveOnly
owner optional team, person, or systemhealth Ok | Warn | Fail | Blocked
remediation AutoFixable | ManualOwned | ObserveOnly
owner optional team, person, or systemThat split matters. A missing package may be Fail + AutoFixable. A missing Git group may be Blocked + ManualOwned + Identity team. A slow package mirror may be Warn + ObserveOnly. The Doctor should not flatten those into one red bucket, because the next action is different in each case. That is the loop closed with Part III: a boundary that was named as ownable becomes a blocking result with an owner, not a vague setup failure.
The user experience has two faces from one run. A human gets a readable report; a machine gets evidence:
welcome doctor
OS Ok Windows 11 26100 (supported platform)
Toolchain Fail dotnet 9.0.100 installed, baseline pins 10.0.100
Network Ok git.company.local reachable, TLS chain trusted
Identity Blocked ManualOwned repository group 'product-core' pending -> Identity team
Project Fail build skipped: toolchain not ready
Security Warn pre-commit hooks not installed
Evidence Ok report written to .welcome/evidence/2026-06-26.json
2 failures, 1 warning, 1 manually owned blocker.
Run 'welcome doctor --fix' to repair the automatable failures.welcome doctor
OS Ok Windows 11 26100 (supported platform)
Toolchain Fail dotnet 9.0.100 installed, baseline pins 10.0.100
Network Ok git.company.local reachable, TLS chain trusted
Identity Blocked ManualOwned repository group 'product-core' pending -> Identity team
Project Fail build skipped: toolchain not ready
Security Warn pre-commit hooks not installed
Evidence Ok report written to .welcome/evidence/2026-06-26.json
2 failures, 1 warning, 1 manually owned blocker.
Run 'welcome doctor --fix' to repair the automatable failures.Three flags carry the rest. --fix repairs only the idempotent AutoFixable failures and re-probes; it never crosses a ManualOwned boundary. --ci runs the Doctor against fresh instances and fails the job on any Fail or Blocked, which is how the baseline gets tested before a newcomer ever sees it — Part V shows how Packer and Vagrant produce the fresh machines those runs stand on. And the machine-readable evidence is the same artifact support reads when something does go wrong, so a diagnosis is a file, not a screenshot.
That file is the literal form of "evidence, not confidence." The human report above and the JSON below are two faces of one run:
{
"schema": "welcome/evidence/v1",
"instance": "backend-dev-03",
"profile": "backend-developer",
"ranAt": "2026-06-26T08:14:22Z",
"summary": { "failures": 2, "warnings": 1, "blockers": 1 },
"probes": [
{ "probe": "OsProbe", "health": "Ok", "detail": "Windows 11 26100" },
{ "probe": "ToolchainProbe", "health": "Fail", "remediation": "AutoFixable",
"detail": "dotnet 9.0.100 installed, baseline pins 10.0.100" },
{ "probe": "IdentityProbe", "health": "Blocked", "remediation": "ManualOwned",
"owner": "Identity team", "detail": "repository group 'product-core' pending" },
{ "probe": "ProjectReadinessProbe", "health": "Fail",
"detail": "build skipped: toolchain not ready" }
]
}{
"schema": "welcome/evidence/v1",
"instance": "backend-dev-03",
"profile": "backend-developer",
"ranAt": "2026-06-26T08:14:22Z",
"summary": { "failures": 2, "warnings": 1, "blockers": 1 },
"probes": [
{ "probe": "OsProbe", "health": "Ok", "detail": "Windows 11 26100" },
{ "probe": "ToolchainProbe", "health": "Fail", "remediation": "AutoFixable",
"detail": "dotnet 9.0.100 installed, baseline pins 10.0.100" },
{ "probe": "IdentityProbe", "health": "Blocked", "remediation": "ManualOwned",
"owner": "Identity team", "detail": "repository group 'product-core' pending" },
{ "probe": "ProjectReadinessProbe", "health": "Fail",
"detail": "build skipped: toolchain not ready" }
]
}A manager can ask for that file. A CI job can diff it between runs. Support can read it without a screen-share. And in the TPE or PME of Part I — where the founder is first-line support — welcome doctor is the whole support team: it tells the one person who can fix the machine exactly what to fix, without making them rediscover the toolchain. The proof is the artifact, not the exit code.
--fix needs strict guardrails:
- it must produce a plan before changing anything
- it should support
--dry-runand require explicit confirmation for local workstation changes - it must never install outside actions declared as fixable by the setup contract
- it must respect privilege boundaries instead of silently escalating
- it must redact secrets, tokens, usernames where policy requires it, and certificate material from evidence
- it should write before/after evidence so support can see what changed
- it should make rollback explicit where rollback is possible, and mark irreversible operations as such
That makes repair scriptable without making it reckless. The contract says what may be repaired; adapters say how to repair it; the Doctor proves the result.
welcome doctor is the smallest, most demonstrable piece of this whole series. It is also the answer to the series' one demand.
Test your onboarding.