Dytools.DeployTool 0.13.0

dotnet tool install --global Dytools.DeployTool --version 0.13.0
                    
This package contains a .NET tool you can call from the shell/command line.
dotnet new tool-manifest
                    
if you are setting up this repo
dotnet tool install --local Dytools.DeployTool --version 0.13.0
                    
This package contains a .NET tool you can call from the shell/command line.
#tool dotnet:?package=Dytools.DeployTool&version=0.13.0
                    
nuke :add-package Dytools.DeployTool --version 0.13.0
                    

DeployTool

CI NuGet License: Apache 2.0

A tool that deploys your .NET applications to your own servers, driven entirely by one deploy-config.json - no per-project deploy scripts.

It runs as a step in your CI pipeline (typically a self-hosted GitHub Actions runner): given the files a push changed, it works out which apps are affected, tests them, builds them, and rolls them out to IIS sites, Windows services/folders, or Velopack feeds - across one server or a fleet - with blue-green cutover, staged soak delays, rollback, and a written run report.

What it solves

Deploying several .NET apps from one repo to on-prem Windows servers is usually a pile of bespoke PowerShell - one script per app, each reimplementing the same chores and drifting over time:

  • Figuring out what to deploy. Which apps did this commit actually affect, including changes to shared libraries they depend on?
  • The mechanics. Stop the app pool or Windows service, copy the files, start it back up - and don't leave it down any longer than necessary.
  • Doing it safely. Gate on unit tests, warm the app before real traffic hits it, and roll back when a deploy fails.
  • Doing it across servers. Push the same build to several boxes without taking them all down at once.

DeployTool folds all of that into one config-driven tool. Change detection, dependency-aware selection, test gating, publish, IIS/folder/Velopack delivery, blue-green cutover, multi-server rollout, rollback, and reporting are built in. Your CI workflow shrinks to "restore the tool, run the tool," and adding an app or a server becomes a config edit - not another script to maintain.

Distributed as a .NET tool on NuGet (Dytools.DeployTool); the command is dytools-deploy.

How it works

  1. A push (or a manual run) hands the tool the list of changed files.
  2. It maps those to affected projects via the .csproj dependency graph, plus any extra triggers you declare.
  3. It runs each project's unit tests - a failure aborts that project.
  4. It publishes and delivers each target: IIS (optionally blue-green), a folder (stopping and starting a Windows service around the copy), or a Velopack package.
  5. On a fleet, the box running the job deploys first, then hands every peer a complete run folder - manifest, artifacts, and the tool itself - staged and moved into place atomically. Peers receive nothing unless the primary succeeded. Each peer's own agent picks the folder up, waits out the soak window, and applies it. See Setting up a peer.
  6. It writes a run report.

Commit-message directives (pub:, srv:, wait:) and command-line overrides (--pub, --srv, --wait) steer any individual run.

Getting started (GitHub Actions)

DeployTool is designed to run from a self-hosted GitHub Actions runner - IIS and folder targets need network access to your on-prem servers. A ready-to-use workflow ships at examples/deploy.yml. In your application repo:

1. Add the tool as a pinned local tool:

dotnet new tool-manifest          # creates .config/dotnet-tools.json
dotnet tool install Dytools.DeployTool

Commit .config/dotnet-tools.json; dotnet tool restore brings it back on the runner.

2. Add deploy-config.json at the repo root. Start from examples/deploy-config.example.json (or scaffold one with dytools-deploy init) and keep the $schema line for editor autocomplete and validation - full field docs in the Configuration reference.

3. Copy the workflow to .github/workflows/deploy.yml (from examples/deploy.yml). It triggers on push to main and on manual dispatch (with a "deploy all" switch), computes the changed files, then runs dotnet tool restore + dytools-deploy.

