Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 13 additions & 12 deletions .claude/skills/release-notes/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ description: >-
# Release notes

The notes for a release are **`.docs/release-notes/vX.Y.Z.md`**. The VS Code task **release (GitHub)**
(`.github/scripts/publish-release.ps1`) publishes that file unchanged as the GitHub release's notes
(`.github/scripts/release-notes.ps1`), and builds it into the exe - so it is written and committed before the release.
The workflow on GitHub (`.github/workflows/ci.yml`) only builds and tests the pushed tag. [`.docs/release-notes/v1.6.0.md`](../../../.docs/release-notes/v1.6.0.md) is the model: match
(`.github/scripts/publish-release.ps1`) pushes the version tag, and the release workflow on GitHub
(`.github/workflows/release.yml`) builds the exes from that tag - this file built into them - and publishes it
unchanged as the GitHub release's notes (`.github/scripts/release-notes.ps1`). So it is written and committed before
the release: a commit without it isn't released. [`.docs/release-notes/v1.6.0.md`](../../../.docs/release-notes/v1.6.0.md) is the model: match
its structure, length and tone.

Readers are **people who use DNN Manager**, deciding whether to update and what changes for them - not developers.
Expand Down Expand Up @@ -49,22 +50,22 @@ Readers are **people who use DNN Manager**, deciding whether to update and what
report, ready to paste.
8. Report what the notes say, the commit message, and anything left unverified. Don't commit, tag or publish - the
owner does, with the VS Code task **release (GitHub)** (`.github/scripts/publish-release.ps1`, see
`.docs/releasing.md`), which picks this file and a commit, builds both exes and publishes the release - or, for a
redo, **release: redo (GitHub)** (below).
`.docs/releasing.md`), which picks this file and a commit and pushes the tag; GitHub's release workflow builds
both exes and publishes the release - or, for a redo, **release: redo (GitHub)** (below).

## Redoing a release

The version's tag is made already - its release workflow failed, or one more change is wanted - and the owner wants
the same version again, not the next one. `.github/scripts/redo-release.ps1` (the VS Code task
**release: redo (GitHub)**, see `.docs/releasing.md#redo-a-release`) amends the release commit with the working copy,
pushes the branch, deletes the tag and its GitHub release, and releases the version again.
pushes the branch, deletes the tag and releases the version again (a draft the failed run left is reused). It refuses
a release that is published.

