diff --git a/.github/workflows/attest-signed.yml b/.github/workflows/attest-signed.yml new file mode 100644 index 0000000..067fb6c --- /dev/null +++ b/.github/workflows/attest-signed.yml @@ -0,0 +1,132 @@ +# Phase C: attest the bill of materials of the SIGNED bytes. +# +# Dispatched by packaging/sign-release.ps1 once the card has signed the executables and the +# repacked archives are on the draft release. +# +# IT DOES NOT ATTEST PROVENANCE, and that is a statement rather than an omission. Build +# provenance says "this workflow produced these bytes". A person produced these bytes, on their +# own machine, with a card in a reader - claiming otherwise would be the one lie an attestation +# must never carry. What CAN be said about the signed archive is what is inside it, so that is +# what is attested here. The unsigned build phase A made has provenance, and phase B verified it +# before touching anything. +# +# A CONSEQUENCE FOR WHOEVER VERIFIES: the tools default to asking for build provenance, and the +# signed archive deliberately has none. Without `--predicate-type https://spdx.dev/Document/v2.3` +# one spelling says "no attestation found" and another returns 404, and both look like a broken +# release. The commands in README.md carry that flag, and phase D runs those commands verbatim. +name: Attest the signed release + +on: + workflow_dispatch: + inputs: + tag: + description: The release tag whose signed assets to attest, e.g. v0.1.0 + required: true + type: string + digests: + description: Comma-separated = of what phase B signed, as a cross-check + required: true + type: string + +permissions: + contents: read + +jobs: + attest: + name: Attest the signed archives + # Nothing here needs Windows: it downloads, hashes and attests. + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + # Attaching the attestation bundles to the release as assets. + contents: write + id-token: write + attestations: write + artifact-metadata: write + env: + GH_TOKEN: ${{ github.token }} + # The dispatch inputs reach the scripts through the environment rather than through `${{ }}` + # inside a `run:` block. Template expansion happens BEFORE the shell sees the script, so an + # input is pasted into the source of the script and the quotes around it in this file + # protect nothing - a value carrying a quote closes the string it landed in and the rest of + # it runs as code, in a job that holds `id-token: write` and `attestations: write`. + TAG: ${{ inputs.tag }} + DIGESTS: ${{ inputs.digests }} + REPO: ${{ github.repository }} + steps: + # THE ARCHIVES COME FROM THE RELEASE, not from the caller. Attesting a digest somebody + # handed us would attest a number rather than a file, and the number and the file are the + # same thing only if nothing went wrong - which is precisely what is being checked. The + # digests that were passed in stay as a cross-check below. + - name: Download what the release actually carries + run: | + mkdir -p assets + gh release download "$TAG" --repo "$REPO" \ + --pattern '*.zip' --pattern '*.spdx.json' --dir assets + ls -l assets + + - name: The bytes on the release must be the bytes phase B signed + run: | + fail=0 + for pair in $(echo "$DIGESTS" | tr ',' ' '); do + name="${pair%%=*}" + expected="${pair#*=}" + if [ ! -f "assets/$name" ]; then + echo "::error::the release carries no $name" + fail=1 + continue + fi + actual=$(sha256sum "assets/$name" | cut -d' ' -f1) + if [ "$actual" != "$expected" ]; then + echo "::error::$name on the release is not what phase B signed" + echo " signed: $expected" + echo " released: $actual" + fail=1 + else + echo "$name matches the digest phase B signed" + fi + done + [ "$fail" -eq 0 ] || exit 1 + + # AND EVERY ARCHIVE HAS TO BE NAMED IN THE INPUT, which the loop above cannot say. + # It walks what DIGESTS carries, so a dispatch with an empty value, a value that is + # only commas, or one that names a single archive passes it without checking the + # others - and the steps below then attest archives nobody cross-checked. This is a + # workflow_dispatch input, so the value is typed by a person on a bad day rather than + # always produced by phase B. Found by the review of PR #5. + for zip in assets/*.zip; do + name=$(basename "$zip") + case ",$DIGESTS," in + *",$name="*) ;; + *) echo "::error::no digest was passed for $name, so nothing cross-checked it"; exit 1 ;; + esac + done + + - name: Attest the bill of materials for the window package + id: gui + uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0 + with: + subject-path: assets/BetterWindowsServices-win-x64.zip + sbom-path: assets/BetterWindowsServices-win-x64.zip.spdx.json + + - name: Attest the bill of materials for the command line package + id: cli + uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0 + with: + subject-path: assets/bws-cli-win-x64.zip + sbom-path: assets/bws-cli-win-x64.zip.spdx.json + + # Published as assets, with the extension the tooling and the scanners recognise, so the + # attestation can be checked offline with `--bundle` by somebody who would rather not call + # the API - and so a scanner looking over the release page finds it at all. + - name: Attach the attestation bundles to the release + env: + GUI_BUNDLE: ${{ steps.gui.outputs.bundle-path }} + CLI_BUNDLE: ${{ steps.cli.outputs.bundle-path }} + run: | + cp "$GUI_BUNDLE" BetterWindowsServices-win-x64.zip.sigstore.json + cp "$CLI_BUNDLE" bws-cli-win-x64.zip.sigstore.json + gh release upload "$TAG" \ + BetterWindowsServices-win-x64.zip.sigstore.json \ + bws-cli-win-x64.zip.sigstore.json \ + --repo "$REPO" --clobber diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..90a3a52 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,186 @@ +# Phase A of the release ritual: build here, prove it was built here, open an empty draft. +# +# The signing key lives on a cryptographic card in a reader and CANNOT be exported - that is the +# whole value of it, so no GitHub-hosted runner will ever reach it. A self-hosted runner could, +# and this is a PUBLIC repository, where a self-hosted runner is a machine strangers can aim a +# pull request at. So the build happens where builds belong and the signature happens where the +# card is. +# +# THIS PHASE PUBLISHES NOTHING. No archive, no checksums, no bill of materials. Those are not the +# bytes a user will download, and an unsigned executable on a public release page - even for a +# quarter of an hour - is a file somebody downloads. The draft it opens is empty on purpose. +# +# The four phases, and who runs each: +# A this file, on a `v*` tag build, attest the UNSIGNED build, open a draft, hand +# the build back as a workflow artifact +# B packaging/sign-release.ps1 on the machine with the card: verify A's attestation, +# sign, repack, write the documents and the checksums, +# upload, wait +# C .github/workflows/attest-signed.yml attest the bill of materials of the SIGNED bytes, +# never their provenance - a person signed those +# D .github/workflows/verify-release.yml on publish: check the release page as a user does +# +# The whole reasoning is ADR-28 in docs/02. +# +# READ THIS BEFORE WONDERING WHY THE BUTTON IS MISSING: "To trigger the workflow_dispatch event, +# your workflow must be in the default branch." That is GitHub's own documentation, and it means +# the manual half of this file does nothing at all until it is merged to main. +# +# The release stays a draft until a person reads it and presses the button. +name: Release + +on: + push: + tags: ['v*'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Build and attest the unsigned build + runs-on: windows-latest + timeout-minutes: 45 + permissions: + # Opening the draft. It gets no assets here. + contents: write + # The three the attestation action needs: an OIDC token for a Sigstore certificate, the + # attestation store, and the artifact metadata record. + id-token: write + attestations: write + artifact-metadata: write + env: + GH_TOKEN: ${{ github.token }} + # THE TAG REACHES THE SCRIPTS THROUGH THE ENVIRONMENT RATHER THAN THROUGH `${{ }}` INSIDE A + # `run:` BLOCK, and that is not style. Template expansion happens BEFORE the shell sees the + # script, so a tag is pasted into the source of the script and the quotes around it in this + # file protect nothing. Git allows a quote, a backtick and a semicolon in a ref name, so a + # tag can close the string it was pasted into and continue as code - in a job holding + # `id-token: write` and `attestations: write`. An environment variable is passed by the + # runner as a value and is never parsed as part of the script. The semgrep gate in this + # repository blocks the other spelling by name. + TAG: ${{ github.ref_name }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # THIS WORKFLOW BUILDS AND PUBLISHES, AND IT DOES NOT TEST. Without this step the only + # thing standing between a red commit and a release is somebody's memory of having looked. + # Asked by WORKFLOW FILE NAME rather than "are all the checks green": a repository + # accumulates checks, some advisory, and "all green" quietly becomes "none of them has said + # no yet". This repository requires five checks on main and `build` is the one that runs + # the suite. + - name: The test workflow must have been green on this exact commit + if: startsWith(github.ref, 'refs/tags/v') + shell: pwsh + run: | + $sha = $env:GITHUB_SHA + $runs = gh api "repos/$env:GITHUB_REPOSITORY/actions/workflows/build.yml/runs?head_sha=$sha&per_page=20" --jq '.workflow_runs[] | "\(.conclusion)|\(.status)|\(.html_url)"' + if ($LASTEXITCODE -ne 0) { throw "cannot read the build runs for $sha" } + $lines = @($runs -split "`n" | Where-Object { $_ }) + if ($lines.Count -eq 0) { + throw "the build workflow never ran on $sha. A tag on a commit nothing has tested is a release nothing checked - push the commit to a branch and let it go through a pull request first." + } + if (-not ($lines | Where-Object { $_.StartsWith('success|') })) { + throw "the build workflow on $sha did not conclude successfully:`n$($lines -join "`n")" + } + Write-Host "build was green on $sha" + + # The version lives in exactly one file - rule 11 of CLAUDE.md makes moving it the owner's + # decision - and a tag is where disagreement costs the most, because every URL and document + # name downstream is written from one of the two. + - name: The tag and the version file must agree + if: startsWith(github.ref, 'refs/tags/v') + shell: pwsh + run: | + $found = Select-String -Path Directory.Build.props -Pattern '([^<]+)' | Select-Object -First 1 + if (-not $found) { throw 'cannot read from Directory.Build.props' } + $version = $found.Matches[0].Groups[1].Value + if ($env:TAG -ne "v$version") { + throw "the tag is $env:TAG and the product version is $version - one of them was not moved" + } + Write-Host "tag $env:TAG matches Directory.Build.props" + + # A changelog with everything still under [Unreleased] is a changelog nobody closed, and the + # release notes are the one part of a release that cannot be regenerated afterwards. + - name: The changelog must be closed for this version + if: startsWith(github.ref, 'refs/tags/v') + shell: pwsh + run: | + $version = $env:TAG.TrimStart('v') + $lines = Get-Content CHANGELOG.md + $headings = @() + for ($i = 0; $i -lt $lines.Count; $i++) { if ($lines[$i] -match '^## \[') { $headings += $i } } + $unreleased = $headings | Where-Object { $lines[$_] -match '^## \[Unreleased\]' } | Select-Object -First 1 + if ($null -eq $unreleased) { throw 'CHANGELOG.md has no [Unreleased] section' } + $next = $headings | Where-Object { $_ -gt $unreleased } | Select-Object -First 1 + if ($null -eq $next) { $next = $lines.Count } + $body = $lines[($unreleased + 1)..($next - 1)] | Where-Object { $_.Trim() -match '^[-*]' } + if ($body) { + throw "CHANGELOG.md still has $($body.Count) entries under [Unreleased] - move them under '## [$version] - ' before tagging" + } + if (-not (Select-String -Path CHANGELOG.md -Pattern "^## \[$([regex]::Escape($version))\] - \d{4}-\d{2}-\d{2}" -Quiet)) { + throw "CHANGELOG.md has no dated section for $version" + } + Write-Host "changelog closed for $version" + + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: '10.0.x' + + # Publishes both halves, checks the pinned third-party bytes, stages the licences beside + # each executable, RUNS the command line program out of the staging folder, packs, and + # writes a bill of materials that checks the component register against the manifest the + # publish produced - in both directions. The restore inside it is also the package audit: + # Directory.Build.props turns an advisory into an error, so a vulnerable package stops this + # workflow before it can produce a file anybody downloads. + - name: Build the packages + shell: pwsh + run: ./packaging/build-dist.ps1 + + # Provenance over the UNSIGNED build, which is what phase B verifies before it touches + # anything. The archives change when the executable inside them is signed, so this + # attestation describes the artifact handed to the card machine and NOT what a user + # downloads. Phase C attests the signed bytes, and deliberately attests only their bill of + # materials - a person signed those, on their own machine. + - name: Attest how the unsigned build was made + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: | + dist/BetterWindowsServices-win-x64.zip + dist/bws-cli-win-x64.zip + + # Empty on purpose. The assets arrive in phase B, signed. + - name: Open the draft release + if: startsWith(github.ref, 'refs/tags/v') + shell: pwsh + run: | + gh release view $env:TAG 2>$null + if ($LASTEXITCODE -ne 0) { + gh release create $env:TAG --draft --title "Better Windows Services $($env:TAG.TrimStart('v'))" --notes "Draft. Built by the Release workflow, signed on the card, assets attached by phase B - see CHANGELOG.md." + if ($LASTEXITCODE -ne 0) { throw 'could not create the draft release' } + Write-Host "opened draft $env:TAG with no assets" + } else { + Write-Host "draft $env:TAG already exists" + } + + # THE SEAM BETWEEN THE BUILD AND THE CARD. The archives, and the build manifest beside each + # one: phase B has to write the bill of materials again over the signed bytes, and the + # resolved version of every runtime pack exists only in that manifest. The checksums and the + # documents build-dist.ps1 wrote describe UNSIGNED bytes and have no business travelling + # any further. + - name: Hand the build to the signing step + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: unsigned-build-${{ github.ref_name }} + path: | + dist/*.zip + dist/*.deps.json + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/verify-release.yml b/.github/workflows/verify-release.yml new file mode 100644 index 0000000..1255525 --- /dev/null +++ b/.github/workflows/verify-release.yml @@ -0,0 +1,170 @@ +# Phase D: check the published release page the way a user does. +# +# The three phases before this one check an artifact, a draft, and a digest handed between +# workflows. NONE OF THEM TOUCHES THE RELEASE PAGE, which is the only thing a user ever sees - +# and that gap is how a broken verification command can sit in a README for two releases without +# anybody noticing. +# +# So this downloads the published assets and asks, of the release itself: +# * is every file there (a missing one means some phase did not finish), +# * do the checksums agree, +# * do the commands in README.md still work - run VERBATIM, not as equivalents, because if the +# command a reader will type has stopped working then IT is the thing that should turn red, +# * is every executable we build signed by the pinned certificate, and timestamped, +# * and, for a full release, does "latest" point here, since that is where the download button +# aims. +# +# READ-ONLY PERMISSIONS. A verifier that can publish is not a verifier any more. +# +# It also runs on demand with a tag, because "does that old release still verify" is a question +# worth being able to ask without cutting a new one. +name: Verify the published release + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: The release tag to re-check, e.g. v0.1.0 + required: true + type: string + +permissions: + contents: read + +jobs: + verify: + name: Check the release page + # Windows, because the Authenticode checks below are the point and they need it. + runs-on: windows-latest + timeout-minutes: 30 + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag || github.event.release.tag_name }} + REPO: ${{ github.repository }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Download everything the release publishes + shell: pwsh + run: | + New-Item -ItemType Directory -Path published -Force | Out-Null + gh release download $env:TAG --repo $env:REPO --dir published + if ($LASTEXITCODE -ne 0) { throw "cannot download the assets of $env:TAG" } + Get-ChildItem published | ForEach-Object { " {0,14:N0} {1}" -f $_.Length, $_.Name } + + - name: Every expected asset must be there + shell: pwsh + run: | + # Built from packaging/components.json rather than written out, so that this workflow + # and the script that uploads the assets read one list. THE DAY AN ASSET IS RENAMED + # this step needs to know both spellings, or re-checking an older tag fails with + # "missing" for files that are present under their own names - which would be this + # workflow lying about a release that is fine. There is no such day yet. + $register = Get-Content -Raw packaging/components.json | ConvertFrom-Json + $archives = @($register.packages.PSObject.Properties | ForEach-Object { $_.Value.zip }) + $expected = $archives + + ($archives | ForEach-Object { "$_.spdx.json" }) + + ($archives | ForEach-Object { "$_.sigstore.json" }) + + @('SHA256SUMS') + $present = (Get-ChildItem published -File).Name + $missing = @($expected | Where-Object { $present -notcontains $_ }) + if ($missing) { + throw "the release is missing $($missing -join ', ') - some phase of the release ritual did not finish" + } + Write-Host "all $($expected.Count) assets present" + + - name: The checksums must agree + shell: pwsh + run: | + $bad = @() + foreach ($line in (Get-Content published/SHA256SUMS)) { + if (-not $line.Trim()) { continue } + $parts = $line -split '\s+', 2 + $expected = $parts[0] + $name = $parts[1].Trim().TrimStart('*') + $path = "published/$name" + if (-not (Test-Path -LiteralPath $path)) { $bad += "$name is in SHA256SUMS and not on the release"; continue } + $actual = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $expected.ToLowerInvariant()) { $bad += "$name : published $expected, actual $actual" } + else { Write-Host " ok $name" } + } + if ($bad) { throw "checksums disagree: $($bad -join '; ')" } + + # EXTRACTED FROM README.md AND RUN AS WRITTEN. Not equivalents: the point is that the + # command a reader will actually type still works, so if it stops working the README is + # what turns red. + - name: The verification commands in the README must work as written + shell: pwsh + working-directory: published + run: | + $readme = Get-Content -Raw ../README.md + $block = [regex]::Match($readme, '(?s)\s*```powershell(.*?)```\s*') + if (-not $block.Success) { throw 'README.md has no block to run' } + $commands = @($block.Groups[1].Value -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -and -not $_.StartsWith('#') }) + if ($commands.Count -lt 2) { throw "expected at least two verification commands in the README, found $($commands.Count)" } + foreach ($command in $commands) { + Write-Host "`n $command" + # RESET BEFORE EACH ONE, and this is not belt and braces. $LASTEXITCODE is set by + # native programs only: a command written in pure PowerShell leaves whatever the + # previous one put there, and before the first command it is $null - which compares + # as not equal to zero and would fail this step on a README that is perfectly fine. + # A pure PowerShell command that goes wrong throws, and the step fails on that. + $global:LASTEXITCODE = 0 + Invoke-Expression $command + if ($LASTEXITCODE -ne 0) { throw "the README's own command failed: $command" } + } + + - name: Every executable we build must be signed by the pinned certificate, and timestamped + shell: pwsh + run: | + $pin = (Get-Content -Raw packaging/codesign.json | ConvertFrom-Json).certificate_sha256 + if ($pin -notmatch '^[0-9a-f]{64}$') { throw 'packaging/codesign.json carries no usable certificate_sha256' } + # One executable per archive, because both halves publish as a single self-contained + # file. Everything else inside them is Microsoft's and is not a file on disk at all. + $register = Get-Content -Raw packaging/components.json | ConvertFrom-Json + $bad = @() + foreach ($entry in $register.packages.PSObject.Properties) { + $archive = $entry.Value.zip + $where = "extracted/$([System.IO.Path]::GetFileNameWithoutExtension($archive))" + Expand-Archive -LiteralPath "published/$archive" -DestinationPath $where -Force + $file = Join-Path $where (Join-Path $entry.Value.folder $entry.Value.executable) + if (-not (Test-Path -LiteralPath $file)) { $bad += "$archive : $($entry.Value.executable) is not in the archive"; continue } + $signature = Get-AuthenticodeSignature -LiteralPath $file + if ($signature.Status -ne 'Valid') { $bad += "$($entry.Value.executable) : signature status $($signature.Status)"; continue } + $sha = (([System.Security.Cryptography.SHA256]::Create().ComputeHash($signature.SignerCertificate.RawData) | + ForEach-Object { $_.ToString('x2') }) -join '') + if ($sha -ne $pin) { $bad += "$($entry.Value.executable) : signed by $sha, and this repository pins $pin" } + # Without a timestamp the signature dies with the certificate, which is a year away. + elseif (-not $signature.TimeStamperCertificate) { $bad += "$($entry.Value.executable) : signed but NOT timestamped" } + else { Write-Host " ok $($entry.Value.folder)/$($entry.Value.executable)" } + } + if ($bad) { throw "signature check failed: $($bad -join '; ')" } + + # The download button aims at "latest", so for a full release that is the claim worth + # checking. A pre-release is not supposed to be latest, and this says so rather than + # failing. + - name: For a full release, latest must point here + shell: pwsh + run: | + # THE FIELD LIST IS QUOTED, AND THE UNQUOTED FORM DID NOT WORK. In PowerShell argument + # mode a space ends a token, so `--json isPrerelease, isDraft` reaches gh as two + # arguments and it answers "accepts at most 1 arg(s), received 2" with exit 1 - + # measured against a real release on gh 2.101.0. Nothing read that exit code, so both + # checks below were being skipped rather than failing. Found by the review of PR #5. + $release = gh release view $env:TAG --repo $env:REPO --json 'isPrerelease,isDraft' | ConvertFrom-Json + if ($LASTEXITCODE -ne 0 -or -not $release) { throw "cannot read the state of $env:TAG, so this step cannot say anything about it" } + if ($release.isDraft) { throw "$env:TAG is still a draft, so it publishes nothing" } + if ($release.isPrerelease) { + Write-Host "$env:TAG is a pre-release, so latest is not expected to point at it" + exit 0 + } + $latest = gh release view --repo $env:REPO --json tagName | ConvertFrom-Json + if ($LASTEXITCODE -ne 0 -or -not $latest) { throw 'cannot read which release is latest, so this step cannot say that it is this one' } + if ($latest.tagName -ne $env:TAG) { + throw "latest points at $($latest.tagName) and this release is $env:TAG - the download button offers the other one" + } + Write-Host "latest points at $env:TAG" diff --git a/.gitignore b/.gitignore index a5ea23d..19cfb77 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,17 @@ artifacts/ publish/ +# What the release ritual writes locally. dist/ is where packaging/build-dist.ps1 puts the two +# archives, their bills of materials and the sums - about 270 MB of it - and build/signing is +# where packaging/sign-release.ps1 unpacks and signs. Neither is source, and the archives a user +# downloads are made by the workflow on a tag rather than here. +# +# NOTE FOR ANYONE READING THE RULE BELOW THIS BLOCK: `packages/` is a NuGet rule and does NOT +# cover `packaging/`, which is source and is committed. Two words one letter apart, doing +# opposite things. +dist/ +build/ + # Visual Studio / Rider / VS Code .vs/ .idea/ diff --git a/CHANGELOG.md b/CHANGELOG.md index bcf6c8d..1a0c007 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,23 @@ Nothing has been released yet. Everything below is what the tool does today. ### Added +- **`bws license`, and `bws license --components`.** What the program is licensed under, that it + comes with no warranty, where the full text is, and what it carries that somebody else wrote. + The second form turns that into every component with its version, its licence and where it came + from. It reads nothing at all - no service manager, no disk, no network - so it answers on a + machine with no internet, which is where a file this size usually ends up: one self-contained + executable an administrator is about to run with administrator rights, and nothing beside it to + read. + +- **Every release now publishes what is inside it, and a way to check the download.** Beside each + archive: a bill of materials in SPDX 2.3 naming every component with its licence, a signed + attestation of that document, and one file of SHA-256 sums. The executables carry an + Authenticode signature with a timestamp. The README carries the four commands that check all of + this, and a workflow runs those exact commands, unchanged, every time a release is published - + so a verification command that stops working turns something red rather than sitting in a + document. The three renderings of what is inside - the bill of materials, the notices file and + `bws license --components` - come from one register, so they cannot disagree. + - **"Force stop..." and "Force restart..." in the window.** The action bar over the list and the menu on a row both offer the plan `bws kill` builds: ask the entry to stop, and end the process behind it only if that does not work - both steps in the preview before anything diff --git a/README.md b/README.md index b612846..9a18c9a 100644 --- a/README.md +++ b/README.md @@ -57,13 +57,50 @@ Grab the latest build from the | `BetterWindowsServices-win-x64.zip` | The window, one self-contained executable - no .NET to install | | `bws-cli-win-x64.zip` | The command line, one self-contained executable, for scripts and CI | -Windows 10 1809 or Windows Server 2019 and later, 64-bit. Unzip anywhere and run +Windows 10 1809 or Windows Server 2019 and later, 64-bit. Each archive unzips to a folder holding +the executable, the licence and the notices for the borrowed code - run `BetterWindowsServices.exe`, or `bws.exe` from a terminal. No installer, nothing written to the registry, and no administrator rights needed to look. Changing anything needs an elevated session, and both halves say so instead of failing quietly - the window offers *Restart as admin*. -The executables carry an Authenticode signature, so Windows names the publisher instead of warning -about an unknown one. +The executables carry an Authenticode signature with a timestamp, so Windows names the publisher +rather than an unknown one. SmartScreen can still warn on a brand new build until enough people +have run it - that reputation is earned over downloads and is not something a signature buys +outright. + +### Check what you downloaded + +Every release publishes, beside each archive: a bill of materials naming everything inside it +that somebody else wrote, a signed attestation of that document, and one file of SHA-256 sums. +Run these in the folder you downloaded into. The first two need nothing at all. The last two need +the [GitHub CLI](https://cli.github.com/), and because the `.sigstore.json` beside each archive is +passed to `--bundle`, they check it against what GitHub signed rather than by asking GitHub. + +The first two match a whole line - the digest **and** the file name it is written against - rather +than looking for the digest anywhere in the file. A sums file that lists your digest under somebody +else's name would otherwise pass. + + +```powershell +if (-not ((Get-Content SHA256SUMS) -match ('^' + (Get-FileHash BetterWindowsServices-win-x64.zip -Algorithm SHA256).Hash.ToLower() + '\s+\*?BetterWindowsServices-win-x64\.zip$'))) { throw 'BetterWindowsServices-win-x64.zip does not match SHA256SUMS' } +if (-not ((Get-Content SHA256SUMS) -match ('^' + (Get-FileHash bws-cli-win-x64.zip -Algorithm SHA256).Hash.ToLower() + '\s+\*?bws-cli-win-x64\.zip$'))) { throw 'bws-cli-win-x64.zip does not match SHA256SUMS' } +gh attestation verify BetterWindowsServices-win-x64.zip --repo donislawdev/BetterWindowsServices --predicate-type https://spdx.dev/Document/v2.3 --bundle BetterWindowsServices-win-x64.zip.sigstore.json +gh attestation verify bws-cli-win-x64.zip --repo donislawdev/BetterWindowsServices --predicate-type https://spdx.dev/Document/v2.3 --bundle bws-cli-win-x64.zip.sigstore.json +``` + + +**`--predicate-type` is not optional and leaving it out looks like a broken release.** The tools +ask for build provenance by default, and a signed archive deliberately has none: a person signed +those bytes on their own machine with a card in a reader, and an attestation saying a workflow +produced them would be a lie. What is attested is what is inside the archive. Without the flag one +spelling answers "no attestation found" and another returns 404. + +These four commands are not decoration: a workflow runs them, out of this file and unchanged, every +time a release is published. If the command you are about to type has stopped working, that +workflow is what turns red. + +`bws license --components` answers the same question from inside the program, with no internet and +nothing to download - what it carries, which version, under which licence. > **Early release.** Both halves do everything on this page, and an automated suite runs on every > commit. What is not there yet is under [Honest limits](#honest-limits) - most of it is the second @@ -407,6 +444,7 @@ bws start-type NAME automatic|manual|disabled [--dry-run] [--json] [--timing] bws snapshot create [FILE] [--note TEXT] [--follow-network] [--force] [--json] [--timing] bws snapshot diff EARLIER LATER [--exit-code] [--json] [--timing] bws snapshot diff EARLIER --live [--exit-code] [--json] [--timing] +bws license [--components] bws --help bws --version ``` @@ -429,6 +467,7 @@ bws --version | `--live` | on `snapshot diff`, compare the file against this machine as it is now rather than against a second file | | `--note TEXT` | on `snapshot create`, what the snapshot was taken for, kept inside the file | | `--full` | on `show`, print the fields that are genuinely empty as well | +| `--components` | on `license`, turn the notice into every component inside this executable with its version, its licence and where it came from. It reads nothing - no service manager, no disk, no network - so it answers on a machine with no internet | Data goes to standard output and everything else to standard error, so `bws list --json | jq` works and a warning never lands in your JSON. diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index f0ea2c5..9ab1ff7 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -88,8 +88,30 @@ question: these two, and nothing else in the build output. ### The .NET runtime -A self-contained publish bundles the .NET runtime and libraries into the executable. Those are -MIT licensed, © .NET Foundation and Contributors - . +`Microsoft.NETCore.App.Runtime.win-x64` - + +A self-contained publish bundles the .NET runtime and libraries into the executable. MIT +licensed, © Microsoft Corporation and the .NET Foundation and Contributors. **Counted rather +than estimated on 2026-09-23**: 187 assemblies inside `bws.exe` and 186 inside +`BetterWindowsServices.exe`. + +### The .NET desktop runtime + +`Microsoft.WindowsDesktop.App.Runtime.win-x64` - + +**This is WPF itself, and until 2026-09-23 this file did not name it.** The section above said +"the .NET runtime and libraries" and that sentence covered, without saying so, the 53 further +assemblies that draw every window this program opens. MIT licensed, © Microsoft Corporation and +the .NET Foundation and Contributors. Only `BetterWindowsServices.exe` carries it: the command +line targets `net10.0-windows` for the Windows API surface, not for a user interface. + +### Which version of those two + +Whatever .NET built the file, rather than a number written here. The exact version is in the +SPDX document published beside each archive on the releases page, and `bws license --components` +prints the one the running program is on. A number in this file would be true on the machine that +wrote it and false on the next build, which is the failure this file already paid for once with +`log 0.4.33` in another project of the same owner. --- @@ -129,6 +151,19 @@ the answer to it. ## Keeping this true +**Since 2026-09-23 this file has a machine-readable twin.** `packaging/components.json` is the +curated register of the same set: name, version, SPDX licence identifier and where each one came +from. The SPDX document attached to every release is generated from it, `bws license +--components` prints it from inside the executable for a machine with no internet, and this file +is the third rendering - the one that carries the legal texts, which neither of the other two +can. `ComponentRegisterGuards` fails the build when the register names something this file does +not, in both directions, and when a pinned binary stops hashing to what the register pins. + +**Why a register at all, when a scanner could read the release.** Both programs publish as a +single self-contained file, so everything named above is inside an executable with no package +metadata left anywhere. A scan of what a user downloads would find two files and assign a licence +to neither. + `LicenceNoticeGuards` in `tests/Bws.Architecture.Tests` fails the build when a package is referenced by a shipped project and is not named in this file. A notice file that quietly stops matching the dependency list is worse than none, because it reads like a completed check. diff --git a/packaging/build-dist.ps1 b/packaging/build-dist.ps1 new file mode 100644 index 0000000..9a3a80f --- /dev/null +++ b/packaging/build-dist.ps1 @@ -0,0 +1,239 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Build both release packages: publish, stage, check the pinned bytes, pack, describe, sum. + +.DESCRIPTION + One command that produces exactly what a release publishes, so that the workflow on a tag and + a person at their own machine run the SAME steps rather than two spellings of them. + + In order: + + 1. publishes each package the way README.md promises it - one self-contained file, x64; + 2. checks the sha256 of every third-party binary the register PINS against the package it + came from. Only the binaries nobody else signs are pinned, and the reasoning for that + line is in packaging/components.json; + 3. stages the executable with LICENSE and THIRD-PARTY-NOTICES.md beside it, because the + licences on the borrowed code require their notices to travel with the binary; + 4. RUNS the command line program out of the staging folder - `--version` and `license` - + because a file that exists is not a file that runs, and publishing exits zero either + way. The window is not run: it would open a window and not come back; + 5. packs each folder into its archive; + 6. writes an SPDX document beside each archive, which is where the register is checked in + both directions against the build manifest; + 7. writes SHA256SUMS over everything. + + WHAT IT DOES NOT DO: sign, upload, tag, or publish anything. The release ritual is four + phases and this is the first half of the first one - see docs/02, ADR-28. + + THE SUMS AND DOCUMENTS IT WRITES DESCRIBE UNSIGNED BYTES. On a real release the archives are + repacked after signing, which changes every hash, and phase B regenerates both. They are + written here so that a local run is complete and checkable on its own. + +.PARAMETER OutputDirectory + Where the archives go. Default: dist/ at the repository root. + +.PARAMETER PackageId + Build only one package (cli or gui). Default: both. +#> +[CmdletBinding()] +param( + [string] $OutputDirectory, + [string] $PackageId +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$root = Split-Path -Parent $PSScriptRoot +$registerPath = Join-Path $PSScriptRoot 'components.json' +if (-not $OutputDirectory) { $OutputDirectory = Join-Path $root 'dist' } + +$register = Get-Content -Raw -LiteralPath $registerPath | ConvertFrom-Json +$ids = @($register.packages.PSObject.Properties.Name) +if ($PackageId) { + if ($ids -notcontains $PackageId) { throw "build-dist: no package '$PackageId' - the register knows $($ids -join ', ')" } + $ids = @($PackageId) +} + +# Everything that travels beside the executable, from the repository root. The licences on the +# borrowed code are the reason this list is not empty: MIT asks for its notice to be included in +# every copy, and GPL asks for its own text. +$Alongside = @('LICENSE', 'THIRD-PARTY-NOTICES.md') + +# The publish properties, written once. Two copies of these would be one copy that eventually +# says something else - and the deps.json path asked of MSBuild below only matches the publish if +# it is asked with the SAME properties. +$Publish = @('-c', 'Release', '-r', 'win-x64', '--self-contained', 'true', '-p:PublishSingleFile=true') + +function Invoke-Step([string[]] $Command) { + Write-Host " `$ $($Command -join ' ')" + & $Command[0] @($Command[1..($Command.Length - 1)]) + if ($LASTEXITCODE -ne 0) { throw "build-dist: '$($Command[0])' failed with exit $LASTEXITCODE" } +} + +function Get-Sha256([string] $path) { + (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() +} + +# Where NuGet put the packages. Asked of the tool rather than assembled from a profile path, +# because a build agent moves it with NUGET_PACKAGES and a guess would quietly check nothing. +function Get-PackageRoot { + $line = dotnet nuget locals global-packages --list + if ($LASTEXITCODE -ne 0) { throw 'build-dist: cannot ask dotnet where the global packages are' } + $folder = ($line | Select-Object -First 1) -replace '^[^:]*:\s*', '' + $folder = $folder.Trim() + if (-not (Test-Path -LiteralPath $folder)) { throw "build-dist: the global package folder '$folder' is not there" } + return $folder +} + +# The build manifest for a project, from MSBuild rather than from a path put together by hand. +# The shape of that path is the SDK's business and it has moved before. +function Get-DepsPath([string] $project) { + $answer = dotnet build (Join-Path $root $project) -getProperty:ProjectDepsFilePath ` + -p:Configuration=Release -p:RuntimeIdentifier=win-x64 -p:SelfContained=true + if ($LASTEXITCODE -ne 0) { throw "build-dist: cannot ask MSBuild for the deps.json path of $project" } + $path = ($answer | Where-Object { $_ -and $_.Trim() } | Select-Object -Last 1).Trim() + if (-not (Test-Path -LiteralPath $path)) { throw "build-dist: MSBuild named '$path' and there is no such file" } + return $path +} + +Write-Host "building into $OutputDirectory" +if (Test-Path -LiteralPath $OutputDirectory) { Remove-Item -LiteralPath $OutputDirectory -Recurse -Force } +New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null +$staging = Join-Path $OutputDirectory 'staging' +$packageRoot = Get-PackageRoot + +$written = [System.Collections.Generic.List[string]]::new() + +foreach ($id in $ids) { + $package = $register.packages.$id + Write-Host "`n== $id == $($package.zip)" + + $folder = Join-Path $staging $package.folder + New-Item -ItemType Directory -Path $folder -Force | Out-Null + + Write-Host "`n[1/6] publishing" + $out = Join-Path $OutputDirectory "publish-$id" + Invoke-Step (@('dotnet', 'publish', (Join-Path $root $package.project)) + $Publish + @('-o', $out)) + + $executable = Join-Path $out $package.executable + if (-not (Test-Path -LiteralPath $executable)) { + throw ("build-dist: the publish produced no $($package.executable). One file per program is what " + + 'section S9 of the specification promises, so this is a broken publish rather than a naming question.') + } + + Write-Host "`n[2/6] the pinned bytes" + $deps = Get-DepsPath $package.project + $resolved = @{} + foreach ($library in (Get-Content -Raw -LiteralPath $deps | ConvertFrom-Json).libraries.PSObject.Properties) { + $parts = $library.Name -split '/', 2 + $resolved[($parts[0] -replace '^runtimepack\.', '')] = $parts[1] + } + $pinned = 0 + foreach ($component in $register.components) { + if ($component.in -notcontains $id) { continue } + if ($component.PSObject.Properties.Name -notcontains 'files') { continue } + if (-not $resolved.ContainsKey($component.name)) { continue } + $version = $resolved[$component.name] + foreach ($file in $component.files) { + # The package id is lower cased in the folder layout NuGet writes, and the version is + # taken from the build rather than from the register so that this check follows a bump + # to the file that moved rather than to the one the register still names. + $onDisk = Join-Path $packageRoot (Join-Path $component.name.ToLowerInvariant() (Join-Path $version $file.package_path)) + if (-not (Test-Path -LiteralPath $onDisk)) { + throw ("build-dist: the register pins $($file.package_path) of $($component.name) $version and " + + "there is no such file at '$onDisk'. A pin that cannot find its file is a check that passes " + + 'by reading nothing, so this stops here.') + } + $actual = Get-Sha256 $onDisk + if ($actual -ne $file.sha256) { + throw ("build-dist: $($component.name) $version does not hash to what the register pins.`n" + + " pinned: $($file.sha256)`n actual: $actual`n" + + 'Either the version moved and the register has not - update it from the package on disk - or ' + + 'somebody replaced a binary on this machine. Nothing has been packed.') + } + Write-Host " ok $($component.name) $version $($file.path)" + $pinned++ + } + } + if ($pinned -eq 0) { Write-Host ' nothing in this package carries a pin' } + + Write-Host "`n[3/6] staging" + Copy-Item -LiteralPath $executable -Destination $folder + foreach ($name in $Alongside) { + $source = Join-Path $root $name + if (-not (Test-Path -LiteralPath $source)) { throw "build-dist: there is no $name at the repository root" } + Copy-Item -LiteralPath $source -Destination $folder + } + Write-Host " $($package.folder)/ carries $((Get-ChildItem -LiteralPath $folder).Count) files" + + Write-Host "`n[4/6] does it actually run" + if ($id -eq 'cli') { + $staged = Join-Path $folder $package.executable + $version = & $staged --version + if ($LASTEXITCODE -ne 0) { throw "build-dist: $($package.executable) --version exited $LASTEXITCODE" } + Write-Host " --version -> $($version | Select-Object -First 1)" + + # The licence notice has to find the register INSIDE the executable, which is the shape no + # test on a checkout exercises - a single-file publish is where an embedded resource goes + # missing without a word. + # + # JOINED INTO ONE STRING FIRST, AND THE FIRST VERSION OF THIS LINE DID NOT. A native + # program hands PowerShell an ARRAY of lines, and `-notmatch` over an array does not + # answer yes or no - it returns every element that does not match, which in an `if` is a + # non-empty collection and therefore true. So the check failed on a program whose output + # named all five components, on the first real run of this script. Measured here rather + # than reasoned about: docs/14 has the family. + $licence = (& $staged license --components) -join "`n" + if ($LASTEXITCODE -ne 0) { throw "build-dist: $($package.executable) license --components exited $LASTEXITCODE" } + foreach ($component in $register.components) { + if ($component.in -notcontains $id) { continue } + if ($licence -notmatch [regex]::Escape($component.name)) { + throw "build-dist: the packaged program does not name $($component.name) in its licence notice" + } + } + Write-Host " license --components names every component of this package" + } + else { + Write-Host ' not run: it would open a window and not come back' + } + + Write-Host "`n[5/6] packing" + $zip = Join-Path $OutputDirectory $package.zip + Compress-Archive -Path $folder -DestinationPath $zip -Force + $written.Add($zip) + Write-Host (" {0} {1:N1} MB" -f $package.zip, ((Get-Item -LiteralPath $zip).Length / 1MB)) + + Write-Host "`n[6/6] bill of materials" + $sbom = Join-Path $OutputDirectory ($package.zip + '.spdx.json') + # No $LASTEXITCODE check after this. Called with `&` the script runs in this runspace, so + # that variable holds whatever the last NATIVE command inside it set rather than the script's + # own outcome - and every failure in sbom.ps1 is a throw, which propagates here and stops + # this script. Trap 4 of docs/14, caught by tools/lint.ps1. + & (Join-Path $PSScriptRoot 'sbom.ps1') -PackageId $id -ZipPath $zip -DepsPath $deps -OutPath $sbom + $written.Add($sbom) + + # THE BUILD MANIFEST TRAVELS WITH THE ARCHIVE, and it is the seam to the card machine. + # + # Phase B signs the executable, which changes the archive, which changes its sha256 - so the + # document written a moment ago describes bytes nobody will ship and has to be written again + # over the signed ones. It cannot be: the resolved version of every runtime pack lives in this + # manifest, which exists only where the publish happened. + # + # It is NOT a release asset. It goes into the workflow artifact beside the archives, phase B + # reads it, and that is the end of it. + Copy-Item -LiteralPath $deps -Destination (Join-Path $OutputDirectory ($package.zip + '.deps.json')) + + Remove-Item -LiteralPath $out -Recurse -Force +} + +$sums = Join-Path $OutputDirectory 'SHA256SUMS' +$lines = $written | ForEach-Object { "{0} {1}" -f (Get-Sha256 $_), (Split-Path -Leaf $_) } +[System.IO.File]::WriteAllText($sums, ($lines -join "`n") + "`n", (New-Object System.Text.UTF8Encoding($false))) + +Write-Host "`n== built ==" +Get-ChildItem -LiteralPath $OutputDirectory -File | ForEach-Object { " {0,14:N0} {1}" -f $_.Length, $_.Name } +Write-Host '' +Write-Host 'These executables are NOT signed and these sums are over unsigned bytes.' +Write-Host 'On a release, phase B signs them on the card and writes both again.' diff --git a/packaging/codesign.json b/packaging/codesign.json new file mode 100644 index 0000000..aec3775 --- /dev/null +++ b/packaging/codesign.json @@ -0,0 +1,20 @@ +{ + "//": "The code-signing certificate this project's releases are signed with, pinned by fingerprint. Read by packaging/sign-release.ps1 to pick the certificate out of the Windows store, and again AFTER signing to check that the file was signed by THAT certificate and not by another one that happened to be on the machine.", + + "//what-is-not-here": "The certificate's subject. A certificate issued to an individual carries the holder's name, town and province, and every signed file carries those with it - that exposure is the certificate holder's to make and not the repository's. Only the fingerprint belongs here.", + + "//why-two-digests": "signtool selects a certificate by SHA-1 because that is the only selector it takes, and this file pins SHA-256 because that is the digest worth pinning. The script resolves one to the other at signing time, so the two can never drift apart in a config file.", + + "//renewal": "A RENEWAL IS A DIFFERENT CERTIFICATE, not the same one with a later date. When the card's certificate is renewed, this fingerprint has to move with it or the next release refuses to sign - and the script says so out loud from 90 days before expiry, so the refusal is never a surprise in the middle of a release with the card already in the reader.", + + "//where-this-came-from": "Copied on 2026-09-23 from the owner's other project, ChronoMock, on the owner's statement that the same certificate signs both products - and CHECKED the same day rather than left as a claim. `pwsh -File packaging/sign-release.ps1 -ListCertificates` found exactly one code-signing certificate in the store on the owner's machine, it answered THIS IS THE PINNED ONE, it reported a usable private key, and it had 331 days of validity left. That is the whole of what was verified: the fingerprint below matches a certificate this machine can reach.", + + "//what-that-check-did-NOT-prove": "That signing works. Nobody has signed anything with it from this repository - `signtool` reaches the card and waits for a PIN, so the first real proof is the first release, with a person at the machine. Run the ritual on a pre-release tag first if that matters.", + + "schema": "bws.codesign/1", + + "certificate_sha256": "47b79ad3cfa53ef846cad03a59148f8c981d0b1196891e48b8c8d7982b10c148", + + "//timestamp_url": "An RFC 3161 timestamp is not optional. Without it the signature dies when the certificate expires, and an open source code-signing certificate is valid for a year. The authority is the issuing CA's own.", + "timestamp_url": "http://time.certum.pl/" +} diff --git a/packaging/components.json b/packaging/components.json new file mode 100644 index 0000000..89d6ead --- /dev/null +++ b/packaging/components.json @@ -0,0 +1,131 @@ +{ + "//": "The curated register of everything Better Windows Services ships that somebody else wrote. This file is the SOURCE, not a report: the SPDX document attached to a release is generated FROM it, THIRD-PARTY-NOTICES.md carries the same set with the full licence texts, and `bws license --components` prints it for a user with no internet. One register, three renderings.", + + "//why-not-a-scan": "A scanner run over the built package would be the obvious alternative and is the wrong tool here for a reason particular to this product: BOTH programs publish as a SINGLE self-contained file. Everything below is INSIDE bws.exe and BetterWindowsServices.exe, so there is no Wpf.Ui.dll on disk for a scanner to find, no .deps.json beside the binary, and no package metadata left anywhere in the shipped bytes. A scan of the release would report two executables and nothing else, and would assign a licence to neither. The job of a scan here is to POLICE this register, not to replace it: packaging/build-dist.ps1 checks every entry below against the .deps.json the publish actually produced, in BOTH directions, by name.", + + "//how-versions-are-kept-true": "Two kinds of version live here and the difference is deliberate. A component we REFERENCE carries its version as a literal, because we chose it and a bump has to be noticed - `version`. A component the SDK resolves carries `version_from: build` instead, because its version is whatever .NET the build machine has, and a literal would be a number that is true on one machine and false on the next. The SPDX document is exact either way: it reads the resolved version out of the build manifest. What is never derived from the build is the LIST or the LICENCES.", + + "//version_at_runtime": "What the PROGRAM can state about a version the register does not carry, which is a different question from what the BUILD knows. `dotnet` means: read it from the runtime that is actually running. Measured 2026-09-23 - Environment.Version answered 10.0.12 and the runtime pack the publish resolved was 10.0.12, so the number is exact rather than approximate. It is absent on Microsoft.Windows.SDK.NET.Ref on purpose, and that absence is the measurement: the package is 10.0.17763.57, the file version of the assembly it ships is 10.0.17763.55, and that assembly's own version is 10.0.17763.38. Three plausible numbers, none of them the answer, so the program says where the exact one is written down instead of printing one that looks right.", + + "//measured": "Read on 2026-09-23 from a real `dotnet publish -c Release -r win-x64 --self-contained true` of both projects, and from the packages in the NuGet cache rather than from any listing. The command line package resolves 2 third-party components and the window package 5. Licence facts come from the LICENSE file inside each package: note that wpf-ui's nuspec says `Copyright (C) 2021-2026` while LICENSE.md inside the same package says `2021-2025`. The licence file is what the MIT terms require us to reproduce, so that is the one THIRD-PARTY-NOTICES.md quotes.", + + "schema": "bws.components/1", + + "product": { + "name": "Better Windows Services", + "supplier": "Person: DonislawDev", + "license": "GPL-3.0-or-later", + "copyright": "Copyright (C) 2026 DonislawDev", + "homepage": "https://betterwindowsservices.donislawdev.com", + "source": "https://github.com/donislawdev/BetterWindowsServices" + }, + + "//packages": "The two things a release publishes, named as README.md already names them. A component belongs to one or both, and each package gets its own SPDX document - a single one describing both would misstate each. `folder` is what the archive unzips to, and it is deliberately NOT versioned: a path that carries the version changes every release, and every check that opens the archive by path would have to learn each one.", + "packages": { + "gui": { + "zip": "BetterWindowsServices-win-x64.zip", + "folder": "BetterWindowsServices", + "executable": "BetterWindowsServices.exe", + "project": "src/Bws.Gui/Bws.Gui.csproj", + "description": "The window: one self-contained executable, no .NET to install" + }, + "cli": { + "zip": "bws-cli-win-x64.zip", + "folder": "bws", + "executable": "bws.exe", + "project": "src/Bws.Cli/Bws.Cli.csproj", + "description": "The command line: one self-contained executable, for scripts and scheduled work" + } + }, + + "//components": "kind is how the thing arrives and how the build manifest names it. `nuget` is a package we reference and appears in .deps.json as `/`. `dotnet-runtime-pack` is part of the self-contained runtime the publish bundles and appears as `runtimepack./`. license_declared is what the component itself states, license_concluded is the option we take under it - the difference is the whole reason SPDX has two fields, and for every entry here the two agree because none of these offers a choice.", + "components": [ + { + "name": "Microsoft.NETCore.App.Runtime.win-x64", + "version_from": "build", + "version_at_runtime": "dotnet", + "kind": "dotnet-runtime-pack", + "license_declared": "MIT", + "license_concluded": "MIT", + "supplier": "Microsoft Corporation", + "source": "https://github.com/dotnet/runtime", + "notice": "The .NET runtime", + "in": ["cli", "gui"], + "//": "187 assemblies in the command line package and 186 in the window package, measured 2026-09-23. The difference is not a mistake and is not worth chasing: the two publishes resolve the same runtime pack and trim nothing, and one assembly is claimed by the desktop pack in the window build instead." + }, + { + "name": "Microsoft.WindowsDesktop.App.Runtime.win-x64", + "version_from": "build", + "version_at_runtime": "dotnet", + "kind": "dotnet-runtime-pack", + "license_declared": "MIT", + "license_concluded": "MIT", + "supplier": "Microsoft Corporation", + "source": "https://github.com/dotnet/wpf", + "notice": "The .NET desktop runtime", + "in": ["gui"], + "//": "53 assemblies, and this is WPF itself. Only the window carries it: the command line targets net10.0-windows for the API surface, not for a user interface, so nothing in it asks for this pack." + }, + { + "name": "Microsoft.Windows.SDK.NET.Ref", + "version_from": "build", + "kind": "dotnet-runtime-pack", + "license_declared": "LicenseRef-WindowsSDK", + "license_concluded": "LicenseRef-WindowsSDK", + "supplier": "Microsoft Corporation", + "source": "https://aka.ms/WinSDKLicenseURL", + "notice": "Windows SDK projection for .NET", + "in": ["cli", "gui"], + "//": "Two assemblies, Microsoft.Windows.SDK.NET.dll and WinRT.Runtime.dll, and THEY ARE NOT UNDER AN OPEN SOURCE LICENCE. The package carries no SPDX expression at all - only a licenceUrl pointing at the Windows SDK terms - so this register names it the way SPDX names a licence that is not on its list, with a LicenseRef the generated document defines. THIRD-PARTY-NOTICES.md holds the reasoning about redistribution and says plainly that nobody qualified to give legal advice was asked." + }, + { + "name": "WPF-UI", + "version": "4.3.0", + "kind": "nuget", + "license_declared": "MIT", + "license_concluded": "MIT", + "supplier": "Leszek Pomianowski and WPF UI Contributors", + "source": "https://github.com/lepoco/wpfui", + "notice": "WPF-UI", + "in": ["gui"], + "files": [ + { + "path": "Wpf.Ui.dll", + "package_path": "lib/net10.0-windows7.0/Wpf.Ui.dll", + "sha256": "966176695819e39752211df271a53cee7df91f55a5e20ef9bf61fc7220fcaa1a" + } + ] + }, + { + "name": "WPF-UI.Abstractions", + "version": "4.3.0", + "kind": "nuget", + "license_declared": "MIT", + "license_concluded": "MIT", + "supplier": "Leszek Pomianowski and WPF UI Contributors", + "source": "https://github.com/lepoco/wpfui", + "notice": "WPF-UI.Abstractions", + "in": ["gui"], + "//": "Nobody asked for this one. It arrives as a dependency of WPF-UI and it ships, which is why it is here - the licence does not care which half of the dependency graph a library came from.", + "files": [ + { + "path": "Wpf.Ui.Abstractions.dll", + "package_path": "lib/net10.0/Wpf.Ui.Abstractions.dll", + "sha256": "defcd3554e362ce84be4dbbf98a18308705bf7c16eac5f483532df83cb1f87fe" + } + ] + } + ], + + "//files": "Why only these two files carry a pinned hash, said here because the omission elsewhere is a decision rather than an oversight. A `files` entry says: we ship somebody else's binary, and these are the bytes of it. Every OTHER binary in both packages is Microsoft's and carries Microsoft's own Authenticode signature, which is a stronger statement than a hash of ours and is checked by Windows rather than by us. Wpf.Ui.dll and Wpf.Ui.Abstractions.dll are the only shipped binaries their own publisher does not sign, so they are the only ones where a hash in this repository is the only thing standing between the release and a swapped file on the build machine. Measured 2026-09-23: the copy the publish produced is byte for byte the copy in the NuGet package.", + + "//the-cost-of-that-pin": "It is paid every time WPF-UI moves. A Dependabot bump changes the version in Bws.Gui.csproj and these hashes go stale in the same commit, so ComponentRegisterGuards turns red until they are updated. That is the intended shape - loud rather than quiet - and it is the opposite of a guard anchored to a number somebody else manages, which goes green and stops checking. Take the new hashes from the package on disk, not from a web page.", + + "//license-refs": "SPDX identifiers that are not on the SPDX list, defined in every document that uses one. Without this section a document naming LicenseRef-WindowsSDK fails validation, and an SBOM that fails validation is worse than none: it looks like an answer.", + "license_refs": { + "LicenseRef-WindowsSDK": { + "name": "Microsoft Software License Terms - Microsoft Windows Software Development Kit", + "url": "https://aka.ms/WinSDKLicenseURL" + } + } +} diff --git a/packaging/sbom.ps1 b/packaging/sbom.ps1 new file mode 100644 index 0000000..86fe1b0 --- /dev/null +++ b/packaging/sbom.ps1 @@ -0,0 +1,358 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Write an SPDX 2.3 bill of materials for one packaged archive, from the curated register. + +.DESCRIPTION + Reads packaging/components.json - the register of everything this product ships that + somebody else wrote - and renders one SPDX document for one package. The register is the + SOURCE. This script invents nothing: the licences and the list of components come from the + register, the exact versions of the runtime packs come from the build manifest the publish + produced, and the sha256 comes from the archive on disk. + + WHY THE LIST IS NOT TAKEN FROM THE ARCHIVE, which is the obvious alternative. Both programs + publish as a SINGLE self-contained file, so the archive holds one executable and two text + files. Everything else - WPF, the .NET runtime, WPF-UI - is inside that executable with no + package metadata left anywhere. A scanner over the release would report the two files it can + see and assign a licence to neither. + + WHAT THIS SCRIPT REFUSES, rather than papering over: + + * a package id the register does not know; + * a component the register puts in this package and the build manifest does not carry; + * a component the build manifest carries and the register does not name - the direction + that matters, because it is the one that appears when a dependency arrives by itself; + * a version literal in the register that disagrees with the build; + * a document that would not validate: a duplicate identifier, an identifier with a + character the format forbids, a relationship pointing at nothing, a missing required + field, or a LicenseRef used and never defined. + + ONE DOCUMENT PER ARCHIVE, not one for both. The two packages have different contents and a + single document describing both would misstate each of them. + + The document carries the archive's own sha256, so it is tied to exact bytes. That is also why + it sits BESIDE the archive in the release rather than inside it: a file cannot contain its + own hash. + +.PARAMETER PackageId + Which package to describe: a key of the register's `packages` object (cli or gui). + +.PARAMETER ZipPath + The archive that was built for it. Its sha256 goes into the document. + +.PARAMETER DepsPath + The .deps.json the publish produced for that package. This is where the resolved version of + every runtime pack is read from. Ask MSBuild for it with -getProperty:ProjectDepsFilePath + rather than assembling the path by hand - build-dist.ps1 does exactly that. + +.PARAMETER OutPath + Where to write the SPDX JSON. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $PackageId, + [Parameter(Mandatory)] [string] $ZipPath, + [Parameter(Mandatory)] [string] $DepsPath, + [Parameter(Mandatory)] [string] $OutPath +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$root = Split-Path -Parent $PSScriptRoot +$registerPath = Join-Path $PSScriptRoot 'components.json' + +function Assert-File([string] $path, [string] $why) { + if (-not (Test-Path -LiteralPath $path)) { throw "sbom: missing '$path' - $why" } +} + +Assert-File $registerPath 'the register is the source of this document' +Assert-File $ZipPath 'build the package before describing it' +Assert-File $DepsPath 'the publish writes it, and the runtime pack versions are read from it' + +$register = Get-Content -Raw -LiteralPath $registerPath | ConvertFrom-Json +$knownPackages = $register.packages.PSObject.Properties.Name +if ($knownPackages -notcontains $PackageId) { + throw "sbom: the register has no package '$PackageId' - it knows: $($knownPackages -join ', ')" +} +$package = $register.packages.$PackageId + +# The product version, read from the one file that sets it rather than retyped. Rule 11 of +# CLAUDE.md makes that number the owner's to move, and a second copy here would be a first place +# to forget. +$props = Get-Content -Raw -LiteralPath (Join-Path $root 'Directory.Build.props') +if ($props -notmatch '([^<]+)') { + throw 'sbom: cannot read from Directory.Build.props' +} +$productVersion = $Matches[1] + +$zipSha = (Get-FileHash -LiteralPath $ZipPath -Algorithm SHA256).Hash.ToLowerInvariant() +$zipItem = Get-Item -LiteralPath $ZipPath + +# --------------------------------------------------------------------------------------------- +# What the build actually resolved +# --------------------------------------------------------------------------------------------- + +# Every library the publish recorded, as name -> version. The manifest spells a runtime pack +# `runtimepack.Microsoft.NETCore.App.Runtime.win-x64/10.0.12` and a package `WPF-UI/4.3.0`, so +# the prefix is stripped here and the register's `kind` says which shape to expect. +# Ordinal-ignore-case: NuGet ids are compared without case everywhere else in this repository. +$deps = Get-Content -Raw -LiteralPath $DepsPath | ConvertFrom-Json +$resolved = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) +$ours = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + +foreach ($library in $deps.libraries.PSObject.Properties) { + $parts = $library.Name -split '/', 2 + if ($parts.Count -ne 2) { throw "sbom: '$($library.Name)' in $DepsPath is not /" } + $name = $parts[0] -replace '^runtimepack\.', '' + # A project is ours - this product's own assemblies. They are not third-party components and + # must not be demanded of the register. + if ($library.Value.type -eq 'project') { [void] $ours.Add($name); continue } + $resolved[$name] = $parts[1] +} + +# --------------------------------------------------------------------------------------------- +# The register, checked against it in both directions +# --------------------------------------------------------------------------------------------- + +$mine = @($register.components | Where-Object { $_.in -contains $PackageId }) +if ($mine.Count -eq 0) { + throw "sbom: the register lists no component in package '$PackageId', which cannot be right" +} + +$versions = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) +foreach ($component in $mine) { + $declared = $component.PSObject.Properties.Name -contains 'version' + $fromBuild = $component.PSObject.Properties.Name -contains 'version_from' + if ($declared -eq $fromBuild) { + throw ("sbom: '$($component.name)' must carry exactly one of `version` and `version_from` - " + + 'a component whose version we choose states it, one the SDK resolves does not') + } + if (-not $resolved.ContainsKey($component.name)) { + throw ("sbom: the register puts '$($component.name)' in package '$PackageId' and the build " + + "did not resolve it. Either it stopped shipping - take it out of the register - or the " + + "name drifted. What the build carries: $(($resolved.Keys | Sort-Object) -join ', ')") + } + $built = $resolved[$component.name] + if ($declared -and $component.version -ne $built) { + throw ("sbom: the register says '$($component.name)' is $($component.version) and the build " + + "resolved $built. Update the register - a document describing a different build than " + + 'the one in the archive is worse than no document.') + } + $versions[$component.name] = $built +} + +# THE DIRECTION THAT FINDS THINGS. A component we chose is in a project file and hard to forget. +# One that arrives as somebody else's dependency arrives with no prompt at all, ships, and creates +# exactly the same licence obligation - WPF-UI.Abstractions is in this register because a person +# noticed, not because anything asked. +$named = [System.Collections.Generic.HashSet[string]]::new( + [string[]] @($register.components | ForEach-Object { $_.name }), [System.StringComparer]::OrdinalIgnoreCase) +$unnamed = @($resolved.Keys | Where-Object { -not $named.Contains($_) }) +if ($unnamed) { + throw ("sbom: the build of '$PackageId' carries $($unnamed.Count) component(s) the register does not " + + "name: $(($unnamed | Sort-Object) -join ', '). Read the licence in the package on disk - not the " + + 'label on its listing - add it to packaging/components.json and to THIRD-PARTY-NOTICES.md.') +} +# And named for the right package. A component listed against the other package only would pass +# the sweep above and be missing from this document. +$mineNames = [System.Collections.Generic.HashSet[string]]::new( + [string[]] @($mine | ForEach-Object { $_.name }), [System.StringComparer]::OrdinalIgnoreCase) +$misfiled = @($resolved.Keys | Where-Object { -not $mineNames.Contains($_) }) +if ($misfiled) { + throw ("sbom: the build of '$PackageId' carries $(($misfiled | Sort-Object) -join ', '), which the " + + "register names but does not list in this package. Add '$PackageId' to its `in` list.") +} + +# --------------------------------------------------------------------------------------------- +# The document +# --------------------------------------------------------------------------------------------- + +# SPDX identifiers accept letters, digits, '.' and '-' and nothing else, so +# `Microsoft.NETCore.App.Runtime.win-x64` survives and anything else is rewritten. Collisions +# after the rewrite are checked rather than assumed away: two components sharing one identifier +# would produce a document that says different things about the same element. +function ConvertTo-SpdxId([string] $name) { + return "SPDXRef-Package-$($name -replace '[^A-Za-z0-9.\-]', '-')" +} + +function Get-Purl($component, [string] $version) { + switch ($component.kind) { + 'nuget' { "pkg:nuget/$($component.name)@$version" } + 'dotnet-runtime-pack' { "pkg:nuget/$($component.name)@$version" } + # A component on no registry has no package URL, and saying nothing is the honest answer - + # a made-up one resolves to somebody else's package. + default { $null } + } +} + +$stem = [System.IO.Path]::GetFileNameWithoutExtension($package.zip) +$rootId = ConvertTo-SpdxId $stem +$packages = [System.Collections.Generic.List[object]]::new() +$relationships = [System.Collections.Generic.List[object]]::new() +$usedRefs = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + +$packages.Add([ordered]@{ + SPDXID = $rootId + name = $stem + versionInfo = $productVersion + downloadLocation = "$($register.product.source)/releases/download/v$productVersion/$($package.zip)" + homepage = $register.product.homepage + filesAnalyzed = $false + licenseConcluded = $register.product.license + licenseDeclared = $register.product.license + copyrightText = $register.product.copyright + supplier = $register.product.supplier + checksums = @([ordered]@{ algorithm = 'SHA256'; checksumValue = $zipSha }) + comment = $package.description + }) +$relationships.Add([ordered]@{ + spdxElementId = 'SPDXRef-DOCUMENT' + relationshipType = 'DESCRIBES' + relatedSpdxElement = $rootId + }) + +# Ordinal, because SPDX identifiers are case-sensitive and a case-insensitive map would report a +# collision the format does not have. +$seen = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) + +foreach ($component in $mine) { + $id = ConvertTo-SpdxId $component.name + if ($seen.ContainsKey($id)) { + throw "sbom: '$($component.name)' and '$($seen[$id])' both become $id once made an SPDX identifier" + } + $seen[$id] = $component.name + + foreach ($licence in @($component.license_declared, $component.license_concluded)) { + if ($licence -like 'LicenseRef-*') { [void] $usedRefs.Add($licence) } + } + + $entry = [ordered]@{ + SPDXID = $id + name = $component.name + versionInfo = $versions[$component.name] + downloadLocation = $component.source + filesAnalyzed = $false + licenseConcluded = $component.license_concluded + licenseDeclared = $component.license_declared + # The register records who holds the copyright rather than the full notice. The notices + # themselves are reproduced in THIRD-PARTY-NOTICES.md, which ships inside the archive - + # the document comment below points a reader there. + copyrightText = $component.supplier + supplier = "Organization: $($component.supplier)" + } + + $purl = Get-Purl $component $versions[$component.name] + if ($purl) { + $entry.externalRefs = @([ordered]@{ + referenceCategory = 'PACKAGE-MANAGER' + referenceType = 'purl' + referenceLocator = $purl + }) + } + + # A pinned file is stated in the COMMENT, and never in `checksums`, which is where the first + # version of this put it. In SPDX a package's `checksums` is the hash of the PACKAGE FILE - + # for a component that arrives from NuGet that is the .nupkg - so a hash of one DLL inside it + # is a false statement that anybody checking the entry against the package would catch, and + # phase C would have attested it. Found by the review of PR #5. + # + # NOT moved into an SPDX `File` element either, which is the other repair the review offered. + # A File wants a SHA1 beside the SHA256 in SPDX 2.x, this register carries only the SHA256, + # and inventing a second hash to satisfy a schema would be a worse answer than a sentence. The + # pin is enforced by ComponentRegisterGuards and again by build-dist.ps1 before packing - the + # document records it, it does not police it. + if ($component.PSObject.Properties.Name -contains 'files') { + $pinned = ($component.files | ForEach-Object { "$($_.path) sha256 $($_.sha256)" }) -join '; ' + $entry.comment = "This build pins the bytes of: $pinned" + } + + $packages.Add($entry) + $relationships.Add([ordered]@{ + spdxElementId = $rootId + relationshipType = 'CONTAINS' + relatedSpdxElement = $id + }) +} + +$created = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') +$document = [ordered]@{ + spdxVersion = 'SPDX-2.3' + dataLicense = 'CC0-1.0' + SPDXID = 'SPDXRef-DOCUMENT' + name = "$stem-$productVersion" + # Unique per build, because it carries the hash of the exact archive this document describes. + documentNamespace = "$($register.product.homepage)/spdx/$stem/$productVersion/$($zipSha.Substring(0, 16))" + creationInfo = [ordered]@{ + created = $created + creators = @("Person: $(($register.product.supplier -split ':\s*', 2)[-1])", 'Tool: bws-sbom-1') + comment = ('Rendered from packaging/components.json, a register maintained by hand and checked ' + + 'in both directions against the .deps.json this publish produced. Not the output of a ' + + 'scanner over the built package: both programs ship as a single self-contained file, so a ' + + 'scan of the archive would find two executables and no package metadata at all.') + } + comment = ('Full licence texts for every component listed here are in ' + + 'THIRD-PARTY-NOTICES.md, which ships inside the archive. `bws license --components` prints ' + + 'the same set from inside the program, for a machine with no internet.') + packages = $packages + relationships = $relationships +} + +if ($usedRefs.Count -gt 0) { + $extracted = [System.Collections.Generic.List[object]]::new() + foreach ($ref in ($usedRefs | Sort-Object)) { + $known = $register.license_refs.PSObject.Properties.Name + if ($known -notcontains $ref) { + throw "sbom: '$ref' is used by a component and defined nowhere in the register's license_refs" + } + $definition = $register.license_refs.$ref + $extracted.Add([ordered]@{ + licenseId = $ref + name = $definition.name + extractedText = "See $($definition.url)" + seeAlsos = @($definition.url) + }) + } + $document.hasExtractedLicensingInfos = $extracted +} + +# --------------------------------------------------------------------------------------------- +# A self-check before anything is written +# --------------------------------------------------------------------------------------------- +# This document goes into a release and gets an attestation of its own. A malformed SBOM is worse +# than no SBOM: it looks like an answer. + +$ids = @($packages | ForEach-Object { $_.SPDXID }) +$unique = [System.Collections.Generic.HashSet[string]]::new([string[]] $ids, [System.StringComparer]::Ordinal) +if ($unique.Count -ne $ids.Count) { throw 'sbom: two packages share an SPDX identifier' } + +foreach ($id in $ids) { + if (($id -replace '^SPDXRef-', '') -notmatch '^[A-Za-z0-9.\-]+$') { + throw "sbom: identifier '$id' has a character the format does not allow" + } +} +foreach ($relationship in $relationships) { + foreach ($end in @($relationship.spdxElementId, $relationship.relatedSpdxElement)) { + if ($end -ne 'SPDXRef-DOCUMENT' -and -not $unique.Contains($end)) { + throw "sbom: a relationship points at '$end', which is in no package of this document" + } + } +} +foreach ($entry in $packages) { + foreach ($field in @('SPDXID', 'name', 'versionInfo', 'downloadLocation', 'licenseConcluded', + 'licenseDeclared', 'copyrightText')) { + if (-not $entry.$field) { throw "sbom: package '$($entry.name)' has no $field, which SPDX requires" } + } +} + +$json = $document | ConvertTo-Json -Depth 12 + +# Written without a byte order mark. A BOM makes the file fail some SPDX validators, and it is +# the same trap that has turned other things in this repository red before. +[System.IO.File]::WriteAllText($OutPath, $json, (New-Object System.Text.UTF8Encoding($false))) + +Write-Host ("== sbom == {0}: {1} component(s), archive {2:N1} MB, sha256 {3}..." -f ` + $package.zip, $mine.Count, ($zipItem.Length / 1MB), $zipSha.Substring(0, 12)) +foreach ($component in $mine) { + Write-Host (" {0} {1} {2}" -f $component.name, $versions[$component.name], $component.license_concluded) +} diff --git a/packaging/sign-release.ps1 b/packaging/sign-release.ps1 new file mode 100644 index 0000000..c62be65 --- /dev/null +++ b/packaging/sign-release.ps1 @@ -0,0 +1,524 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Phase B: sign a release build with the card, then hand it back to the workflow. + +.DESCRIPTION + The signing key lives on a cryptographic card in a reader and cannot be exported - that is + the whole value of it, so no GitHub-hosted runner will ever reach it. A self-hosted runner + could, and this is a PUBLIC repository, where a self-hosted runner is a machine strangers can + aim a pull request at. So the build happens in a workflow and the signature happens here, and + this script is the seam between them. + + In order, and what it refuses at each step: + + 1. downloads the unsigned build phase A produced for this tag; + 2. VERIFIES that build's provenance attestation before touching it - signing something you + did not check is how a supply chain gets a signature on it; + 3. signs OUR executables, and only ours, with an RFC 3161 timestamp. Without a timestamp + the signature dies when the certificate expires, and this one is valid for a year; + 4. reads the certificate back OUT of each signed file and refuses to go on unless it hashes + to the pin in packaging/codesign.json. A second code-signing certificate on the same + machine - a renewal, a test one, one from another project - is exactly this accident; + 5. repacks both archives, regenerates their bills of materials over the SIGNED bytes, and + writes SHA256SUMS over what will actually ship; + 6. uploads all five to the DRAFT release and asks phase C to attest the signed bytes; + 7. waits for that and confirms the draft is COMPLETE - a draft missing one file looks + almost exactly like a finished one. + + Nothing here publishes. The release stays a draft until a person reads it and presses the + button, and pressing it runs phase D, which re-checks the published page the way a user does. + + BE AT THE MACHINE. signtool reaches the card and then waits for its PIN, so this cannot run + unattended. Whether the card asks once or once per file depends on the card middleware's own + PIN caching, and there are two files here, so watch the first run before assuming. + +.PARAMETER Tag + The release tag, e.g. v0.1.0. + +.PARAMETER ListCertificates + Print every code-signing certificate in the store with its subject, fingerprints and expiry, + and do nothing else. This is how the pin in packaging/codesign.json is set, and how the + holder sees exactly which personal details a signature would make public. + +.PARAMETER DryRun + Everything except signing, uploading and dispatching. + +.PARAMETER Wait + How long to wait for phase C to attach its bundles, in seconds. +#> +[CmdletBinding()] +param( + [Parameter(Position = 0)] [string] $Tag, + [switch] $ListCertificates, + [switch] $DryRun, + [int] $Wait = 300, + [string] $Work +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$root = Split-Path -Parent $PSScriptRoot +$repo = 'donislawdev/BetterWindowsServices' +$attestWorkflow = 'attest-signed.yml' +if (-not $Work) { $Work = Join-Path $root 'build/signing' } + +# The Enhanced Key Usage OID for code signing. Matched by OID and never by the friendly name, +# because the friendly name is LOCALISED: on the Polish Windows this project is developed on, the +# same certificate reads "Podpisywanie kodu", and a filter written against "Code Signing" reports +# an empty store. That is rule 3 of CLAUDE.md - identity by immutable identifier - arriving in a +# place nobody expected it. +$CODE_SIGNING_OID = '1.3.6.1.5.5.7.3.3' + +$WARN_DAYS = 90 + +$register = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'components.json') | ConvertFrom-Json + +# EXACTLY THE BINARIES WE BUILD, per archive, by path inside the zip. Not a glob. +# +# Both halves publish as one self-contained file, so this is one path per archive - and that is +# the whole list, not a shortened one. Everything else inside those executables is Microsoft's +# and is not a file on disk at all: re-signing somebody else's binary would both destroy their +# signature and assert that we produced it. +# +# Derived from the register rather than written out again, because the register is where the +# archive layout is decided and ComponentRegisterGuards holds it against what the projects +# actually build. A second copy here would be a second answer to one question. +$OURS = @{} +$PACKAGE_ID = @{} +foreach ($entry in $register.packages.PSObject.Properties) { + $OURS[$entry.Value.zip] = @("$($entry.Value.folder)/$($entry.Value.executable)") + $PACKAGE_ID[$entry.Value.zip] = $entry.Name +} + +# What a complete draft carries. A missing one of these is a phase that did not finish. +$EXPECTED_ASSETS = @($OURS.Keys) + @($OURS.Keys | ForEach-Object { "$_.spdx.json" }) + @('SHA256SUMS') + +function Invoke-Step([string[]] $Command) { + Write-Host " `$ $($Command -join ' ')" + $output = & $Command[0] @($Command[1..($Command.Length - 1)]) 2>&1 + if ($LASTEXITCODE -ne 0) { + $output | ForEach-Object { Write-Host $_ } + throw "sign-release: '$($Command[0])' failed with exit $LASTEXITCODE" + } + return $output +} + +function Get-CodeSigningCertificates { + # Wrapped in @() at both levels on purpose. Under Set-StrictMode a certificate carrying no + # enhanced key usage at all makes a bare property walk throw rather than return nothing, and + # a Windows store holds several of those. + Get-ChildItem Cert:\CurrentUser\My, Cert:\LocalMachine\My -ErrorAction SilentlyContinue | + Where-Object { @($_.EnhancedKeyUsageList | ForEach-Object { $_.ObjectId }) -contains $CODE_SIGNING_OID } +} + +function Get-CertificateSha256($certificate) { + (([System.Security.Cryptography.SHA256]::Create().ComputeHash($certificate.RawData) | + ForEach-Object { $_.ToString('x2') }) -join '') +} + +function Find-SignTool { + $kits = 'C:\Program Files (x86)\Windows Kits\10\bin' + $found = @() + if (Test-Path -LiteralPath $kits) { + foreach ($version in (Get-ChildItem -LiteralPath $kits -Directory | Sort-Object Name)) { + $candidate = Join-Path $version.FullName 'x64\signtool.exe' + if (Test-Path -LiteralPath $candidate) { $found += $candidate } + } + } + if (-not $found) { + throw ("sign-release: no signtool.exe under $kits - install the Windows SDK " + + "('Windows SDK Signing Tools' is enough)") + } + return $found[-1] +} + +# The certificate expires on a known date, and a script that merely PRINTS that date draws no +# conclusion from it. The first release after expiry would then fail in the middle of the ritual, +# at the signing step, with the card already in the reader - the worst moment to learn of a +# certificate problem. Pure, so `now` is passed in and this can be reasoned about without a card. +function Get-ExpiryNotice($notAfter, [datetime] $now) { + if (-not $notAfter) { return @(' the store reported no expiry date - check the card by hand') } + $when = [datetime] $notAfter + $days = [int] ($when - $now).TotalDays + if ($days -lt 0) { + throw ("sign-release: the pinned certificate EXPIRED $(-$days) days ago ($($when.ToString('yyyy-MM-dd'))).`n" + + "Signing with it now produces a signature Windows will reject. Renew the certificate, then move`n" + + "certificate_sha256 in packaging/codesign.json to the NEW one - a renewal is a different`n" + + 'certificate, not the same one with a later date.') + } + if ($days -le $WARN_DAYS) { + return @(" WARNING: $days days left on this certificate ($($when.ToString('yyyy-MM-dd'))).", + ' Renewing issues a NEW certificate, so certificate_sha256 in packaging/codesign.json', + ' has to move with it or the next release refuses to sign at all.') + } + return @(" $days days left on the certificate") +} + +function Get-Pin { + $path = Join-Path $PSScriptRoot 'codesign.json' + if (-not (Test-Path -LiteralPath $path)) { throw "sign-release: missing the pin at $path" } + $pin = Get-Content -Raw -LiteralPath $path | ConvertFrom-Json + if (-not $pin.certificate_sha256 -or $pin.certificate_sha256 -notmatch '^[0-9a-f]{64}$') { + throw ('sign-release: packaging/codesign.json has no usable certificate_sha256. Run this script ' + + "with -ListCertificates, find the card's certificate and paste its sha256 there.") + } + if (-not $pin.timestamp_url) { throw 'sign-release: packaging/codesign.json has no timestamp_url' } + return $pin +} + +function Get-Sha256([string] $path) { + (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() +} + +# Every binary in a directory tree with the state of its Authenticode signature. Used before and +# after signing: the difference has to be exactly the files we meant to sign, which is what +# catches a path list that reached further than it should. +function Get-SignatureStates([string] $directory) { + $states = @{} + foreach ($file in Get-ChildItem -LiteralPath $directory -Recurse -Include *.exe, *.dll -File) { + $relative = $file.FullName.Substring($directory.Length).TrimStart('\', '/') -replace '\\', '/' + $states[$relative] = (Get-AuthenticodeSignature -LiteralPath $file.FullName).Status.ToString() + } + return $states +} + +# --------------------------------------------------------------------------------------------- +# -ListCertificates: the only mode that touches nothing +# --------------------------------------------------------------------------------------------- + +if ($ListCertificates) { + $pinned = $null + $path = Join-Path $PSScriptRoot 'codesign.json' + if (Test-Path -LiteralPath $path) { + $pinned = (Get-Content -Raw -LiteralPath $path | ConvertFrom-Json).certificate_sha256 + } + $certificates = @(Get-CodeSigningCertificates) + if (-not $certificates) { + Write-Host 'No code-signing certificate in the Windows store.' + Write-Host 'Plug in the card reader and check the card middleware can see the card.' + Write-Host "Matched by the code-signing OID $CODE_SIGNING_OID rather than by name, because the" + Write-Host 'friendly name is localised and a name filter reports an empty store on a localised Windows.' + return + } + Write-Host '' + Write-Host 'A certificate issued to an individual carries the holder name, town and province in its' + Write-Host 'subject, and every signed file carries that with it. The first signed release makes it' + Write-Host 'public and nothing takes it back. Read the subject below before signing anything.' + Write-Host '' + foreach ($certificate in $certificates) { + $sha256 = Get-CertificateSha256 $certificate + Write-Host "subject : $($certificate.Subject)" + Write-Host "issuer : $($certificate.Issuer)" + Write-Host "sha1 thumb : $($certificate.Thumbprint) (what signtool selects by)" + Write-Host "sha256 : $sha256 (what packaging/codesign.json pins)" + Write-Host "valid : $($certificate.NotBefore.ToString('yyyy-MM-dd')) .. $($certificate.NotAfter.ToString('yyyy-MM-dd'))" + Get-ExpiryNotice $certificate.NotAfter ([datetime]::Now) | ForEach-Object { Write-Host $_ } + Write-Host "private key : $($certificate.HasPrivateKey)" + if ($pinned -and $pinned -eq $sha256) { Write-Host ' THIS IS THE PINNED ONE' } + elseif ($pinned) { Write-Host ' not the pinned certificate' } + else { Write-Host ' nothing is pinned yet - paste the sha256 above into packaging/codesign.json' } + Write-Host '' + } + return +} + +# --------------------------------------------------------------------------------------------- +# The ritual +# --------------------------------------------------------------------------------------------- + +if (-not $Tag) { throw 'sign-release: give the tag, e.g. ./packaging/sign-release.ps1 v0.1.0' } +if (-not $IsWindows) { throw 'sign-release: the card lives on Windows, run this there' } + +# THE CHECKOUT HAS TO BE THE TAGGED COMMIT, and this is not tidiness - it is the difference +# between a document that describes the archive and one that describes whatever main looks like +# today. The archive comes from the TAG. Everything this script writes over it comes from the +# WORKING TREE: sbom.ps1 reads the version out of Directory.Build.props and the components out of +# packaging/components.json, and the signing list above is read from that same register. If main +# has moved since the tag - a version bump, a licence corrected, a component added - the document +# states the wrong version, the wrong download URL or the wrong licences for bytes that do not +# have them, and phase C attests exactly that. The check against deps.json cannot see any of it. +# Found by the review of PR #5. +$tagged = (git rev-parse "$Tag^{commit}" 2>$null) +if ($LASTEXITCODE -ne 0 -or -not $tagged) { + throw "sign-release: this checkout does not know the tag $Tag. Fetch it: git fetch --tags" +} +$head = (git rev-parse 'HEAD^{commit}') +if ($tagged.Trim() -ne $head.Trim()) { + throw ("sign-release: the working tree is not at $Tag.`n" + + " $Tag is $($tagged.Trim())`n HEAD is $($head.Trim())`n" + + "Everything written over the archive - the bill of materials, the version, the signing`n" + + "list - is read from THIS checkout, while the archive comes from the tag. Check out the`n" + + "tag first: git checkout $Tag") +} +if (git status --porcelain) { + throw ("sign-release: the working tree has uncommitted changes, and the documents written " + + 'over the signed archive are read from it. Commit, stash or clean them first.') +} + +# THE LOCAL IS NOT CALLED 'work', AND THAT IS NOT STYLE. PowerShell ignores case in variable +# names, so a local spelled that way and the parameter $Work above would be ONE variable, and +# assigning to it here would quietly overwrite the parameter. Trap 1 of docs/14, caught by +# tools/lint.ps1 on its first pass over this file. +$workspace = Join-Path $Work $Tag +if (Test-Path -LiteralPath $workspace) { Remove-Item -LiteralPath $workspace -Recurse -Force } +New-Item -ItemType Directory -Path $workspace -Force | Out-Null +Write-Host "working in $workspace" + +Write-Host "`n[1/8] fetching the build this tag produced" +# The generic "gh failed with exit 1" is true and unhelpful at the one step where somebody is +# most likely to be standing here for the first time. gh's own line is printed above it, and +# this says what to do about the two ways it fails. +try { + Invoke-Step @('gh', 'run', 'download', '--repo', $repo, '--name', "unsigned-build-$Tag", '--dir', $workspace) | Out-Null +} +catch { + throw ("sign-release: there is no build artefact called 'unsigned-build-$Tag'.`n" + + " Either phase A has not run for this tag - push the tag, or check the Release workflow -`n" + + " or it has expired. Phase A keeps it for 14 days, and re-running the workflow on the tag`n" + + " produces it again. Nothing has been signed.") +} +$archives = @(Get-ChildItem -LiteralPath $workspace -Filter *.zip -File) +if ($archives.Count -ne $OURS.Count) { + throw "sign-release: expected $($OURS.Count) archives in the artefact, got $($archives.Name -join ', ')" +} + +Write-Host "`n[2/8] verifying what the workflow says it built" +# --repo ALONE IS NOT ENOUGH, and the gap is the whole reason this step exists. It proves only +# that SOME workflow in this repository attested these bytes. release.yml also answers +# workflow_dispatch, and a dispatch from a BRANCH skips every tag-only check in it while still +# uploading an artefact called unsigned-build- - so a branch named like the tag would +# produce an artefact that reaches the card. Naming the workflow and the source ref closes it: +# the attestation has to come from release.yml, running on refs/tags/. +# +# Every flag below was read out of `gh attestation verify --help` on gh 2.101.0 rather than +# taken on trust. Found by the review of PR #5. +foreach ($archive in $archives) { + Invoke-Step @('gh', 'attestation', 'verify', $archive.FullName, '--repo', $repo, + '--signer-workflow', "$repo/.github/workflows/release.yml", + '--source-ref', "refs/tags/$Tag", + '--deny-self-hosted-runners') | Out-Null +} + +Write-Host "`n[3/8] unpacking" +$unpacked = @{} +$before = @{} +foreach ($archive in $archives) { + if (-not $OURS.ContainsKey($archive.Name)) { + throw "sign-release: the artefact carries '$($archive.Name)', which this script has no signing list for" + } + $target = Join-Path $workspace ('unpacked/' + [System.IO.Path]::GetFileNameWithoutExtension($archive.Name)) + Expand-Archive -LiteralPath $archive.FullName -DestinationPath $target -Force + $unpacked[$archive.Name] = $target + $before[$archive.Name] = Get-SignatureStates $target + foreach ($relative in $OURS[$archive.Name]) { + if (-not (Test-Path -LiteralPath (Join-Path $target $relative))) { + throw "sign-release: the signing list names '$relative' and $($archive.Name) does not carry it" + } + } + # The build manifest phase A handed over with the archives. Needed to write the bill of + # materials again over the signed bytes - see the note in build-dist.ps1. + $deps = Join-Path $workspace ($archive.Name + '.deps.json') + if (-not (Test-Path -LiteralPath $deps)) { + throw ("sign-release: the artefact has no $($archive.Name).deps.json. Phase A uploads it beside the " + + 'archive precisely so that the document can be written again over the signed bytes.') + } + Write-Host (" {0}: {1} binaries, {2} of them ours" -f $archive.Name, + $before[$archive.Name].Count, $OURS[$archive.Name].Count) +} + +Write-Host "`n[4/8] signing with the card" +$pin = Get-Pin +$certificate = Get-CodeSigningCertificates | Where-Object { (Get-CertificateSha256 $_) -eq $pin.certificate_sha256 } | Select-Object -First 1 +if (-not $certificate) { + throw ("sign-release: the pinned certificate ($($pin.certificate_sha256.Substring(0, 16))...) is not in the " + + 'Windows store. Plug in the card reader and check the middleware sees the card. If the certificate ' + + 'was renewed, certificate_sha256 in packaging/codesign.json has to move with it - run this script ' + + 'with -ListCertificates to see what is there.') +} +Write-Host " certificate: $(($certificate.Subject -split ',')[0])" +Get-ExpiryNotice $certificate.NotAfter ([datetime]::Now) | ForEach-Object { Write-Host $_ } +$signtool = Find-SignTool +Write-Host " signtool: $signtool" + +foreach ($archive in $archives) { + foreach ($relative in $OURS[$archive.Name]) { + $file = Join-Path $unpacked[$archive.Name] $relative + if ($DryRun) { + Write-Host " DRY RUN, would sign $relative" + continue + } + Invoke-Step @($signtool, 'sign', '/sha1', $certificate.Thumbprint, '/fd', 'sha256', + '/tr', $pin.timestamp_url, '/td', 'sha256', '/q', $file) | Out-Null + + # READ THE CERTIFICATE BACK OUT OF THE SIGNED FILE. A second code-signing certificate on + # this machine would sign just as willingly and the release page would look identical. + $signature = Get-AuthenticodeSignature -LiteralPath $file + if ($signature.Status -ne 'Valid') { + throw "sign-release: $relative came back with signature status $($signature.Status). Nothing has been uploaded." + } + $actual = Get-CertificateSha256 $signature.SignerCertificate + if ($actual -ne $pin.certificate_sha256) { + throw ("sign-release: $relative was signed by a DIFFERENT certificate.`n" + + " expected $($pin.certificate_sha256)`n got $actual`nNothing has been uploaded.") + } + if (-not $signature.TimeStamperCertificate) { + throw ("sign-release: $relative carries no timestamp. Without one the signature dies with the " + + 'certificate. Nothing has been uploaded.') + } + } + if (-not $DryRun) { + # Exactly the files we meant to sign changed state, and nobody else's signature broke. + # The comparison list is $OURS VERBATIM rather than a second list derived from the + # directory: a check that rebuilds the value it is checking against agrees with its own + # arithmetic and with nothing on disk. Note that -DryRun skips this block entirely, so a + # dry run cannot prove it. + $after = Get-SignatureStates $unpacked[$archive.Name] + $changed = @($after.Keys | Where-Object { $after[$_] -ne $before[$archive.Name][$_] }) + $unexpected = @($changed | Where-Object { @($OURS[$archive.Name]) -notcontains $_ }) + if ($unexpected) { + throw ("sign-release: signing changed files it should not have touched in $($archive.Name): " + + ($unexpected -join ', ')) + } + $broken = @($after.Keys | Where-Object { $before[$archive.Name][$_] -eq 'Valid' -and $after[$_] -ne 'Valid' }) + if ($broken) { + throw "sign-release: signing broke somebody else's signature in $($archive.Name): $($broken -join ', ')" + } + Write-Host (" {0}: {1} file(s) signed by the pinned certificate, timestamped, nothing else touched" -f + $archive.Name, $OURS[$archive.Name].Count) + } +} + +if ($DryRun) { + Write-Host "`ndry run finished - nothing was signed, uploaded or published" + return +} + +Write-Host "`n[5/8] repacking" +$shipped = @() +foreach ($archive in $archives) { + Remove-Item -LiteralPath $archive.FullName -Force + Compress-Archive -Path (Join-Path $unpacked[$archive.Name] '*') -DestinationPath $archive.FullName -Force + $shipped += $archive.FullName + Write-Host (" {0} {1}" -f $archive.Name, (Get-Sha256 $archive.FullName)) +} + +Write-Host "`n[6/8] bills of materials over the signed bytes, and the checksums" +# Regenerated HERE rather than reused from phase A. Each document carries the sha256 of the +# archive it describes, and repacking after signing changes that hash - a document generated +# before the signature would describe an archive nobody ships. Phase C then attests these against +# the signed bytes. +foreach ($archive in $archives) { + # `.spdx.json`, keeping the `.zip`, and the extension is load-bearing rather than + # cosmetic. GitHub sorts a release's assets by file name with no other lever, so keeping the + # archive's own name as a prefix puts the shorter one first and a reader meets the download + # before the document about it. + $sbom = Join-Path $workspace ($archive.Name + '.spdx.json') + # NO $LASTEXITCODE CHECK AFTER THIS, and its absence is deliberate. Called with `&` the + # script runs in this runspace, so $LASTEXITCODE is whatever the last NATIVE command inside + # it happened to set - not the script's own outcome. Every failure in sbom.ps1 is a throw, + # and a throw propagates here and stops this script under $ErrorActionPreference = 'Stop'. + # A check on $LASTEXITCODE would be a check on somebody else's number. Trap 4 of docs/14, + # caught by tools/lint.ps1. + & (Join-Path $PSScriptRoot 'sbom.ps1') -PackageId $PACKAGE_ID[$archive.Name] -ZipPath $archive.FullName ` + -DepsPath (Join-Path $workspace ($archive.Name + '.deps.json')) -OutPath $sbom + $shipped += $sbom +} +$sums = Join-Path $workspace 'SHA256SUMS' +$lines = $shipped | ForEach-Object { "{0} {1}" -f (Get-Sha256 $_), (Split-Path -Leaf $_) } +[System.IO.File]::WriteAllText($sums, ($lines -join "`n") + "`n", (New-Object System.Text.UTF8Encoding($false))) +$shipped += $sums +Write-Host " SHA256SUMS over $($shipped.Count - 1) files" + +Write-Host "`n[7/8] handing it back to the workflow" + +# THE OLD ATTESTATION BUNDLES GO FIRST, AND WITHOUT THIS A RE-RUN PRINTS PASS OVER A LIE. +# Step 8 waits for phase C by counting `.sigstore.json` assets on the draft. On a second run of +# this script the bundles from the FIRST run are still there, so that count is already satisfied +# the moment the wait starts: the loop breaks immediately, this script reports the draft +# complete, and the bundles on it describe the digests of the archives that were replaced a +# minute ago. Phase D then fails the README's own --bundle command - after publication, which is +# the one moment nobody wants to find out. Taking them off first makes the count mean what step 8 +# reads it as meaning. Found by the review of PR #5. +$stale = @($OURS.Keys | ForEach-Object { "$_.sigstore.json" }) +# One field, so no comma to be split - but the exit code is read for the same reason as in the +# wait loop below: a read that failed and a draft with no assets look identical from here, and +# the difference decides whether a stale bundle is left behind. +$draft = gh release view $Tag --repo $repo --json assets 2>$null | ConvertFrom-Json +if ($LASTEXITCODE -ne 0 -or -not $draft) { + throw "sign-release: cannot read the draft release $Tag. Phase A opens it - check that it ran for this tag." +} +$onDraft = @($draft.assets | ForEach-Object { $_.name }) +foreach ($bundle in ($stale | Where-Object { $onDraft -contains $_ })) { + Write-Host " removing the previous attestation bundle $bundle" + Invoke-Step @('gh', 'release', 'delete-asset', $Tag, $bundle, '--repo', $repo, '--yes') | Out-Null +} + +Invoke-Step (@('gh', 'release', 'upload', $Tag) + $shipped + @('--repo', $repo, '--clobber')) | Out-Null +$digests = $archives | ForEach-Object { "$($_.Name)=$(Get-Sha256 $_.FullName)" } +Invoke-Step @('gh', 'workflow', 'run', $attestWorkflow, '--repo', $repo, + '-f', "tag=$Tag", '-f', "digests=$($digests -join ',')") | Out-Null + +Write-Host "`n[8/8] confirming the draft is complete" +# This step exists because the script would otherwise end at "dispatched, go look". The upload and +# the dispatch are two calls, phase C is a third thing, and a half-finished draft looks almost +# exactly like a finished one. +$deadline = (Get-Date).AddSeconds([Math]::Max(0, $Wait)) +$signedDigests = @{} +foreach ($archive in $archives) { $signedDigests[$archive.Name] = Get-Sha256 $archive.FullName } +$names = @() +while ($true) { + # QUOTED, AND MEASURED RATHER THAN STYLED. In PowerShell argument mode a space ends a token, + # so `--json assets, isDraft` reaches gh as TWO arguments and it answers "accepts at most 1 + # arg(s), received 2" with exit 1 - checked against a real release on gh 2.101.0. With + # 2>$null and no exit check, that failure was SILENT: $view came back empty, the loop below + # saw no assets, and this script would have sat here until the timeout and then blamed + # phase C. Rule 8 of CLAUDE.md, in a comma. Found by the review of PR #5. + $view = gh release view $Tag --repo $repo --json 'assets,isDraft' 2>$null | ConvertFrom-Json + if ($LASTEXITCODE -ne 0 -or -not $view) { + throw ("sign-release: cannot read the release $Tag while waiting for phase C. Nothing has " + + 'been published, and the assets that were uploaded are still on the draft.') + } + $names = @($view.assets | ForEach-Object { $_.name }) + $missing = @($EXPECTED_ASSETS | Where-Object { $names -notcontains $_ }) + $bundles = @($names | Where-Object { $_.EndsWith('.sigstore.json') }) + if (-not $missing -and $bundles.Count -ge $archives.Count) { break } + if ((Get-Date) -gt $deadline) { + Write-Host " waited $Wait s and the draft is still incomplete." + if ($missing) { Write-Host " missing: $($missing -join ', ')" } + if ($bundles.Count -lt $archives.Count) { Write-Host " attestation bundles present: $($bundles.Count) of $($archives.Count)" } + Write-Host " assets present: $(($names | Sort-Object) -join ', ')" + throw ('sign-release: the draft is NOT complete. Nothing was published, so nothing is broken - but do ' + + "not press publish until the missing piece is there. Check the run log of $attestWorkflow. " + + 'Re-running this script is safe, the upload uses --clobber.') + } + Start-Sleep -Seconds 5 +} + +# The digests are checked against what the RELEASE carries, not against the local files we made - +# those are the same bytes only if the upload really landed. +$confirm = Join-Path $workspace 'confirm' +New-Item -ItemType Directory -Path $confirm -Force | Out-Null +Invoke-Step @('gh', 'release', 'download', $Tag, '--repo', $repo, '--pattern', 'SHA256SUMS', '--dir', $confirm) | Out-Null +$published = Get-Content -Raw -LiteralPath (Join-Path $confirm 'SHA256SUMS') +foreach ($name in $signedDigests.Keys) { + if ($published -notmatch [regex]::Escape($signedDigests[$name])) { + throw ("sign-release: SHA256SUMS on the release does NOT name the digest we signed for $name.`n" + + " signed: $($signedDigests[$name])`n published: $published") + } +} +$state = gh release view $Tag --repo $repo --json isDraft | ConvertFrom-Json +if ($LASTEXITCODE -ne 0 -or -not $state) { + throw "sign-release: cannot read whether $Tag is still a draft, so this cannot say that it is." +} +if (-not $state.isDraft) { + throw "sign-release: $Tag is NOT a draft any more - it is already public" +} + +Write-Host '' +foreach ($name in ($names | Sort-Object)) { Write-Host " asset $name" } +Write-Host ' PASS: every expected asset is there, the published checksums name the digests we signed, still a draft' +Write-Host '' +Write-Host 'Done. The release is still a DRAFT.' +Write-Host 'Read it, then publish. Publishing runs phase D, which re-checks the published bytes the way a user would.' diff --git a/site/i18n/en.json b/site/i18n/en.json index 4cc4297..03759e6 100644 --- a/site/i18n/en.json +++ b/site/i18n/en.json @@ -48,6 +48,7 @@ "switch.force": "On kill, end the process straight away without asking politely - the preview then shows one step instead of two. On snapshot create, write over a file that is already there.", "switch.restart": "On kill, bring the entry back once the process is gone, along with everything that shared it.", "switch.full": "On show, print the fields that are genuinely empty as well. A field nobody could read is printed either way.", + "switch.components": "On license, turn the notice into every component inside this executable with its version, its licence and where it came from. The same set the bill of materials published beside the download carries, because both are rendered from one register.", "switch.json": "The same document, machine readable, on standard output.", "switch.note": "What the snapshot was taken for, kept inside the file.", "switch.exit-code": "End with code 5 when anything differs. Off by default, so a script that only wants the differences printed is not tripped by finding some.", diff --git a/site/i18n/pl.json b/site/i18n/pl.json index 7e2cb2a..683a4d7 100644 --- a/site/i18n/pl.json +++ b/site/i18n/pl.json @@ -48,6 +48,7 @@ "switch.force": "Przy kill kończy proces od razu, bez uprzejmego pytania - podgląd pokazuje wtedy jeden krok zamiast dwóch. Przy snapshot create nadpisuje istniejący plik.", "switch.restart": "Przy kill podnosi wpis z powrotem, gdy proces zniknie, razem ze wszystkim, co ten proces dzieliło.", "switch.full": "Przy show drukuje także pola, które są naprawdę puste. Pole, którego nie dało się odczytać, drukuje się i tak.", + "switch.components": "Przy license zamienia notę w pełną listę: każdy składnik wewnątrz tego pliku wykonywalnego, z wersją, licencją i adresem źródeł. Ten sam zestaw, który niesie spis składników publikowany obok pobrania, bo oba powstają z jednego rejestru.", "switch.json": "Ten sam dokument, czytelny dla maszyny, na wyjściu standardowym.", "switch.note": "Po co snapshot został zrobiony - zapisane w środku pliku.", "switch.exit-code": "Kończy kodem 5, gdy cokolwiek się różni. Domyślnie wyłączone, żeby skrypt, który chce tylko zobaczyć różnice, nie wywracał się na tym, że je znalazł.", diff --git a/site/pages/cli-reference/en.html b/site/pages/cli-reference/en.html index 26d64bc..415645d 100644 --- a/site/pages/cli-reference/en.html +++ b/site/pages/cli-reference/en.html @@ -29,11 +29,12 @@

