diff --git a/packaging/sign-release.ps1 b/packaging/sign-release.ps1
index a0d0bcbf..fc761086 100644
--- a/packaging/sign-release.ps1
+++ b/packaging/sign-release.ps1
@@ -47,6 +47,11 @@
.PARAMETER DryRun
Everything except signing, uploading and dispatching.
+.PARAMETER CheckDraft
+ Step 8 alone - is the draft complete, and does it carry the bytes this machine signed - over the
+ work folder an earlier run of this script left behind. No card, no upload, no dispatch. For a
+ run that signed, uploaded and dispatched, and then stopped while confirming.
+
.PARAMETER Wait
How long to wait for phase C to attach its bundles, in seconds.
#>
@@ -55,6 +60,7 @@ param(
[Parameter(Position = 0)] [string] $Tag,
[switch] $ListCertificates,
[switch] $DryRun,
+ [switch] $CheckDraft,
[int] $Wait = 300,
[string] $Work
)
@@ -223,6 +229,128 @@ function Get-SignatureStates([string] $directory) {
return $states
}
+# ONE READ OF THE RELEASE, ASKED MORE THAN ONCE, WITH WHAT gh SAID KEPT. Until 2026-11-03 every read
+# here was one call with 2>$null, so a single failed request ended the ritual and took its reason
+# with it. That is how the signing of 0.5.0 stopped in step 8: phase C had finished, the draft was
+# complete, and all this script could say was "cannot read the release". A failed read is now asked
+# again a few seconds later, and only a run of failures stops the script - with gh's own words in
+# the message, because "cannot read" without them is the silence rule 8 of CLAUDE.md forbids in a
+# result. The exit code is still what decides, as it was: a read that failed and a draft with no
+# assets look identical from here. A read that exits 0 and prints nothing is not an answer either,
+# and ConvertFrom-Json turns an empty string into $null without a word, so it counts as a failure.
+#
+# $Fields is passed to gh as ONE argument. In PowerShell argument mode a space ends a token, so
+# `--json assets, isDraft` written out 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. Found by the
+# review of PR #5.
+function Read-Release([string] $Fields, [string] $While, [string] $Then = '', [int] $Tries = 3, [int] $PauseSeconds = 5) {
+ $said = ''
+ for ($try = 1; $try -le $Tries; $try++) {
+ $output = @(& gh release view $Tag --repo $repo --json $Fields 2>&1)
+ $code = $LASTEXITCODE
+ $text = (@($output | Where-Object { $_ -isnot [System.Management.Automation.ErrorRecord] }) -join "`n").Trim()
+ if ($code -eq 0 -and $text) {
+ try { return ($text | ConvertFrom-Json) }
+ catch { $said = "gh printed something that is not JSON: $text" }
+ }
+ elseif ($code -eq 0) { $said = 'gh exited 0 and printed no document' }
+ else { $said = "gh exited $code and said: " + (@($output | ForEach-Object { "$_" }) -join ' ').Trim() }
+ if ($try -lt $Tries) { Start-Sleep -Seconds $PauseSeconds }
+ }
+ $message = "sign-release: cannot read the release $Tag $While. $Tries reads in a row failed, the last one with: $said"
+ if ($Then) { $message += "`n$Then" }
+ throw $message
+}
+
+# STEP 8, A FUNCTION OF ITS OWN SINCE 2026-11-03 so that -CheckDraft can run it alone. It reads the
+# release and the files it is given, and nothing else: no card, no upload, no dispatch. $Files is
+# what phase C was asked to attest - the archives and, for a release that gets one, the installer.
+function Confirm-Draft([string[]] $Files) {
+ 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.
+ $again = ('Nothing has been published, and the assets that were uploaded are still on the draft. Once the ' +
+ "release reads again, this step alone is: ./packaging/sign-release.ps1 $Tag -CheckDraft")
+ $deadline = (Get-Date).AddSeconds([Math]::Max(0, $Wait))
+ $signedDigests = @{}
+ foreach ($file in $Files) { $signedDigests[(Split-Path -Leaf $file)] = Get-Sha256 $file }
+ # One bundle per attested file, by NAME. A count would let a bundle nobody expects stand in for the
+ # one an archive is missing, and the step would print PASS over it (review of #111).
+ $expectedBundles = @($Files | ForEach-Object { (Split-Path -Leaf $_) + '.sigstore.json' })
+ $names = @()
+ while ($true) {
+ $view = Read-Release 'assets,isDraft' 'while waiting for phase C' $again
+ # Asked on every read rather than once at the end: on a release somebody already published, a
+ # missing asset would otherwise keep this waiting out -Wait and then say nothing was published.
+ if (-not $view.isDraft) {
+ throw "sign-release: $Tag is NOT a draft any more - it is already public"
+ }
+ $names = @($view.assets | ForEach-Object { $_.name })
+ $missing = @($EXPECTED_ASSETS | Where-Object { $names -notcontains $_ })
+ $missingBundles = @($expectedBundles | Where-Object { $names -notcontains $_ })
+ if (-not $missing -and -not $missingBundles) { 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 ($missingBundles) { Write-Host " attestation bundles missing: $($missingBundles -join ', ')" }
+ 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. " +
+ "Once it is there, this step alone is: ./packaging/sign-release.ps1 $Tag -CheckDraft")
+ }
+ 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. Each one on the line of its OWN file
+ # name, because a digest found on any line would pass for local files whose names and bytes no
+ # longer go together (review of #111). The format is the one step 6 writes: digest, two spaces,
+ # name. --clobber because -CheckDraft may run over a work folder that already holds this file.
+ $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, '--clobber') | Out-Null
+ $published = Get-Content -Raw -LiteralPath (Join-Path $confirm 'SHA256SUMS')
+ $sums = @{}
+ foreach ($line in ($published -split "`r?`n")) {
+ if ($line -match '^([0-9a-f]{64}) (.+)$') { $sums[$Matches[2].Trim()] = $Matches[1] }
+ }
+ foreach ($name in $signedDigests.Keys) {
+ if (-not $sums.ContainsKey($name) -or $sums[$name] -ne $signedDigests[$name]) {
+ throw ("sign-release: SHA256SUMS on the release does NOT give $name the digest we signed.`n" +
+ " signed: $($signedDigests[$name])`n published: $published")
+ }
+ }
+
+ # AND THE FILES THEMSELVES. SHA256SUMS is one more asset, and it names the right digests even when
+ # an archive beside it was replaced after phase C (review of #111). GitHub gives every release asset
+ # a digest of its own bytes, read here from the same view. An asset without one is downloaded and
+ # hashed instead, so a missing field is never taken as a match.
+ foreach ($name in $signedDigests.Keys) {
+ $asset = @($view.assets | Where-Object { $_.name -eq $name })[0]
+ $digest = if ($asset.PSObject.Properties['digest'] -and $asset.digest) { ([string]$asset.digest) -replace '^sha256:', '' } else { '' }
+ if (-not $digest) {
+ Invoke-Step @('gh', 'release', 'download', $Tag, '--repo', $repo, '--pattern', $name, '--dir', $confirm, '--clobber') | Out-Null
+ $digest = Get-Sha256 (Join-Path $confirm $name)
+ }
+ if ($digest -ne $signedDigests[$name]) {
+ throw ("sign-release: $name on the release is NOT the file we signed.`n" +
+ " signed: $($signedDigests[$name])`n on the release: $digest")
+ }
+ }
+ $state = Read-Release 'isDraft' 'to say whether it is still a draft'
+ 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, its bytes and the published checksums are the ones 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.'
+}
+
# ---------------------------------------------------------------------------------------------
# -ListCertificates: the only mode that touches nothing
# ---------------------------------------------------------------------------------------------
@@ -269,6 +397,9 @@ if ($ListCertificates) {
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' }
+if ($CheckDraft -and $DryRun) {
+ throw 'sign-release: -CheckDraft and -DryRun do not go together - a dry run uploads nothing, so it leaves no draft of its own to check'
+}
# 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
@@ -296,24 +427,53 @@ if (git status --porcelain) {
'over the signed archive are read from it. Commit, stash or clean them first.')
}
+$withInstaller = Test-Installer $Tag
+if ($withInstaller) { $EXPECTED_ASSETS += @($INSTALLER, "$INSTALLER.spdx.json") }
+
+# 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
+
+# STEP 8 ALONE, over the work folder a signing run of this tag left behind - for a run that signed,
+# uploaded and dispatched, and then stopped while confirming. The signing of 0.5.0 did, on one failed
+# read with phase C already finished. Signing again for that would put new signatures on the draft
+# and dispatch phase C a second time, and nothing about the draft asks for either. So the folder is
+# read rather than cleared, and nothing here reaches the card, uploads or dispatches. A folder a dry
+# run left holds UNSIGNED bytes, and step 8 refuses it at the checksums, as it should.
+if ($CheckDraft) {
+ if (-not (Test-Path -LiteralPath $workspace)) {
+ throw "sign-release: there is no work folder at $workspace. -CheckDraft reads the one a signing run of $Tag left behind."
+ }
+ $archives = @(Get-ChildItem -LiteralPath $workspace -Filter *.zip -File)
+ if ($archives.Count -ne $OURS.Count) {
+ throw "sign-release: expected $($OURS.Count) archives in $workspace, got $($archives.Name -join ', ')"
+ }
+ # The list step 7 hands to phase C, built the same way from what the folder holds.
+ $attested = @($archives | ForEach-Object { $_.FullName })
+ if ($withInstaller) {
+ $installerPath = Join-Path $workspace $INSTALLER
+ if (-not (Test-Path -LiteralPath $installerPath)) {
+ throw "sign-release: $Tag gets an installer, and $workspace holds no $INSTALLER"
+ }
+ $attested += $installerPath
+ }
+ Confirm-Draft $attested
+ return
+}
+
# BEFORE ANYTHING IS DOWNLOADED OR SIGNED: a machine that cannot build the installer would otherwise stop
# at step 5, with the programs already signed and a draft that can never get its installer from this run.
# build-msi.ps1 runs as its own process, because a refusal there ends the process it runs in.
-$withInstaller = Test-Installer $Tag
Write-Host "`n[0/8] whether the installer can be built here"
if ($withInstaller) {
Invoke-Step @('pwsh', '-NoProfile', '-File', $BUILD_MSI, '-Tag', $Tag, '-Check') | ForEach-Object { Write-Host " $_" }
- $EXPECTED_ASSETS += @($INSTALLER, "$INSTALLER.spdx.json")
}
else {
Write-Host " $Tag is a release candidate, or its tree names no installer, so it gets none"
}
-# 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"
@@ -536,12 +696,15 @@ Write-Host "`n[7/8] handing it back to the workflow"
$attested = @($archives | ForEach-Object { $_.FullName })
if ($withInstaller) { $attested += $installerPath }
$stale = @($attested | ForEach-Object { (Split-Path -Leaf $_) + '.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."
+# Through Read-Release, whose exit code check matters here most: 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 = Read-Release 'assets,isDraft' 'to see what the draft already carries' 'Phase A opens the draft - check that it ran for this tag. Nothing has been uploaded.'
+# BEFORE ANYTHING ON THE RELEASE IS DELETED OR REPLACED. A second run on a release somebody already
+# published would otherwise take its attestation bundles off and overwrite its files with --clobber,
+# and the draft check in step 8 would only say so afterwards (review of #111).
+if (-not $draft.isDraft) {
+ throw "sign-release: $Tag is NOT a draft any more - it is already public, and nothing on it has been changed"
}
$onDraft = @($draft.assets | ForEach-Object { $_.name })
foreach ($bundle in ($stale | Where-Object { $onDraft -contains $_ })) {
@@ -554,65 +717,4 @@ $digests = $attested | ForEach-Object { "$(Split-Path -Leaf $_)=$(Get-Sha256 $_)
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 ($file in $attested) { $signedDigests[(Split-Path -Leaf $file)] = Get-Sha256 $file }
-$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 $attested.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 $attested.Count) { Write-Host " attestation bundles present: $($bundles.Count) of $($attested.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.'
+Confirm-Draft $attested
diff --git a/tests/Bws.Architecture.Tests/SigningRitualGuards.cs b/tests/Bws.Architecture.Tests/SigningRitualGuards.cs
new file mode 100644
index 00000000..f406a1f8
--- /dev/null
+++ b/tests/Bws.Architecture.Tests/SigningRitualGuards.cs
@@ -0,0 +1,317 @@
+using System.Diagnostics;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+
+namespace Bws.Architecture.Tests;
+
+///
+/// The reads packaging/sign-release.ps1 makes of the release after the card has signed, step 8 that
+/// confirms the draft, and the mode that runs step 8 alone.
+///
+/// Why this class exists. The signing of 0.5.0 (2026-11-03) stopped in step 8 on ONE failed read
+/// of the release, with phase C finished and the draft complete, and all the script could say was
+/// "cannot read the release" - the read was a single call with its error output thrown away. Every read
+/// of the release now goes through Read-Release, which asks again and keeps what gh said, step 8 is
+/// Confirm-Draft, and -CheckDraft runs it over the work folder without signing again. The review of #111
+/// then found four ways Confirm-Draft could say PASS or wait over the wrong thing, and those are the
+/// scenarios below.
+///
+/// The script's own functions are run, not copies. The ritual cannot run in a test - it needs a
+/// tag, the card and the real repository - so the functions are cut out of the script with PowerShell's
+/// own parser and run against a stand-in gh: a batch file first on the path that hands its arguments to
+/// a small PowerShell script, which answers from a scenario written beside it and counts its calls.
+///
+public sealed class SigningRitualGuards
+{
+ private const string Script = "packaging/sign-release.ps1";
+
+ private static readonly string Alpha = Digest("alpha");
+ private static readonly string Beta = Digest("beta");
+
+ [Fact]
+ public void A_read_of_the_release_that_fails_twice_is_asked_again_and_answers()
+ {
+ var said = Read("fail", times: 2);
+ Assert.True(said.Contains("READ isDraft=True", StringComparison.Ordinal) && said.Contains("CALLS 3", StringComparison.Ordinal),
+ $"Two failed reads and a third that answers must come back as the answer, after three calls. Read-Release said:{Environment.NewLine}{said}");
+ }
+
+ [Fact]
+ public void A_read_that_keeps_failing_stops_with_what_gh_said()
+ {
+ var said = Read("fail", times: 99);
+ Assert.True(said.Contains("THREW", StringComparison.Ordinal) && said.Contains("CALLS 3", StringComparison.Ordinal),
+ $"A read that never answers stops the script after three calls. Read-Release said:{Environment.NewLine}{said}");
+ Assert.True(said.Contains("HTTP 502: Bad Gateway", StringComparison.Ordinal),
+ $"The refusal carries gh's own words - without them nobody can tell a network fault from a missing release. Read-Release said:{Environment.NewLine}{said}");
+ }
+
+ [Fact]
+ public void A_read_that_prints_nothing_is_not_an_answer()
+ {
+ var said = Read("empty", times: 99);
+ Assert.True(said.Contains("THREW", StringComparison.Ordinal) && said.Contains("printed no document", StringComparison.Ordinal),
+ $"gh exiting 0 with nothing on its output is no release - ConvertFrom-Json makes an empty string into $null without a word. Read-Release said:{Environment.NewLine}{said}");
+ }
+
+ [Fact]
+ public void A_read_that_is_not_json_is_asked_again()
+ {
+ var said = Read("garbled", times: 1);
+ Assert.True(said.Contains("READ isDraft=True", StringComparison.Ordinal) && said.Contains("CALLS 2", StringComparison.Ordinal),
+ $"A read that printed something other than JSON is asked again, like any failed read. Read-Release said:{Environment.NewLine}{said}");
+ }
+
+ [Fact]
+ public void A_complete_draft_with_the_signed_bytes_passes()
+ {
+ var said = Confirm(Complete());
+ Assert.True(said.Contains("CONFIRMED", StringComparison.Ordinal) && said.Contains("PASS:", StringComparison.Ordinal),
+ $"A draft with every asset, every bundle and the signed bytes is confirmed. Confirm-Draft said:{Environment.NewLine}{said}");
+ }
+
+ [Fact]
+ public void A_release_already_published_is_refused_at_once_rather_than_waited_for()
+ {
+ var said = Confirm(Complete() with { IsDraft = false, Bundles = ["a.zip.sigstore.json"] });
+ Assert.True(said.Contains("NOT a draft any more", StringComparison.Ordinal),
+ $"A published release with a bundle missing is refused as published - not waited for and then called a draft nothing was published from. Confirm-Draft said:{Environment.NewLine}{said}");
+ }
+
+ [Fact]
+ public void A_bundle_nobody_expects_does_not_stand_in_for_a_missing_one()
+ {
+ var said = Confirm(Complete() with { Bundles = ["a.zip.sigstore.json", "other.zip.sigstore.json"] });
+ Assert.True(said.Contains("THREW", StringComparison.Ordinal) && said.Contains("attestation bundles missing: b.zip.sigstore.json", StringComparison.Ordinal),
+ $"Two bundles where two are expected, but not the two expected, is an incomplete draft. Confirm-Draft said:{Environment.NewLine}{said}");
+ }
+
+ [Fact]
+ public void A_digest_under_another_name_in_the_checksums_is_refused()
+ {
+ var said = Confirm(Complete() with { Sums = $"{Beta} a.zip\n{Alpha} b.zip\n" });
+ Assert.True(said.Contains("THREW", StringComparison.Ordinal) && said.Contains("does NOT give", StringComparison.Ordinal),
+ $"Each signed digest has to stand on the line of its own file. Confirm-Draft said:{Environment.NewLine}{said}");
+ }
+
+ [Fact]
+ public void An_asset_replaced_after_phase_C_is_refused_by_its_own_digest()
+ {
+ var said = Confirm(Complete() with { DigestOfA = Digest("gamma") });
+ Assert.True(said.Contains("THREW", StringComparison.Ordinal) && said.Contains("a.zip on the release is NOT the file we signed", StringComparison.Ordinal),
+ $"Checksums that name the signed digests prove nothing about an archive replaced beside them. Confirm-Draft said:{Environment.NewLine}{said}");
+ }
+
+ [Fact]
+ public void An_asset_without_a_digest_is_downloaded_and_hashed()
+ {
+ var said = Confirm(Complete() with { DigestOfA = null });
+ Assert.True(said.Contains("CONFIRMED", StringComparison.Ordinal),
+ $"An asset the release gives no digest for is downloaded and hashed, and the signed bytes pass. Confirm-Draft said:{Environment.NewLine}{said}");
+ }
+
+ [Fact]
+ public void An_asset_without_a_digest_whose_bytes_differ_is_refused()
+ {
+ var said = Confirm(Complete() with { DigestOfA = null, BytesOfA = "gamma" });
+ Assert.True(said.Contains("THREW", StringComparison.Ordinal) && said.Contains(Digest("gamma"), StringComparison.Ordinal),
+ $"A missing digest is never taken as a match - the downloaded bytes are hashed and named. Confirm-Draft said:{Environment.NewLine}{said}");
+ }
+
+ [Fact]
+ public void Every_read_of_the_release_goes_through_the_one_that_asks_again()
+ {
+ var script = Text(Script);
+ var reads = script.Split("gh release view").Length - 1;
+ Assert.True(reads == 1, $"sign-release.ps1 calls gh release view {reads} times. Once, inside Read-Release - a read beside it fails on the first error and says nothing of why.");
+ foreach (var line in new[] { "$draft = Read-Release 'assets,isDraft'", "$view = Read-Release 'assets,isDraft'", "$state = Read-Release 'isDraft'" })
+ {
+ Assert.True(script.Contains(line, StringComparison.Ordinal), $"sign-release.ps1 has no line saying {line}");
+ }
+ }
+
+ [Fact]
+ public void A_published_release_is_refused_before_anything_on_it_is_changed()
+ {
+ var script = Text(Script);
+ var read = script.IndexOf("$draft = Read-Release 'assets,isDraft'", StringComparison.Ordinal);
+ var refused = script.IndexOf("if (-not $draft.isDraft) {", StringComparison.Ordinal);
+ var deleted = script.IndexOf("'gh', 'release', 'delete-asset'", StringComparison.Ordinal);
+ var uploaded = script.IndexOf("'gh', 'release', 'upload'", StringComparison.Ordinal);
+ Assert.True(read > 0 && refused > read && deleted > refused && uploaded > refused,
+ $"Step 7 reads the release at {read}, refuses a published one at {refused}, deletes bundles at {deleted} and uploads at {uploaded}. The refusal has to come before both - a second run on a published release would take its bundles off and overwrite its files.");
+ }
+
+ [Fact]
+ public void The_draft_check_alone_comes_before_the_work_folder_is_cleared_and_the_card_is_reached()
+ {
+ var lines = Text(Script).Split('\n').Select(line => line.Trim()).ToArray();
+ var alone = Array.IndexOf(lines, "if ($CheckDraft) {");
+ var cleared = Array.FindIndex(lines, line => line.StartsWith("if (Test-Path -LiteralPath $workspace) { Remove-Item", StringComparison.Ordinal));
+ var card = Array.FindIndex(lines, line => line.StartsWith("$certificate = Get-CodeSigningCertificates", StringComparison.Ordinal));
+ Assert.True(alone >= 0 && cleared > alone && card > alone,
+ $"-CheckDraft is decided on line {alone}, the work folder is cleared on {cleared} and the card reached on {card}. It has to be decided first - it reads the folder a signing run left, and signs nothing.");
+
+ var branch = lines[alone..cleared];
+ Assert.True(branch.Contains("Confirm-Draft $attested") && branch.Contains("return"),
+ "The -CheckDraft branch no longer has the lines that run step 8 and return - without the return it would go on to clear the folder and sign.");
+ Assert.True(lines.Last(line => line.Length > 0) == "Confirm-Draft $attested",
+ "The ritual no longer ends with a line that runs step 8 - the same function -CheckDraft runs, so the two cannot drift apart.");
+ }
+
+ /// What a draft looks like in a scenario: whether it is one, file a's digest and bytes, the bundles, SHA256SUMS.
+ private sealed record Release(bool IsDraft, string? DigestOfA, string BytesOfA, string[] Bundles, string Sums);
+
+ /// The draft a finished signing run leaves: everything there, a.zip and b.zip with the bytes that were signed.
+ private static Release Complete() =>
+ new(IsDraft: true, DigestOfA: Alpha, BytesOfA: "alpha", Bundles: ["a.zip.sigstore.json", "b.zip.sigstore.json"], Sums: $"{Alpha} a.zip\n{Beta} b.zip\n");
+
+ private static string Read(string mode, int times) =>
+ Run("read", JsonSerializer.Serialize(new { mode, times, view = new { isDraft = true, assets = Array.Empty