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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <checkout>/commands/New-Release.ps1 -Repository FormaUI/Forma -Version 1.0.0-preview.4 -Path <clone>
```

- **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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.0.0-preview.4
1.0.0-preview.5
165 changes: 163 additions & 2 deletions actions/Versioning.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -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 '^(?<lead>\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*(?<key>[\w-]+):\s*(?<value>.*)$') {
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 "^'(?<text>(?:[^']|'')*)'\s*(?:#.*)?$") {
$inputs[$key] = $Matches['text'] -replace "''", "'"
}
elseif ($value -match '^"(?<text>[^"]*)"\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 '^## `(?<version>[^`]+)`$') {
$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)
Expand Down Expand Up @@ -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
Loading