1. **Is a redo still right?** Look the release up:
1. **Is a redo still possible?** Look the release up:
`curl -s https://api.github.com/repos/<owner>/<repo>/releases/tags/vX.Y.Z` (the repository is `origin`'s) - a
404 means none is published (a draft isn't listed there). When it is published, give its date and the files'
`download_count`s: DNN Manager updates only to a *newer* version, so whoever installed the first build is never
offered the redone one. If anyone but the owner may have it, recommend the next version instead and let the owner
decide.
404 means none is published (a draft isn't listed there). When it is published, a redo is refused: DNN Manager
updates only to a *newer* version, so whoever installed it would never be offered the redone one. Recommend the
next version instead.
2. **The range** is the tag before it to the working copy: the previous release is
`git describe --tags --abbrev=0 vX.Y.Z^`, and the changes are its commits plus what isn't committed yet
(`git status`, `git diff HEAD`) - the redo folds them into the release commit.
Expand All @@ -75,7 +76,7 @@ pushes the branch, deletes the tag and its GitHub release, and releases the vers
(Enter keeps the old one).
5. **Show the plan, don't run it**: `.github\scripts\redo-release.ps1 -Version X.Y.Z -DryRun` changes nothing and
prints what the redo would amend, push and delete - fine to run and report. The redo itself force-pushes the
branch and deletes a release: only the owner runs it.
branch and deletes the tag: only the owner runs it.

## Structure

Expand Down
24 changes: 21 additions & 3 deletions .docs/adr/0001-undo-on-failure.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# ADR 0001 - A failed operation is undone, like a cancelled one

**Status:** accepted (2026-10)
**Status:** accepted (2026-10); amended (2026-10) - undo steps have time limits,
an undo can be stopped, an unfinished operation is recorded

## Context

Expand Down Expand Up @@ -29,7 +30,21 @@ journal at all.
undo) doesn't add to the journal while it runs.
- Quitting while an operation runs cancels it and closes the window only once it
has stopped and undone what it did (`OperationRunner.WhenIdleAsync`). Setup, which
is waiting to replace the files, gives it 20 seconds.
is waiting to replace the files, gives it 20 seconds; Windows signing out or
shutting down, 60 (held up once, with a reason).
- **The undo itself is bounded** (amended): an undo that waits on a SQL Server or
IIS that doesn't answer would otherwise keep the operation - and quitting - from
ever ending. Each step gets a token cancelled after its time
(`OperationUndo.StepLimit` 60 s, `FileStepLimit` 30 s, or a step's own - 20 min
for putting an upgraded site back), the whole undo about 5 minutes
(`TotalLimit`); a step whose time is up is named as not undone and the next one
runs. **Cancel pressed again** while undoing - *Stop undoing* - and **Quit now**
(offered after 20 seconds of quitting) stop the undo the same way.
- **An unfinished operation is recorded** (amended): while an operation runs, its
title and what it would undo now (`OperationUndo.Pending`) are kept in the
`state` table's `operation` area, deleted when it ends. Still there at a start,
DNN Manager ended first (a crash, a shutdown, *Quit now*), and says what may be
left half done.

## Consequences

Expand All @@ -39,7 +54,10 @@ journal at all.
folder that was there) is said in the Output tab, after a failure as after a
cancel.
- Quitting can take a while: an operation that ignores its cancel (Restore, once it
has started changing the site) runs to its end first.
has started changing the site) runs to its end first - *Quit now* ends the wait,
leaving it as it is.
- A stopped or timed-out undo can leave something half made; it is named in the
Output tab, or at the next start - never left unsaid.
- Options considered: *keep everything for inspection* (the old behaviour - it
blocked retrying and left orphans nobody cleaned up) and *undo only before the
install step* (two rules to explain instead of one, with `Keep()` covering the
Expand Down
82 changes: 68 additions & 14 deletions .docs/adr/0003-update-trust.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# ADR 0003 - What an update is trusted on

**Status:** accepted (2026-10)
**Status:** accepted (2026-10); amended (2026-10) - releases built in CI with
build provenance; amended again (2026-10) - publishing approved by a reviewer,
the tag checked again, signing in a job of its own, a smoke test, the launcher

## Context

Expand All @@ -16,6 +18,12 @@ Administrator rights. Until 1.8.0:
started testing its tag;
- the exes aren't signed.

Up to 1.8.1, the published files were also built on the publisher's PC. CI rebuilt
and tested the tag, but it was the PC's build that was uploaded, with a broad
GitHub credential that the release script read from Git. Anyone who controlled
that PC, or the tools it cached (the extracted Inno Setup compiler), controlled
what every DNN Manager installed - and nothing showed where a file had come from.

## Decision

- **The file of the release, or nothing:** a release that lists no SHA-256 isn't
Expand All @@ -24,21 +32,67 @@ Administrator rights. Until 1.8.0:
`%ProgramData%\DnnManager\temp\update` (only administrators can change it), and
the helper checks the file's size and SHA-256 again right before it copies or
runs it, holding it open meanwhile.
- **Tested before it is offered:** [`publish-release.ps1`](../../.github/scripts/publish-release.ps1)
keeps the release a draft until CI has passed on its tag, and checks GitHub's
digest of every file it uploads. `-SkipTests` and `-SkipCi` are for pre-releases,
which nobody is offered.
- **Built by GitHub, from the tag:** the
[release workflow](../releasing.md#the-release-workflow)
([`release.yml`](../../.github/workflows/release.yml)) builds both exes from the
pushed tag on a pinned runner image. It uses the SDK `global.json` names, the
packages `packages.lock.json` names (from nuget.org only, through `nuget.config`)
and the pinned Inno Setup, which is extracted again from its hash-checked package
on every build. Its actions are pinned to commits. The publisher's PC only runs
the fast tests and pushes the tag; nothing it builds is uploaded.
- **Provenance:** the workflow attests both exes
(`actions/attest-build-provenance`, signed by GitHub through Sigstore), so
`gh attestation verify <file> --repo Albadit/DnnManager.NET` shows which
workflow, tag and commit produced a file.
- **Tested and tried before it is offered:** the release stays a draft until the
whole test suite (CI, called on the tag), the build, the signing and a smoke test
have passed - Setup installed on a fresh runner, DNN Manager started through its
launcher, uninstalled, and the portable exe started. Only then are the files
uploaded to a draft for the commit that was built, and GitHub's digest of each
checked.
- **A person approves the publish:** the publish job runs in the GitHub
environment `publish`, which needs a required reviewer's approval. Right before
publishing it checks again that the tag still points at the commit that was
built and that the draft holds exactly the files in `SHA256SUMS.txt`. A release
becomes the latest only when its version is newer than the latest one.
- **Least privilege:** each job gets only the permissions it needs. The build job,
which runs the packages' and the tests' code, gets no OpenID Connect token; the
sign job, which restores no package and runs no test, is the only one that can
sign in to Azure (just before the first file is signed) and attest. Only the
draft and publish jobs may write to the repository. The workflow's own
`GITHUB_TOKEN` is its only GitHub credential. The release scripts read no token:
Git's credential pushes the tag, and the GitHub API is asked without signing in.
- **A published version never changes:** the release workflow and
`redo-release.ps1` refuse to touch a published release. A change to one gets a
new version number. With GitHub's immutable releases turned on, neither its files
nor its tag can change any more.
- **Signing, ready to switch on:** with Azure Artifact Signing's repository
variables set, the workflow signs `DnnManager.exe`, the launcher
(`DnnManager-launcher.exe`), the portable exe, Setup and its uninstaller
(Authenticode, timestamped), and fails rather than publish an unsigned file -
and with `REQUIRE_SIGNING` set, a run without the variables fails too. It signs
in to Azure with OpenID Connect, and there is no signing secret in the
repository ([Code signing](../releasing.md#code-signing)).

## Consequences

- An update can't be swapped on this PC by a program without administrator rights,
and a release nobody tested isn't offered.
- What is trusted is still the GitHub release: whoever can publish one (the
publisher's GitHub credential) can publish an update every DNN Manager installs.
**Code signing** (Authenticode, with the helper checking the signer) is the next
step - it needs a certificate or a signing service, and goes into `build.ps1`
and `DnnManager.iss` (`SignTool`). Until then it is listed under
[Known open risks](../security.md#known-open-risks).
- Building releases in CI (with build provenance) instead of on the publisher's
PC remains open: today CI rebuilds and tests the tag, and the PC's build is
what is published.
- A compromised publisher PC can no longer slip its own build into a release:
what is published is what the workflow built from the tag, and the attestation
shows it. Releases up to 1.8.1 have no attestation.
- **What remains trusted is whoever can push a version tag and approve the
publish job**, or change the workflow and then tag. A tag alone builds a draft;
publishing waits for the `publish` environment's reviewer - once the owner has
added one on GitHub (without it the job runs at once). DNN Manager installs any
release whose file matches GitHub's SHA-256. The tag and branch rulesets and the
environments ([Set up GitHub](../releasing.md#set-up-github)) limit who that is.
The attestation shows where a file came from, but DNN Manager doesn't check it.
- **The sign job still compiles Setup**: Inno Setup (the pinned, hash-checked
copy) runs in the job that can sign in to Azure - an accepted residual risk.
- **Until signing is set up, the gap stays:** the exes are unsigned, Windows
SmartScreen warns about them, and the helper can't check a signer before it runs
an update. The next steps are to set up Artifact Signing (the workflow is ready
for it), then have the update helper and Setup check that the downloaded file is
signed by the publisher's certificate. Until then the exes' missing signature
stays under [Known open risks](../security.md#known-open-risks).
96 changes: 96 additions & 0 deletions .docs/adr/0004-elevation-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# ADR 0004 - What the user's account can change goes through a few checked choke points

**Status:** accepted (2026-10)

## Context

DNN Manager is a desktop app that runs elevated, as the same user who signs in.
That user's everyday programs run without administrator rights - and they can
change a lot of what DNN Manager reads and acts on:

- its settings (`Documents\DnnManager\dnnmanager.db`) and the user's environment
variables (`DNNMANAGER_*`, but also .NET's own: a profiler, a startup hook, a
diagnostic port);
- the folders it writes, unpacks and deletes in: `%TEMP%`, Documents, the
projects folder - and a site's folder, which its app pool can write too;
- the programs it starts: anything on the user's PATH or in a per-user install;
- a site's `web.config`, which names the database DNN Manager drops or signs in
to, and is XML the site can change.

Each of these is a way for a program without administrator rights to get them:
a junction under a folder DNN Manager deletes, a `docker.exe` earlier on the
PATH, a `CORECLR_PROFILER` that loads a DLL into the elevated process, a
projects folder set to `C:\Windows`. Up to 1.8.1 the checks were made at call
sites, one by one, and each new feature had to remember them.

## Options

1. **A check at each call site** - as before. Cheap to start, but nothing tells a
new call site it needs one, and the checks drift apart.
2. **A few choke points, each enforced by tests** - one class per kind of input
that every use goes through, and tests that fail when code goes around them.
3. **A broker split**: a small elevated service that does only the
administrator work, with a UI that runs as the user. The cleanest boundary,
but a second process and a protocol between them, an installer service, and
most of the app rewritten.

## Decision

Option 2. The choke points:

- **Settings and environment**:
[`SettingRules`](../../src/DnnManager.Application/Configuration/SettingRules.cs) holds
every value that feeds an administrator action (the projects folder, release
sources, the hostname suffix, the collation, Docker names, IIS feature names),
for the saved settings (`UserSettings.Validate`) and the `DNNMANAGER_*`
overrides (`AppOptions.Problems`) alike. A saved value that breaks a rule goes
back to its default; overrides that break one are all ignored.
- **Folders others can write**:
[`SafePath`](../../src/DnnManager.Application/SafePath.cs) (is a path inside a
folder, links on the way, deleting a tree) and
[`SafeZip`](../../src/DnnManager.Infrastructure/Files/SafeZip.cs) (every zip
entry unpacked) are the only ways to write, extract or delete there, on top of
Windows' redirection trust for the process
([`RedirectionTrust`](../../src/DnnManager.Infrastructure/Files/RedirectionTrust.cs)).
A site's XML is read through
[`SiteXml`](../../src/DnnManager.Infrastructure/WebConfigs/SiteXml.cs) (no DTD).
- **Starting a program elevated**: only
[`ProcessRunner`](../../src/DnnManager.Infrastructure/Processes/ProcessRunner.cs)
and [`ElevatedStart`](../../src/DnnManager.Infrastructure/Processes/ElevatedStart.cs),
which take only a copy administrators alone can change
(`TrustedPrograms.Resolve`) and give it
[`ChildEnvironment`](../../src/DnnManager.Infrastructure/Processes/ChildEnvironment.cs)'s
environment. A program for the user starts as the user.
- **DNN Manager's own start**: the launcher
([`src/DnnManager.Launcher`](../../src/DnnManager.Launcher/DnnManager.Launcher.csproj)),
a Native AOT exe that drops .NET's variables before it starts `DnnManager.exe` -
what the sign-in task and the UAC relaunch start.
- **Terminals**: without administrator rights by default (a restricted copy of
DNN Manager's token); an Administrator terminal only when asked for, with
shells installed for all users and no profile scripts.
- **The tests**: `LayeringTests` scans the source and fails on a `Process.Start`
or `new ProcessStartInfo` outside the allowed files, and on a Presentation use
of an Infrastructure namespace not on its list; `ElevationBoundaryTests`,
`SecurityHardeningTests`, `SettingsResilienceTests` and
`LauncherEnvironmentTests` hold the rules themselves ([testing.md](../testing.md)).

## Consequences

- A new feature that deletes, unpacks, starts a program or reads a setting gets
the checks by using the one class there is for it; one that goes around
`ProcessRunner` / `ElevatedStart` fails the fast tests.
- The source scan is textual, like the layering test: it doesn't see a process
started through reflection or a library. Code review still has to look.
- Residual risks are accepted and listed under
[Known open risks](../security.md#known-open-risks): the portable exe has no
launcher and extracts its native DLLs into `%TEMP%\.net`; the SQL container's
`sa` password is in its environment and SqlPackage's passwords on its command
line; an Administrator terminal runs what is typed with those rights; an
unelevated terminal runs in the elevated sign-in session.
- **The installer's default follows from it**: Setup installs for all users, into
Program Files, where only administrators can change the exe that runs elevated -
and the sign-in task is made only for such an installation. A per-user
installation (`/CURRENTUSER`, or one made by 1.8.1 and older) keeps working,
with a warning, and is offered the move.
- Option 3 (a broker) stays open as the stronger boundary. The choke points are
where its interface would be cut.
Loading
Loading