diff --git a/CHANGELOG.md b/CHANGELOG.md index 50f6011..134955e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ Each release of Versioning, newest first. An entry is dated when its release is cut, and reads *Unreleased* until then. +## `1.0.0-preview.3` + +*November 5, 2026* + +- The release check accepts a release tag whose signature GitHub verifies, so the maintainer signs with any key + their GitHub account holds. It refuses a tag whose tagger email the signing account hasn't verified, so a + release is tagged under an email that account has verified. + ## `1.0.0-preview.2` *November 5, 2026* diff --git a/VERSION b/VERSION index 64e1c09..de9c07d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0-preview.2 +1.0.0-preview.3 diff --git a/actions/Versioning.psm1 b/actions/Versioning.psm1 index f8431bf..43e66c0 100644 --- a/actions/Versioning.psm1 +++ b/actions/Versioning.psm1 @@ -161,61 +161,6 @@ function Get-ChangelogEntry { $entry } -# The release signers the releases document lists, each by account and primary key fingerprint. -function Get-ReleaseSigner { - param([Parameter(Mandatory)] [string] $Path) - - foreach ($line in [System.IO.File]::ReadAllLines($Path)) { - $match = [regex]::Match($line, '^\|\s*\[([A-Za-z0-9-]+)\]\(https://github\.com/\1\)\s*\|\s*`([0-9A-F ]+)`\s*\|\s*$') - - if ($match.Success) { - $fingerprint = $match.Groups[2].Value -replace ' ', '' - - if ($fingerprint -cmatch '^[0-9A-F]{40}$') { - [pscustomobject]@{ Account = $match.Groups[1].Value; Fingerprint = $fingerprint } - } - } - } -} - -# The release signer whose key made a tag's signature, or nothing where none of their keys did. -function Find-TagSigner { - param([Parameter(Mandatory)] [string] $Tag, [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Signer) - - # The signers' keys are read into a keyring of the check's own, with the GnuPG Git verifies with, so only their - # keys can verify the tag. - $program = git config --get gpg.program - $program = if ($program) { $program } else { 'gpg' } - $gpgconf = if (Split-Path -Path $program) { Join-Path (Split-Path -Path $program) 'gpgconf' } else { 'gpgconf' } - $keyring = Join-Path ([System.IO.Path]::GetTempPath()) "versioning-keys-$([guid]::NewGuid().ToString('N'))" - $null = New-Item -ItemType Directory -Path $keyring - $previousHome = $env:GNUPGHOME - $env:GNUPGHOME = $keyring - - try { - foreach ($account in @($Signer | ForEach-Object { $_.Account } | Sort-Object -Unique)) { - $keys = Invoke-RestMethod -Uri "https://github.com/$account.gpg" - - if ($keys -match 'BEGIN PGP PUBLIC KEY BLOCK') { - $null = $keys | & $program --batch --quiet --import 2>&1 - } - } - - $status = (git verify-tag --raw $Tag 2>&1 | ForEach-Object { "$_" }) -join "`n" - $valid = [regex]::Match($status, '(?m)^\[GNUPG:\] VALIDSIG .*$') - - if ($status -match '(?m)^\[GNUPG:\] GOODSIG ' -and $valid.Success) { - $primary = ($valid.Value.Trim() -split '\s+')[-1] - $Signer | Where-Object { $_.Fingerprint -ceq $primary } | Select-Object -First 1 - } - } - finally { - $env:GNUPGHOME = $previousHome - $null = & $gpgconf --homedir $keyring --kill all 2>&1 - Remove-Item -LiteralPath $keyring -Recurse -Force -ErrorAction SilentlyContinue - } -} - # The latest run of the full-gate workflow that passed on a push to candidate for a commit, or nothing. function Find-GateRun { param( @@ -480,6 +425,5 @@ function Write-CheckResult { } Export-ModuleMember -Function Test-VersionRule, Compare-Version, Invoke-GitHubApi, Get-ReleaseVersion, Get-HighestVersion, - Get-ChangelogEntry, Get-ReleaseSigner, Find-TagSigner, Find-GateRun, Get-RunArtifact, Save-RunArtifact, - Get-PackageInventory, Get-PublicationOrder, Test-FeedVersion, New-DigestFile, Test-DigestFile, New-CheckResult, - Write-CheckResult + Get-ChangelogEntry, Find-GateRun, Get-RunArtifact, Save-RunArtifact, Get-PackageInventory, Get-PublicationOrder, + Test-FeedVersion, New-DigestFile, Test-DigestFile, New-CheckResult, Write-CheckResult diff --git a/actions/release-check/Test-Release.ps1 b/actions/release-check/Test-Release.ps1 index c16956e..1d36951 100755 --- a/actions/release-check/Test-Release.ps1 +++ b/actions/release-check/Test-Release.ps1 @@ -7,12 +7,12 @@ Holds a release tag to the release check. .DESCRIPTION Checks the tag the workflow runs for, with the repository checked out at its commit: the tag is named v and a version -by the version rules, it's annotated, and its signature verifies against a release signer's key; its commit is the -head commit of a merged promotion pull request, and main's history holds it; the project's full-gate workflow passed -on that commit on a push to candidate; the tag's version is the one the commit declares; the changelog holds the -version's entry, dated; and the release's packages are held by the full-gate run or attached to the tag's GitHub -release. The release's version and the full-gate run are written to the step's outputs. Every result is written to -the log and the job's summary, and any check that fails fails the step. +by the version rules, it's annotated, and GitHub verifies its signature; its commit is the head commit of a merged +promotion pull request, and main's history holds it; the project's full-gate workflow passed on that commit on a push +to candidate; the tag's version is the one the commit declares; the changelog holds the version's entry, dated; and +the release's packages are held by the full-gate run or attached to the tag's GitHub release. The release's version +and the full-gate run are written to the step's outputs. Every result is written to the log and the job's summary, +and any check that fails fails the step. .PARAMETER Repository The repository, as owner/name. @@ -64,15 +64,15 @@ if (-not (Test-VersionRule -Version $tagVersion)) { if ((git cat-file -t "refs/tags/$Tag" 2>&1) -cne 'tag') { $tagProblems.Add("$Tag is lightweight; a release tag is annotated") } +else { + $tagObject = Invoke-GitHubApi -Path "repos/$Repository/git/tags/$((git rev-parse "refs/tags/$Tag").Trim())" -$signers = @(Get-ReleaseSigner -Path (Join-Path $PSScriptRoot '..' '..' 'docs' 'releases.md')) -$signer = Find-TagSigner -Tag $Tag -Signer $signers - -if (-not $signer) { - $tagProblems.Add("$Tag's signature doesn't verify against a release signer's key") + if (-not $tagObject.verification.verified) { + $tagProblems.Add("GitHub doesn't verify $Tag's signature: $($tagObject.verification.reason)") + } } -$tagDetail = if ($tagProblems.Count -gt 0) { $tagProblems -join '; ' } else { "$Tag, annotated, signed by $($signer.Account)" } +$tagDetail = if ($tagProblems.Count -gt 0) { $tagProblems -join '; ' } else { "$Tag, annotated, with a signature GitHub verifies" } $results.Add((New-CheckResult -Check 'Tag' -Holds ($tagProblems.Count -eq 0) -Detail $tagDetail)) $commit = (git rev-parse "refs/tags/$Tag^{commit}").Trim() diff --git a/docs/releases.md b/docs/releases.md index fbc280c..51c3ae4 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -6,7 +6,7 @@ separate, so a commit stays released when its publication fails, and publication ## Promotion -Promotion is a pull request from `candidate` into `main`. The promotion check runs on it, and a release signer, +Promotion is a pull request from `candidate` into `main`. The promotion check runs on it, and the maintainer, approving it, fast-forwards `main` to its head commit, `git push origin candidate:main`, which marks the pull request merged. `main` then holds the candidate's exact commit, so every commit `main` has advanced to is a release. @@ -25,16 +25,9 @@ The promotion check holds the pull request to: - A release's tag is named `v` and the package version, as `v1.0.0-preview.1`, and marks the commit `main` was promoted to. - It's annotated, with the project's name and the version as its message, as `Forma 1.0.0-preview.1`, and signed by - a release signer. -- Only a release signer creates a release tag, and a pushed tag is never moved or deleted. - -### Release signers - -| Account | Primary key fingerprint | -| --- | --- | -| [soyeyalon](https://github.com/soyeyalon) | `CAFC 1934 0836 6FD4 B53B 48C5 2AFD EA51 2887 E037` | - -A tag's signature verifies when Git verifies it and the primary key that made it is one listed here. + the maintainer, with a key their GitHub account holds and under an email it has verified, so GitHub verifies the + signature. +- Only the maintainer creates a release tag, and a pushed tag is never moved or deleted. ## The release check @@ -42,7 +35,7 @@ The release check runs on the tag's push, before anything is published, and hold | Check | Holds when | | --- | --- | -| Tag | Its name is `v` and a version by the [version rules](versions.md), it's annotated, and its signature verifies against a release signer's key | +| Tag | Its name is `v` and a version by the [version rules](versions.md), it's annotated, and GitHub verifies its signature | | Commit | Its commit is the head commit of a merged promotion pull request, and `main`'s history holds it | | Gate | The project's full-gate workflow passed on that exact SHA, in a run on a push to `candidate`, in this repository | | Version | The tag's version equals the package version's declaration at the tagged commit | diff --git a/tests/Versioning.Tests.ps1 b/tests/Versioning.Tests.ps1 index 8772cee..9a57197 100755 --- a/tests/Versioning.Tests.ps1 +++ b/tests/Versioning.Tests.ps1 @@ -76,20 +76,6 @@ $cases = [ordered]@{ if ((Get-ChangelogEntry -Path (Join-Path $scratch 'none.md') -Version '1.0.0').Found) { 'a missing changelog held an entry' } } - 'the release signers are read from the releases document' = { - $signers = @(Get-ReleaseSigner -Path (Join-Path $root 'docs' 'releases.md')) - - if ($signers.Count -lt 1) { 'the releases document listed no signer' } - - foreach ($signer in $signers) { - if ($signer.Fingerprint -cnotmatch '^[0-9A-F]{40}$') { "$($signer.Account)'s fingerprint is $($signer.Fingerprint)" } - } - - if (-not ($signers | Where-Object { $_.Account -ceq 'soyeyalon' -and $_.Fingerprint -ceq 'CAFC193408366FD4B53B48C52AFDEA512887E037' })) { - 'soyeyalon wasn''t read with the primary key''s fingerprint' - } - } - 'a directory''s packages are read from the packages themselves' = { $directory = Join-Path $scratch 'packages' $null = New-Item -ItemType Directory -Path $directory