4. Add any secrets your config references. If deploy-config.json uses %AZ_KEY%, add an AZ_KEY repository secret and pass it as env on the Deploy step:

      - name: Deploy
        env:
          AZ_ACCOUNT: ${{ secrets.AZ_ACCOUNT }}
          AZ_KEY: ${{ secrets.AZ_KEY }}
        run: |
          dotnet dytools-deploy `
            --config deploy-config.json `
            --changed "${{ steps.changed_files.outputs.files }}" `
            --force-all "${{ inputs.force_all }}"

The workflow stays thin - .NET setup, git, a HEAD~1 changed-file diff, then restore and run. Everything else (resolution, build, test, publish, rollout, rollback, reporting) happens inside the tool.

Runner: a self-hosted runner is required for IIS/folder targets. A cloud runner works only if every target is cloud-reachable (e.g. Velopack to Azure/S3).

Running it by hand

You can also run it locally - handy for a one-off deploy or to test a config. Install it globally:

dotnet tool install -g Dytools.DeployTool

Then select projects by name with --pub, independent of git - this is what makes a manual publish clean, with no commit dance:

# publish specific projects:
dytools-deploy --config deploy-config.json --changed "" --pub "WebApp|Proc*"
# or everything, applying to peers immediately:
dytools-deploy --config deploy-config.json --changed "" --pub "*" --wait 0

Command-line reference

dytools-deploy --config deploy-config.json --changed "src/App/Foo.cs|src/Lib/Bar.cs"
Argument Description
--config <path> Path to deploy-config.json. Its directory is treated as the repo root. Required.
--changed "<a\|b\|...>" Pipe-delimited list of changed file paths. Empty deploys nothing (unless a pub:/--pub or --force-all selects projects).
--force-all <true\|false> Deploy every enabled project regardless of changed files.
--pub "<patterns>" Override the commit's pub: - pipe-separated name globs ("Web\|Proc*", "*", "none"). Selects projects by name regardless of --changed.
--srv "<patterns>" Override the commit's srv: - which servers take part. Same glob syntax, matched against servers[] name or hostname.
--wait <seconds> Override the commit's wait: rollout soak delay. 0 = peers apply immediately.
--skip-tests [true\|false] Bypass the unit-test gate. Usable bare (--skip-tests). Overrides the commit's skiptests; pass false to force the gate on for a commit that asked to skip it.
--server <name> Pin which servers[] entry this box is, instead of matching on machine name. See Which server am I?.

The tool exits 0 on success, 1 on failure. Which projects deploy is decided from the changed-file list against each project's folder, its .csproj ProjectReferences (resolved automatically), and any extra dependentProjects triggers. Directives pub:<pattern>, srv:<pattern>, wait:<seconds> and skiptests are read from the HEAD commit; --pub / --srv / --wait / --skip-tests override them field by field (see Running it by hand).

Other commands

Command Description
dytools-deploy init [path] Scaffold a deploy-config.json interactively.
dytools-deploy edit <path> Edit an existing one.
dytools-deploy hostname [--config <path>] Print every name this box is known by, and which servers[] entry it matches.
dytools-deploy test-peers [--config <path>] Check every peer is reachable and writable - resolve, tcp/445, authenticate, write. Exits 1 if any is not.
dytools-deploy install-agent Stand this box up as a peer: folder layout, poll script, scheduled task.
dytools-deploy uninstall-agent Remove the schedule. Folders and deploy history are left alone.
dytools-deploy apply [incoming] Apply whatever is due in an incoming folder. Run by the agent; usually not by hand.
dytools-deploy help Usage summary.

Minimal config

{
  "projectsFolder": "src",
  "unitTestsFolder": "tests",
  "unitTestProjectSuffix": "UnitTest",
  "projects": [
    {
      "name": "WebApp",
      "targets": [
        {
          "type": "Iis",
          "build": { "configuration": "Release", "runtime": "win-x64" },
          "iis": {
            "siteName": "MyWeb",
            "deployPath": "C:\\inetpub\\MyWeb",
            "appPool": "MyWebPool"
          }
        }
      ]
    }
  ]
}

Add a servers array (matched by hostname) and a rollout block to turn on multi-server rollout. The full field-by-field reference is below; see docs/ARCHITECTURE.md for the rollout model and design rationale.

Configuration reference

deploy-config.json is deserialized into DeployConfig. All paths are repo-root-relative (the repo root is the directory containing the config file) unless noted. String values support %ENV_VAR% environment-variable expansion - the intended way to keep secrets (e.g. storage keys) out of the file; set them as env vars on the run.

Editor support (JSON Schema)

The repo ships a generated deploy-config.schema.json. Add a $schema key to the top of your config and Rider / VS Code will give you autocomplete, enum dropdowns, required-field and type validation, and hover documentation for every field:

{
  "$schema": "https://raw.githubusercontent.com/dyzenment/DeployTool/main/deploy-config.schema.json",
  "projectsFolder": "src",
  "projects": []
}

A complete, schema-bound sample lives at examples/deploy-config.example.json.

The schema is generated from the C# models (so it can't drift) by tools/Dytools.SchemaGen - regenerate it after changing the config models with:

dotnet run --project tools/Dytools.SchemaGen

Scaffolding and editing a config

dytools-deploy init builds a deploy-config.json and drops you into a small line-by-line menu editor (navigate with the shown letter/number keys; R# removes an item; S saves). It first offers to scan your solution and pre-fill projects, their kinds, deployments, and test wiring - so a repo with dozens of projects opens mostly filled in, leaving mainly the deploy destinations.

dytools-deploy init                            # ask to scan, then open the editor
dytools-deploy init --auto                     # scan the solution in this folder (--scan is an alias)
dytools-deploy init --solution My.sln          # scan a specific solution (implies scan)
dytools-deploy init --all-csproj               # discover every .csproj instead of reading a solution
dytools-deploy init --save [file]              # write the scanned/blank config as-is, skip the editor
dytools-deploy edit deploy-config.json         # open an existing config in the editor

Auto-scan classifies each project from its .csproj (web → IIS; a project referencing Velopack → Velopack; other console/desktop → folder; libraries and test projects are excluded) and infers projectsFolder, unitTestsFolder, and the test-project suffix. Deploy destinations (paths, app pools, servers) aren't in the source, so they come in as placeholders to fill. With no solution present, --auto exits. Everything is built from the real DeployConfig models, so the output is always valid, and each write prints the file's full path.

Root object

Field Type Default Description
projectsFolder string required Folder holding each deployable project. A project named X must live at {projectsFolder}/X/X.csproj.
unitTestsFolder string null Folder holding unit-test projects, for convention-based test discovery.
unitTestProjectSuffix string null Suffix appended to a project name to find its test project. "UnitTest" → project Admin{unitTestsFolder}/AdminUnitTest/AdminUnitTest.csproj.
noWarn string null Repo-wide warning codes suppressed on every build/test, comma-separated ("CS8600,CS8618"). Concatenated with per-project and per-build noWarn.
packCache object null Velopack release-history cache (required for delta packages). See Pack cache.
doNotPublishIfNoPubInCommitMessage bool false When true, a commit with no pub: directive deploys nothing - publishing becomes opt-in per commit. --force-all and --pub still override.
servers array [] The server fleet. Empty = single-box (no propagation). See Server.
rollout object null Peer soak-delay and retention. See Rollout.
projects array [] The deployable projects. See Project.

Server

Multi-server rollout. There is no "primary" flag - whichever box the job runs on is the primary for that run (matched by hostname), and every other listed server is a peer.

Field Type Default Description
name string "" Short label used in logs, manifests, and reports.
hostname string "" Matched against the running box's name to decide which entry it is. See Which server am I?.
incomingShare string null UNC path where the primary drops run folders for this peer, e.g. \\WEB02\deploy\incoming. Unused on whichever box is currently the primary (it applies inline).
username string null Account the primary authenticates to this peer's share with. Leave unset on a domain, or with mirrored local accounts. See Reaching a peer's share.
password string null Password for username. Write it as "%DEPLOY_SHARE_PASSWORD%" - never a literal.

incomingShare must be a subfolder of a share, not the share root: the primary copies into a staging folder beside it (\\WEB02\deploy\staging\<runId>) and then moves the finished folder into incoming. Within one share that move is a rename, and therefore atomic - which is what stops a peer ever seeing a half-copied run. Point incomingShare at a share root and there is nowhere to stage beside it, so the run fails saying so rather than quietly degrading to a non-atomic copy.

The account the deploy runs as needs write access to that share, and the primary needs to reach port 445 on the peer.

A delivered run folder is entirely self-describing - deploy-config.json never travels:

\\WEB02\deploy\incoming\20260819-154000-f419e13\
  manifest.json          <- this peer's steps, plus notBeforeUtc
  DeployTool.exe         <- the binary that planned the run, so peer and primary cannot skew
  artifacts\
    Web-release-win-x64\...   <- only what this peer's own steps reference

Delivery happens only after the primary's own apply succeeded, so a broken build never reaches the fleet. A peer that cannot be written to fails that peer alone and fails the run; the others are still delivered.

Rollout

Field Type Default Description
delaySeconds int 3600 Soak time between the primary going live and peers applying. Clock starts at primary success. Overridden per run by wait: / --wait.
keepRuns int 5 Run folders retained per peer before the oldest are pruned.
precheckPeers bool true Verify every peer is reachable and writable before building, and abort if one is not. Off = the run still fails, just after a full build. --no-precheck overrides per run.
applyViaAgent bool? null Who applies this box's server-scoped steps. true: always the agent installed here, fire and forget like a peer. false: always this process, inline. null: inline first, then hand any target that failed with access denied to the agent. See When the runner lacks rights.

When the runner lacks rights

Stopping an app pool, stopping a service, or writing under C:\inetpub needs a local Administrator, and a self-hosted Actions runner installs as NETWORK SERVICE, which is not one. The symptom is appcmd failing with "Cannot read configuration file due to insufficient permissions" on redirection.config, or sc with "Access is denied". Two ways out:

  • Run the runner service as an Administrator or as SYSTEM. Nothing else changes.
  • Install the agent on the primary too and give this host's own servers[] entry an incomingShare (install-agent prints it). The runner then needs build tools and write access to that folder, and nothing else.

With an incomingShare on its own entry the primary can treat itself as one more peer: same precheck, same run folder - manifest, artifacts, the tool itself - and, like a peer, fire and forget: the run folder lands, the agent applies it on its next poll, and the agent's own agent.log and result.json are the record of what happened. The only difference from a peer is that the steps are due immediately. rollout.applyViaAgent decides when that happens:

Inline attempt Handed to the agent
true Global-scope steps only (a Velopack upload needs no rights here) every server-scoped step, once the build and tests pass
null (default) everything only targets that failed inline with an access-denied signature - IIS and folder alike
false everything nothing; a permissions failure fails the run

In this run's result.json a handed-off target is appliedBy: "agent" and succeeds when the handoff landed - the summary shows it [via agent]. Failures that are not permissions failures are never handed off; retrying them as SYSTEM would only repeat them.

An IIS target asks the permissions question before it acts on it. One read-only appcmd list apppool runs ahead of the load-balancer precheck, and a denial there abandons the target immediately - Precheck IIS access in the report - so a box that was never going to be deployable is not drained out of rotation and put back having had nothing done to it. The probe only ever stops on a positive denial: an unknown pool, a missing appcmd, or any other failure falls through to the real steps. A pool that reads fine and still refuses to stop takes the original path, drain and restore included.

Note what fire-and-forget means for the fleet: peers' soak clock starts when the primary's run finishes, which for a handed-off target is when the handoff landed, not when the agent finished applying. If you need this box proven live before peers go, use false and run the runner as an Administrator.

Anything that can write to the incoming folder gets its steps run as SYSTEM - that is the trust model for peers already, and a share on the primary's own entry adds the runner's account to it. Keep that folder's ACL tight.

Deploying to some servers only

By default every box in servers[] takes part. srv: narrows that for one run, using the same glob syntax as pub::

fix session timeout pub:Web srv:web02

Matched against each entry's name or hostname, case-insensitively, so either is fine to type. --srv "web02" overrides it on the command line, and omitting it entirely means the same thing as srv:*.

Directive Effect
(none) Every server. The default, and what every existing config keeps doing.
srv:web02 Only web02. Other peers get nothing.
srv:web* Every server whose name or hostname starts with web.
srv:web02\|web03 Those two.

Excluding the primary is allowed and useful. srv:web02 run from web01 still builds on web01 - nobody else can - but applies nothing there, and ships to web02 only:

  S1  [self, primary, excluded by srv:]  0 step(s)
  S2  [peer]  1 step(s)
  S3  [peer, excluded by srv:]  0 step(s)
  – Built but not applied here (srv: excludes this server).

That is how you put a build on one box to watch it before the rest of the fleet gets it, and srv:web01 is the reverse - live here, leave everyone else alone.

One exception, deliberately: an excluded primary still runs Global-scope steps. A Velopack upload is not something done to a server, so narrowing a rollout to one box does not also cancel the package publish.

Excluded servers stay visible in the plan marked excluded by srv: rather than disappearing, and they are skipped by the peer precheck as well - a box that is not part of the run is not a box that has to be reachable.

Which server am I?

Every box works out which servers[] entry it is on its own - there is no primary flag and no per-machine config file. The name it identifies as comes from the first of these that is set:

Source When to use it
--server <name> One run, by hand. Also handy for testing a config against another box's identity.
DEPLOYTOOL_SERVER env var Set once on a box the fleet calls something other than what Windows does - a renamed host, a container, an image cloned from a template.
Machine name The default, and the zero-configuration case.

That name is then matched against servers[] in three passes, strictest first: exact hostname, exact name, then short name (so WEB01 and web01.corp.local are the same box, either way round). All comparisons are case-insensitive. If two entries claim the same box the run stops rather than guessing.

A box that matches nothing still deploys itself perfectly well - it just propagates to nobody, and a green run says nothing about it. That silence is the reason for:

dytools-deploy hostname --config deploy-config.json

It prints every name the box is known by, which entry it matched and why, and - when nothing matched - the entry to paste in. It exits 1 on no match, so a fleet check can be scripted.

Setting up a peer

Two steps per box. On the peer, in an elevated shell:

dytools-deploy install-agent

That creates C:\deploy\{agent,staging,incoming}, writes the poll script, and registers a DeployAgent scheduled task that runs it every minute as SYSTEM. It is idempotent - run it again to upgrade a box, and existing run folders are left alone. It finishes by printing the servers[] entry to paste into your deploy-config.json, which is step two.

Switch Default Description
--root <path> C:\deploy (Windows), /var/lib/deploytool Agent root. incoming and staging must stay siblings - the handoff relies on it.
--interval <minutes> 1 How often the task polls.
--task-name <name> DeployAgent Scheduled task name.
--user <account> SYSTEM Who the scheduled task runs as: SYSTEM, LOCALSERVICE or NETWORKSERVICE. Anything needing a password is printed as a command for you to run.
--account <name> deploysvc Local account the primary connects as. Install offers to create it and grant it the share.
--no-prompt off Skip that offer and print the commands instead. Implied when stdin is not a console.

Then share the root so the primary can reach \\PEER\deploy\incoming, and give the primary's account write access to it - both the share permission and the NTFS permission. This is the usual reason a first rollout fails.

What actually happens on a peer. The primary copies the run into staging\<runId> and then moves it into incoming\<runId>; a move within one share is atomic, so the poller can never see a half-copied run. Every minute the task runs poll.cmd, which finds the newest run folder and launches the tool that arrived inside it - the peer has no installed copy to keep in step with the primary. That tool scans incoming, skips runs whose soak window has not passed, applies the rest oldest-first, and writes a result.json into each.

That result.json is the completion marker as well as the audit record: manifest present and no result.json means pending. It is written on failure too, so a broken run is recorded once rather than retried every minute forever. Watch it all in C:\deploy\agent\agent.log, where every line carries an HH:mm:ss.fff stamp and any colour codes a build tool emitted have been stripped out. An interactive run is unaffected - the log formatting only applies when output is redirected.

The peer needs the .NET runtime installed - it does not need the SDK, a checkout, or a deploy-config.json. Everything it is meant to do arrives in the run folder's manifest.json.

On Linux or macOS install-agent creates the same layout and a poll.sh, then prints the crontab line for you to add - it will not edit a server's crontab behind your back.

Reaching a peer's share

install-agent prints all of this for the box you run it on, with the real names filled in. Summarised here for reference.

Share the root, not incoming. The primary copies into staging\<runId> and then moves it into incoming\<runId> - the move is what makes the handoff atomic - and prunes old runs afterwards. It needs write and delete on both folders:

net share deploy=C:\deploy /grant:deploysvc,CHANGE
icacls C:\deploy /grant "deploysvc:(OI)(CI)M"

Both commands matter: the SMB share permission and the NTFS permission are separate gates and the stricter one wins.

Then give the primary an identity the peer accepts. On a domain, grant the runner's account and you are done. In a workgroup there is no shared identity, and a self-hosted Actions runner installs as a service running as NETWORK SERVICE - which authenticates over the network as the machine account (WEB01$), a name a peer with no domain controller cannot resolve. Granting it on the share will not help. Two ways out:

Option A - credentials in config Option B - mirrored local account
Peer create deploysvc, grant it the share create deploysvc, grant it the share
Primary nothing create the same username and password, repoint the runner service at it
Config username + password nothing
Cost a secret to manage reconfiguring the runner, and two passwords to keep in step forever

install-agent offers to do the peer half for you. Run elevated, answer y, and type a password at the prompt: it creates the account, shares the folder, and grants both permissions. The password is read without echo and passed to the Win32 NetUserAdd API rather than to net user <name> <password> /add - a password on a command line is readable by any user on the box via Win32_Process for as long as the process lives. It skips the offer and prints the commands instead when stdin is not a console (CI) or you pass --no-prompt; --account <name> changes the name from deploysvc.

The commands below are what it runs, for when you would rather do it yourself.

Option A - credentials in config

On the peer only, elevated:

New-LocalUser -Name deploysvc -PasswordNeverExpires -Description "DeployTool share access" `
  -Password (Read-Host -AsSecureString "Password for deploysvc")
