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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*
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.2
1.0.0-preview.3
60 changes: 2 additions & 58 deletions actions/Versioning.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
24 changes: 12 additions & 12 deletions actions/release-check/Test-Release.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down
17 changes: 5 additions & 12 deletions docs/releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -25,24 +25,17 @@ 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

The release check runs on the tag's push, before anything is published, and holds the release to:

| 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 |
Expand Down
14 changes: 0 additions & 14 deletions tests/Versioning.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down