The commands

bws snapshot create [FILE] [--note TEXT] [--follow-network] [--force] [--json] [--timing] bws snapshot diff EARLIER LATER [--exit-code] [--json] [--timing] bws snapshot diff EARLIER --live [--exit-code] [--json] [--timing] +bws license [--components] bws --help bws --version

A mistyped verb is offered the one you probably meant, and the run ends with code 2 rather than doing something close to what you asked.

$ bws lst
-There is no command lst. There is: list, show, stop, start, restart, start-type, kill, snapshot.
+There is no command lst. Did you mean list? @@ -62,6 +63,10 @@

start-type - a setting, not a move

snapshot create and snapshot diff

create always reads signatures and hashes, because a snapshot is kept and compared later and one without them would compare against one with them as though the machine had changed. diff says what changed going from the first file to the second, or from a file to this machine with --live. The snapshot page has the format and the reasoning.

+
+

license - what is inside the file you downloaded

+

It reads nothing at all - no service manager, no disk, no network - and answers out of a register compiled into the executable. --components turns the notice into every component with its version, its licence and where it came from. The same set the bill of materials published beside the download carries, because both are rendered from one register, and the reason it is worth having: the program is one self-contained file, so there is nothing beside it for a scanner to read.

+
diff --git a/site/pages/cli-reference/pl.html b/site/pages/cli-reference/pl.html index 108d2e5..154948a 100644 --- a/site/pages/cli-reference/pl.html +++ b/site/pages/cli-reference/pl.html @@ -22,11 +22,12 @@