net share deploy=C:\deploy /grant:deploysvc,CHANGE
icacls C:\deploy /grant "deploysvc:(OI)(CI)M"

Read-Host -AsSecureString keeps the password out of your shell history. The quotes on icacls are required in PowerShell - unquoted, it evaluates (OI) as a command.

The account needs nothing beyond the share - it never logs on interactively, so it does not need to be an administrator and should not be one. Then in deploy-config.json:

{
  "name": "web02",
  "hostname": "EC2AMAZ-FFQRJ6U",
  "incomingShare": "\\\\10.0.1.20\\deploy\\incoming",
  "username": "EC2AMAZ-FFQRJ6U\\deploysvc",
  "password": "%DEPLOY_SHARE_PASSWORD%"
}

Nothing changes on the primary.

Option B - mirrored local account

Create the same username with the same password on both boxes:

New-LocalUser -Name deploysvc -PasswordNeverExpires `
  -Password (Read-Host -AsSecureString "Password for deploysvc")

Grant it the share on the peer (the net share / icacls pair above), then on the primary point the runner service at it: Services → actions.runner.* → Log On → .\deploysvc, and restart. The Log On tab grants "Log on as a service" for you. Nothing goes in the config - workgroup pass-through does the rest: the peer validates the incoming credentials against its own SAM and lets it in.

Where the password actually lives

Three places, and only the first holds the real password. The other two are references, and both are safe to commit.

# Where What goes there
1 GitHub, in your application repo: Settings → Secrets and variables → Actions → New repository secret Name DEPLOY_SHARE_PASSWORD, value = the actual password
2 .github/workflows/deploy.yml DEPLOY_SHARE_PASSWORD: ${{ secrets.DEPLOY_SHARE_PASSWORD }}
3 deploy-config.json "password": "%DEPLOY_SHARE_PASSWORD%"

1 - store the secret. In the repo that holds your deploy-config.json:

Settings → Secrets and variables → Actions — or straight to https://github.com/<owner>/<repo>/settings/secrets/actions.

That page stacks three sections. Scroll past Environment secrets to Repository secrets and click the green New repository secret. Name it DEPLOY_SHARE_PASSWORD, paste the deploysvc password, Add secret. It is encrypted and write-only - you cannot read it back afterwards, only replace it.

Do not use an environment secret unless you know you want one. The Environment secrets section prompts you to create an environment, and a secret stored there is only visible to a job that declares environment: <name>. The Deploy job does not, so ${{ secrets.… }} would silently expand to an empty string and the peer would reject the logon - an error pointing at the wrong problem entirely. Environments are for approval gates and per-stage config; this needs neither.

Secret names cannot begin with GITHUB_. An organisation secret works fine if you would rather scope it there.

2 - hand it to the step. In the Deploy step of your workflow:

      - name: Deploy
        env:
          DEPLOY_SHARE_PASSWORD: ${{ secrets.DEPLOY_SHARE_PASSWORD }}
        run: |
          dotnet dytools-deploy --config deploy-config.json --changed "$CHANGED_FILES"

${{ secrets.… }} is a GitHub expression, not the value - GitHub substitutes the real password when the job starts. examples/deploy.yml ships this line commented out.

3 - reference it from the config. "password": "%DEPLOY_SHARE_PASSWORD%", as above.

How it gets from there into the connection

Ordinary environment inheritance - no secret store, nothing written to disk:

GitHub secret  →  ${{ secrets.X }} in the workflow  →  env var on the step
               →  dytools-deploy inherits it as a child process
               →  %DEPLOY_SHARE_PASSWORD% expanded when the share is opened

Expansion happens at connect time, not at config load, so the plaintext exists only in the runner's process memory and only for the duration of the handoff. It is the same %VAR% mechanism every other secret in the config uses (AZ_KEY and friends) and it behaves identically on Windows and Linux runners.

The three names do not have to match, but keeping them identical saves you tracing it later.

Two consequences worth knowing. Actions masks the secret in job logs, so even an accidental echo prints ***. And because child processes inherit the environment, the variable is also visible to the build and IIS commands the tool spawns - unavoidable with env-var passing, and the reason this account should be scoped to the share and nothing else.

What the tool does with it

It opens a deviceless session to the share for the length of the handoff and drops it after - no drive letters, nothing left mapped, and concurrent deliveries to different peers cannot collide over one. A bare username is qualified with the peer's name automatically, except when the share is addressed by IP, where a bare name is what Windows accepts.

The password is never logged, never written to result.json, and never reaches a manifest - result.json records only which username connected. A literal password in the config warns on every run, and an environment reference that resolves to nothing is a hard error naming the variable, rather than a mystifying logon failure from the peer.

Credentials are a Windows facility. A Linux primary ignores them with a warning - mount the share with its credentials before running the deploy.

Use the IP, not the computer name

Put the peer's private IP in incomingShare. On a cloud fleet a Windows computer name will essentially never resolve between instances: name resolution for it is NetBIOS broadcast, and an AWS VPC (or an Azure VNet) carries no broadcast traffic. There is no DNS record for it either - VPC DNS knows ip-10-0-1-20.ec2.internal, not EC2AMAZ-BKBMCL6.

"incomingShare": "\\\\10.0.1.20\\deploy\\incoming"

hostname still stays the computer name. It is how that box recognises itself, resolved locally, and it never goes over the wire - the two fields are independent.

This one is worth knowing because of how it fails: SMB reports an unresolvable host with the same error it uses for a missing share (67, "bad net name"), so the message reads "the share does not exist" about a share that is sitting right there. test-peers separates the two.

Checking it works
dytools-deploy test-peers --config deploy-config.json

Four checks per peer, in the order the failures actually happen - resolve the host, reach tcp/445, authenticate, write a probe into staging - because each one makes the next meaningless. It runs in about a second and exits 1 if any peer is not ready, so it can gate a script. The same checks run automatically before every deploy (see rollout.precheckPeers), which is what stops an unreachable peer costing you a full build to discover.

To test the network path on its own, from the primary:

Test-NetConnection 10.0.1.20 -Port 445

TcpTestSucceeded : False means the port is shut. On EC2 or Azure that is a security-group / NSG rule — the peer's group needs inbound TCP 445 from the primary — not the Windows firewall, which already allows File and Printer Sharing on the private profile.

And to test the credential end to end, as the runner's account rather than your own login - that is the mistake that makes this look fixed when it is not:

psexec -u .\deploysvc -p "<password>" cmd /c "dir \\10.0.1.20\deploy\incoming"

Pack cache

Field Type Default Description
path string "" Cache directory. Tokens: {CommonAppData} (always-writable local app data, no elevation), {packId}, and %ENV_VAR%.
keepReleases int null Max historical releases retained per packId.

Project

Field Type Default Description
name string required Must match the subfolder and .csproj filename under projectsFolder.
disabled bool false Excluded from all runs without removing it; skips the existence check.
runTests bool true When false, unit tests are skipped for this project.
abortOnUnitTestFailure bool true When false, test failures are logged but deployment continues.
unitTestProject string convention Override: a test project name ("AdminUnitTest") or a repo-root-relative .csproj path.
dependentProjects string | string[] [] Extra change triggers beyond this project's own folder. "Name" → triggers on changes in that project's folder; "some/path" → substring match against changed paths; "*" → any solution-level change (outside all project folders). .csproj ProjectReferences are resolved automatically - no need to list them.
preBuild string[] auto Shell commands run in the project folder before targets build. If omitted and package.json exists, npm install runs automatically.
noWarn string null Per-project warning suppressions; concatenated with the root noWarn.
targets array [] Deploy targets for this project. See Target.

Target

Field Type Default Description
type enum required Iis, Folder, or Velopack. Selects which type-specific block below is required.
prerequisites string[] [] Software auto-installed if missing, only for selected targets. Supported: nodejs, vpk.
build object null Publish/build settings. See Build.
rollback bool false Snapshot the destination before deploying and restore it on failure.
iis object - Required when type: Iis. See IIS target.
folder object - Required when type: Folder. See Folder target.
velopack object - Required when type: Velopack. See Velopack target.

Build

Field Type Default Description
configuration string "Release" dotnet build configuration.
runtime string null Runtime identifier (RID), e.g. win-x64.
selfContained bool null Self-contained publish (bundle the runtime).
targetFramework string inferred Target framework moniker to build. Required for multi-target projects. A .NET Framework TFM (e.g. net481) switches the build to msbuild instead of dotnet publish.
singleFile bool null Publish as a single file.
noWarn string null Comma-separated warning codes, passed as /nowarn:.

A project is published once per distinct build block, not once per target. Two targets whose build settings match (an omitted block counts as the defaults) share a single dotnet publish and a single artifact folder, named artifacts/<Project>-<configuration>-<runtime>-<tfm>; each target then applies from that folder. Change any build field on one target and it gets its own publish. The plan printed at the start of a run lists each publish and how many targets ride on it.

IIS target

Classic mode: stop app pool → mirror artifact into deployPath → start pool. Set secondaryDeployPath to enable blue-green instead (no pool stop during copy). Set loadBalancer to drain the instance out of rotation around either of them.

Field Type Default Description
deployPath string "" Deploy destination (slot A). In classic mode this is simply the target folder.
appPool string "" App pool name.
siteName string null IIS site name. Required only for blue-green (the site is what gets flipped).
secondaryDeployPath string null Slot B. Setting it turns on blue-green: the artifact is mirrored into whichever slot is not live, then the site's physical path is flipped to it.
warmupUrl string null URL hit immediately after the flip so cold start lands here, not on a real user. A non-success response fails the deploy and flips back to the previous slot.
stopSite bool false Stop and start the site as well as the pool. A stopped pool still accepts connections and answers 503; a stopped site closes its bindings entirely. Classic mode only.
loadBalancer object null Drain this instance out of rotation before the deploy and put it back after warmup. See below.
Behind a load balancer

Omit loadBalancer and nothing changes. With it, a classic deploy runs:

precheck  ── not in rotation ─→ plain routine, no drain, no restore
    │ in rotation
    ↓
drain notify → drain verify → drain wait
  → stop site → stop pool → snapshot → mirror → start pool → start site → warmup
→ restore notify → restore verify → restore wait
"loadBalancer": {
  "precheck": { "url": "http://localhost/health", "liveStatus": 200 },
  "drain": {
    "notify":       { "type": "http", "url": "http://localhost/admin/health?node={hostname}&site={site}&up=false" },
    "verifyUrl":    "http://localhost/health",
    "expectStatus": 500,
    "waitSeconds":  20
  },
  "restore": {
    "notify":       { "type": "http", "url": "http://localhost/admin/health?node={hostname}&site={site}&up=true" },
    "verifyUrl":    "http://localhost/health",
    "expectStatus": 200,
    "waitSeconds":  30
  }
}

Every part is optional. A phase with only waitSeconds is a plain pause; verification is skipped unless both verifyUrl and expectStatus are set, in which case the phase assumes the notify worked. notify.type is http (default), file (create/delete a marker the probe looks for), or command. URLs, paths, bodies and arguments support %ENV_VAR% expansion and the tokens {hostname}, {site}, {project}, all resolved on the box that runs the step - never at plan time, since peers receive the same config.

precheck handles the offline box. Deploying to a server that's already out of rotation — stopped site, maintenance, a flag flipped by hand — shouldn't try to drain what's already drained. More than that: on an offline box the health endpoint is unreachable, so the drain notification would fail, and a failed drain aborts the deploy. Without the precheck an offline server couldn't be updated at all.

The test is positive: the routine runs only when the endpoint answers with liveStatus (default 200). Anything else — 500, 503, or a connection refused — means "not in rotation" and both phases are skipped for that run. It polls for timeoutSeconds (default 10) rather than asking once, so a momentary blip on a genuinely live box isn't misread as offline, which would stop the site on live traffic. Omit precheck and the phases always run.

Three more things worth knowing:

  • drain.waitSeconds has a floor. A probe-based balancer only removes the node after unhealthyThreshold × probeInterval, and in-flight requests still need to finish. Set it below that sum and the site stops on live traffic. Verifying localhost proves the flag flipped; it cannot prove the balancer noticed, which is what the wait covers.
  • Point verifyUrl at localhost, not the VIP. A VIP response proves some node is healthy, not that this one is in or out of rotation.
  • A failed drain aborts before anything stops. The site is still serving at that point, so aborting costs a deploy while continuing would cost requests.

After a failed deploy the instance is restored to rotation only if the previous build is still what's live - either the deploy rolled back, or it failed before touching live files. Otherwise it is left drained and the run fails, because mirror deletes files the artifact doesn't contain, so an interrupted mirror leaves neither the old build nor the new one. Note rollback defaults to false; with it off, a mid-mirror failure leaves the instance out of rotation for a human.

Folder target

Field Type Default Description
destinationPath string "" Where the published output is copied.
serviceName string null Windows service to stop before the copy and start after.

Velopack target

Field Type Default Description
packId string "" Velopack package id.
packTitle string null Display title.
icon string csproj icon Icon path override (repo-relative, project-relative, or absolute).
channel string null Release channel.
runtime string null Velopack RID. A cross-compile directive ([win]/[osx]/[linux]) is injected automatically when the target OS differs from the runner OS.
framework string null Velopack framework dependency string.
mainExe string null Main executable name.
delivery enum PackOnly PackOnly, DownloadAndPack, or DownloadPackAndUpload.
source object null Release feed to download prior releases from and/or upload to. See Velopack source.

Velopack source

type selects which fields apply.

type Fields
Az (Azure Blob) account, key, sas, container, prefix, endpoint
S3 keyId, secret, region, bucket, prefix, endpoint
GitHub / Gitea repoUrl, token
Local path
Http (download only) url

Upload targets also honor keepMaxReleases (int) to prune old releases. Credential fields (key, secret, token, sas) should use %ENV_VAR% expansion rather than literals.

Directives (recap)

Directives in the HEAD commit message steer a run, and the matching command-line flags override them field by field - see the command-line reference.

Directive Effect CLI override
pub:Web\|Proc* Publish exactly these projects (pipe-separated name globs). pub:* publishes everything, pub:none nothing. --pub
srv:<patterns> Which servers take part. Pipe-separated globs over servers[] name or hostname ("S2", "web*", "*"). Omit for every server. --srv
wait:<seconds> Rollout soak delay before peers apply. --wait
skiptests Deploy without running the unit-test gate. Also spelled skip-tests / skip_tests, and skiptests:false forces the gate back on. --skip-tests
git commit -m "urgent hotfix pub:WebApp wait:0 skiptests"

Because a flag that was never passed must not countermand the commit, --skip-tests is three-state: absent leaves the commit's decision alone, --skip-tests skips, and --skip-tests false runs the gate even when the commit asked to skip it. A skipped gate is recorded in result.json, not just printed.

doNotPublishIfNoPubInCommitMessage above turns pub: into a required opt-in.

Architecture

For the design rationale - the pure planner, the server/global scope model, blue-green folder-flip, the multi-server peer agent, and commit directives - see docs/ARCHITECTURE.md.

Building from source

dotnet test                                   # run the unit tests
dotnet pack src/Dytools.DeployTool -c Release -o ./artifacts   # produce Dytools.DeployTool.<version>.nupkg

License

Apache License 2.0.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

This package has no dependencies.

Version Downloads Last Updated
0.13.0 80 9/15/2026
0.12.0 78 9/15/2026
0.11.0 94 9/11/2026
0.10.0 98 9/10/2026
0.9.0 97 9/10/2026
0.8.0 93 9/10/2026
0.7.0 118 8/23/2026
0.6.0 117 8/23/2026
0.5.0 105 8/23/2026
0.4.0 201 8/16/2026
0.3.0 114 8/15/2026