Skip to content

Development

Rod Christiansen edited this page Sep 3, 2026 · 1 revision

Development

Repository layout

startset/
├── src/
│   ├── Core/            models, enums, path constants
│   ├── Infrastructure/  preferences, logging, tracking, validation, network
│   ├── Engine/          execution engine, processors, user-session launcher
│   ├── CLI/             System.CommandLine surface -> managedstatekeeper.exe
│   └── Service/         Generic Host workers -> StartSetService.exe
├── tests/StartSet.Tests/
├── build/
│   ├── msi/             WiX v6 Package.wxs and .wixproj
│   ├── nupkg/           nuspec template
│   └── pkg/             build-info.yaml, preinstall.ps1, postinstall.ps1 templates
├── scripts/             pre/postinstall for a repo-root package layout
├── .github/workflows/   ci.yml, release.yml
├── build.ps1
├── build-info.yaml
├── Directory.Build.props
└── StartSet.sln

The five projects are named StartSet.Core, StartSet.Infrastructure, StartSet.Engine, StartSet.CLI and StartSet.Service — note that the project directories are src/Core, src/Engine and so on, without the prefix.

Prerequisites

Tool Needed for
.NET 10 SDK Building and testing
Windows 10/11 The projects target net10.0-windows and use Win32 APIs
Windows 10/11 SDK (Signing Tools) signtool.exe, only for signed builds
cimipkg.exe Building .msi and .pkg artifacts via build.ps1
nuget.exe Building and signing .nupkg artifacts
IntuneWinAppUtil.exe Building .intunewin artifacts

Shared build properties live in Directory.Build.props: net10.0-windows, runtime identifiers win-x64;win-arm64, LangVersion preview, nullable and implicit usings enabled, and self-contained single-file ReadyToRun publishing with compression.

Building

Plain SDK build and test:

dotnet build StartSet.sln --configuration Release
dotnet test StartSet.sln --configuration Release

build.ps1 is the full pipeline. With no arguments it builds binaries for both architectures, signs them if a certificate is found, and produces MSI, NuGet and .pkg artifacts into release\.

.\build.ps1

Binaries only, unsigned — the fastest useful loop and what CI uses for releases:

.\build.ps1 -NoSign -Binaries

Development mode stops the StartSet service and any running processes first, builds Debug, and skips signing:

.\build.ps1 -Dev -Install

build.ps1 parameters

Parameter Effect
-Sign Sign binaries with a code-signing certificate
-NoSign Skip signing
-Thumbprint <hex> Use a specific certificate thumbprint
-Binaries Build binaries only, then exit
-Install Install the built MSI for the current architecture (requires elevation)
-IntuneWin Also produce .intunewin packages
-Dev Stop services, build Debug, skip signing
-SignMSI Sign existing MSIs in release\ and exit; cannot be combined with other build flags
-SkipMSI Skip MSI packaging
-PackageOnly Package existing binaries (MSI and NuGet), skip the build
-NupkgOnly NuGet packages only
-MsiOnly MSI packages only
-PkgOnly .pkg packages only; cannot be combined with -MsiOnly or -NupkgOnly
-Clean Remove release\ and every bin/obj under src\ first
-Test Run dotnet test after building
-Configuration <Debug|Release> Build configuration; default Release
-Architecture <x64|arm64|both> Target architecture; default both

Output lands in release\<arch>\ for binaries and release\ for packages.

Executable renaming

Both projects publish under their assembly names and are then renamed:

Published Shipped
StartSet.CLI.exe managedstatekeeper.exe
StartSet.Service.exe StartSetService.exe

Anything that references the CLI by path must use managedstatekeeper.exe.

Versioning

build.ps1 derives the version from the build time:

Form Pattern Example
Full — assembly version yyyy.MM.dd.HHmm 2026.03.01.1030
Semantic — NuGet YY.M.D.HHmm 26.3.1.1030
MSI-compatible YY.M.DDHH 26.3.0110

The CLI prints the assembly version as Major.MM.DD.HHMM.

Signing configuration

build.ps1 loads a .env from the repo root if present (it is gitignored). The certificate is selected from these environment variables, in order:

Variable Purpose
STARTSET_CERT_CN / CIMIAN_CERT_CN / SIGNING_CERT_CN Certificate common name, used for NuGet signing
STARTSET_CERT_SUBJECT / CIMIAN_CERT_SUBJECT Subject substring used to find the certificate in the store