Komendy

bws snapshot create [FILE] [--note TEXT] [--follow-network] [--force] [--json] [--timing] bws snapshot diff EARLIER LATER [--exit-code] [--json] [--timing] bws snapshot diff EARLIER --live [--exit-code] [--json] [--timing] +bws license [--components] bws --help bws --version

Literówka w czasowniku dostaje podpowiedź, o który prawdopodobnie chodziło, a przebieg kończy się kodem 2, zamiast zrobić coś zbliżonego do tego, o co prosiłeś.

$ bws lst
-There is no command lst. There is: list, show, stop, start, restart, start-type, kill, snapshot.
+There is no command lst. Did you mean list? @@ -55,6 +56,10 @@

start-type - ustawienie, nie ruch

snapshot create i snapshot diff

create zawsze czyta podpisy i skróty, bo snapshot jest trzymany i porównywany później, a taki bez nich porównywałby się z takim z nimi tak, jakby maszyna się zmieniła. diff mówi, co się zmieniło w drodze z pierwszego pliku do drugiego, albo z pliku do tej maszyny przy --live. Strona o snapshotach niesie format i uzasadnienie.

+
+

license - co jest w środku pobranego pliku

+

Nie czyta niczego - ani menedżera usług, ani dysku, ani sieci - i odpowiada z rejestru wkompilowanego w plik wykonywalny. --components zamienia notę w pełną listę: każdy składnik z wersją, licencją i adresem źródeł. Ten sam zestaw, który niesie spis składników publikowany obok pobrania, bo oba powstają z jednego rejestru. I stąd jego wartość: program jest jednym plikiem samowystarczalnym, więc obok niego nie ma czego czytać.

