diff --git a/CHANGELOG.md b/CHANGELOG.md index 05c846a..837232c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ Each release of Versioning, newest first. An entry is dated when its release is cut, and reads *Unreleased* until then. +## `1.0.0-preview.5` + +*November 8, 2026* + +- `commands/New-Release.ps1` releases a project's candidate: it holds the release to the rules, signs the tag in the + project's clone and pushes `main` and the tag in one atomic push, and resumes a release that stopped partway. + ## `1.0.0-preview.4` *November 5, 2026* diff --git a/README.md b/README.md index 1b944c0..7427d0a 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,25 @@ in a comment beside it. - **The checks read** with `contents: read`, `actions: read` and `pull-requests: read`, and `release-packages` writes the release with `contents: write`. +## The release command + +[`commands/New-Release.ps1`](commands/New-Release.ps1) releases a project's candidate once the maintainer has decided to +release it. It holds the release to the rules before anything is pushed, signs the tag in the project's clone and +verifies the signature, then pushes `main` and the tag in one atomic push, so both land or neither does: + +```powershell +pwsh -NoProfile -File /commands/New-Release.ps1 -Repository FormaUI/Forma -Version 1.0.0-preview.4 -Path +``` + +- **It runs from a checkout of a released Versioning tag,** outside the project's clone, so what's being released can't + change what the command does. It reads the candidate as text and runs none of its code. +- **It reads the release check's settings** from the project's release workflow at the candidate's commit, so a + project states them once. +- **It resumes a release that stopped partway:** a signed tag the clone holds for the candidate is pushed as it is, + and `main` already at the candidate isn't pushed again. +- **A standing permission can name it alone,** for an agent that releases at the maintainer's word: what the + permission allows is what the command checks, not any push to `main`. + ## Following a release A project follows one release of Versioning. It links to these documents at that release's tag, as diff --git a/VERSION b/VERSION index 2c24b09..e07e2a5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0-preview.4 +1.0.0-preview.5 diff --git a/actions/Versioning.psm1 b/actions/Versioning.psm1 index 43e66c0..64b6311 100644 --- a/actions/Versioning.psm1 +++ b/actions/Versioning.psm1 @@ -395,6 +395,166 @@ function Test-DigestFile { } } +# The inputs a workflow gives the release check, read as text from the workflow's file: each key of the step's with +# mapping, a block scalar's lines joined, a quoted scalar unquoted. Nothing where the file calls no release check. +function Get-ReleaseCheckInput { + param([Parameter(Mandatory)] [string] $Path) + + if (-not (Test-Path -LiteralPath $Path)) { + return $null + } + + $lines = [System.IO.File]::ReadAllLines($Path) + $indentOf = { param($line) $line.Length - $line.TrimStart().Length } + $use = -1 + + for ($index = 0; $index -lt $lines.Count; $index++) { + if ($lines[$index] -match '^(?\s*(?:-\s+)?)uses:\s*\S*actions/release-check(?:@\S+)?\s*(?:#.*)?$') { + $use = $index + $keyIndent = $Matches['lead'].Length + break + } + } + + if ($use -lt 0) { + return $null + } + + # The step runs from its dash to the next line that is less indented than its keys or opens the next step. + $start = $use + + while ($start -gt 0 -and $lines[$start] -notmatch "^\s{$($keyIndent - 2)}-\s") { + $start-- + } + + $end = $start + 1 + + while ($end -lt $lines.Count -and -not ($lines[$end].Trim() -and ((& $indentOf $lines[$end]) -lt $keyIndent -or $lines[$end] -match "^\s{$($keyIndent - 2)}-\s"))) { + $end++ + } + + $inputs = [ordered]@{} + $with = -1 + + for ($index = $start; $index -lt $end; $index++) { + if ($lines[$index] -match "^\s{$keyIndent}with:\s*$") { + $with = $index + break + } + } + + if ($with -lt 0) { + return $inputs + } + + $index = $with + 1 + + while ($index -lt $end) { + $line = $lines[$index] + + if (-not $line.Trim() -or $line.TrimStart().StartsWith('#')) { + $index++ + continue + } + + $entryIndent = & $indentOf $line + + if ($entryIndent -le $keyIndent -or $line -notmatch '^\s*(?[\w-]+):\s*(?.*)$') { + break + } + + $key = $Matches['key'] + $value = $Matches['value'] + $index++ + + if ($value -match '^[|>][+-]?\s*(?:#.*)?$') { + $block = [System.Collections.Generic.List[string]]::new() + + while ($index -lt $end -and (-not $lines[$index].Trim() -or (& $indentOf $lines[$index]) -gt $entryIndent)) { + $block.Add($lines[$index]) + $index++ + } + + while ($block.Count -gt 0 -and -not $block[$block.Count - 1].Trim()) { + $block.RemoveAt($block.Count - 1) + } + + $common = ($block | Where-Object { $_.Trim() } | ForEach-Object { & $indentOf $_ } | Measure-Object -Minimum).Minimum + $inputs[$key] = ($block | ForEach-Object { if ($_.Length -ge $common) { $_.Substring($common) } else { '' } }) -join "`n" + } + elseif ($value -match "^'(?(?:[^']|'')*)'\s*(?:#.*)?$") { + $inputs[$key] = $Matches['text'] -replace "''", "'" + } + elseif ($value -match '^"(?[^"]*)"\s*(?:#.*)?$') { + $inputs[$key] = $Matches['text'] + } + else { + $inputs[$key] = ($value -replace '\s+#.*$', '').Trim() + } + } + + $inputs +} + +# The versions a changelog's entries name, newest first, as their headings give them. +function Get-ChangelogVersion { + param([Parameter(Mandatory)] [string] $Path) + + if (-not (Test-Path -LiteralPath $Path)) { + return + } + + foreach ($line in [System.IO.File]::ReadAllLines($Path)) { + if ($line.TrimEnd() -cmatch '^## `(?[^`]+)`$') { + $Matches['version'] + } + } +} + +# What a release of a candidate still needs, from where main and the tag stand: the steps left, in order, or the reason +# it can't go on. A tag already pushed for the candidate, with main at it, leaves nothing; a tag pushed for another +# commit is never moved; a signed tag made in the clone for the candidate is pushed as it is. +function Get-ReleaseStep { + param( + [Parameter(Mandatory)] [string] $Candidate, + [Parameter(Mandatory)] [string] $Main, + [Parameter(Mandatory)] [bool] $MainIsBehind, + [AllowEmptyString()] [string] $PushedTag = '', + [AllowEmptyString()] [string] $ClonedTag = '' + ) + + $state = [pscustomobject]@{ Steps = @(); Problem = $null } + + if ($PushedTag) { + if ($PushedTag -cne $Candidate) { + $state.Problem = "The tag is already pushed for $PushedTag, and a pushed tag is never moved" + } + elseif ($Main -cne $Candidate) { + $state.Problem = "The tag is pushed for $Candidate, and main is at $Main" + } + + return $state + } + + if ($Main -cne $Candidate -and -not $MainIsBehind) { + $state.Problem = "main is at $Main, which isn't behind $Candidate, so it can't fast-forward to it" + return $state + } + + if ($ClonedTag -and $ClonedTag -cne $Candidate) { + $state.Problem = "The clone's tag marks $ClonedTag rather than $Candidate" + return $state + } + + $state.Steps = @( + if (-not $ClonedTag) { 'Sign' } + if ($Main -cne $Candidate) { 'Promote' } + 'Push' + ) + + $state +} + # One check's result: whether it holds, and what it found. function New-CheckResult { param([Parameter(Mandatory)] [string] $Check, [Parameter(Mandatory)] [bool] $Holds, [Parameter(Mandatory)] [string] $Detail) @@ -425,5 +585,6 @@ function Write-CheckResult { } Export-ModuleMember -Function Test-VersionRule, Compare-Version, Invoke-GitHubApi, Get-ReleaseVersion, Get-HighestVersion, - Get-ChangelogEntry, Find-GateRun, Get-RunArtifact, Save-RunArtifact, Get-PackageInventory, Get-PublicationOrder, - Test-FeedVersion, New-DigestFile, Test-DigestFile, New-CheckResult, Write-CheckResult + Get-ChangelogEntry, Get-ChangelogVersion, Find-GateRun, Get-RunArtifact, Save-RunArtifact, Get-PackageInventory, + Get-PublicationOrder, Test-FeedVersion, New-DigestFile, Test-DigestFile, Get-ReleaseCheckInput, Get-ReleaseStep, + New-CheckResult, Write-CheckResult diff --git a/commands/New-Release.ps1 b/commands/New-Release.ps1 new file mode 100644 index 0000000..374ce2a --- /dev/null +++ b/commands/New-Release.ps1 @@ -0,0 +1,265 @@ +#!/usr/bin/env pwsh +#Requires -Version 7 + +<# +.SYNOPSIS +Releases a project's candidate: promotes it to main and pushes its signed release tag. + +.DESCRIPTION +The maintainer runs it once they've decided to release, in a clone of the project, from a checkout of a released +Versioning tag outside that clone, so what's being released can't change what the command does. It reads nothing from +the candidate but text, and runs none of its code. + +Before anything is pushed, it holds the release to every rule: + +- The promotion pull request from candidate into main has the candidate's commit at its head, open with its promotion + check passed, or merged. +- The project's full-gate workflow passed on that commit on a push to candidate. +- The project's release workflow at that commit names the release check's settings, which the command reads as text. +- The changelog's newest entry at that commit names the version being released, dated. The promotion check, which ran + on that commit, holds the version the commit declares to a dated entry above every release, so the tag names the + version the commit declares. +- The full-gate run still holds the packages, for a project that publishes them. + +It then signs the tag in the clone and verifies its signature, so a signature that fails pushes nothing, and pushes +main and the tag in one atomic push, so both land or neither does. A release that stopped partway resumes: a signed tag +the clone already holds for the commit is pushed as it is, and main already at the commit isn't pushed again. A +release whose tag is pushed, with main at its commit, is reported done. + +.PARAMETER Repository +The project's repository, as owner/name, whose name the tag's message carries. + +.PARAMETER Version +The version being released, which the tag is named for. + +.PARAMETER Path +The clone of the project the tag is signed in and pushed from. + +.PARAMETER ReleaseWorkflow +The file name of the project's release workflow, which calls the release check. + +.PARAMETER PromotionWorkflow +The file name of the project's promotion workflow, which runs the promotion check on the pull request. + +.EXAMPLE +pwsh -NoProfile -File ~/.versioning/v1.0.0-preview.5/commands/New-Release.ps1 -Repository FormaUI/Forma -Version 1.0.0-preview.4 -Path ~/Projects/formaui-net +#> + +[CmdletBinding(SupportsShouldProcess)] +param( + [Parameter(Mandatory)] [ValidatePattern('^[\w.-]+/[\w.-]+$')] [string] $Repository, + [Parameter(Mandatory)] [string] $Version, + [string] $Path = (Get-Location).Path, + [string] $ReleaseWorkflow = 'release.yml', + [string] $PromotionWorkflow = 'promotion.yml' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot '..' 'actions' 'Versioning.psm1') -Force + +# Runs git in the clone and returns what it wrote and its code. +function Invoke-Git { + param([Parameter(Mandatory)] [string[]] $Arguments) + + $output = & git -C $Path @Arguments 2>&1 + [pscustomobject]@{ Code = $LASTEXITCODE; Text = (($output | ForEach-Object { "$_" }) -join "`n").Trim() } +} + +if (-not (Test-VersionRule -Version $Version)) { + throw "$Version isn't a version by the version rules." +} + +$tag = "v$Version" +$owner, $name = $Repository -split '/' +$origin = Invoke-Git @('remote', 'get-url', 'origin') + +if ($origin.Code -ne 0 -or $origin.Text -notmatch "github\.com[:/]$([regex]::Escape($Repository))(\.git)?$") { + throw "$Path isn't a clone of $Repository; its origin is '$($origin.Text)'." +} + +$fetched = Invoke-Git @('fetch', '--quiet', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main', '+refs/heads/candidate:refs/remotes/origin/candidate') + +if ($fetched.Code -ne 0) { + throw "Fetching main and candidate failed: $($fetched.Text)" +} + +$candidate = (Invoke-Git @('rev-parse', 'refs/remotes/origin/candidate')).Text +$main = (Invoke-Git @('rev-parse', 'refs/remotes/origin/main')).Text +$mainIsBehind = (Invoke-Git @('merge-base', '--is-ancestor', $main, $candidate)).Code -eq 0 + +# The commit the pushed tag marks, peeled where it's annotated. +$pushedTag = '' +$listed = Invoke-Git @('ls-remote', 'origin', "refs/tags/$tag", "refs/tags/$tag^{}") + +foreach ($line in ($listed.Text -split "`n" | Where-Object { $_ })) { + $sha, $ref = $line -split "`t" + + if ($ref -ceq "refs/tags/$tag^{}" -or -not $pushedTag) { + $pushedTag = $sha + } +} + +$cloned = Invoke-Git @('rev-parse', '--verify', '--quiet', "refs/tags/$tag^{commit}") +$clonedTag = if ($cloned.Code -eq 0) { $cloned.Text } else { '' } +$step = Get-ReleaseStep -Candidate $candidate -Main $main -MainIsBehind $mainIsBehind -PushedTag $pushedTag -ClonedTag $clonedTag + +if ($step.Problem) { + throw $step.Problem +} + +if ($step.Steps.Count -eq 0) { + Write-Host "$tag is released: main and the tag mark $candidate." + return +} + +$results = [System.Collections.Generic.List[object]]::new() + +# The promotion: open with its check passed while main is behind, merged once main is at the candidate. +if ($main -cne $candidate) { + $pull = @(Invoke-GitHubApi -Path "repos/$Repository/pulls?state=open&base=main&head=$($owner):candidate&per_page=10") | + Where-Object { $_.head.sha -ceq $candidate } | Select-Object -First 1 + $runs = (Invoke-GitHubApi -Path "repos/$Repository/actions/workflows/$PromotionWorkflow/runs?event=pull_request&head_sha=$candidate&per_page=100").workflow_runs + $promotion = @($runs | Where-Object { $_.head_sha -ceq $candidate } | Sort-Object -Property id -Descending) | Select-Object -First 1 + $promoted = $pull -and $promotion -and $promotion.status -eq 'completed' -and $promotion.conclusion -eq 'success' + $promotionDetail = if (-not $pull) { + "No open pull request from candidate into main has $candidate at its head" + } + elseif (-not $promoted) { + "Pull request #$($pull.number)'s promotion check hasn't passed on $candidate" + } + else { + "Pull request #$($pull.number) has $candidate at its head, and its promotion check passed" + } +} +else { + $pull = @(Invoke-GitHubApi -Path "repos/$Repository/commits/$candidate/pulls") | Where-Object { + $_.merged_at -and $_.base.ref -ceq 'main' -and $_.head.ref -ceq 'candidate' -and $_.head.sha -ceq $candidate + } | Select-Object -First 1 + $promoted = [bool]$pull + $promotionDetail = if ($promoted) { "Pull request #$($pull.number) promoted $candidate" } else { "No merged promotion pull request has $candidate at its head" } +} + +$results.Add((New-CheckResult -Check 'Promotion' -Holds ([bool]$promoted) -Detail $promotionDetail)) + +# The candidate's files the release reads, each as git holds it at the commit, written to a folder of their own. +$tree = Join-Path ([System.IO.Path]::GetTempPath()) "versioning-release-$([guid]::NewGuid().ToString('N'))" + +function Save-CandidateFile { + param([Parameter(Mandatory)] [string] $RelativePath) + + $target = Join-Path $tree $RelativePath + $null = New-Item -ItemType Directory -Force -Path (Split-Path -Parent $target) -WhatIf:$false + + # git writes the file as it holds it, UTF-8, which the console's own code page would misread. + $encoding = [Console]::OutputEncoding + [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) + + try { + $text = & git -C $Path show "$($candidate):$($RelativePath -replace '\\', '/')" 2>$null + $code = $LASTEXITCODE + } + finally { + [Console]::OutputEncoding = $encoding + } + + if ($code -ne 0) { + return $null + } + + [System.IO.File]::WriteAllText($target, (($text | ForEach-Object { "$_" }) -join "`n") + "`n") + $target +} + +# Reading the candidate's files is no change, so a described run reads them too. +$null = New-Item -ItemType Directory -Path $tree -WhatIf:$false + +try { + $workflowPath = Save-CandidateFile -RelativePath ".github/workflows/$ReleaseWorkflow" + $inputs = if ($workflowPath) { Get-ReleaseCheckInput -Path $workflowPath } else { $null } + + if ($null -eq $inputs -or -not $inputs.Contains('gate-workflow')) { + throw "$ReleaseWorkflow at $candidate calls no release check with a gate-workflow, so the release's rules can't be read." + } + + $gateWorkflow = $inputs['gate-workflow'] + $changelog = if ($inputs.Contains('changelog') -and $inputs['changelog']) { $inputs['changelog'] } else { 'CHANGELOG.md' } + $packagesArtifact = if ($inputs.Contains('packages-artifact')) { $inputs['packages-artifact'] } else { '' } + + $run = Find-GateRun -Repository $Repository -Workflow $gateWorkflow -Commit $candidate + $gateDetail = if ($run) { "$gateWorkflow passed on $candidate, in run $($run.id)" } else { "$gateWorkflow hasn't passed on a push to candidate for $candidate" } + $results.Add((New-CheckResult -Check 'Gate' -Holds ([bool]$run) -Detail $gateDetail)) + + $changelogPath = Save-CandidateFile -RelativePath $changelog + + if (-not $changelogPath) { + $changelogPath = Join-Path $tree $changelog + } + + $newest = @(Get-ChangelogVersion -Path $changelogPath) | Select-Object -First 1 + $entry = Get-ChangelogEntry -Path $changelogPath -Version $Version + $isNewest = $newest -ceq $Version -and $entry.Found -and $entry.Dated + $changelogDetail = if ($newest -cne $Version) { + "$changelog's newest entry is $newest, not $Version" + } + elseif (-not $entry.Dated) { + "The entry for $Version isn't dated: $($entry.Line)" + } + else { + "$changelog's newest entry is $Version, dated: $($entry.Line)" + } + + $results.Add((New-CheckResult -Check 'Changelog' -Holds $isNewest -Detail $changelogDetail)) + + if (-not $packagesArtifact) { + $results.Add((New-CheckResult -Check 'Packages' -Holds $true -Detail 'The project publishes no package')) + } + else { + $held = $run -and (Get-RunArtifact -Repository $Repository -RunId $run.id -Name $packagesArtifact) + $packagesDetail = if ($held) { "Run $($run.id) holds the artifact $packagesArtifact" } else { "The full-gate run doesn't hold the artifact $packagesArtifact" } + $results.Add((New-CheckResult -Check 'Packages' -Holds ([bool]$held) -Detail $packagesDetail)) + } +} +finally { + Remove-Item -LiteralPath $tree -Recurse -Force -ErrorAction SilentlyContinue -WhatIf:$false +} + +$failed = Write-CheckResult -Title "Releasing $tag at $candidate" -Result $results.ToArray() + +if ($failed -gt 0) { + throw 'The release holds to the rules only once every check holds. Nothing was signed or pushed.' +} + +# The tag is signed first, so a signature that fails leaves nothing pushed. +if ($step.Steps -contains 'Sign') { + if ($PSCmdlet.ShouldProcess("$candidate, in $Path", "Sign the tag $tag")) { + $signed = Invoke-Git @('tag', '-s', $tag, $candidate, '-m', "$name $Version") + + if ($signed.Code -ne 0) { + throw "Signing $tag failed, and nothing was pushed: $($signed.Text)" + } + } +} + +if (-not $WhatIfPreference) { + $verified = Invoke-Git @('tag', '-v', $tag) + + if ($verified.Code -ne 0 -or (Invoke-Git @('cat-file', '-t', "refs/tags/$tag")).Text -cne 'tag') { + throw "$tag isn't an annotated tag with a signature that verifies, and nothing was pushed: $($verified.Text)" + } +} + +$refs = @( + if ($step.Steps -contains 'Promote') { "$($candidate):refs/heads/main" } + "refs/tags/$tag" +) + +if ($PSCmdlet.ShouldProcess($Repository, "Push $($refs -join ' and ') in one atomic push")) { + $pushed = Invoke-Git (@('push', '--atomic', 'origin') + $refs) + + if ($pushed.Code -ne 0) { + throw "The atomic push failed, so neither main nor the tag moved: $($pushed.Text)" + } + + Write-Host "$tag is pushed, with main at $candidate; the release workflow runs at https://github.com/$Repository/actions" +} diff --git a/formaui-net.Versioning.code-workspace b/formaui-net.Versioning.code-workspace new file mode 100644 index 0000000..db0fd4e --- /dev/null +++ b/formaui-net.Versioning.code-workspace @@ -0,0 +1,8 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": {} +} diff --git a/tests/Versioning.Tests.ps1 b/tests/Versioning.Tests.ps1 index 9a57197..f8f5398 100755 --- a/tests/Versioning.Tests.ps1 +++ b/tests/Versioning.Tests.ps1 @@ -152,6 +152,80 @@ $cases = [ordered]@{ if (-not $cycle) { 'packages depending on each other in a cycle were ordered' } } + 'the release check''s settings are read as text from a release workflow, a block scalar''s lines kept' = { + $workflow = Join-Path $scratch 'release.yml' + [System.IO.File]::WriteAllText($workflow, @' +jobs: + release: + steps: + - uses: actions/checkout@v7 + + - name: Hold the tag to the release check + id: check + uses: FormaUI/Versioning/actions/release-check@0123456789abcdef0123456789abcdef01234567 # v1.0.0-preview.4 + with: + gate-workflow: full.yml + # The packages the gate kept. + packages-artifact: 'packages' + version-command: | + $properties = [xml](Get-Content Directory.Build.props -Raw) + + if ($properties) { 'x' } + + - name: Attach the packages + with: + changelog: elsewhere.md +'@) + $inputs = Get-ReleaseCheckInput -Path $workflow + + if (-not $inputs) { 'no settings were read' } + elseif ($inputs['gate-workflow'] -cne 'full.yml') { "the gate workflow read as '$($inputs['gate-workflow'])'" } + elseif ($inputs['packages-artifact'] -cne 'packages') { "a quoted scalar read as '$($inputs['packages-artifact'])'" } + elseif ($inputs['version-command'] -cne "`$properties = [xml](Get-Content Directory.Build.props -Raw)`n`nif (`$properties) { 'x' }") { "the block scalar read as '$($inputs['version-command'])'" } + elseif ($inputs.Contains('changelog')) { 'a later step''s setting was read as the release check''s' } + + $own = Join-Path $scratch 'own.yml' + [System.IO.File]::WriteAllText($own, "jobs:`n release:`n steps:`n - uses: ./actions/release-check`n with:`n gate-workflow: ci.yml`n version-command: (Get-Content VERSION -Raw).Trim()`n") + $ownInputs = Get-ReleaseCheckInput -Path $own + + if (-not $ownInputs -or $ownInputs['gate-workflow'] -cne 'ci.yml' -or $ownInputs['version-command'] -cne '(Get-Content VERSION -Raw).Trim()') { 'a step opening with uses read wrong' } + + $none = Join-Path $scratch 'none.yml' + [System.IO.File]::WriteAllText($none, "jobs:`n build:`n steps:`n - run: echo`n") + + if ($null -ne (Get-ReleaseCheckInput -Path $none)) { 'a workflow calling no release check gave settings' } + } + + 'a changelog''s versions are read newest first' = { + $changelog = Join-Path $scratch 'versions.md' + [System.IO.File]::WriteAllText($changelog, "# Changelog`n`n## ``1.0.0-preview.2```n`n*Unreleased*`n`n## ``1.0.0-preview.1```n`n*November 5, 2026*`n") + $versions = @(Get-ChangelogVersion -Path $changelog) + + if (($versions -join ',') -cne '1.0.0-preview.2,1.0.0-preview.1') { "the versions read as '$($versions -join ',')'" } + } + + 'a release''s steps follow from where main and the tag stand' = { + $candidate = 'c' * 40 + $main = 'm' * 40 + $other = 'o' * 40 + $states = @( + @{ Name = 'a fresh release'; Main = $main; Behind = $true; Pushed = ''; Cloned = ''; Steps = 'Sign,Promote,Push'; Problem = $false } + @{ Name = 'a release signed but not pushed'; Main = $main; Behind = $true; Pushed = ''; Cloned = $candidate; Steps = 'Promote,Push'; Problem = $false } + @{ Name = 'a release with main promoted and the tag unpushed'; Main = $candidate; Behind = $true; Pushed = ''; Cloned = ''; Steps = 'Sign,Push'; Problem = $false } + @{ Name = 'a release done'; Main = $candidate; Behind = $true; Pushed = $candidate; Cloned = $candidate; Steps = ''; Problem = $false } + @{ Name = 'a tag pushed for another commit'; Main = $main; Behind = $true; Pushed = $other; Cloned = ''; Steps = ''; Problem = $true } + @{ Name = 'main ahead of the candidate'; Main = $main; Behind = $false; Pushed = ''; Cloned = ''; Steps = ''; Problem = $true } + @{ Name = 'a cloned tag for another commit'; Main = $main; Behind = $true; Pushed = ''; Cloned = $other; Steps = ''; Problem = $true } + ) + + foreach ($state in $states) { + $step = Get-ReleaseStep -Candidate $candidate -Main $state.Main -MainIsBehind $state.Behind -PushedTag $state.Pushed -ClonedTag $state.Cloned + + if (($step.Steps -join ',') -cne $state.Steps) { "$($state.Name) took the steps '$($step.Steps -join ',')'" } + if ([bool]$step.Problem -ne $state.Problem) { "$($state.Name) $(if ($state.Problem) { 'went on' } else { "stopped: $($step.Problem)" })" } + } + } + 'a digests file names every package, and a changed, missing or unlisted one is found' = { $directory = Join-Path $scratch 'digests' $null = New-Item -ItemType Directory -Path $directory