The script searches Cert:\CurrentUser\My then Cert:\LocalMachine\My for a certificate with a private key whose subject matches, newest expiry first. signtool.exe is located on PATH, then under the Windows Kits bin tree, then via HKLM:\SOFTWARE\Microsoft\Windows Kits\Installed Roots — always preferring an x64 build. Signing retries up to four times across three timestamp authorities (DigiCert, Sectigo, Entrust).

If no certificate is found the build continues and warns; it does not fail.

Tests

xUnit, under tests/StartSet.Tests:

Area Covers
Core/ ExecutionResult, Paths, PayloadType extensions, ScriptPayload, preferences model
Engine/ Processor routing and execution, script output formatting, user-context deferral
Infrastructure/ Checksum service, items report, permission validator, preferences service, run-once tracker, session log retention
Integration/ Engine integration, run-once lifecycle
dotnet test StartSet.sln --configuration Release

ExecutionEngine takes an optional payloadRoot constructor argument that redirects discovery to a directory the test controls. This is not a convenience — without it, any test exercising discovery reads the live payload directories of whatever machine it runs on, and on a machine with StartSet installed would execute real login payloads for effect before failing its assertion. Always pass a temporary root in tests that touch discovery; tests/StartSet.Tests/Helpers/TempDirectory.cs exists for this.

Continuous integration

.github/workflows/ci.yml runs on pull requests to main and pushes to main, on windows-latest, with a 45-minute timeout and in-progress cancellation per ref:

  1. Checkout, set up .NET 10, restore NuGet from cache
  2. dotnet restore StartSet.sln
  3. dotnet build StartSet.sln --configuration Release --no-restore
  4. dotnet test StartSet.sln --configuration Release --no-restore with a TRX logger
  5. Upload TestResults/*.trx as an artifact, always

Releases

.github/workflows/release.yml triggers on a pushed tag matching v*. There is no manual dispatch and no branch trigger — a merge to main ships nothing.

git tag v2026.03.01.1030
git push origin v2026.03.01.1030

The workflow, on windows-latest with contents: write:

  1. Checks out and sets up .NET 10
  2. Derives the version by stripping the leading v from the tag name
  3. Runs .\build.ps1 -NoSign -Binariesbinaries only, unsigned
  4. Copies any .msi, .nupkg, .pkg, .intunewin or .zip from release\ into release-out\
  5. Zips release\x64\*.exe and release\arm64\*.exe into startset-x64.zip and startset-arm64.zip, failing the run if StartSetService.exe is missing from either
  6. Generates release notes via the GitHub API, strips the contributor attributions and the "New Contributors" section, and wraps them with a build-info preamble and signing instructions
  7. Creates the GitHub release with gh release create and uploads everything in release-out\

Because step 3 passes -Binaries, no MSI or NuGet package is produced in CI. The published release contains only the two unsigned zips. Signed MSIs are a downstream concern: consumers sign the binaries with their own certificate and repackage, per the instructions the workflow embeds in the release notes.

The release body always states that artifacts are unsigned and links back to the workflow run and the commit that produced them.

Conventions

  • Comments explain why, not what. The codebase's comments are unusually long and are load bearing: they record the failure that motivated the current behaviour (why there is no SYSTEM fallback for user-context payloads, why logon type 11 is watched, why DWM-n is a regex and not a list, why the retention sweep is not recursive). Preserve that reasoning when changing the code around it.
  • A silent success is a bug. Deferred exists because a login payload that did nothing looked identical to one that worked. When adding a new failure mode, give it a status that says what is true rather than folding it into Success or Failed.
  • Payload-type behaviour belongs in the enum extensions. IsRunOnce, RequiresElevation, IsUserContext and DeleteAfterExecution on PayloadTypeExtensions are the single source of truth. UserContextExecution was extracted from the PowerShell processor precisely because the user-session decision is a property of the payload type, not the file extension — .bat, .cmd and .exe payloads had been running as SYSTEM in session 0 while .ps1 did the right thing.
  • Pure functions where a test would otherwise need a machine. FormatOutputLines, BuildItemRecord, CountRecentFailures and SweepExpiredFiles are deliberately static and side-effect-light so they can be tested without a session on disk.
  • No Co-Authored-By trailers, and no machine, user or organisation identifiers in the repository — it is public.

Clone this wiki locally