From 0b7ce84c959f1bd66b6750ae57e1f309cb7386f6 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 3 Nov 2026 15:26:22 +0100 Subject: [PATCH 1/2] Ask the release again before the signing gives up on it The last step of packaging/sign-release.ps1 read the draft release with one gh call and threw its error output away, so a single failed request ended the ritual after the card had signed, the assets were uploaded and phase C had attested them - and the message could not say why. Every read of the release now goes through Read-Release, which asks up to three times a few seconds apart and stops with gh's own words. A read that exits 0 and prints nothing counts as a failure, since ConvertFrom-Json turns an empty string into $null without a word. Step 8 is a function of its own, and -CheckDraft runs it alone over the work folder a signing run left behind: no card, no upload, no dispatch. Finishing a run that stopped while confirming no longer means signing again. SigningRitualGuards cuts Read-Release out of the script with the PowerShell parser and runs it against a stand-in gh that fails a set number of times. Four mutation entries hold the retry, the kept message, the empty read and the place -CheckDraft is decided. Co-Authored-By: Claude Opus 5.5 --- packaging/sign-release.ps1 | 217 ++++++++++++------ .../SigningRitualGuards.cs | 184 +++++++++++++++ tests/mutation/entries.ps1 | 28 +++ 3 files changed, 353 insertions(+), 76 deletions(-) create mode 100644 tests/Bws.Architecture.Tests/SigningRitualGuards.cs diff --git a/packaging/sign-release.ps1 b/packaging/sign-release.ps1 index a0d0bcbf..16333c4f 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,97 @@ 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 } + $names = @() + while ($true) { + $view = Read-Release 'assets,isDraft' 'while waiting for phase C' $again + $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 $Files.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 $Files.Count) { Write-Host " attestation bundles present: $($bundles.Count) of $($Files.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. " + + "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. --clobber because -CheckDraft may run + # over a work folder that already holds this file from an earlier confirmation. + $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') + 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 = 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, 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.' +} + # --------------------------------------------------------------------------------------------- # -ListCertificates: the only mode that touches nothing # --------------------------------------------------------------------------------------------- @@ -269,6 +366,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 +396,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,13 +665,10 @@ 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' 'to see what the draft already carries' 'Phase A opens the draft - check that it ran for this tag. Nothing has been uploaded.' $onDraft = @($draft.assets | ForEach-Object { $_.name }) foreach ($bundle in ($stale | Where-Object { $onDraft -contains $_ })) { Write-Host " removing the previous attestation bundle $bundle" @@ -554,65 +680,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..0c0279f4 --- /dev/null +++ b/tests/Bws.Architecture.Tests/SigningRitualGuards.cs @@ -0,0 +1,184 @@ +using System.Diagnostics; + +namespace Bws.Architecture.Tests; + +/// +/// The reads packaging/sign-release.ps1 makes of the release after the card has signed, and the mode +/// that finishes its last step 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, and +/// -CheckDraft runs step 8 over the work folder without signing again. +/// +/// The read is asked of the script's own function, not of a copy. The ritual cannot run in a +/// test - it needs a tag, the card and the real repository - so Read-Release is 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 +/// fails as many times as it is told and counts how often it was asked. +/// +public sealed class SigningRitualGuards +{ + private const string Script = "packaging/sign-release.ps1"; + + [Fact] + public void A_read_of_the_release_that_fails_twice_is_asked_again_and_answers() + { + var said = ReadRelease(fails: 2, empty: false); + 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 = ReadRelease(fails: 99, empty: false); + 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 = ReadRelease(fails: 0, empty: true); + 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 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'", "$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 The_draft_check_alone_comes_before_the_work_folder_is_cleared_and_the_card_is_reached() + { + var script = Text(Script); + var alone = script.IndexOf("if ($CheckDraft) {", StringComparison.Ordinal); + var cleared = script.IndexOf("Remove-Item -LiteralPath $workspace", StringComparison.Ordinal); + var card = script.IndexOf("$certificate = Get-CodeSigningCertificates", StringComparison.Ordinal); + Assert.True(alone > 0 && cleared > alone && card > alone, + $"-CheckDraft is decided at {alone}, the work folder is cleared at {cleared} and the card reached at {card}. It has to be decided first - it reads the folder a signing run left, and signs nothing."); + + var branch = script[alone..cleared]; + Assert.True(branch.Contains("Confirm-Draft $attested", StringComparison.Ordinal) && branch.Contains("return", StringComparison.Ordinal), + "The -CheckDraft branch no longer runs step 8 and returns - without the return it would go on to clear the folder and sign."); + Assert.True(script.TrimEnd().EndsWith("Confirm-Draft $attested", StringComparison.Ordinal), + "The ritual no longer ends with step 8 - the same function -CheckDraft runs, so the two cannot drift apart."); + } + + /// + /// Read-Release cut out of the script and run once against a stand-in gh, with what it printed: + /// READ and the answer, or THREW and the message, then CALLS and how often gh was asked. + /// + private static string ReadRelease(int fails, bool empty) + { + var folder = Directory.CreateTempSubdirectory("bws-signing-read-"); + try + { + WriteStandIn(folder.FullName); + WriteDriver(folder.FullName); + var said = RunDriver(folder.FullName, fails, empty); + Assert.False(said.Contains("NO READ-RELEASE", StringComparison.Ordinal), $"{Script} has no function called Read-Release any more."); + return said; + } + finally + { + folder.Delete(recursive: true); + } + } + + /// + /// A gh that fails FAKE_GH_FAILS times with a line on its error channel and then answers, or exits 0 + /// with nothing printed when FAKE_GH_EMPTY is 1 - counting every call in a file beside itself. + /// + private static void WriteStandIn(string folder) => + File.WriteAllText(Path.Combine(folder, "gh.cmd"), string.Join("\r\n", + "@echo off", + "set n=0", + "if exist \"%~dp0count\" set /p n=<\"%~dp0count\"", + "set /a n=n+1", + ">\"%~dp0count\" echo %n%", + "if \"%FAKE_GH_EMPTY%\"==\"1\" exit /b 0", + "if %n% LEQ %FAKE_GH_FAILS% (", + " echo HTTP 502: Bad Gateway 1>&2", + " exit /b 1", + ")", + "echo {\"isDraft\":true}", + "")); + + /// + /// The script that finds Read-Release in sign-release.ps1 with PowerShell's parser, defines it, puts the + /// stand-in first on the path and asks it once, under the same Stop and StrictMode the ritual runs with. + /// + private static void WriteDriver(string folder) + { + var driver = Path.Combine(folder, "driver.ps1"); + File.WriteAllText(driver, string.Join("\n", + "param([string] $Script, [string] $Fake)", + "$ErrorActionPreference = 'Stop'", + "Set-StrictMode -Version Latest", + "$tokens = $null", + "$errors = $null", + "$tree = [System.Management.Automation.Language.Parser]::ParseFile($Script, [ref]$tokens, [ref]$errors)", + "$read = $tree.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Read-Release' }, $true)", + "if (-not $read) { 'NO READ-RELEASE'; exit 2 }", + ". ([scriptblock]::Create($read.Extent.Text))", + "$env:PATH = $Fake + [IO.Path]::PathSeparator + $env:PATH", + "$Tag = 'v9.9.9'", + "$repo = 'nobody/nothing'", + "try { $view = Read-Release 'isDraft' 'in a test' -PauseSeconds 0; \"READ isDraft=$($view.isDraft)\" }", + "catch { \"THREW $($_.Exception.Message)\" }", + "\"CALLS $((Get-Content -LiteralPath (Join-Path $Fake 'count') -Raw).Trim())\"", + "")); + } + + /// The driver WriteDriver left in the folder, as its own process, with what it said on both channels. + private static string RunDriver(string folder, int fails, bool empty) + { + var start = new ProcessStartInfo("pwsh") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; + foreach (var argument in new[] { "-NoProfile", "-File", Path.Combine(folder, "driver.ps1"), "-Script", Path.Combine(SourceTree.Root(), Script.Replace('/', Path.DirectorySeparatorChar)), "-Fake", folder }) + { + start.ArgumentList.Add(argument); + } + + start.Environment["FAKE_GH_FAILS"] = fails.ToString(System.Globalization.CultureInfo.InvariantCulture); + start.Environment["FAKE_GH_EMPTY"] = empty ? "1" : "0"; + + // Both channels read as they arrive and a limit on the wait, for the reason InstallerGuards gives + // beside the same lines: a pwsh that hangs must not hang the test run with it. + var output = new System.Text.StringBuilder(); + var error = new System.Text.StringBuilder(); + using var process = new Process { StartInfo = start }; + process.OutputDataReceived += (_, line) => output.AppendLine(line.Data); + process.ErrorDataReceived += (_, line) => error.AppendLine(line.Data); + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + if (!process.WaitForExit(TimeSpan.FromSeconds(60))) + { + process.Kill(entireProcessTree: true); + Assert.Fail($"The driver of Read-Release did not finish in 60 s and was ended. It had said:{Environment.NewLine}{output}{error}"); + } + + process.WaitForExit(); + return output.ToString() + error; + } + + /// A file of the repository with its whole-line comments taken out, so a word in the explanation of a line never counts as the line. + private static string Text(string relative) + { + var path = Path.Combine(SourceTree.Root(), relative.Replace('/', Path.DirectorySeparatorChar)); + Assert.True(File.Exists(path), $"There is no {relative}."); + return string.Join('\n', File.ReadAllLines(path).Where(line => !line.TrimStart().StartsWith('#'))); + } +} diff --git a/tests/mutation/entries.ps1 b/tests/mutation/entries.ps1 index 034a4b75..d3bc1f15 100644 --- a/tests/mutation/entries.ps1 +++ b/tests/mutation/entries.ps1 @@ -13058,6 +13058,34 @@ internal sealed class SizeValue(NumberOperator operation, long low, long high) : To = "return [bool]`$INSTALLER" Test = 'InstallerGuards.Every_phase_asks_the_same_question_of_a_candidate_and_of_an_older_tree' } + @{ + Name = 'signing: a failed read of the release is asked again' + File = 'packaging\sign-release.ps1' + From = 'for ($try = 1; $try -le $Tries; $try++) {' + To = 'for ($try = 1; $try -le 1; $try++) {' + Test = 'SigningRitualGuards.A_read_of_the_release_that_fails_twice_is_asked_again_and_answers' + } + @{ + Name = 'signing: a read that keeps failing says what gh said' + File = 'packaging\sign-release.ps1' + From = "else { `$said = `"gh exited `$code and said: `" + (@(`$output | ForEach-Object { `"`$_`" }) -join ' ').Trim() }" + To = "else { `$said = `"gh exited `$code`" }" + Test = 'SigningRitualGuards.A_read_that_keeps_failing_stops_with_what_gh_said' + } + @{ + Name = 'signing: a read that prints nothing is not an answer' + File = 'packaging\sign-release.ps1' + From = 'if ($code -eq 0 -and $text) {' + To = 'if ($code -eq 0) {' + Test = 'SigningRitualGuards.A_read_that_prints_nothing_is_not_an_answer' + } + @{ + Name = 'signing: the draft check alone is decided before anything is cleared or signed' + File = 'packaging\sign-release.ps1' + From = 'if ($CheckDraft) {' + To = 'if ($false) {' + Test = 'SigningRitualGuards.The_draft_check_alone_comes_before_the_work_folder_is_cleared_and_the_card_is_reached' + } @{ Name = 'installer phase D: the published page is asked for the installer' File = '.github\workflows\verify-release.yml' From 28a63078fd73809db50c740410a17219884d81b3 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 3 Nov 2026 15:51:54 +0100 Subject: [PATCH 2/2] Refuse a published release before changing it, and confirm the draft by name Answers the review of #111. Step 7 reads whether the release is still a draft and refuses before it deletes any attestation bundle or uploads with --clobber. A second run on a published release would otherwise have changed it and only said so in step 8. Confirm-Draft asks the same on every read, so a published release with a missing asset is refused at once instead of waited out and described as a draft nothing was published from. It wants each attested file's own bundle by name rather than a count of bundles, holds each signed digest to the line of its own file in SHA256SUMS, and checks every attested asset's bytes against the digest GitHub gives it - downloading and hashing an asset that has none, so a missing field is never a match. SigningRitualGuards now cuts four functions out of the script and runs them against a stand-in gh that answers from a scenario: the read paths, including a reply that is not JSON, and seven drafts - complete, published, a stray bundle, swapped checksums, a replaced asset, and an asset without a digest with the signed bytes and with others. Nine more mutation entries, fourteen in all for the signing, every one caught. Co-Authored-By: Claude Opus 5.5 --- packaging/sign-release.ps1 | 55 +++- .../SigningRitualGuards.cs | 305 +++++++++++++----- tests/mutation/entries.ps1 | 56 ++++ 3 files changed, 321 insertions(+), 95 deletions(-) diff --git a/packaging/sign-release.ps1 b/packaging/sign-release.ps1 index 16333c4f..fc761086 100644 --- a/packaging/sign-release.ps1 +++ b/packaging/sign-release.ps1 @@ -275,17 +275,25 @@ function Confirm-Draft([string[]] $Files) { $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 $_ }) - $bundles = @($names | Where-Object { $_.EndsWith('.sigstore.json') }) - if (-not $missing -and $bundles.Count -ge $Files.Count) { break } + $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 ($bundles.Count -lt $Files.Count) { Write-Host " attestation bundles present: $($bundles.Count) of $($Files.Count)" } + 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. " + @@ -295,18 +303,41 @@ function Confirm-Draft([string[]] $Files) { } # 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. --clobber because -CheckDraft may run - # over a work folder that already holds this file from an earlier confirmation. + # 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 ($published -notmatch [regex]::Escape($signedDigests[$name])) { - throw ("sign-release: SHA256SUMS on the release does NOT name the digest we signed for $name.`n" + + 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" @@ -314,7 +345,7 @@ function Confirm-Draft([string[]] $Files) { 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 ' 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.' @@ -668,7 +699,13 @@ $stale = @($attested | ForEach-Object { (Split-Path -Leaf $_) + '.sigstore.json' # 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' 'to see what the draft already carries' 'Phase A opens the draft - check that it ran for this tag. Nothing has been uploaded.' +$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 $_ })) { Write-Host " removing the previous attestation bundle $bundle" diff --git a/tests/Bws.Architecture.Tests/SigningRitualGuards.cs b/tests/Bws.Architecture.Tests/SigningRitualGuards.cs index 0c0279f4..f406a1f8 100644 --- a/tests/Bws.Architecture.Tests/SigningRitualGuards.cs +++ b/tests/Bws.Architecture.Tests/SigningRitualGuards.cs @@ -1,30 +1,38 @@ 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, and the mode -/// that finishes its last step alone. +/// 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, and -/// -CheckDraft runs step 8 over the work folder without signing again. +/// 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 read is asked of the script's own function, not of a copy. The ritual cannot run in a -/// test - it needs a tag, the card and the real repository - so Read-Release is 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 -/// fails as many times as it is told and counts how often it was asked. +/// 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 = ReadRelease(fails: 2, empty: false); + 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}"); } @@ -32,7 +40,7 @@ public void A_read_of_the_release_that_fails_twice_is_asked_again_and_answers() [Fact] public void A_read_that_keeps_failing_stops_with_what_gh_said() { - var said = ReadRelease(fails: 99, empty: false); + 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), @@ -42,53 +50,156 @@ public void A_read_that_keeps_failing_stops_with_what_gh_said() [Fact] public void A_read_that_prints_nothing_is_not_an_answer() { - var said = ReadRelease(fails: 0, empty: true); + 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'", "$view = Read-Release 'assets,isDraft'", "$state = Read-Release 'isDraft'" }) + 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 The_draft_check_alone_comes_before_the_work_folder_is_cleared_and_the_card_is_reached() + public void A_published_release_is_refused_before_anything_on_it_is_changed() { var script = Text(Script); - var alone = script.IndexOf("if ($CheckDraft) {", StringComparison.Ordinal); - var cleared = script.IndexOf("Remove-Item -LiteralPath $workspace", StringComparison.Ordinal); - var card = script.IndexOf("$certificate = Get-CodeSigningCertificates", StringComparison.Ordinal); - Assert.True(alone > 0 && cleared > alone && card > alone, - $"-CheckDraft is decided at {alone}, the work folder is cleared at {cleared} and the card reached at {card}. It has to be decided first - it reads the folder a signing run left, and signs nothing."); + 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() }, files = new Dictionary() })); - var branch = script[alone..cleared]; - Assert.True(branch.Contains("Confirm-Draft $attested", StringComparison.Ordinal) && branch.Contains("return", StringComparison.Ordinal), - "The -CheckDraft branch no longer runs step 8 and returns - without the return it would go on to clear the folder and sign."); - Assert.True(script.TrimEnd().EndsWith("Confirm-Draft $attested", StringComparison.Ordinal), - "The ritual no longer ends with step 8 - the same function -CheckDraft runs, so the two cannot drift apart."); + private static string Confirm(Release release) + { + var assets = new List + { + new { name = "a.zip", digest = release.DigestOfA is null ? null : "sha256:" + release.DigestOfA }, + new { name = "b.zip", digest = (string?)("sha256:" + Beta) }, + new { name = "SHA256SUMS", digest = (string?)null }, + }; + assets.AddRange(release.Bundles.Select(bundle => (object)new { name = bundle, digest = (string?)null })); + var files = new Dictionary { ["SHA256SUMS"] = release.Sums, ["a.zip"] = release.BytesOfA }; + return Run("confirm", JsonSerializer.Serialize(new { mode = "answer", times = 0, view = new { isDraft = release.IsDraft, assets }, files })); } /// - /// Read-Release cut out of the script and run once against a stand-in gh, with what it printed: - /// READ and the answer, or THREW and the message, then CALLS and how often gh was asked. + /// The four functions cut out of the script and one of them run against the stand-in gh, with what the + /// driver printed: READ or CONFIRMED, or THREW and the message, then CALLS and how often gh was asked. /// - private static string ReadRelease(int fails, bool empty) + private static string Run(string call, string scenario) { - var folder = Directory.CreateTempSubdirectory("bws-signing-read-"); + var folder = Directory.CreateTempSubdirectory("bws-signing-"); try { - WriteStandIn(folder.FullName); - WriteDriver(folder.FullName); - var said = RunDriver(folder.FullName, fails, empty); - Assert.False(said.Contains("NO READ-RELEASE", StringComparison.Ordinal), $"{Script} has no function called Read-Release any more."); + File.WriteAllText(Path.Combine(folder.FullName, "gh.cmd"), "@pwsh -NoProfile -File \"%~dp0stand-in.ps1\" %*\r\n@exit /b %errorlevel%\r\n"); + File.WriteAllText(Path.Combine(folder.FullName, "stand-in.ps1"), StandIn); + File.WriteAllText(Path.Combine(folder.FullName, "driver.ps1"), Driver); + File.WriteAllText(Path.Combine(folder.FullName, "scenario.json"), scenario); + File.WriteAllText(Path.Combine(folder.FullName, "a.zip"), "alpha"); + File.WriteAllText(Path.Combine(folder.FullName, "b.zip"), "beta"); + var said = Execute(folder.FullName, call); + Assert.False(said.Contains("NO FUNCTION", StringComparison.Ordinal), $"{Script} lost a function this class runs:{Environment.NewLine}{said}"); return said; } finally @@ -97,83 +208,37 @@ private static string ReadRelease(int fails, bool empty) } } - /// - /// A gh that fails FAKE_GH_FAILS times with a line on its error channel and then answers, or exits 0 - /// with nothing printed when FAKE_GH_EMPTY is 1 - counting every call in a file beside itself. - /// - private static void WriteStandIn(string folder) => - File.WriteAllText(Path.Combine(folder, "gh.cmd"), string.Join("\r\n", - "@echo off", - "set n=0", - "if exist \"%~dp0count\" set /p n=<\"%~dp0count\"", - "set /a n=n+1", - ">\"%~dp0count\" echo %n%", - "if \"%FAKE_GH_EMPTY%\"==\"1\" exit /b 0", - "if %n% LEQ %FAKE_GH_FAILS% (", - " echo HTTP 502: Bad Gateway 1>&2", - " exit /b 1", - ")", - "echo {\"isDraft\":true}", - "")); - - /// - /// The script that finds Read-Release in sign-release.ps1 with PowerShell's parser, defines it, puts the - /// stand-in first on the path and asks it once, under the same Stop and StrictMode the ritual runs with. - /// - private static void WriteDriver(string folder) - { - var driver = Path.Combine(folder, "driver.ps1"); - File.WriteAllText(driver, string.Join("\n", - "param([string] $Script, [string] $Fake)", - "$ErrorActionPreference = 'Stop'", - "Set-StrictMode -Version Latest", - "$tokens = $null", - "$errors = $null", - "$tree = [System.Management.Automation.Language.Parser]::ParseFile($Script, [ref]$tokens, [ref]$errors)", - "$read = $tree.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Read-Release' }, $true)", - "if (-not $read) { 'NO READ-RELEASE'; exit 2 }", - ". ([scriptblock]::Create($read.Extent.Text))", - "$env:PATH = $Fake + [IO.Path]::PathSeparator + $env:PATH", - "$Tag = 'v9.9.9'", - "$repo = 'nobody/nothing'", - "try { $view = Read-Release 'isDraft' 'in a test' -PauseSeconds 0; \"READ isDraft=$($view.isDraft)\" }", - "catch { \"THREW $($_.Exception.Message)\" }", - "\"CALLS $((Get-Content -LiteralPath (Join-Path $Fake 'count') -Raw).Trim())\"", - "")); - } - - /// The driver WriteDriver left in the folder, as its own process, with what it said on both channels. - private static string RunDriver(string folder, int fails, bool empty) + /// The driver as its own process, with what it said on both channels. + private static string Execute(string folder, string call) { var start = new ProcessStartInfo("pwsh") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; - foreach (var argument in new[] { "-NoProfile", "-File", Path.Combine(folder, "driver.ps1"), "-Script", Path.Combine(SourceTree.Root(), Script.Replace('/', Path.DirectorySeparatorChar)), "-Fake", folder }) + foreach (var argument in new[] { "-NoProfile", "-File", Path.Combine(folder, "driver.ps1"), "-Script", Path.Combine(SourceTree.Root(), Script.Replace('/', Path.DirectorySeparatorChar)), "-Fake", folder, "-Call", call }) { start.ArgumentList.Add(argument); } - start.Environment["FAKE_GH_FAILS"] = fails.ToString(System.Globalization.CultureInfo.InvariantCulture); - start.Environment["FAKE_GH_EMPTY"] = empty ? "1" : "0"; - // Both channels read as they arrive and a limit on the wait, for the reason InstallerGuards gives // beside the same lines: a pwsh that hangs must not hang the test run with it. - var output = new System.Text.StringBuilder(); - var error = new System.Text.StringBuilder(); + var output = new StringBuilder(); + var error = new StringBuilder(); using var process = new Process { StartInfo = start }; process.OutputDataReceived += (_, line) => output.AppendLine(line.Data); process.ErrorDataReceived += (_, line) => error.AppendLine(line.Data); process.Start(); process.BeginOutputReadLine(); process.BeginErrorReadLine(); - if (!process.WaitForExit(TimeSpan.FromSeconds(60))) + if (!process.WaitForExit(TimeSpan.FromSeconds(90))) { process.Kill(entireProcessTree: true); - Assert.Fail($"The driver of Read-Release did not finish in 60 s and was ended. It had said:{Environment.NewLine}{output}{error}"); + Assert.Fail($"The driver did not finish in 90 s and was ended. It had said:{Environment.NewLine}{output}{error}"); } process.WaitForExit(); return output.ToString() + error; } + private static string Digest(string text) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(text))); + /// A file of the repository with its whole-line comments taken out, so a word in the explanation of a line never counts as the line. private static string Text(string relative) { @@ -181,4 +246,72 @@ private static string Text(string relative) Assert.True(File.Exists(path), $"There is no {relative}."); return string.Join('\n', File.ReadAllLines(path).Where(line => !line.TrimStart().StartsWith('#'))); } + + /// + /// The gh this class puts first on the path. A view misbehaves for the first `times` calls in the + /// scenario's mode - fail with a line on the error channel, exit 0 with nothing, or print what is not + /// JSON - and then answers the scenario's view. A download writes the scenario's file of that name. + /// + private const string StandIn = """ + $scenario = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'scenario.json') | ConvertFrom-Json + $count = Join-Path $PSScriptRoot 'count' + $n = 1 + if (Test-Path -LiteralPath $count) { $n += [int](Get-Content -Raw -LiteralPath $count) } + Set-Content -LiteralPath $count -Value $n + if ($args[1] -eq 'view') { + if ($n -le $scenario.times) { + if ($scenario.mode -eq 'empty') { exit 0 } + if ($scenario.mode -eq 'garbled') { 'this is not json'; exit 0 } + [Console]::Error.WriteLine('HTTP 502: Bad Gateway') + exit 1 + } + $scenario.view | ConvertTo-Json -Depth 5 -Compress + exit 0 + } + if ($args[1] -eq 'download') { + $pattern = $args[[array]::IndexOf($args, '--pattern') + 1] + $dir = $args[[array]::IndexOf($args, '--dir') + 1] + [IO.File]::WriteAllText((Join-Path $dir $pattern), [string]$scenario.files.$pattern) + exit 0 + } + [Console]::Error.WriteLine("the stand-in gh was asked: $($args -join ' ')") + exit 1 + """; + + /// + /// The script that finds the four functions in sign-release.ps1 with PowerShell's parser, defines them, + /// puts the stand-in first on the path and makes one call, under the Stop and StrictMode the ritual runs with. + /// + private const string Driver = """ + param([string] $Script, [string] $Fake, [string] $Call) + $ErrorActionPreference = 'Stop' + Set-StrictMode -Version Latest + $tokens = $null + $errors = $null + $tree = [System.Management.Automation.Language.Parser]::ParseFile($Script, [ref]$tokens, [ref]$errors) + foreach ($wanted in 'Invoke-Step', 'Get-Sha256', 'Read-Release', 'Confirm-Draft') { + $found = $tree.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $wanted }, $true) + if (-not $found) { "NO FUNCTION $wanted"; exit 2 } + . ([scriptblock]::Create($found.Extent.Text)) + } + $env:PATH = $Fake + [IO.Path]::PathSeparator + $env:PATH + $Tag = 'v9.9.9' + $repo = 'nobody/nothing' + $Wait = 0 + $attestWorkflow = 'attest-signed.yml' + $workspace = $Fake + $EXPECTED_ASSETS = @('a.zip', 'b.zip', 'SHA256SUMS') + try { + if ($Call -eq 'read') { + $view = Read-Release 'isDraft' 'in a test' -PauseSeconds 0 + "READ isDraft=$($view.isDraft)" + } + else { + Confirm-Draft @((Join-Path $Fake 'a.zip'), (Join-Path $Fake 'b.zip')) + 'CONFIRMED' + } + } + catch { "THREW $($_.Exception.Message)" } + "CALLS $((Get-Content -LiteralPath (Join-Path $Fake 'count') -Raw).Trim())" + """; } diff --git a/tests/mutation/entries.ps1 b/tests/mutation/entries.ps1 index d3bc1f15..e2ee8c35 100644 --- a/tests/mutation/entries.ps1 +++ b/tests/mutation/entries.ps1 @@ -13086,6 +13086,62 @@ internal sealed class SizeValue(NumberOperator operation, long low, long high) : To = 'if ($false) {' Test = 'SigningRitualGuards.The_draft_check_alone_comes_before_the_work_folder_is_cleared_and_the_card_is_reached' } + @{ + Name = 'signing: a read that is not JSON is asked again' + File = 'packaging\sign-release.ps1' + From = 'catch { $said = "gh printed something that is not JSON: $text" }' + To = 'catch { throw }' + Test = 'SigningRitualGuards.A_read_that_is_not_json_is_asked_again' + } + @{ + Name = 'signing: the release is read in one place' + File = 'packaging\sign-release.ps1' + From = "`$draft = Read-Release 'assets,isDraft'" + To = "`$draft = gh release view `$Tag --repo `$repo --json 'assets,isDraft' | ConvertFrom-Json #" + Test = 'SigningRitualGuards.Every_read_of_the_release_goes_through_the_one_that_asks_again' + } + @{ + Name = 'signing: a published release is refused before anything on it is changed' + File = 'packaging\sign-release.ps1' + From = 'if (-not $draft.isDraft) {' + To = 'if ($false) {' + Test = 'SigningRitualGuards.A_published_release_is_refused_before_anything_on_it_is_changed' + } + @{ + Name = 'signing: a published release is refused at once while confirming' + File = 'packaging\sign-release.ps1' + From = 'if (-not $view.isDraft) {' + To = 'if ($false) {' + Test = 'SigningRitualGuards.A_release_already_published_is_refused_at_once_rather_than_waited_for' + } + @{ + Name = 'signing: every attested file has its own bundle, by name' + File = 'packaging\sign-release.ps1' + From = 'if (-not $missing -and -not $missingBundles) { break }' + To = 'if (-not $missing) { break }' + Test = 'SigningRitualGuards.A_bundle_nobody_expects_does_not_stand_in_for_a_missing_one' + } + @{ + Name = 'signing: each signed digest stands on the line of its own file' + File = 'packaging\sign-release.ps1' + From = 'if (-not $sums.ContainsKey($name) -or $sums[$name] -ne $signedDigests[$name]) {' + To = 'if ($published -notmatch [regex]::Escape($signedDigests[$name])) {' + Test = 'SigningRitualGuards.A_digest_under_another_name_in_the_checksums_is_refused' + } + @{ + Name = 'signing: the files on the release are the ones signed' + File = 'packaging\sign-release.ps1' + From = 'if ($digest -ne $signedDigests[$name]) {' + To = 'if ($false) {' + Test = 'SigningRitualGuards.An_asset_replaced_after_phase_C_is_refused_by_its_own_digest' + } + @{ + Name = 'signing: an asset with no digest is downloaded and hashed' + File = 'packaging\sign-release.ps1' + From = 'if (-not $digest) {' + To = 'if ($false) {' + Test = 'SigningRitualGuards.An_asset_without_a_digest_is_downloaded_and_hashed' + } @{ Name = 'installer phase D: the published page is asked for the installer' File = '.github\workflows\verify-release.yml'