+
diff --git a/src/Bws.Cli/Arguments.cs b/src/Bws.Cli/Arguments.cs index fe13342..a050624 100644 --- a/src/Bws.Cli/Arguments.cs +++ b/src/Bws.Cli/Arguments.cs @@ -74,6 +74,11 @@ internal static bool TryVerb(string argument, out CommandKind kind) // The word taskkill says it replaces, and the word PowerShell aliases Stop-Process // to. Somebody reaching for this has typed it before somewhere else. "kill" => CommandKind.Kill, + + // The word every other tool puts this under, and the spelling SPDX uses for the + // field. This repository writes the noun the British way in its own prose, and the + // command follows the tools rather than the prose - see CommandKind.License. + "license" => CommandKind.License, _ => CommandKind.None }; diff --git a/src/Bws.Cli/Bws.Cli.csproj b/src/Bws.Cli/Bws.Cli.csproj index 17649f7..d8f7b55 100644 --- a/src/Bws.Cli/Bws.Cli.csproj +++ b/src/Bws.Cli/Bws.Cli.csproj @@ -65,6 +65,30 @@ + + + diff --git a/src/Bws.Cli/CommandLine.Reading.cs b/src/Bws.Cli/CommandLine.Reading.cs index 5afebb0..35af405 100644 --- a/src/Bws.Cli/CommandLine.Reading.cs +++ b/src/Bws.Cli/CommandLine.Reading.cs @@ -51,6 +51,7 @@ internal static CommandLine Read(string[] arguments) var against = string.Empty; var exitCode = false; var live = false; + var components = false; string? note = null; string? badSubcommand = null; string? badTimeout = null; @@ -208,6 +209,7 @@ internal static CommandLine Read(string[] arguments) if (Arguments.Matches(argument, "--force")) { force = true; given.Add("--force"); continue; } if (Arguments.Matches(argument, "--restart")) { restart = true; given.Add("--restart"); continue; } if (Arguments.Matches(argument, "--exit-code")) { exitCode = true; given.Add("--exit-code"); continue; } + if (Arguments.Matches(argument, "--components")) { components = true; given.Add("--components"); continue; } if (Arguments.Matches(argument, "--live")) { live = true; given.Add("--live"); continue; } // Both spellings, because both are what people's fingers do. @@ -312,6 +314,7 @@ internal static CommandLine Read(string[] arguments) Against = against, ExitCodeOnDifference = exitCode, Live = live, + Components = components, Note = note, BadSubcommand = badSubcommand, BadVerb = badVerb, diff --git a/src/Bws.Cli/CommandLine.cs b/src/Bws.Cli/CommandLine.cs index 7112988..8c0087f 100644 --- a/src/Bws.Cli/CommandLine.cs +++ b/src/Bws.Cli/CommandLine.cs @@ -233,6 +233,16 @@ internal sealed partial record CommandLine /// internal bool Version { get; private init; } + /// + /// Somebody asked the licence question at its full depth. + /// + /// Without it the answer is the notice: what this program is under, that it comes with no + /// warranty, and the names of what it carries. With it, every component the release ships + /// with its version, its licence and where it came from - the same set the SPDX document + /// published beside the archive carries, because both are rendered from one register. + /// + internal bool Components { get; private init; } + /// /// A first word that is not a verb. /// @@ -278,6 +288,33 @@ internal sealed partial record CommandLine /// internal IReadOnlyList Misplaced { get; private init; } = []; + /// + /// Whether the reading produced no complaint of any kind. + /// + /// It exists for one caller and the reason is an ordering trap, not tidiness. + /// answers before Refusals does, because help and version are + /// questions about the tool rather than about a command - somebody typing --help after + /// a line that went wrong wants the help. license is answered in the same place and + /// must NOT inherit that: bws license --json would print the notice and swallow the + /// switch, which is exactly the silence the belonging table in + /// was built to end. So it answers only when there is nothing to refuse, and everything else + /// falls through to the sentence Refusals already writes. + /// + /// Every list of complaints on this record, named rather than counted. A list added + /// later and forgotten here would make this say yes to a line that has something wrong with + /// it - so LicenceCommandTests walks the whole option surface and proves that no + /// option belonging to another verb can be swallowed by this one. + /// + internal bool NothingWrong => + Rejected.Count == 0 + && Extra.Count == 0 + && Misplaced.Count == 0 + && Incomplete.Count == 0 + && Repeated.Count == 0 + && BadVerb is null + && BadSubcommand is null + && BadTimeout is null; + /// Which ask this is, when it is one. See . internal ActionKind Action => WriteCommands.AskedFor(Kind, Restart); diff --git a/src/Bws.Cli/Immediate.cs b/src/Bws.Cli/Immediate.cs index c94f673..3adc6dd 100644 --- a/src/Bws.Cli/Immediate.cs +++ b/src/Bws.Cli/Immediate.cs @@ -51,6 +51,26 @@ internal static class Immediate return ExitCode.Ok; } + // THE THIRD QUESTION THAT NEEDS NOTHING, AND THE ONE THAT WAITS FOR THE LINE TO BE CLEAN. + // + // It belongs here for the same reason as the two above: it reads no service manager, no + // disk and no network, so making it wait for a machine would be making it wait for + // something it never asks. It answers out of a register compiled into this executable. + // + // NOTHINGWRONG IS THE WHOLE DIFFERENCE, and leaving it out would have been a bug of + // exactly the kind the belonging table exists to prevent. Help and version are read + // BEFORE what somebody typed is judged, on purpose - a line that went wrong is still a + // line whose author may be asking for the help. A verb is not that: `bws license --json` + // under that rule would have printed the notice, exited zero and said nothing about the + // switch it ignored. So anything with a complaint against it falls through to Refusals, + // which already has the sentence for every one of them. + if (options.Kind == CommandKind.License && options.NothingWrong) + { + Output.Data(Licence.Answer(options.Components)); + + return ExitCode.Ok; + } + return null; } } diff --git a/src/Bws.Cli/Licence.cs b/src/Bws.Cli/Licence.cs new file mode 100644 index 0000000..c8feded --- /dev/null +++ b/src/Bws.Cli/Licence.cs @@ -0,0 +1,200 @@ +using System.Reflection; +using System.Text; +using System.Text.Json; + +namespace Bws.Cli; + +/// +/// What this program is licensed under, and what it carries that somebody else wrote. +/// +/// Answered out of a register compiled INTO the executable, and that is the whole point. +/// An administrator on a machine with no internet, holding one 98 MB file they are about to run +/// with administrator rights, can ask what is inside it and be answered by the file itself. A +/// link to a web page is not an answer on that machine. +/// +/// One register, three renderings. packaging/components.json is the source. The +/// SPDX document published beside every archive is rendered from it by +/// packaging/sbom.ps1, THIRD-PARTY-NOTICES.md carries the same set with the full +/// licence texts, and this is the third. They cannot drift apart while they are one file: +/// ComponentRegisterGuards holds the notices against the register, and +/// packaging/build-dist.ps1 holds the register against the manifest the publish produced, +/// in both directions. +/// +/// Only what THIS program carries. The register describes both packages and the window +/// ships three components this file does not - naming them here would be a list that reads like +/// an inventory of this binary and is not one. +/// +internal static class Licence +{ + /// + /// The register, linked into this project from packaging/ rather than copied into it. A copy + /// would be a second file to update and a first one to forget. + /// + private const string ResourceName = "Bws.Cli.Resources.components.json"; + + /// + /// Which package of the register this executable is. + /// + /// Written down rather than worked out, because there is nothing at runtime to work it out + /// from - and a wrong answer here is the one failure this whole file exists to avoid: a + /// component list that belongs to the other program. packaging/build-dist.ps1 runs the + /// packaged binary and checks that it names every component the register puts in this + /// package, which is the only place that can prove this constant right. + /// + private const string ThisPackage = "cli"; + + /// + /// A component as this program can state it: everything the register knows, with the version + /// already resolved to something true on this machine. + /// + private sealed record Component( + string Name, string Version, string Licence, string Supplier, string Source, string What); + + internal static string Answer(bool components) + { + using var document = JsonDocument.Parse(Register()); + var root = document.RootElement; + var mine = Components(root); + + return components ? Full(root, mine) : Summary(root, mine); + } + + /// The notice: what this is under, where the texts are, and what it carries. + private static string Summary(JsonElement root, IReadOnlyList mine) + { + var text = new StringBuilder(); + + text.AppendLine(Texts.Of( + "cli.licence.notice", + Product(root, "name"), + Release.Number, + Product(root, "license"), + Product(root, "copyright"))); + + // Named in prose rather than listed, because this half of the answer is for somebody + // asking "may I put this on a server", and the names are what they need to see. The + // version of each is the other half, under --components. + text.AppendLine(); + text.AppendLine(Texts.Of("cli.licence.carries", Executable(root))); + text.AppendLine(" " + string.Join(", ", mine.Select(component => component.What))); + text.AppendLine(); + text.AppendLine(Texts.Of("cli.licence.notices")); + text.Append(Texts.Of("cli.licence.more")); + + return text.ToString(); + } + + /// Every component, with its version, its licence and where it came from. + private static string Full(JsonElement root, IReadOnlyList mine) + { + var text = new StringBuilder(); + + text.AppendLine(Texts.Of("cli.licence.components", Executable(root), Release.Number)); + + foreach (var component in mine) + { + text.AppendLine(); + text.AppendLine(" " + component.Name); + text.AppendLine(" " + Texts.Of("cli.licence.what", component.What)); + text.AppendLine(" " + Texts.Of("cli.licence.at", component.Version)); + text.AppendLine(" " + Texts.Of("cli.licence.under", component.Licence)); + text.AppendLine(" " + Texts.Of("cli.licence.from", component.Supplier, component.Source)); + } + + text.AppendLine(); + text.Append(Texts.Of("cli.licence.notices")); + + return text.ToString(); + } + + private static IReadOnlyList Components(JsonElement root) + { + var mine = new List(); + + foreach (var entry in root.GetProperty("components").EnumerateArray()) + { + if (!entry.GetProperty("in").EnumerateArray().Any( + package => string.Equals(package.GetString(), ThisPackage, StringComparison.Ordinal))) + { + continue; + } + + mine.Add(new Component( + Text(entry, "name"), + Version(entry), + Text(entry, "license_concluded"), + Text(entry, "supplier"), + Text(entry, "source"), + Text(entry, "notice"))); + } + + // Rule 8 of CLAUDE.md on a very small thing. An empty list here would print a heading + // and nothing under it, which reads as "it carries nothing" - and this program carries + // the whole .NET runtime. Every way that could happen is a broken build rather than a + // true answer: a register that lost its entries, or the constant above naming a package + // nobody ships. + if (mine.Count == 0) + { + throw new InvalidOperationException( + $"The component register names nothing in package '{ThisPackage}'. This program " + + "carries a bundled .NET runtime, so an empty answer would be a false one."); + } + + return mine; + } + + /// + /// The version to state, and the two cases are not a style choice. + /// + /// A component we REFERENCE carries its version in the register, because we chose it. One the + /// SDK resolves does not, because its version is whatever .NET built this file - so the + /// register marks it and the number is read from the runtime that is actually running. + /// Measured 2026-09-23: Environment.Version answered 10.0.12 and the runtime pack the + /// publish resolved was 10.0.12. + /// + /// Why nothing is probed out of an assembly for the rest. Measured the same day on + /// Microsoft.Windows.SDK.NET, whose package version is 10.0.17763.57: the file version of the + /// assembly it ships is 10.0.17763.55 and its assembly version is 10.0.17763.38. Three + /// numbers, all plausible, none of them the answer. A number that looks right and is wrong is + /// worse than saying where the exact one is written down. + /// + private static string Version(JsonElement entry) + { + if (entry.TryGetProperty("version", out var stated)) + { + return stated.GetString() ?? string.Empty; + } + + if (entry.TryGetProperty("version_at_runtime", out var probe) + && string.Equals(probe.GetString(), "dotnet", StringComparison.Ordinal)) + { + return Environment.Version.ToString(); + } + + return Texts.Of("cli.licence.versionFromBuild"); + } + + private static string Text(JsonElement entry, string field) => + entry.GetProperty(field).GetString() ?? string.Empty; + + private static string Product(JsonElement root, string field) => + Text(root.GetProperty("product"), field); + + private static string Executable(JsonElement root) => + Text(root.GetProperty("packages").GetProperty(ThisPackage), "executable"); + + /// + /// Naming what the assembly does carry turns "it is missing" into "it is called something + /// else", which is the difference between a puzzle and a fix. The same reasoning, and the + /// same hard lesson, as one file over. + /// + private static Stream Register() + { + var assembly = Assembly.GetExecutingAssembly(); + + return assembly.GetManifestResourceStream(ResourceName) + ?? throw new InvalidOperationException( + $"The component register '{ResourceName}' is not in this executable. " + + $"It carries: [{string.Join(", ", assembly.GetManifestResourceNames())}]."); + } +} diff --git a/src/Bws.Cli/OptionSurface.cs b/src/Bws.Cli/OptionSurface.cs index 05e3e36..d1713de 100644 --- a/src/Bws.Cli/OptionSurface.cs +++ b/src/Bws.Cli/OptionSurface.cs @@ -97,7 +97,28 @@ internal enum CommandKind /// specification had already made it - and the OptionSurface.Surface of the command line is a frozen /// contract, so that would have been a breaking change bought by accident. /// - SnapshotDiff + SnapshotDiff, + + /// + /// What this program is licensed under, and what it carries that somebody else wrote. + /// + /// The one verb here that reads nothing at all - no service manager, no disk, no + /// network. It answers out of a register compiled into the executable, which is the whole + /// point of it: an administrator on a machine with no internet, holding a 98 MB file they + /// are about to run with administrator rights, can ask what is inside it and get an answer + /// from the file itself rather than from a web page. + /// + /// Spelled the American way while this repository writes the noun the British way. + /// The guard next door is LicenceNoticeGuards and the field in every bill of materials is + /// spelled `license`, as is the flag every other command line tool offers. The command is + /// the word a person types, so it follows the tools rather than our prose. + /// + /// A verb rather than a switch, owner's decision 2026-09-23. The alternative on the + /// table was bws --licenses beside --help and --version. Thirty lines of answer under + /// a flag reads as an option, and this is a question with two depths - the notice, and the + /// full register under --components. + /// + License } /// @@ -203,7 +224,18 @@ internal static readonly (string Option, CommandKind[] Verbs)[] Surface = // the manager answers when the configuration is written and there is no state to wait for - // so either switch here would be a word that does nothing, which is the silence this table // was built to end. - ("--timeout", [CommandKind.Stop, CommandKind.Start, CommandKind.Restart, CommandKind.Kill]) + ("--timeout", [CommandKind.Stop, CommandKind.Start, CommandKind.Restart, CommandKind.Kill]), + + // The only verb with anything to say about components, and NOT accepted anywhere else + // even though a listing could be imagined to carry one. It turns the notice into the + // whole register: every component with its version, its licence and where it came from. + // + // NOT --json, and that omission is a decision rather than an oversight. The + // machine-readable rendering of these exact facts is the SPDX document published beside + // every archive, and a second JSON shape for one set of facts is a second public + // contract to keep true. Adding it later is additive and breaks nothing; taking it away + // would not be. + ("--components", [CommandKind.License]) ]; /// @@ -276,8 +308,14 @@ .. Surface.Single(entry => entry.Option == option).Verbs.Select(Spelling) /// somebody is far likelier to have meant - it is three words shorter and it is what people /// come to this tool for. /// + /// + /// license is LAST, and the order is doing the same work it does for start-type above. + /// A mistyped word equally close to two commands is offered the earlier one, and nothing + /// somebody types at three in the morning on a server was meant to be this. It is also the + /// only verb here that is read far more often than it is typed. + /// internal static IReadOnlyList Verbs => - ["list", "show", "stop", "start", "restart", "start-type", "kill", "snapshot"]; + ["list", "show", "stop", "start", "restart", "start-type", "kill", "snapshot", "license"]; /// /// Whether the command is about ONE entry somebody named, rather than about whatever a query @@ -320,6 +358,12 @@ internal static bool TakesAName(CommandKind kind) => CommandKind.SnapshotCreate => "cli.takes.oneFile", CommandKind.SnapshotDiff => "cli.takes.twoFiles", + // The only verb here that takes no words at all, so the sentence it feeds says that + // rather than naming a shape. Somebody typing `bws license GPL` is asking a question the + // verb cannot narrow - the answer is the same either way and pretending otherwise would + // be worse than saying so. + CommandKind.License => "cli.takes.nothing", + // Show, stop, start and restart. Not a default arm that guesses, for the reason For gives // in the window: a fifth shape must fail here loudly rather than quietly claim to take one // name when it does not. diff --git a/src/Bws.Cli/Resources/cli.en.json b/src/Bws.Cli/Resources/cli.en.json index 8b67546..1a22f87 100644 --- a/src/Bws.Cli/Resources/cli.en.json +++ b/src/Bws.Cli/Resources/cli.en.json @@ -1,4 +1,4 @@ -{ +{ "_meta": { "code": "en", "name": "English", @@ -10,7 +10,7 @@ "cli.unknownCommand": "There is no command {0}. There is: {1}.", "cli.unknownCommandDidYouMean": "There is no command {0}. Did you mean {1}?", - "cli.usage": "Examples:\n bws list --query \"start:auto !status:running\" what should be up and is not\n bws show Spooler everything known about one entry\n bws stop Spooler --dry-run --dependents what stopping it would take down\n bws start-type Spooler manual --dry-run what taking it off automatic would do\n bws kill Spooler --dry-run what ending its process would take with it\n bws snapshot create before.json freeze the machine before a change\n\nUsage:\n bws [-h|--help] [--version]\n bws list [--query TEXT] [--signatures] [--memory] [--required-by] [--follow-network]\n [--json] [--timing]\n bws show NAME [--full] [--follow-network] [--json] [--timing]\n bws stop|start|restart NAME [--dry-run] [--dependents] [--timeout SECONDS] [--json] [--timing]\n bws kill NAME [--force] [--restart] [--dry-run] [--dependents] [--timeout SECONDS] [--json] [--timing]\n bws start-type NAME automatic|manual|disabled [--dry-run] [--json] [--timing]\n bws snapshot create [FILE] [--note TEXT] [--follow-network] [--force] [--json] [--timing]\n bws snapshot diff EARLIER LATER [--exit-code] [--json] [--timing]\n bws snapshot diff EARLIER --live [--exit-code] [--json] [--timing]\n\n --signatures reads who signed each binary. Several seconds, so it is off unless asked.\n --memory reads what each running entry's process is using. Fast, and off by default\n because it is a measurement rather than a setting: it is different a second later.\n A query about either turns that one on by itself.\n --required-by reads which entries break if one is stopped. Windows is asked directly\n rather than the answer being worked out from what everything declares, so entries grouped\n by a load order name are counted too. It costs a call per entry, so it is off unless asked.\n show and snapshot create read it every time, without a switch.\n --follow-network lets the tool reach off this machine at all. Two things need that,\n and both are off unless you ask.\n One is a launch path that lives on somebody else's share. One unreachable share costs\n twenty one seconds, and the connection authenticates as whoever ran it. Without the\n switch the disk question for such an entry is reported as not read, never as missing,\n and the path itself is still shown.\n The other is checking a signature whose certificate chain this machine does not already\n hold: left to itself Windows goes and fetches the missing certificate, which is a\n connection to a third party in the middle of an ordinary listing. Without the switch\n the check uses only what is here, and any result that might have been caused by not\n looking is reported as unread rather than as a verdict about the certificate - so this\n never calls a certificate bad on the strength of not having looked it up. A file this\n machine can verify on its own reads the same either way.\n --timeout is how long the tool waits for one step to reach the state it asked for,\n counted from the moment the manager accepts the request. Sixty seconds unless you say\n otherwise. Running out of it is not a failure, it is the end of watching: the report\n says where the entry was left, and an entry left stopping usually arrives by itself.\n It is not a cap on how long the command takes. The manager answers in its own time,\n and for a service that never reports itself that answer takes tens of seconds - the\n report says so when it happens.\n\n kill is for a service that will not stop. It asks politely first and ends the process\n behind the entry only if that does not work, so an entry which stops on its own is never\n ended - the preview shows both steps and the second one says it is conditional.\n Ending a process is the one thing this tool does that nobody can refuse on the machine's\n behalf, and it takes every other service living in that process with it whether or not\n they stopped first. The preview names them, and names the process by number.\n --force skips asking politely and ends the process straight away. It changes the plan\n rather than the running of it, so the preview shows one step instead of several and the\n difference is visible before anything happens.\n --restart brings the entry back once the process is gone, along with anything that shared\n it. Without it the machine is left with those services stopped, and the report says how to\n start them again.\n Windows spells this idea --force on Stop-Service and means something else by it - there it\n means \"even if something depends on it\", which is what --dependents does here. That is why\n this is a verb of its own rather than a switch on stop.\n\n show prints everything this tool knows about one entry, including the parts a listing\n leaves out unless asked: the signature, the privileges, the security descriptor and the\n memory. It reads all of them every time, because over one entry that costs about sixty\n milliseconds where over the whole machine it costs a second.\n Fields that are genuinely empty are left out. --full prints those as well. A field\n nobody could read is printed either way, because leaving one out would look like an\n answer.\n --json gives the same document bws list --json gives for that entry, on its own rather\n than inside an array of one.\n\n start-type says what the manager will do with an entry at the next boot. It changes a\n setting and moves nothing: an entry that is running keeps running, and one that is\n stopped stays stopped. Disabled is the one worth pausing over, because it stops the\n manager starting the entry at all - including on demand, for something else that needs\n it. Neither --dependents nor --timeout applies here: nothing comes down with a setting\n and there is no state to wait for.\n An automatic entry can also be marked to start late, and this tool has no word for that\n state: automatic here does not say whether the entry starts at boot or after it. So\n after changing the start type of such an entry, the report offers no way back rather\n than a line whose effect depends on a flag it does not name.\n\n snapshot create always reads signatures and hashes, because a snapshot is kept and\n compared later, and one without them would compare against one with them as though the\n machine had changed. Without a file name it writes into the current directory.\n A snapshot describes the whole machine and is worth filing accordingly: every launch\n path and file hash, the account each entry runs as, its privileges and its security\n descriptor, and the name of this machine and of the account that took it. It is written\n with whatever permissions its directory already gives it, and this tool narrows nothing -\n so a directory other people can read is one they can read all of that in.\n --note says what the snapshot was taken for.\n --force here writes over a file that is already there. Without it, an existing file is left\n alone and the command ends without writing - a snapshot is kept for months, and this\n will not replace one, or anything else, unless told to.\n\n snapshot diff says what changed going from the first file to the second. Configuration\n differences and running state are reported apart, because two snapshots taken a day\n apart differ in what was running and almost none of it is drift.\n --exit-code ends with code 5 when anything differs. Off by default, so a script that\n only wants the differences printed is not tripped by finding some.\n --live compares the file against this machine as it is now. It reads signatures and\n hashes, like snapshot create, because the file it is compared against has them.\n\n Every command, switch and exit code, laid out to be read: https://betterwindowsservices.donislawdev.com/cli-reference/", + "cli.usage": "Examples:\n bws list --query \"start:auto !status:running\" what should be up and is not\n bws show Spooler everything known about one entry\n bws stop Spooler --dry-run --dependents what stopping it would take down\n bws start-type Spooler manual --dry-run what taking it off automatic would do\n bws kill Spooler --dry-run what ending its process would take with it\n bws snapshot create before.json freeze the machine before a change\n\nUsage:\n bws [-h|--help] [--version]\n bws list [--query TEXT] [--signatures] [--memory] [--required-by] [--follow-network]\n [--json] [--timing]\n bws show NAME [--full] [--follow-network] [--json] [--timing]\n bws stop|start|restart NAME [--dry-run] [--dependents] [--timeout SECONDS] [--json] [--timing]\n bws kill NAME [--force] [--restart] [--dry-run] [--dependents] [--timeout SECONDS] [--json] [--timing]\n bws start-type NAME automatic|manual|disabled [--dry-run] [--json] [--timing]\n bws snapshot create [FILE] [--note TEXT] [--follow-network] [--force] [--json] [--timing]\n bws snapshot diff EARLIER LATER [--exit-code] [--json] [--timing]\n bws snapshot diff EARLIER --live [--exit-code] [--json] [--timing]\n bws license [--components]\n\n license says what this program is under and what it carries that somebody else\n wrote. It reads nothing at all - no service manager, no disk, no network - so it\n answers on a machine with no internet. --components turns the notice into every\n component with its version, its licence and where it came from.\n\n --signatures reads who signed each binary. Several seconds, so it is off unless asked.\n --memory reads what each running entry's process is using. Fast, and off by default\n because it is a measurement rather than a setting: it is different a second later.\n A query about either turns that one on by itself.\n --required-by reads which entries break if one is stopped. Windows is asked directly\n rather than the answer being worked out from what everything declares, so entries grouped\n by a load order name are counted too. It costs a call per entry, so it is off unless asked.\n show and snapshot create read it every time, without a switch.\n --follow-network lets the tool reach off this machine at all. Two things need that,\n and both are off unless you ask.\n One is a launch path that lives on somebody else's share. One unreachable share costs\n twenty one seconds, and the connection authenticates as whoever ran it. Without the\n switch the disk question for such an entry is reported as not read, never as missing,\n and the path itself is still shown.\n The other is checking a signature whose certificate chain this machine does not already\n hold: left to itself Windows goes and fetches the missing certificate, which is a\n connection to a third party in the middle of an ordinary listing. Without the switch\n the check uses only what is here, and any result that might have been caused by not\n looking is reported as unread rather than as a verdict about the certificate - so this\n never calls a certificate bad on the strength of not having looked it up. A file this\n machine can verify on its own reads the same either way.\n --timeout is how long the tool waits for one step to reach the state it asked for,\n counted from the moment the manager accepts the request. Sixty seconds unless you say\n otherwise. Running out of it is not a failure, it is the end of watching: the report\n says where the entry was left, and an entry left stopping usually arrives by itself.\n It is not a cap on how long the command takes. The manager answers in its own time,\n and for a service that never reports itself that answer takes tens of seconds - the\n report says so when it happens.\n\n kill is for a service that will not stop. It asks politely first and ends the process\n behind the entry only if that does not work, so an entry which stops on its own is never\n ended - the preview shows both steps and the second one says it is conditional.\n Ending a process is the one thing this tool does that nobody can refuse on the machine's\n behalf, and it takes every other service living in that process with it whether or not\n they stopped first. The preview names them, and names the process by number.\n --force skips asking politely and ends the process straight away. It changes the plan\n rather than the running of it, so the preview shows one step instead of several and the\n difference is visible before anything happens.\n --restart brings the entry back once the process is gone, along with anything that shared\n it. Without it the machine is left with those services stopped, and the report says how to\n start them again.\n Windows spells this idea --force on Stop-Service and means something else by it - there it\n means \"even if something depends on it\", which is what --dependents does here. That is why\n this is a verb of its own rather than a switch on stop.\n\n show prints everything this tool knows about one entry, including the parts a listing\n leaves out unless asked: the signature, the privileges, the security descriptor and the\n memory. It reads all of them every time, because over one entry that costs about sixty\n milliseconds where over the whole machine it costs a second.\n Fields that are genuinely empty are left out. --full prints those as well. A field\n nobody could read is printed either way, because leaving one out would look like an\n answer.\n --json gives the same document bws list --json gives for that entry, on its own rather\n than inside an array of one.\n\n start-type says what the manager will do with an entry at the next boot. It changes a\n setting and moves nothing: an entry that is running keeps running, and one that is\n stopped stays stopped. Disabled is the one worth pausing over, because it stops the\n manager starting the entry at all - including on demand, for something else that needs\n it. Neither --dependents nor --timeout applies here: nothing comes down with a setting\n and there is no state to wait for.\n An automatic entry can also be marked to start late, and this tool has no word for that\n state: automatic here does not say whether the entry starts at boot or after it. So\n after changing the start type of such an entry, the report offers no way back rather\n than a line whose effect depends on a flag it does not name.\n\n snapshot create always reads signatures and hashes, because a snapshot is kept and\n compared later, and one without them would compare against one with them as though the\n machine had changed. Without a file name it writes into the current directory.\n A snapshot describes the whole machine and is worth filing accordingly: every launch\n path and file hash, the account each entry runs as, its privileges and its security\n descriptor, and the name of this machine and of the account that took it. It is written\n with whatever permissions its directory already gives it, and this tool narrows nothing -\n so a directory other people can read is one they can read all of that in.\n --note says what the snapshot was taken for.\n --force here writes over a file that is already there. Without it, an existing file is left\n alone and the command ends without writing - a snapshot is kept for months, and this\n will not replace one, or anything else, unless told to.\n\n snapshot diff says what changed going from the first file to the second. Configuration\n differences and running state are reported apart, because two snapshots taken a day\n apart differ in what was running and almost none of it is drift.\n --exit-code ends with code 5 when anything differs. Off by default, so a script that\n only wants the differences printed is not tripped by finding some.\n --live compares the file against this machine as it is now. It reads signatures and\n hashes, like snapshot create, because the file it is compared against has them.\n\n Every command, switch and exit code, laid out to be read: https://betterwindowsservices.donislawdev.com/cli-reference/", "cli.unknownOption": "Unknown option: {0}", "cli.wordsNotTaken": "{0} takes {1}. Nothing here can use: {2}.", "cli.takes.oneName": "one name", @@ -18,6 +18,18 @@ "cli.takes.query": "no name - it narrows with --query", "cli.takes.oneFile": "one file name at most", "cli.takes.twoFiles": "two file names", + "cli.takes.nothing": "no words at all - the answer is the same either way", + + "cli.licence.notice": "{0} {1}\n{3}\n\nThis program is free software under {2}: you are free to change and redistribute it.\nIt comes with ABSOLUTELY NO WARRANTY, to the extent permitted by law.\nThe full text is in LICENSE, beside this executable, and at https://www.gnu.org/licenses/gpl-3.0.html", + "cli.licence.carries": "{0} also carries software written by other people:", + "cli.licence.components": "Third-party components inside {0} {1}:", + "cli.licence.what": "what {0}", + "cli.licence.at": "version {0}", + "cli.licence.under": "licence {0}", + "cli.licence.from": "from {0} - {1}", + "cli.licence.versionFromBuild": "chosen by the build - the exact number is in the bill of materials beside the download", + "cli.licence.notices": "Their licences are reproduced in full in THIRD-PARTY-NOTICES.md, which ships beside this\nexecutable. Nothing here is a lawyer's reading of them.", + "cli.licence.more": "Run `bws license --components` for every one of them with its version and licence.", "cli.snapshot.fileExists": "There is already a file at {0}. A snapshot is kept and compared later, so this will not replace one without being told to. Pick another name, or add --force.", "cli.snapshot.quarantined": "What was at {0} could not be read as a snapshot, so it was kept rather than replaced. It is now at {1}.", "cli.snapshot.cannotQuarantine": "What was at {0} could not be read as a snapshot and could not be moved aside either, so nothing was written. {1}", diff --git a/tests/Bws.Architecture.Tests/ComponentRegisterGuards.cs b/tests/Bws.Architecture.Tests/ComponentRegisterGuards.cs new file mode 100644 index 0000000..559e41f --- /dev/null +++ b/tests/Bws.Architecture.Tests/ComponentRegisterGuards.cs @@ -0,0 +1,393 @@ +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Bws.Architecture.Tests; + +/// +/// The register of what this product ships that somebody else wrote, held against the build. +/// +/// packaging/components.json is one file with three renderings, and that is what makes it +/// worth guarding. The SPDX document published beside every release archive is generated +/// from it, THIRD-PARTY-NOTICES.md carries the same set with the full licence texts, and +/// bws license --components prints it from inside the executable for a machine with no +/// internet. One register means they cannot disagree. It also means a mistake in it is a +/// mistake in all three. +/// +/// Why the list is not taken from a scan of the release, which is the obvious alternative. +/// Both programs publish as a SINGLE self-contained file. WPF, the .NET runtime and WPF-UI are +/// inside that file with no package metadata left anywhere, so a scanner over the archive would +/// report two executables and assign a licence to neither. A curated register is the only thing +/// that can carry the licences, and the job of every check here is to police it rather than to +/// replace it. +/// +/// What this cannot do, said so a green run is not read as more than it is. It compares +/// names, versions and bytes. It does not read anybody's licence and cannot tell whether the +/// terms changed between versions - the same limit LicenceNoticeGuards states about itself, and +/// the same answer: that is a question for a person, and the notices file says when one last +/// looked. The register-against-the-publish-manifest direction lives in +/// packaging/build-dist.ps1, because only a real publish writes that manifest. +/// +public sealed class ComponentRegisterGuards +{ + private const string RegisterPath = "packaging/components.json"; + private const string Notices = "THIRD-PARTY-NOTICES.md"; + + private static JsonDocument Register() + { + var path = Path.Combine(SourceTree.Root(), RegisterPath.Replace('/', Path.DirectorySeparatorChar)); + + Assert.True( + File.Exists(path), + $"There is no {RegisterPath}. It is the source of the bill of materials attached to " + + "every release, of the notices file, and of what the program answers about itself."); + + return JsonDocument.Parse(File.ReadAllText(path)); + } + + private static IEnumerable Components(JsonDocument register) => + register.RootElement.GetProperty("components").EnumerateArray(); + + [Fact] + public void Every_component_carries_what_a_bill_of_materials_needs() + { + using var register = Register(); + var missing = new List(); + + foreach (var component in Components(register)) + { + var name = component.GetProperty("name").GetString() ?? "(unnamed)"; + + foreach (var field in new[] { "name", "kind", "license_declared", "license_concluded", "supplier", "source", "notice", "in" }) + { + if (!component.TryGetProperty(field, out var value) || value.ValueKind == JsonValueKind.Null) + { + missing.Add($" {name} has no {field}"); + } + } + + // EXACTLY ONE OF THE TWO, and the distinction is the reason this register can be + // true on two machines at once. A component we reference states its version, because + // we chose it and a bump has to be noticed. One the SDK resolves does not, because + // its version is whatever .NET built the file - and a literal there would be a + // number that is right here and wrong on the build agent. + var stated = component.TryGetProperty("version", out _); + var fromBuild = component.TryGetProperty("version_from", out _); + + if (stated == fromBuild) + { + missing.Add($" {name} must carry exactly one of version and version_from"); + } + } + + Assert.True( + missing.Count == 0, + $"{RegisterPath} is the source of a document that goes out with a release, and a " + + "malformed one is worse than none because it looks like an answer:" + + Environment.NewLine + string.Join(Environment.NewLine, missing)); + } + + [Fact] + public void Every_package_that_ships_is_in_the_register() + { + using var register = Register(); + + var named = Components(register) + .Select(component => component.GetProperty("name").GetString()!) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + // THE DIRECTION THAT FINDS THINGS. A package we chose is in a project file and hard to + // forget. One arriving as somebody else's dependency arrives with no prompt at all, + // ships, and creates the same obligation - WPF-UI.Abstractions is in this register + // because a person noticed, not because anything asked. + // + // Asked of LicenceNoticeGuards rather than worked out again here: "which packages + // actually ship" is a question with four false answers in this tree, and one + // implementation is the only way both checks keep giving the same one. + var shipping = new[] { "Bws.Core", "Bws.Cli", "Bws.Gui" } + .SelectMany(LicenceNoticeGuards.ShippingAssetsOf) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Where(package => !named.Contains(package)) + .ToList(); + + Assert.True( + shipping.Count == 0, + "These packages put an assembly into the build output and are not in " + + $"{RegisterPath}. Read the licence in the package on disk - not the label on its " + + "listing - then add it to the register AND to the notices:" + + Environment.NewLine + string.Join(Environment.NewLine, shipping.Select(name => " " + name))); + } + + [Fact] + public void The_register_names_no_package_that_stopped_shipping() + { + using var register = Register(); + + var shipping = new[] { "Bws.Core", "Bws.Cli", "Bws.Gui" } + .SelectMany(LicenceNoticeGuards.ShippingAssetsOf) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + // The other direction, and the one that rots quietly. Only the components that arrive as + // packages can be checked this way: a runtime pack is resolved by the SDK and is in no + // package graph at all, which is what build-dist.ps1 checks against the real manifest. + var gone = Components(register) + .Where(component => component.GetProperty("kind").GetString() == "nuget") + .Select(component => component.GetProperty("name").GetString()!) + .Where(name => !shipping.Contains(name)) + .ToList(); + + Assert.True( + gone.Count == 0, + $"{RegisterPath} lists packages that no shipped project carries any more. A register " + + "describing a build nobody makes is worse than none:" + + Environment.NewLine + string.Join(Environment.NewLine, gone.Select(name => " " + name))); + } + + [Fact] + public void Every_stated_version_is_the_version_the_build_resolved() + { + using var register = Register(); + var resolved = ResolvedPackages(); + var wrong = new List(); + + foreach (var component in Components(register)) + { + if (!component.TryGetProperty("version", out var stated)) + { + continue; + } + + var name = component.GetProperty("name").GetString()!; + + if (!resolved.TryGetValue(name, out var built)) + { + wrong.Add($" {name} states {stated.GetString()} and nothing in this tree resolves it"); + continue; + } + + if (!string.Equals(stated.GetString(), built, StringComparison.OrdinalIgnoreCase)) + { + wrong.Add($" {name}: the register says {stated.GetString()} and the build resolved {built}"); + } + } + + Assert.True( + wrong.Count == 0, + "A bill of materials describing a different build than the one in the archive is " + + "worse than no bill of materials. Update the register:" + + Environment.NewLine + string.Join(Environment.NewLine, wrong)); + } + + [Fact] + public void Every_pinned_binary_still_hashes_to_what_the_register_pins() + { + using var register = Register(); + var resolved = ResolvedPackages(); + var packageRoot = PackageFolder(); + var checkedFiles = 0; + var wrong = new List(); + + foreach (var component in Components(register)) + { + if (!component.TryGetProperty("files", out var files)) + { + continue; + } + + var name = component.GetProperty("name").GetString()!; + + // The version from the BUILD rather than from the register, so that a bump moves this + // check to the file that moved instead of leaving it on one nobody ships. The version + // itself is held to the register by the guard above, so the two cannot drift apart + // without one of them going red. + Assert.True(resolved.ContainsKey(name), $"{name} carries a pin and nothing in this tree resolves it."); + + foreach (var file in files.EnumerateArray()) + { + var relative = file.GetProperty("package_path").GetString()!; + var path = Path.Combine( + packageRoot, + name.ToLowerInvariant(), + resolved[name], + relative.Replace('/', Path.DirectorySeparatorChar)); + + // FAILS CLOSED. A pin that cannot find its file is a check that passes by reading + // nothing, which is the failure mode this repository refuses everywhere else. + Assert.True( + File.Exists(path), + $"The register pins {relative} of {name} {resolved[name]} and there is no such " + + $"file at '{path}'. Restore writes it, so either the package layout changed " + + "or these guards are running against a tree nobody restored."); + + var actual = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))).ToLowerInvariant(); + + if (!string.Equals(actual, file.GetProperty("sha256").GetString(), StringComparison.Ordinal)) + { + wrong.Add($" {name} {resolved[name]} {relative}" + + $"{Environment.NewLine} pinned {file.GetProperty("sha256").GetString()}" + + $"{Environment.NewLine} actual {actual}"); + } + + checkedFiles++; + } + } + + // A sweep that read nothing finds nothing and reports it in the same green as one that + // read everything. Two files carry a pin today and the register says why only those two. + Assert.True(checkedFiles > 0, "No pinned file was checked at all, so this guard proved nothing."); + + Assert.True( + wrong.Count == 0, + "A shipped binary does not hash to what this repository pins. Either the version moved " + + "and the register has not - take the new hash from the package on disk - or somebody " + + "replaced a file on this machine:" + + Environment.NewLine + string.Join(Environment.NewLine, wrong)); + } + + [Fact] + public void Every_component_is_named_in_the_notices() + { + using var register = Register(); + var text = File.ReadAllText(Path.Combine(SourceTree.Root(), Notices)); + var missing = new List(); + + foreach (var component in Components(register)) + { + foreach (var field in new[] { "name", "notice" }) + { + var value = component.GetProperty(field).GetString()!; + + if (!text.Contains(value, StringComparison.OrdinalIgnoreCase)) + { + missing.Add($" {value} (the {field} of {component.GetProperty("name").GetString()})"); + } + } + } + + Assert.True( + missing.Count == 0, + $"The register ships these and {Notices} does not name them. The licences on the " + + "borrowed code are what require the notice to travel with the program, and the " + + "register is not that notice - it is a list:" + + Environment.NewLine + string.Join(Environment.NewLine, missing)); + } + + [Fact] + public void Every_licence_identifier_that_is_not_on_the_spdx_list_is_defined() + { + using var register = Register(); + var defined = register.RootElement.GetProperty("license_refs") + .EnumerateObject() + .Select(property => property.Name) + .ToHashSet(StringComparer.Ordinal); + + var used = Components(register) + .SelectMany(component => new[] + { + component.GetProperty("license_declared").GetString()!, + component.GetProperty("license_concluded").GetString()! + }) + .Where(licence => licence.StartsWith("LicenseRef-", StringComparison.Ordinal)) + .Distinct(StringComparer.Ordinal) + .Where(licence => !defined.Contains(licence)) + .ToList(); + + Assert.True( + used.Count == 0, + "A document using one of these would not validate, and an invalid bill of materials " + + "looks like an answer:" + Environment.NewLine + string.Join(Environment.NewLine, used)); + } + + [Fact] + public void Every_package_the_register_describes_is_a_project_that_exists() + { + using var register = Register(); + var wrong = new List(); + + foreach (var package in register.RootElement.GetProperty("packages").EnumerateObject()) + { + var project = package.Value.GetProperty("project").GetString()!; + var path = Path.Combine(SourceTree.Root(), project.Replace('/', Path.DirectorySeparatorChar)); + + if (!File.Exists(path)) + { + wrong.Add($" {package.Name}: there is no project at {project}"); + continue; + } + + // THE EXECUTABLE NAME IS A FROZEN CONTRACT - docs/02 lists it, and scripts and + // scheduled tasks call these files by name. The register writes it down to build the + // archive, which makes it a second copy, so it is held against the one the assembly + // actually carries. + var expected = Regex + .Match(File.ReadAllText(path), @"([^<]+)", RegexOptions.None, Sources.Ceiling) + .Groups[1].Value; + var stated = package.Value.GetProperty("executable").GetString()!; + + if (!string.Equals(stated, expected + ".exe", StringComparison.OrdinalIgnoreCase)) + { + wrong.Add($" {package.Name}: the register packs {stated} and {project} builds {expected}.exe"); + } + } + + Assert.True( + wrong.Count == 0, + "The register would build an archive around a file that is not there:" + + Environment.NewLine + string.Join(Environment.NewLine, wrong)); + } + + /// + /// Every package the restore resolved, as name to version. + /// + /// Read out of the assets file the build writes, which is the only place the resolved graph + /// is written down. Runtime packs are deliberately absent from it: the SDK resolves those and + /// they are in no package graph, which is why the register marks their versions as the + /// build's to decide and build-dist.ps1 checks them against the publish manifest instead. + /// + private static Dictionary ResolvedPackages() + { + var resolved = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var project in new[] { "Bws.Core", "Bws.Cli", "Bws.Gui" }) + { + var assets = Path.Combine(SourceTree.Root(), "src", project, "obj", "project.assets.json"); + + Assert.True(File.Exists(assets), $"There is no '{assets}'. Restore writes it, so this guard would read nothing."); + + foreach (Match match in Regex.Matches( + File.ReadAllText(assets), + @"""([A-Za-z][A-Za-z0-9._-]*)/(\d+\.\d+\.\d+[^""]*)""\s*:\s*\{", + RegexOptions.None, + Sources.Ceiling)) + { + resolved[match.Groups[1].Value] = match.Groups[2].Value; + } + } + + return resolved; + } + + /// + /// Where NuGet put the packages, read out of the assets file rather than assembled from a + /// profile path - a build agent moves it with NUGET_PACKAGES, and a guessed path would make + /// the hash check quietly check nothing. + /// + private static string PackageFolder() + { + var assets = Path.Combine(SourceTree.Root(), "src", "Bws.Gui", "obj", "project.assets.json"); + + using var document = JsonDocument.Parse(File.ReadAllText(assets)); + + foreach (var folder in document.RootElement.GetProperty("packageFolders").EnumerateObject()) + { + if (Directory.Exists(folder.Name)) + { + return folder.Name; + } + } + + Assert.Fail($"'{assets}' names no package folder that exists on this machine."); + + return string.Empty; + } +} diff --git a/tests/Bws.Architecture.Tests/LicenceNoticeGuards.cs b/tests/Bws.Architecture.Tests/LicenceNoticeGuards.cs index 52dcbf6..39ed0a0 100644 --- a/tests/Bws.Architecture.Tests/LicenceNoticeGuards.cs +++ b/tests/Bws.Architecture.Tests/LicenceNoticeGuards.cs @@ -166,7 +166,13 @@ public void The_notices_do_not_name_packages_that_no_longer_exist() /// Absent before a restore, and an empty answer then, which makes this check blinder rather /// than louder. These tests run after a build, so it is present. /// - private static IEnumerable ShippingAssetsOf(string project) + /// + /// internal rather than private since 2026-09-23, because ComponentRegisterGuards + /// asks the same question of the same file and a second implementation of "which packages + /// actually ship" would be a second answer. The note above is the whole reason that answer is + /// hard to get right: a naive read of the graph reports four packages that ship nothing. + /// + internal static IEnumerable ShippingAssetsOf(string project) { var assets = Path.Combine(SourceTree.Root(), "src", project, "obj", "project.assets.json"); diff --git a/tests/Bws.Architecture.Tests/PublicSurfaceGuards.cs b/tests/Bws.Architecture.Tests/PublicSurfaceGuards.cs index dbc5d1d..7af3fb8 100644 --- a/tests/Bws.Architecture.Tests/PublicSurfaceGuards.cs +++ b/tests/Bws.Architecture.Tests/PublicSurfaceGuards.cs @@ -369,9 +369,30 @@ private static IEnumerable Published() .Distinct(StringComparer.OrdinalIgnoreCase); } + /// + /// dist/ joined obj/ and bin/ on 2026-09-23, and it arrived by turning this guard red. + /// packaging/build-dist.ps1 stages LICENSE and THIRD-PARTY-NOTICES.md beside each executable, + /// because the licences on the borrowed code require their notices to travel with it - and + /// the notices file quotes a copyright sign and a name with an umlaut in it, both of which + /// are on the permission list under the file's real path at the repository root. The copy in + /// the staging folder is a different path, so it had no permission and was reported as prose + /// that wandered in. + /// + /// The finding was real and was about the guard: a sweep that reads build output reports the + /// same file twice and one of the two can never be fixed, because it is written by a script + /// every time it runs. + /// + /// build/ came the same day and from a review rather than from a red run, which is the + /// better way round. packaging/sign-release.ps1 unpacks each archive into + /// build/signing/<tag>/unpacked/, and that folder holds the same staged copy of + /// the notices file plus the documents beside it. The failure was identical and nobody had + /// met it yet, because nobody has signed a release from this repository. + /// private static bool NotBuildOutput(string path) => !path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal) - && !path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal); + && !path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal) + && !path.Contains($"{Path.DirectorySeparatorChar}dist{Path.DirectorySeparatorChar}", StringComparison.Ordinal) + && !path.Contains($"{Path.DirectorySeparatorChar}build{Path.DirectorySeparatorChar}", StringComparison.Ordinal); /// /// The folders kept out of version control on purpose, which is where the private things are diff --git a/tests/Bws.Cli.Tests/Bws.Cli.Tests.csproj b/tests/Bws.Cli.Tests/Bws.Cli.Tests.csproj index af31d3e..7d35cb8 100644 --- a/tests/Bws.Cli.Tests/Bws.Cli.Tests.csproj +++ b/tests/Bws.Cli.Tests/Bws.Cli.Tests.csproj @@ -31,4 +31,18 @@ + + + + + diff --git a/tests/Bws.Cli.Tests/LicenceCommandTests.cs b/tests/Bws.Cli.Tests/LicenceCommandTests.cs new file mode 100644 index 0000000..1a1f14b --- /dev/null +++ b/tests/Bws.Cli.Tests/LicenceCommandTests.cs @@ -0,0 +1,167 @@ +using System.Text.Json; +using Bws.Tests; + +namespace Bws.Cli.Tests; + +/// +/// The licence command answers about THIS program, and cannot swallow a switch on the way. +/// +/// Two different worries, and the second one is why this file exists at all. The first is +/// ordinary: the notice has to name what this executable carries and nothing it does not. The +/// second is an ordering trap - Immediate answers BEFORE what somebody typed is judged, +/// because help and version are questions about the tool rather than about a command. A verb +/// answered in that position would print its answer and drop every switch beside it, which is +/// precisely the silence the belonging table in OptionSurface exists to end. +/// +/// So the guard below walks the WHOLE option surface rather than naming a switch or two. A tenth +/// option added next year is covered on the day it is added, without anybody remembering this +/// file - and that is the difference between a guard and a note. +/// +public sealed class LicenceCommandTests +{ + private const string Register = "packaging/components.json"; + + [Fact] + public void The_notice_names_the_licence_and_where_the_full_text_is() + { + var notice = Licence.Answer(components: false); + + Assert.Contains("GPL-3.0-or-later", notice, StringComparison.Ordinal); + + // The warranty disclaimer is not decoration. Sections 15 and 16 of the GPL ask for it to + // be shown, and a tool asking for administrator rights is exactly where somebody should + // read it. + Assert.Contains("NO WARRANTY", notice, StringComparison.Ordinal); + Assert.Contains("LICENSE", notice, StringComparison.Ordinal); + Assert.Contains("THIRD-PARTY-NOTICES.md", notice, StringComparison.Ordinal); + } + + [Fact] + public void It_names_every_component_this_package_ships_and_none_of_the_other_package() + { + var full = Licence.Answer(components: true); + var (mine, theirs) = Components(); + + // THE CONSTANT THIS CHECKS IS THE ONE FAILURE THE WHOLE FILE IS ABOUT. Licence carries a + // literal saying which package of the register this executable is, because there is + // nothing at runtime to work it out from. Name the wrong one and the answer is a + // confident inventory of the other program - so both directions are asserted here. + Assert.NotEmpty(mine); + + foreach (var component in mine) + { + Assert.Contains(component, full, StringComparison.Ordinal); + } + + foreach (var component in theirs) + { + Assert.DoesNotContain(component, full, StringComparison.Ordinal); + } + } + + [Fact] + public void The_component_list_states_a_version_for_every_one_of_them() + { + var full = Licence.Answer(components: true); + var (mine, _) = Components(); + + // One "version" line per component, whether the number is a literal from the register or + // read off the runtime. A component listed without one would be a row somebody has to go + // and ask about, which is the whole thing this command exists to save them. + var stated = full.Split('\n').Count(line => line.TrimStart().StartsWith("version", StringComparison.Ordinal)); + + Assert.Equal(mine.Count, stated); + } + + [Fact] + public void Asking_for_the_licence_is_answered_with_a_code_of_zero() + { + var options = CommandLine.Read(["license"]); + + Assert.Equal(CommandKind.License, options.Kind); + Assert.True(options.NothingWrong); + Assert.Equal(ExitCode.Ok, Immediate.Answer(options)); + } + + [Fact] + public void The_components_switch_belongs_to_it() + { + var options = CommandLine.Read(["license", "--components"]); + + Assert.True(options.Components); + Assert.True(options.NothingWrong); + Assert.Equal(ExitCode.Ok, Immediate.Answer(options)); + } + + [Fact] + public void No_option_belonging_to_another_verb_can_be_swallowed_by_it() + { + // Every switch this tool has, asked of this verb. The ones that belong to it are expected + // to be taken; every other one has to leave a complaint behind, because Immediate hands + // the line back to Refusals the moment there is one - and Refusals is where the sentence + // naming the verbs it DOES work with is written. + var swallowed = new List(); + + foreach (var (option, verbs) in OptionSurface.Surface) + { + if (verbs.Contains(CommandKind.License)) + { + continue; + } + + var options = CommandLine.Read(["license", option]); + + if (options.NothingWrong || Immediate.Answer(options) is not null) + { + swallowed.Add(option); + } + } + + Assert.True( + swallowed.Count == 0, + "These options were accepted on `license` and did nothing, which is a switch somebody " + + "typed being dropped in silence: " + string.Join(", ", swallowed)); + } + + [Fact] + public void A_word_it_has_no_room_for_is_not_quietly_ignored() + { + var options = CommandLine.Read(["license", "GPL"]); + + Assert.False(options.NothingWrong); + Assert.Null(Immediate.Answer(options)); + Assert.Contains("GPL", options.Extra, StringComparer.Ordinal); + } + + /// + /// The component names the register puts in this package, and the ones it puts only in the + /// other. Read from the file rather than listed here: a list in a test is a second register. + /// + private static (IReadOnlyList Mine, IReadOnlyList Theirs) Components() + { + var path = Path.Combine(SourceTree.Root(), Register.Replace('/', Path.DirectorySeparatorChar)); + + Assert.True(File.Exists(path), $"There is no {Register}, which is what this program answers from."); + + using var document = JsonDocument.Parse(File.ReadAllText(path)); + var mine = new List(); + var theirs = new List(); + + foreach (var entry in document.RootElement.GetProperty("components").EnumerateArray()) + { + var packages = entry.GetProperty("in").EnumerateArray().Select(value => value.GetString()).ToList(); + var name = entry.GetProperty("name").GetString()!; + + if (packages.Contains("cli", StringComparer.Ordinal)) + { + mine.Add(name); + } + else + { + theirs.Add(name); + } + } + + return (mine, theirs); + } +}